From 5ce1b23cc19f040b513b4c35a4f2541643586bf6 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 17:54:33 +0200 Subject: [PATCH 01/10] feat: bootstrap.sh and lss update install only verified releases Servers so far got lss from a git clone, with nothing proving where the code came from. bootstrap.sh installs a release only after the SHA-256 of the archive matches SHA256SUMS and the GitHub build attestation of both verifies for the release workflow on main (gh attestation verify with a pinned and checksum-checked GitHub CLI, so no account or token is needed). Versions live in /opt/lss/; /opt/lss/current, /opt/lss/previous and /usr/local/sbin/lss are switched with rename(2), the new version must answer `lss version` or the links go back, and a file list per version lets a rollback refuse a tree that changed after it was installed. `lss update [VERSION]` runs the bootstrap.sh of the running version with the lock held: the latest release (never older than the active one), a named version (also a downgrade, with a warning) or --rollback without a download. lss runs main only when executed, so the unit tests can source it. Refs #9 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- bootstrap.sh | 530 ++++++++++++++++++++++++++++++++++++++ lss | 29 ++- tests/unit/bootstrap.bats | 345 +++++++++++++++++++++++++ tests/unit/update.bats | 61 +++++ 4 files changed, 964 insertions(+), 1 deletion(-) create mode 100755 bootstrap.sh create mode 100644 tests/unit/bootstrap.bats create mode 100644 tests/unit/update.bats diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..e0c8c29 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,530 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# bootstrap.sh: installs lss from a signed release, and is what `lss update` runs. +# +# sudo bash bootstrap.sh [VERSION] install VERSION (default: the latest release) and make it the active one +# sudo bash bootstrap.sh --rollback switch back to the version that was active before +# sudo bash bootstrap.sh --dry-run [V] download and verify only +# +# Nothing from a download runs before two checks pass: the SHA-256 of the archive matches SHA256SUMS of the release, +# and the GitHub build attestation (Sigstore, SLSA provenance) of both files proves that the release workflow of +# VectoDE/Linux-Server-Setup-Script built them from main on a GitHub-hosted runner. The attestation is checked with +# the GitHub CLI, downloaded at a pinned version and checked against its pinned SHA-256 first. +# +# Layout: every version in /opt/lss/ (root-owned, not writable for others), the active one behind the +# symbolic link /opt/lss/current, the one before it behind /opt/lss/previous, and /usr/local/sbin/lss pointing to +# /opt/lss/current/lss. Links are replaced with rename(2), so a reader sees the old or the new version, never none. +# Older versions than these two are deleted. + +set -Eeuo pipefail +shopt -s inherit_errexit + +BS_REPO=VectoDE/Linux-Server-Setup-Script +# GitHub CLI used to check attestations. A new version needs both checksums from its gh__checksums.txt. +BS_GH_VERSION=2.102.0 +BS_GH_SHA256_AMD64=bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386 +BS_GH_SHA256_ARM64=7862c86c72f43df3a2d93ddde6f473285b4e2af61b494849846827e513ef6484 +BS_VERSION_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + +LSS_PREFIX=${LSS_PREFIX:-/opt/lss} +LSS_BIN_LINK=${LSS_BIN_LINK:-/usr/local/sbin/lss} +LSS_LOCK=${LSS_LOCK:-/run/lss.lock} +# Where releases come from: https:// (the GitHub releases of this repository, or a mirror of them) or file:// (a +# directory with the same layout, used by the tests). The files are checked the same way wherever they come from. +LSS_RELEASE_URL=${LSS_RELEASE_URL:-https://github.com/${BS_REPO}/releases} +# The workflow and git ref that must have signed a release. The tests of this repository sign test builds with their +# own workflow on the pull request ref and set these two; releases are signed by release.yml on main. +LSS_RELEASE_WORKFLOW=${LSS_RELEASE_WORKFLOW:-release.yml} +LSS_RELEASE_REF=${LSS_RELEASE_REF:-refs/heads/main} +LSS_DRY_RUN=${LSS_DRY_RUN:-0} + +# --- output ------------------------------------------------------------------------------------------------------- + +# Same format as lss itself: terminal, the run log of `lss update` and the journal (identifier "lss"). +bs_log() { + local level=$1 + shift + printf '%-5s %s\n' "${level}" "$*" >&2 + if [[ -n ${LSS_LOG_FILE:-} ]]; then + printf '%s %-5s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "${level}" "$*" >>"${LSS_LOG_FILE}" 2>/dev/null || true + fi + if [[ ${LSS_JOURNAL:-1} == 1 ]] && command -v logger >/dev/null 2>&1; then + logger -t lss -- "${level} $*" 2>/dev/null || true + fi + return +} + +bs_info() { + bs_log INFO "$*" + return +} + +bs_ok() { + bs_log OK "$*" + return +} + +bs_warn() { + bs_log WARN "$*" + return +} + +bs_die() { + bs_log ERROR "$*" + exit 1 +} + +# --- versions ----------------------------------------------------------------------------------------------------- + +bs_valid_version() { + [[ $1 =~ ${BS_VERSION_RE} ]] + return +} + +# _bs_ident_cmp A B: compares two pre-release identifiers by SemVer rules (numbers numerically and below words). +_bs_ident_cmp() { + local a=$1 b=$2 + if [[ ${a} =~ ^[0-9]+$ && ${b} =~ ^[0-9]+$ ]]; then + if ((10#${a} > 10#${b})); then + printf '1' + elif ((10#${a} < 10#${b})); then + printf -- '-1' + else + printf '0' + fi + elif [[ ${a} =~ ^[0-9]+$ ]]; then + printf -- '-1' + elif [[ ${b} =~ ^[0-9]+$ ]]; then + printf '1' + elif [[ ${a} > "${b}" ]]; then + printf '1' + elif [[ ${a} < "${b}" ]]; then + printf -- '-1' + else + printf '0' + fi + return +} + +# bs_version_cmp A B: prints 1, 0 or -1 when version A is newer than, equal to or older than B (SemVer precedence: +# 2.1.0 > 2.1.0-rc.2 > 2.1.0-rc.1 > 2.0.9). +bs_version_cmp() { + local a=$1 b=$2 a_pre='' b_pre='' i result + local -a x=() y=() + [[ ${a} != *-* ]] || a_pre=${a#*-} + [[ ${b} != *-* ]] || b_pre=${b#*-} + IFS=. read -ra x <<<"${a%%-*}" + IFS=. read -ra y <<<"${b%%-*}" + for i in 0 1 2; do + result=$(_bs_ident_cmp "${x[i]}" "${y[i]}") + if [[ ${result} != 0 ]]; then + printf '%s' "${result}" + return 0 + fi + done + if [[ -z ${a_pre} || -z ${b_pre} ]]; then + # A release ranks above its own pre-releases. + if [[ -z ${a_pre} && -n ${b_pre} ]]; then + printf '1' + elif [[ -n ${a_pre} && -z ${b_pre} ]]; then + printf -- '-1' + else + printf '0' + fi + return 0 + fi + IFS=. read -ra x <<<"${a_pre}" + IFS=. read -ra y <<<"${b_pre}" + for ((i = 0; i < ${#x[@]} || i < ${#y[@]}; i++)); do + if ((i >= ${#x[@]})); then + printf -- '-1' + return 0 + fi + if ((i >= ${#y[@]})); then + printf '1' + return 0 + fi + result=$(_bs_ident_cmp "${x[i]}" "${y[i]}") + if [[ ${result} != 0 ]]; then + printf '%s' "${result}" + return 0 + fi + done + printf '0' + return +} + +# --- downloads and checks ----------------------------------------------------------------------------------------- + +# bs_fetch URL DEST: HTTPS only (redirects included), or file:// for a local release directory. +bs_fetch() { + local url=$1 dest=$2 proto + case ${url} in + https://*) proto='=https' ;; + file://*) proto='=file' ;; + *) bs_die "Refusing ${url}: releases are downloaded over HTTPS." ;; + esac + curl --proto "${proto}" --proto-redir "${proto}" --tlsv1.2 -fsSL --retry 3 --retry-delay 2 -o "${dest}" -- "${url}" || + bs_die "Download failed: ${url}" + return +} + +# bs_sum_of SUMS NAME: the SHA-256 that a SHA256SUMS file lists for NAME. +bs_sum_of() { + local sums=$1 name=$2 + awk -v name="${name}" '$2 == name || $2 == "*" name { print $1; exit }' "${sums}" + return +} + +# bs_check_sum FILE SUMS: the file's SHA-256 must be the one SHA256SUMS lists for its name. +bs_check_sum() { + local file=$1 sums=$2 expected actual + expected=$(bs_sum_of "${sums}" "${file##*/}") + [[ ${expected} =~ ^[0-9a-f]{64}$ ]] || bs_die "SHA256SUMS lists ${file##*/} without a SHA-256; the release is incomplete." + actual=$(sha256sum -- "${file}" | cut -d ' ' -f 1) + [[ ${actual} == "${expected}" ]] || + bs_die "Checksum mismatch for ${file##*/}: SHA256SUMS lists ${expected}, the download has ${actual}." + return +} + +# bs_latest_version DIR: the version of the latest release, read from the file list in its SHA256SUMS. +bs_latest_version() { + local dir=$1 version + bs_fetch "${LSS_RELEASE_URL}/latest/download/SHA256SUMS" "${dir}/latest.sums" + version=$(sed -nE 's/^[0-9a-f]{64} [ *]lss-(.+)\.tar\.gz$/\1/p' "${dir}/latest.sums") + bs_valid_version "${version}" || bs_die "The latest release at ${LSS_RELEASE_URL} names its version unclearly ('${version}')." + printf '%s' "${version}" + return +} + +bs_arch() { + local machine + machine=$(uname -m) + case ${machine} in + x86_64 | amd64) printf 'amd64' ;; + aarch64 | arm64) printf 'arm64' ;; + *) bs_die "lss runs on x86_64 and aarch64; this machine is ${machine}." ;; + esac + return +} + +# bs_fetch_gh DIR: downloads the pinned GitHub CLI, checks its SHA-256 and prints the path of the gh binary. +bs_fetch_gh() { + local dir=$1 arch name sum actual + arch=$(bs_arch) + name="gh_${BS_GH_VERSION}_linux_${arch}" + if [[ ${arch} == arm64 ]]; then sum=${BS_GH_SHA256_ARM64}; else sum=${BS_GH_SHA256_AMD64}; fi + bs_fetch "https://github.com/cli/cli/releases/download/v${BS_GH_VERSION}/${name}.tar.gz" "${dir}/gh.tar.gz" + actual=$(sha256sum -- "${dir}/gh.tar.gz" | cut -d ' ' -f 1) + [[ ${actual} == "${sum}" ]] || + bs_die "Checksum mismatch for the GitHub CLI ${BS_GH_VERSION}: expected ${sum}, got ${actual}." + mkdir -p "${dir}/gh" + tar -xzf "${dir}/gh.tar.gz" -C "${dir}/gh" --strip-components=2 "${name}/bin/gh" + printf '%s' "${dir}/gh/gh" + return +} + +# bs_signer: the certificate identity a release must be signed with (the workflow file and the ref it ran on). +bs_signer() { + [[ ${LSS_RELEASE_WORKFLOW} =~ ^[A-Za-z0-9._-]+\.ya?ml$ ]] || + bs_die "LSS_RELEASE_WORKFLOW '${LSS_RELEASE_WORKFLOW}' is a workflow file name like release.yml." + [[ ${LSS_RELEASE_REF} =~ ^refs/(heads|tags|pull)/[A-Za-z0-9._/-]+$ && ${LSS_RELEASE_REF} != *..* ]] || + bs_die "LSS_RELEASE_REF '${LSS_RELEASE_REF}' is a git ref like refs/heads/main." + printf 'https://github.com/%s/.github/workflows/%s@%s' "${BS_REPO}" "${LSS_RELEASE_WORKFLOW}" "${LSS_RELEASE_REF}" + return +} + +# bs_check_attestation GH FILE BUNDLE: the Sigstore bundle must hold a valid SLSA provenance attestation for the +# file, signed for the release workflow of this repository on a GitHub-hosted runner. +bs_check_attestation() { + local gh=$1 file=$2 bundle=$3 dir output signer + dir=$(dirname -- "${bundle}") + signer=$(bs_signer) + output=$( + GH_CONFIG_DIR="${dir}/.gh" XDG_CACHE_HOME="${dir}/.cache" XDG_STATE_HOME="${dir}/.state" \ + XDG_DATA_HOME="${dir}/.data" GH_NO_UPDATE_NOTIFIER=1 GH_PROMPT_DISABLED=1 NO_COLOR=1 \ + "${gh}" attestation verify "${file}" --bundle "${bundle}" --repo "${BS_REPO}" \ + --cert-identity "${signer}" --source-ref "${LSS_RELEASE_REF}" --deny-self-hosted-runners 2>&1 + ) || { + printf '%s\n' "${output}" | tail -n 5 | sed 's/^/ gh: /' >&2 + bs_die "The attestation of ${file##*/} does not verify; it was not built by ${BS_REPO} (${LSS_RELEASE_WORKFLOW} on ${LSS_RELEASE_REF}). Nothing was installed." + } + return +} + +# bs_download VERSION DIR: downloads and checks one release; prints the path of its verified archive. +bs_download() { + local version=$1 dir=$2 base archive gh + base="${LSS_RELEASE_URL}/download/v${version}" + archive="${dir}/lss-${version}.tar.gz" + bs_info "Downloading lss ${version}" + bs_fetch "${base}/SHA256SUMS" "${dir}/SHA256SUMS" + bs_fetch "${base}/lss-${version}.tar.gz" "${archive}" + bs_fetch "${base}/lss-${version}.intoto.jsonl" "${dir}/lss-${version}.intoto.jsonl" + bs_check_sum "${archive}" "${dir}/SHA256SUMS" + gh=$(bs_fetch_gh "${dir}") + bs_check_attestation "${gh}" "${dir}/SHA256SUMS" "${dir}/lss-${version}.intoto.jsonl" + bs_check_attestation "${gh}" "${archive}" "${dir}/lss-${version}.intoto.jsonl" + bs_ok "lss ${version}: checksum and build attestation verified" + printf '%s' "${archive}" + return +} + +# --- installed versions ------------------------------------------------------------------------------------------- + +# bs_link_target LINK: the version a link in the prefix points to, or nothing. +bs_link_target() { + local link="${LSS_PREFIX}/$1" target + [[ -L ${link} ]] || return 0 + target=$(readlink -- "${link}") + if bs_valid_version "${target}" && [[ -d ${LSS_PREFIX}/${target} ]]; then + printf '%s' "${target}" + fi + return +} + +# bs_set_link TARGET LINK: points LINK to TARGET in one rename, so the link is never missing. +bs_set_link() { + local target=$1 link=$2 next + next="$(dirname -- "${link}")/.${link##*/}.next" + rm -f -- "${next}" + ln -s -- "${target}" "${next}" + mv -fT -- "${next}" "${link}" + return +} + +# bs_write_manifest DIR: SHA-256 of every file of an installed version, checked again before a rollback. +bs_write_manifest() { + local dir=$1 + ( + cd -- "${dir}" + find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum >"../.manifest.$$" + ) + mv -f -- "${dir}/../.manifest.$$" "${dir}/.lss-manifest" + chmod 0644 "${dir}/.lss-manifest" + return +} + +bs_check_manifest() { + local dir=$1 + [[ -f ${dir}/.lss-manifest ]] || bs_die "${dir} has lost its file list (.lss-manifest); install that version again." + (cd -- "${dir}" && sha256sum --quiet --strict -c .lss-manifest >/dev/null 2>&1) || + bs_die "Files in ${dir} changed after it was installed; install that version again instead of switching to it." + return +} + +# bs_unpack ARCHIVE VERSION: unpacks a verified archive into LSS_PREFIX/VERSION, replacing an earlier copy. +bs_unpack() { + local archive=$1 version=$2 stage tree old + mkdir -p -- "${LSS_PREFIX}" + chmod 0755 "${LSS_PREFIX}" + stage=$(mktemp -d "${LSS_PREFIX}/.stage-${version}.XXXXXX") + tar -xzf "${archive}" -C "${stage}" --no-same-owner --no-same-permissions + tree="${stage}/lss-${version}" + [[ -f ${tree}/lss && -f ${tree}/VERSION ]] || bs_die "The archive of ${version} holds no lss-${version}/ directory with lss in it." + [[ $(<"${tree}/VERSION") == "${version}" ]] || + bs_die "The archive of ${version} contains version $(<"${tree}/VERSION"); refusing to install it under the wrong name." + if [[ ${EUID} -eq 0 ]]; then + chown -R 0:0 -- "${tree}" + fi + chmod -R u+rwX,go+rX,go-w -- "${tree}" + chmod 0755 "${tree}/lss" + bs_write_manifest "${tree}" + if [[ -e ${LSS_PREFIX}/${version} ]]; then + old=$(mktemp -d "${LSS_PREFIX}/.old-${version}.XXXXXX") + mv -- "${LSS_PREFIX}/${version}" "${old}/" + rm -rf -- "${old}" + fi + mv -T -- "${tree}" "${LSS_PREFIX}/${version}" + rm -rf -- "${stage}" + return +} + +# bs_activate VERSION: makes an installed version the active one and keeps the one before as "previous". The new +# version must answer `lss version` with its own version, or the links go back to what they were. +bs_activate() { + local version=$1 current previous answer + current=$(bs_link_target current) + previous=$(bs_link_target previous) + if [[ -n ${current} && ${current} != "${version}" ]]; then + bs_set_link "${current}" "${LSS_PREFIX}/previous" + fi + bs_set_link "${version}" "${LSS_PREFIX}/current" + mkdir -p -- "$(dirname -- "${LSS_BIN_LINK}")" + bs_set_link "${LSS_PREFIX}/current/lss" "${LSS_BIN_LINK}" + answer=$("${LSS_PREFIX}/current/lss" version 2>&1) || true + if [[ ${answer} != "lss ${version}" ]]; then + if [[ -n ${current} ]]; then + bs_set_link "${current}" "${LSS_PREFIX}/current" + fi + if [[ -n ${previous} ]]; then + bs_set_link "${previous}" "${LSS_PREFIX}/previous" + else + rm -f -- "${LSS_PREFIX}/previous" + fi + bs_die "lss ${version} does not start ('${answer}'); ${current:-the earlier state} stays active." + fi + return +} + +# bs_prune: deletes versions other than the active and the previous one, and leftovers of interrupted runs. +bs_prune() { + local current previous dir name + current=$(bs_link_target current) + previous=$(bs_link_target previous) + for dir in "${LSS_PREFIX}"/* "${LSS_PREFIX}"/.stage-* "${LSS_PREFIX}"/.old-*; do + [[ -d ${dir} && ! -L ${dir} ]] || continue + name=${dir##*/} + case ${name} in + .stage-* | .old-*) ;; + *) + bs_valid_version "${name}" || continue + [[ ${name} != "${current}" && ${name} != "${previous}" ]] || continue + bs_info "Removing lss ${name}" + ;; + esac + rm -rf -- "${dir}" + done + return +} + +# --- commands ----------------------------------------------------------------------------------------------------- + +bs_require() { + local tool + local -a missing=() + if [[ ${EUID} -ne 0 ]]; then + bs_die "bootstrap.sh installs lss for the whole system and must run as root (sudo bash bootstrap.sh)." + fi + for tool in curl tar gzip sha256sum flock mktemp readlink awk sed find xargs; do + command -v "${tool}" >/dev/null 2>&1 || missing+=("${tool}") + done + [[ ${#missing[@]} -eq 0 ]] || + bs_die "bootstrap.sh needs ${missing[*]}; install them with apt-get or dnf first." + return +} + +# bs_check_layout: an lss checkout in the prefix (the way 2.0 development versions were installed) must move first. +bs_check_layout() { + if [[ -e ${LSS_PREFIX}/lss && ! -L ${LSS_PREFIX}/current ]]; then + bs_die "${LSS_PREFIX} holds an lss checkout, where releases go now. Move it away (mv ${LSS_PREFIX} ${LSS_PREFIX}.checkout) and run this again; /etc/lss and /var/lib/lss stay as they are." + fi + return +} + +bs_lock() { + # `lss update` holds the lock already and runs this script with LSS_LOCK_HELD=1. + [[ ${LSS_LOCK_HELD:-0} != 1 ]] || return 0 + exec 9>"${LSS_LOCK}" + flock -n 9 || bs_die "Another lss run is in progress." + return +} + +bs_install() { + local requested=$1 dir=$2 version current archive order + current=$(bs_link_target current) + if [[ -n ${requested} ]]; then + version=${requested#v} + bs_valid_version "${version}" || bs_die "'${requested}' is a version like 2.0.1." + else + version=$(bs_latest_version "${dir}") + if [[ -n ${current} && $(bs_version_cmp "${version}" "${current}") == -1 ]]; then + bs_ok "lss ${current} is newer than the latest release ${version} and stays active." + return 0 + fi + fi + if [[ ${version} == "${current}" ]]; then + bs_ok "lss ${version} is installed and active." + return 0 + fi + if [[ -n ${current} ]]; then + order=$(bs_version_cmp "${version}" "${current}") + if [[ ${order} == -1 ]]; then + bs_warn "Going back from ${current} to the older ${version}; settings that only ${current} knows produce a warning." + fi + fi + archive=$(bs_download "${version}" "${dir}") + if [[ ${LSS_DRY_RUN} == 1 ]]; then + bs_ok "Dry run: lss ${version} verified; nothing was changed." + return 0 + fi + bs_unpack "${archive}" "${version}" + bs_activate "${version}" + bs_prune + if [[ -n ${current} ]]; then + bs_ok "lss ${version} is active (was ${current}; 'lss update --rollback' goes back)." + bs_info "Run 'lss verify' now, and 'lss configure MODULE...' to apply what the new version changes in a module." + else + bs_ok "lss ${version} is installed: ${LSS_PREFIX}/${version}, command ${LSS_BIN_LINK}." + bs_info "Next: sudo lss install --profile minimal-secure (see 'lss --help')." + fi + return +} + +bs_rollback() { + local current previous + current=$(bs_link_target current) + previous=$(bs_link_target previous) + [[ -n ${previous} ]] || bs_die "There is only one installed version of lss; rollback needs the one before it." + bs_check_manifest "${LSS_PREFIX}/${previous}" + if [[ ${LSS_DRY_RUN} == 1 ]]; then + bs_ok "Dry run: lss ${previous} is intact; nothing was changed." + return 0 + fi + bs_activate "${previous}" + bs_ok "lss ${previous} is active again (was ${current}; 'lss update ${current}' returns to it)." + return +} + +bs_usage() { + cat <>"${GH_LOG}"\nexit "${GH_EXIT}"\n' >"$1/gh/gh" + chmod +x "$1/gh/gh" + printf '%s' "$1/gh/gh" + return + } + # shellcheck disable=SC2329 # called by bs_download and bs_latest_version + bs_fetch() { + [[ $1 == file://* ]] || bs_die "Refusing $1: releases are downloaded over HTTPS." + cp -- "${1#file://}" "$2" 2>/dev/null || bs_die "Download failed: $1" + return + } + return +} + +# make_release VERSION [ANSWER]: a release directory with an archive whose lss answers "lss VERSION" (or ANSWER). +make_release() { + local version=$1 answer=${2:-lss $1} tree dir + tree=${BATS_TEST_TMPDIR}/build/lss-${version} + dir=${RELEASES}/download/v${version} + mkdir -p "${tree}" "${dir}" + printf '#!/usr/bin/env bash\necho "%s"\n' "${answer}" >"${tree}/lss" + printf '#!/usr/bin/env bash\n' >"${tree}/bootstrap.sh" + printf '%s\n' "${version}" >"${tree}/VERSION" + tar -czf "${dir}/lss-${version}.tar.gz" -C "${BATS_TEST_TMPDIR}/build" "lss-${version}" + (cd "${dir}" && sha256sum "lss-${version}.tar.gz" >SHA256SUMS) + printf '{}\n' >"${dir}/lss-${version}.intoto.jsonl" + mkdir -p "${RELEASES}/latest/download" + cp "${dir}/SHA256SUMS" "${RELEASES}/latest/download/SHA256SUMS" + return +} + +active() { + readlink "${LSS_PREFIX}/current" + return +} + +# --- versions ----------------------------------------------------------------------------------------------------- + +@test "versions are compared by SemVer precedence" { + local pair + for pair in "2.0.1 2.0.0 1" "2.0.0 2.0.1 -1" "2.10.0 2.9.9 1" "2.0.0 2.0.0 0" "2.0.0 2.0.0-rc.1 1" \ + "2.0.0-rc.1 2.0.0 -1" "2.0.0-rc.2 2.0.0-rc.10 -1" "2.0.0-rc.1 2.0.0-beta 1" "2.0.0-alpha 2.0.0-alpha.1 -1" \ + "2.0.0-1 2.0.0-alpha -1" "2.0.0-ci.2 2.0.0-ci.1 1" "3.0.0 10.0.0 -1"; do + read -r a b expected <<<"${pair}" + [ "$(bs_version_cmp "${a}" "${b}")" = "${expected}" ] || { + echo "${a} vs ${b}: $(bs_version_cmp "${a}" "${b}"), expected ${expected}" + return 1 + } + done +} + +@test "only release versions are accepted" { + bs_valid_version 2.0.1 + bs_valid_version 2.0.0-rc.1 + run ! bs_valid_version v2.0.1 + run ! bs_valid_version 2.0 + run ! bs_valid_version '2.0.1; rm -rf /' + run ! bs_valid_version ../2.0.1 +} + +# --- checks ------------------------------------------------------------------------------------------------------- + +@test "the checksum of the archive must match SHA256SUMS" { + printf 'release\n' >"${WORK}/lss-1.0.0.tar.gz" + (cd "${WORK}" && sha256sum lss-1.0.0.tar.gz >SHA256SUMS) + bs_check_sum "${WORK}/lss-1.0.0.tar.gz" "${WORK}/SHA256SUMS" + printf 'changed\n' >"${WORK}/lss-1.0.0.tar.gz" + run bs_check_sum "${WORK}/lss-1.0.0.tar.gz" "${WORK}/SHA256SUMS" + [ "${status}" -eq 1 ] + [[ ${output} == *"Checksum mismatch for lss-1.0.0.tar.gz"* ]] + printf '%064d *other.tar.gz\n' 0 >"${WORK}/SHA256SUMS" + run bs_check_sum "${WORK}/lss-1.0.0.tar.gz" "${WORK}/SHA256SUMS" + [[ ${output} == *"lists lss-1.0.0.tar.gz without a SHA-256"* ]] +} + +@test "downloads use HTTPS or a local release directory" { + source "${REPO_ROOT}/bootstrap.sh" + run bs_fetch http://example.com/lss.tar.gz "${WORK}/x" + [ "${status}" -eq 1 ] + [[ ${output} == *"Refusing http://example.com/lss.tar.gz"* ]] + run bs_fetch ftp://example.com/lss.tar.gz "${WORK}/x" + [ "${status}" -eq 1 ] +} + +@test "the GitHub CLI is used only with its pinned checksum" { + source "${REPO_ROOT}/bootstrap.sh" + bs_fetch() { + printf 'not the GitHub CLI' >"$2" + return + } + run bs_fetch_gh "${WORK}" + [ "${status}" -eq 1 ] + [[ ${output} == *"Checksum mismatch for the GitHub CLI ${BS_GH_VERSION}"* ]] + [[ ${BS_GH_SHA256_AMD64} =~ ^[0-9a-f]{64}$ && ${BS_GH_SHA256_ARM64} =~ ^[0-9a-f]{64}$ ]] +} + +@test "releases must be signed by the release workflow on main" { + [ "$(bs_signer)" = "https://github.com/VectoDE/Linux-Server-Setup-Script/.github/workflows/release.yml@refs/heads/main" ] + LSS_RELEASE_WORKFLOW=distribution.yml LSS_RELEASE_REF=refs/pull/12/merge + [ "$(bs_signer)" = "https://github.com/VectoDE/Linux-Server-Setup-Script/.github/workflows/distribution.yml@refs/pull/12/merge" ] + LSS_RELEASE_WORKFLOW='../other/repo/x.yml' run bs_signer + [ "${status}" -eq 1 ] + LSS_RELEASE_REF='refs/heads/../x' run bs_signer + [ "${status}" -eq 1 ] +} + +@test "the attestation is checked for the repository, workflow, ref and hosted runners" { + make_release 1.0.0 + bs_download 1.0.0 "${WORK}" >/dev/null + [ "$(grep -c . "${GH_LOG}")" -eq 2 ] + grep -q "attestation verify ${WORK}/SHA256SUMS --bundle ${WORK}/lss-1.0.0.intoto.jsonl" "${GH_LOG}" + grep -q "attestation verify ${WORK}/lss-1.0.0.tar.gz --bundle" "${GH_LOG}" + grep -q -- "--repo VectoDE/Linux-Server-Setup-Script --cert-identity https://github.com/VectoDE/Linux-Server-Setup-Script/.github/workflows/release.yml@refs/heads/main --source-ref refs/heads/main --deny-self-hosted-runners" "${GH_LOG}" +} + +# --- install, update, rollback ----------------------------------------------------------------------------------- + +@test "the latest release is installed with links, a file list and no writable files" { + make_release 1.0.0 + run bs_install '' "${WORK}" + [ "${status}" -eq 0 ] + [[ ${output} == *"lss 1.0.0: checksum and build attestation verified"* ]] + [[ ${output} == *"lss 1.0.0 is installed"* ]] + [ "$(active)" = 1.0.0 ] + [ ! -e "${LSS_PREFIX}/previous" ] + [ "$(readlink "${LSS_BIN_LINK}")" = "${LSS_PREFIX}/current/lss" ] + [ "$("${LSS_BIN_LINK}" version)" = "lss 1.0.0" ] + grep -q ' ./lss$' "${LSS_PREFIX}/1.0.0/.lss-manifest" + [ -z "$(find "${LSS_PREFIX}/1.0.0" -perm /022 ! -type l)" ] + [ -z "$(find "${LSS_PREFIX}" -maxdepth 1 -name '.stage-*')" ] +} + +@test "the active version is not downloaded again" { + make_release 1.0.0 + bs_install 1.0.0 "${WORK}" + : >"${GH_LOG}" + run bs_install v1.0.0 "${WORK}" + [[ ${output} == *"lss 1.0.0 is installed and active."* ]] + [ ! -s "${GH_LOG}" ] +} + +@test "an update keeps the version before it, a third version removes the oldest" { + make_release 1.0.0 + bs_install '' "${WORK}" + make_release 1.1.0 + run bs_install '' "${WORK}" + [[ ${output} == *"lss 1.1.0 is active (was 1.0.0"* ]] + [ "$(active)" = 1.1.0 ] + [ "$(readlink "${LSS_PREFIX}/previous")" = 1.0.0 ] + make_release 1.2.0 + bs_install 1.2.0 "${WORK}" + [ "$(active)" = 1.2.0 ] + [ "$(readlink "${LSS_PREFIX}/previous")" = 1.1.0 ] + [ ! -e "${LSS_PREFIX}/1.0.0" ] +} + +@test "other directories in the prefix survive the pruning" { + mkdir -p "${LSS_PREFIX}/pgadmin" "${LSS_PREFIX}/.stage-0.9.0.abc" + make_release 1.0.0 + bs_install '' "${WORK}" + [ -d "${LSS_PREFIX}/pgadmin" ] + [ ! -e "${LSS_PREFIX}/.stage-0.9.0.abc" ] +} + +@test "lss update without a version never goes back" { + make_release 1.0.0 + make_release 1.1.0 + bs_install 1.1.0 "${WORK}" + cp "${RELEASES}/download/v1.0.0/SHA256SUMS" "${RELEASES}/latest/download/SHA256SUMS" + run bs_install '' "${WORK}" + [[ ${output} == *"lss 1.1.0 is newer than the latest release 1.0.0 and stays active."* ]] + [ "$(active)" = 1.1.0 ] +} + +@test "a named older version is a downgrade with a warning" { + make_release 1.0.0 + make_release 1.1.0 + bs_install 1.1.0 "${WORK}" + run bs_install 1.0.0 "${WORK}" + [ "${status}" -eq 0 ] + [[ ${output} == *"Going back from 1.1.0 to the older 1.0.0"* ]] + [ "$(active)" = 1.0.0 ] + [ "$(readlink "${LSS_PREFIX}/previous")" = 1.1.0 ] +} + +@test "rollback switches to the previous version and back" { + make_release 1.0.0 + make_release 1.1.0 + bs_install 1.0.0 "${WORK}" + bs_install 1.1.0 "${WORK}" + run bs_rollback + [ "${status}" -eq 0 ] + [[ ${output} == *"lss 1.0.0 is active again (was 1.1.0"* ]] + [ "$(active)" = 1.0.0 ] + [ "$(readlink "${LSS_PREFIX}/previous")" = 1.1.0 ] + bs_rollback + [ "$(active)" = 1.1.0 ] +} + +@test "rollback refuses a version whose files changed, and needs a version to go back to" { + make_release 1.0.0 + bs_install 1.0.0 "${WORK}" + run bs_rollback + [ "${status}" -eq 1 ] + [[ ${output} == *"rollback needs the one before it"* ]] + make_release 1.1.0 + bs_install 1.1.0 "${WORK}" + printf 'echo changed\n' >>"${LSS_PREFIX}/1.0.0/lss" + run bs_rollback + [ "${status}" -eq 1 ] + [[ ${output} == *"changed after it was installed"* ]] + [ "$(active)" = 1.1.0 ] +} + +@test "a release whose attestation does not verify installs nothing" { + make_release 1.0.0 + GH_EXIT=1 run bs_install '' "${WORK}" + [ "${status}" -eq 1 ] + [[ ${output} == *"The attestation of SHA256SUMS does not verify"* ]] + [[ ${output} == *"Nothing was installed."* ]] + [ ! -e "${LSS_PREFIX}/1.0.0" ] + [ ! -e "${LSS_PREFIX}/current" ] +} + +@test "a changed archive is refused before its attestation is checked" { + make_release 1.0.0 + printf '\0' >>"${RELEASES}/download/v1.0.0/lss-1.0.0.tar.gz" + run bs_install 1.0.0 "${WORK}" + [ "${status}" -eq 1 ] + [[ ${output} == *"Checksum mismatch for lss-1.0.0.tar.gz"* ]] + [ ! -s "${GH_LOG}" ] + [ ! -e "${LSS_PREFIX}/1.0.0" ] +} + +@test "an archive with another version inside is refused" { + make_release 1.0.0 + mv "${RELEASES}/download/v1.0.0" "${RELEASES}/download/v1.0.1" + mv "${RELEASES}/download/v1.0.1/lss-1.0.0.tar.gz" "${RELEASES}/download/v1.0.1/lss-1.0.1.tar.gz" + (cd "${RELEASES}/download/v1.0.1" && sha256sum lss-1.0.1.tar.gz >SHA256SUMS) + cp "${RELEASES}/download/v1.0.1/lss-1.0.0.intoto.jsonl" "${RELEASES}/download/v1.0.1/lss-1.0.1.intoto.jsonl" + run bs_install 1.0.1 "${WORK}" + [ "${status}" -eq 1 ] + [[ ${output} == *"holds no lss-1.0.1/ directory"* ]] + [ ! -e "${LSS_PREFIX}/1.0.1" ] +} + +@test "a version that does not start leaves the active one in place" { + make_release 1.0.0 + bs_install 1.0.0 "${WORK}" + make_release 1.1.0 'broken' + run bs_install 1.1.0 "${WORK}" + [ "${status}" -eq 1 ] + [[ ${output} == *"lss 1.1.0 does not start ('broken'); 1.0.0 stays active."* ]] + [ "$(active)" = 1.0.0 ] + [ ! -e "${LSS_PREFIX}/previous" ] +} + +@test "a dry run verifies and changes nothing" { + make_release 1.0.0 + LSS_DRY_RUN=1 run bs_install '' "${WORK}" + [ "${status}" -eq 0 ] + [[ ${output} == *"Dry run: lss 1.0.0 verified; nothing was changed."* ]] + [ ! -e "${LSS_PREFIX}/1.0.0" ] +} + +@test "the latest version comes from the file list of the latest release" { + printf '%064d lss-2.3.4.tar.gz\n%064d vectode-lss-2.3.4.tar.gz\n%064d bootstrap.sh\n' 1 2 3 \ + >"${BATS_TEST_TMPDIR}/sums" + mkdir -p "${RELEASES}/latest/download" + cp "${BATS_TEST_TMPDIR}/sums" "${RELEASES}/latest/download/SHA256SUMS" + [ "$(bs_latest_version "${WORK}")" = 2.3.4 ] + printf '%064d other.tar.gz\n' 1 >"${RELEASES}/latest/download/SHA256SUMS" + run bs_latest_version "${WORK}" + [ "${status}" -eq 1 ] + [[ ${output} == *"names its version unclearly"* ]] +} + +@test "an lss checkout in the prefix must move first" { + mkdir -p "${LSS_PREFIX}" + : >"${LSS_PREFIX}/lss" + run bs_check_layout + [ "${status}" -eq 1 ] + [[ ${output} == *"holds an lss checkout"* ]] + rm "${LSS_PREFIX}/lss" + bs_check_layout +} + +@test "arguments: one version, or --rollback alone" { + run bs_main 1.0.0 1.1.0 + [[ ${output} == *"Name one version, not '1.0.0' and '1.1.0'."* ]] + run bs_main --rollback 1.0.0 + [[ ${output} == *"--rollback goes back to the previous version"* ]] + run bs_main --bogus + [[ ${output} == *"Unknown option '--bogus'"* ]] + run bs_main --help + [ "${status}" -eq 0 ] + [[ ${output} == *"Usage: sudo bash bootstrap.sh"* ]] + run bs_install 'latest' "${WORK}" + [[ ${output} == *"'latest' is a version like 2.0.1."* ]] +} + +@test "a second run waits for no one: the lock is taken once" { + bs_lock + ( + exec 9>&- + LSS_LOCK_HELD=0 run bs_lock + [ "${status}" -eq 1 ] + [[ ${output} == *"Another lss run is in progress."* ]] + ) + LSS_LOCK_HELD=1 bs_lock +} + +@test "bootstrap.sh output and errors never start with Not or No" { + run ! grep -nE 'bs_(info|ok|warn|die|log [A-Z]+) "(Not|No)[ t]' "${REPO_ROOT}/bootstrap.sh" +} diff --git a/tests/unit/update.bats b/tests/unit/update.bats new file mode 100644 index 0000000..8506b60 --- /dev/null +++ b/tests/unit/update.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# lss update: the command hands its version, --rollback and --dry-run to bootstrap.sh of the running version and +# tells it that the lock is held. bootstrap.sh itself is tested in bootstrap.bats. + +setup() { + bats_require_minimum_version 1.5.0 + REPO_ROOT=$(cd "${BATS_TEST_DIRNAME}/../.." && pwd) + export FAKE_ROOT=${BATS_TEST_TMPDIR}/root + export CALLS=${BATS_TEST_TMPDIR}/calls + mkdir -p "${FAKE_ROOT}" + printf '#!/usr/bin/env bash\nprintf "%%s|%%s\\n" "$*" "${LSS_LOCK_HELD:-}" >>"${CALLS}"\nexit "${BOOTSTRAP_EXIT:-0}"\n' \ + >"${FAKE_ROOT}/bootstrap.sh" + export LSS_JOURNAL=0 LSS_COLOR=never + return +} + +# update ARGS...: parses the arguments as lss does and runs `lss update` against the stub bootstrap.sh. +update() { + bash -c 'source "$1"; shift; parse_args "$@"; LSS_ROOT=${FAKE_ROOT}; cmd_update' _ "${REPO_ROOT}/lss" "$@" + return +} + +@test "lss update runs bootstrap.sh with the lock held" { + update update + [ "$(cat "${CALLS}")" = "|1" ] +} + +@test "lss update VERSION passes the version" { + update update 2.0.1 + [ "$(cat "${CALLS}")" = "2.0.1|1" ] +} + +@test "--rollback and --dry-run reach bootstrap.sh" { + update --dry-run update --rollback + [ "$(cat "${CALLS}")" = "--rollback --dry-run|1" ] +} + +@test "two versions are refused" { + run update update 2.0.1 2.0.2 + [ "${status}" -eq 1 ] + [[ ${output} == *"Usage: lss update [VERSION] | lss update --rollback"* ]] + [ ! -e "${CALLS}" ] +} + +@test "a failed update fails the command" { + BOOTSTRAP_EXIT=1 run update update + [ "${status}" -eq 1 ] +} + +@test "the help lists update, and lss runs only when executed" { + run bash "${REPO_ROOT}/lss" --help + [[ ${output} == *"update [VERSION]"* && ${output} == *"update --rollback"* ]] + run bash "${REPO_ROOT}/lss" version + [ "${output}" = "lss $(<"${REPO_ROOT}/VERSION")" ] + run bash -c 'source "$1"; echo sourced' _ "${REPO_ROOT}/lss" + [ "${output}" = sourced ] +} From e756b6f6c0474647bd2a5ad36e97f8aef0aff3e5 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 17:54:49 +0200 Subject: [PATCH 02/10] feat(ansible): collection vectode.lss with a role and a module Fleets are set up with Ansible more often than by hand. The role vectode.lss.lss copies bootstrap.sh to the target, so lss is installed only from a verified release, writes /etc/lss/lss.conf (0600) and installs or removes modules; the module vectode.lss.lss runs lss install or remove, reports a change only when lss changed something, and supports check mode through lss --dry-run. The collection is released with lss at the same version and installs exactly that version by default. tests/integration/ansible.sh runs the role over SSH against the systemd test containers: install, a second run without changes, an update of lss through the role, and a removal in check mode and for real. ansible-core and ansible-lint are pinned with hashes in tests/ansible/requirements.txt. Refs #9 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- ansible/.ansible-lint | 6 + ansible/CHANGELOG.md | 4 + ansible/README.md | 64 ++ ansible/galaxy.yml | 32 + ansible/meta/runtime.yml | 5 + ansible/plugins/modules/lss.py | 224 +++++++ ansible/roles/lss/defaults/main.yml | 16 + ansible/roles/lss/meta/argument_specs.yml | 38 ++ ansible/roles/lss/meta/main.yml | 17 + ansible/roles/lss/tasks/main.yml | 53 ++ ansible/roles/lss/templates/lss.conf.j2 | 7 + tests/ansible/requirements.in | 8 + tests/ansible/requirements.txt | 717 ++++++++++++++++++++++ tests/ansible/site.yml | 23 + tests/integration/ansible.sh | 73 +++ 15 files changed, 1287 insertions(+) create mode 100644 ansible/.ansible-lint create mode 100644 ansible/CHANGELOG.md create mode 100644 ansible/README.md create mode 100644 ansible/galaxy.yml create mode 100644 ansible/meta/runtime.yml create mode 100644 ansible/plugins/modules/lss.py create mode 100644 ansible/roles/lss/defaults/main.yml create mode 100644 ansible/roles/lss/meta/argument_specs.yml create mode 100644 ansible/roles/lss/meta/main.yml create mode 100644 ansible/roles/lss/tasks/main.yml create mode 100644 ansible/roles/lss/templates/lss.conf.j2 create mode 100644 tests/ansible/requirements.in create mode 100644 tests/ansible/requirements.txt create mode 100644 tests/ansible/site.yml create mode 100755 tests/integration/ansible.sh diff --git a/ansible/.ansible-lint b/ansible/.ansible-lint new file mode 100644 index 0000000..f99c9f1 --- /dev/null +++ b/ansible/.ansible-lint @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT +--- +# The strictest profile; CI runs ansible-lint in this directory (.github/workflows/distribution.yml). +profile: production diff --git a/ansible/CHANGELOG.md b/ansible/CHANGELOG.md new file mode 100644 index 0000000..26a7301 --- /dev/null +++ b/ansible/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +The collection `vectode.lss` is released together with lss and has its version. Its changes are listed in the +[changelog of lss](https://github.com/VectoDE/Linux-Server-Setup-Script/blob/main/CHANGELOG.md). diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..0325530 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,64 @@ +# Ansible collection vectode.lss + +Sets up hardened Linux servers with [lss](https://github.com/VectoDE/Linux-Server-Setup-Script) from Ansible. The +collection is built and attached with every release of lss and has its version: collection 2.0.1 installs lss 2.0.1. + +- Role `vectode.lss.lss`: installs lss on the target, writes `/etc/lss/lss.conf` and installs or removes modules. +- Module `vectode.lss.lss`: `lss install` and `lss remove` as a task, with check mode and a change only when lss + changed something. + +## Install + +Download `vectode-lss-.tar.gz` from the [release](https://github.com/VectoDE/Linux-Server-Setup-Script/releases), +check it, and install it: + +```bash +gh attestation verify vectode-lss-2.0.1.tar.gz --repo VectoDE/Linux-Server-Setup-Script \ + --cert-identity https://github.com/VectoDE/Linux-Server-Setup-Script/.github/workflows/release.yml@refs/heads/main +ansible-galaxy collection install vectode-lss-2.0.1.tar.gz +``` + +The role copies `bootstrap.sh` to the target, which installs lss only after the SHA-256 and the GitHub build +attestation of the release verify (it downloads the GitHub CLI at a pinned version and checksum for that). Targets +need Python 3, `curl` and access to github.com. Supported: Debian 12 and 13, Ubuntu 22.04, 24.04 and 26.04, RHEL, +Rocky Linux and AlmaLinux 9 and 10, on x86_64 and aarch64. + +## Use + +```yaml +- name: Hardened web server + hosts: web + become: true + roles: + - role: vectode.lss.lss + vars: + lss_profile: minimal-secure + lss_modules: [nginx, tls, vhost] + lss_settings: + LSS_ADMIN_USER_NAME: alice + LSS_ADMIN_USER_GITHUB_USER: alice-on-github + LSS_TLS_DOMAINS: www.example.com + LSS_VHOST_SITES: "site:static:www.example.com" +``` + +| Variable | Default | Meaning | +| -------------------- | ------------------------- | ------------------------------------------------------------- | +| `lss_version` | version of the collection | lss version to install; a different one updates or downgrades | +| `lss_settings` | `{}` | `/etc/lss/lss.conf` as `KEY: value` (mode 0600) | +| `lss_profile` | `""` | profile to install | +| `lss_modules` | `[]` | modules to install | +| `lss_modules_absent` | `[]` | modules to remove | +| `lss_mode` | `""` | `native` or `docker` for services that support both | + +The module on its own, once lss is installed: + +```yaml +- name: Remove Adminer and its data + vectode.lss.lss: + modules: [adminer] + state: absent + purge: true +``` + +`ansible-doc vectode.lss.lss` describes every option. A second run with the same settings reports no change; check +mode runs `lss --dry-run install`, which checks every setting without changing anything. diff --git a/ansible/galaxy.yml b/ansible/galaxy.yml new file mode 100644 index 0000000..ec5bed1 --- /dev/null +++ b/ansible/galaxy.yml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# The collection vectode.lss. scripts/release/build.sh builds it with every release, at the version of lss it +# installs; the release workflow raises "version" here together with VERSION. +--- +namespace: vectode +name: lss +version: "2.0.0-dev" +readme: README.md +authors: + - VectoDE <157636760+VectoDE@users.noreply.github.com> +description: Installs lss from a verified release and sets up hardened Linux servers with its modules. +license: + - MIT +tags: + - linux + - security + - hardening + - server + - infrastructure +dependencies: {} +repository: https://github.com/VectoDE/Linux-Server-Setup-Script +documentation: https://github.com/VectoDE/Linux-Server-Setup-Script/blob/main/ansible/README.md +homepage: https://github.com/VectoDE/Linux-Server-Setup-Script +issues: https://github.com/VectoDE/Linux-Server-Setup-Script/issues +build_ignore: + - .ansible + - .ansible-lint + - "*.pyc" + - __pycache__ diff --git a/ansible/meta/runtime.yml b/ansible/meta/runtime.yml new file mode 100644 index 0000000..b5de753 --- /dev/null +++ b/ansible/meta/runtime.yml @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT +--- +requires_ansible: ">=2.18.0" diff --git a/ansible/plugins/modules/lss.py b/ansible/plugins/modules/lss.py new file mode 100644 index 0000000..4603835 --- /dev/null +++ b/ansible/plugins/modules/lss.py @@ -0,0 +1,224 @@ +#!/usr/bin/python +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +"""Ansible module vectode.lss.lss: installs and removes lss modules through the lss command.""" + +from __future__ import annotations + +import os +import re + +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = r""" +--- +module: lss +short_description: Install and remove lss modules +version_added: "2.0.0" +description: + - Runs C(lss install) or C(lss remove) on the target, the command that sets up and hardens the server. + - Install lss first with the role M(vectode.lss.lss) or with C(bootstrap.sh), both of which verify the release. + - A module counts as changed when lss reports changes for it, so a second run with the same settings reports no change. +author: + - VectoDE (@VectoDE) +options: + modules: + description: + - lss modules to install or remove, such as V(nginx) or V(ssh-hardening). C(lss list) shows them all. + - Modules they require are installed with them. + type: list + elements: str + default: [] + profile: + description: + - Profile to install, such as V(minimal-secure). Only with O(state=present). + type: str + state: + description: + - V(present) installs the modules and applies their settings, V(absent) removes them. + type: str + choices: [present, absent] + default: present + settings: + description: + - Settings for this run, each passed as C(--set KEY=VALUE), on top of C(/etc/lss/lss.conf). + - Keys are named C(LSS__