diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..8fd2e66 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# The website: built from the module metadata by scripts/build-site.sh and held to Lighthouse 100 in every category +# on every pull request and push to main; deployed to GitHub Pages only from main. The page is never committed, so a +# new module is on the site with the next deploy. +name: Pages + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + # A deploy that has started finishes; pull request runs make way for newer commits. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + build: + name: Build and Lighthouse + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build + # Also fails when the README tables differ from data/systems.conf. + run: scripts/build-site.sh _site + - name: Lighthouse + uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # 12.6.2 + with: + configPath: site/lighthouserc.json + uploadArtifacts: true + temporaryPublicStorage: false + - name: Upload the site for Pages + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: _site + + deploy: + name: Deploy + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.gitignore b/.gitignore index a9ec1ec..66908fd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ # Local Claude Code data (worktrees, settings) .claude/ + +# Website build output (scripts/build-site.sh) +_site/ diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 21173e3..5620267 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MIT # SonarQube Cloud automatic analysis reads this file: https://sonarcloud.io/project/overview?id=VectoDE_Linux-Server-Setup-Script -sonar.sources=lss,lib,libexec,modules,scripts,install-configure-script.bash +sonar.sources=lss,lib,libexec,modules,scripts,site,install-configure-script.bash sonar.tests=tests # Accepted findings, each with its reason. diff --git a/CHANGELOG.md b/CHANGELOG.md index e5aa3d0..d65ac41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,10 @@ now or later. its results for Prometheus through the node exporter. - The services of the backup, monitoring and network modules run with a systemd sandbox drop-in; a service that listens on the WireGuard address starts after the interface. +- Website on GitHub Pages: lss explained as a film that plays while scrolling, generated from the module metadata, + the profiles and `data/systems.conf` on every deploy. A field of packets drawn on a canvas closes into a shield + while the module groups pass. Strict Content Security Policy, a still version for reduced motion and Lighthouse + 100 checked in CI. ### Removed diff --git a/README.md b/README.md index 5ffdbb8..6a986d9 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ row is the cost of the whole set, including the web server or PHP it needs. time and the peak memory go. Later runs reuse the build. The integration jobs print these figures as `RESOURCES` lines, so every new module and every new system adds its own. +The tables "Supported systems" and "System requirements" above come from `data/systems.conf`, which the website reads +too; CI fails when they differ. ## Quick start @@ -499,6 +501,7 @@ well, where an alert rule picks it up. - [Troubleshooting](docs/troubleshooting.md): locked out, failed modules, common messages - [Assurance case](docs/assurance.md): threat model, trust boundaries and how lss counters common weaknesses - [Writing a module](docs/module-guide.md) +- [Website](docs/website.md): the scroll film on GitHub Pages, built from the module files - [Changelog](CHANGELOG.md) ## Contributing and security diff --git a/REUSE.toml b/REUSE.toml index cac0518..fc09087 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -11,6 +11,7 @@ path = [ ".github/CODEOWNERS", ".github/allowed_signers", "templates/**", + "site/lighthouserc.json", "tests/fixtures/os-release/**", ] SPDX-FileCopyrightText = "2025-2026 VectoDE" diff --git a/data/systems.conf b/data/systems.conf new file mode 100644 index 0000000..9e537d9 --- /dev/null +++ b/data/systems.conf @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Supported systems and what lss needs to run on them. One source for the tables "Supported systems" and "System +# requirements" in README.md and for the website: scripts/build-site.sh renders the website from it, and +# `scripts/build-site.sh --check` (run in CI) fails when the README tables say something else. +# +# Fields are separated by "|". Text in backticks is shown as code. +# +# system||| +# arch| +# requirement|||| +# +# The requirement figures come from the RESOURCES lines of the integration jobs (tests/integration/run.sh): the +# largest value across the tested systems, rounded up. + +system|Debian|12, 13|12, 13 +system|Ubuntu|22.04, 24.04, 26.04|22.04, 24.04, 26.04 +system|Rocky Linux, AlmaLinux, RHEL|9, 10|Rocky 9, AlmaLinux 10 + +arch|x86_64 and arm64 (aarch64). CI runs on x86_64: on every pull request it installs, verifies, installs again and removes every module on each tested system. + +requirement|Profile `minimal-secure`|1 GB; 2 GB on the RHEL family|4 GB|3 to 5 min plus AIDE +requirement|`minimal-secure` with `crowdsec`|1.5 GB; 2 GB on the RHEL family|4.5 GB|about 30 s more +requirement|`minimal-secure` with `clamav`|2.5 GB|4.5 GB|about 1 min more +requirement|Profile `office`|1.5 GB; 2 GB on the RHEL family|10 GB|9 to 16 min +requirement|Profile `devops`|1 GB; 1.5 GB on the RHEL family|7 GB|6 to 11 min diff --git a/docs/website.md b/docs/website.md new file mode 100644 index 0000000..cf2451b --- /dev/null +++ b/docs/website.md @@ -0,0 +1,108 @@ +# Website + +The website explains lss as a film that plays while you scroll: every scene sticks to the screen while its lines +appear, its list rolls past or its items change, with a caption at the bottom. It is one static page on GitHub +Pages, built from the repository on every deploy. + +## What it is built from + +`scripts/build-site.sh` writes the page and reads everything it shows from the repository: + +| On the page | Source | +| ------------------------------------- | ------------------------------------------------------------------------ | +| One scene per module group | `MOD_NAME`, `MOD_GROUP`, `MOD_DESCRIPTION`, `MOD_CONFLICTS` and `MOD_OPTIONS` of every module (sourced in a subshell, like `lss info`) | +| The order of an example run | `MOD_REQUIRES` | +| Profiles | `profiles/*.conf` (`# Profile:` line and `LSS_MODULES`) | +| Guard time and thresholds | the defaults in `MOD_OPTIONS` of `guard` | +| `lss --help` | the `usage` function of `lss` | +| The menu | `ui_menu` in `lib/ui.sh` | +| Supported systems, requirements | `data/systems.conf` | +| Version | `VERSION` | + +A new module, group or profile is on the site with the next deploy; nobody edits the page. A group the generator +does not know yet gets a scene with a plain caption after the known groups. The captions of the known groups, the +security model and the verified install are written in the generator. + +`data/systems.conf` is also the source of the tables "Supported systems" and "System requirements" in the README: +`scripts/build-site.sh --check` (part of every build, so part of CI) fails when they say something else. Change +both together. + +## Build and preview + +```bash +scripts/build-site.sh _site # writes _site/index.html, film.js, favicon.svg and robots.txt +python3 -m http.server -d _site 8000 # any static server will do +``` + +`_site/` is not committed. The page is generated in the Pages workflow (`.github/workflows/pages.yml`), so it +cannot fall behind the modules, and a pull request shows the result of its own changes. + +## Motion, keyboard and screen readers + +Every word of every scene is in the HTML from the start. `site/film.js` only moves and marks it: it sets the scene +counter, shows lines, rolls the stage and highlights the current item. What moves while you scroll: + +| Scene | Motion | +| ---------------------- | ---------------------------------------------------------------------------------------- | +| Opening | The title steps back and shrinks; the counts of modules, groups, profiles and tested systems run up from 0 once after loading | +| Every scene | The words of the heading rise out of their masks as it scrolls in; the caption comes in word by word (opacity and blur) | +| A run | Log lines appear one by one, the newest marked, and the log rolls like a terminal | +| Profiles, menu | One profile or menu item at a time | +| Module groups | The list rolls past and the current module is marked | +| Run to last group | Packets drifting across the screen leave their lines in random order and close into a shield with "lss" cut out | +| Guard | A scan line sweeps over the shield | +| After the guard | The field fades out and stops drawing | + +The field is a 2D canvas behind the text, drawn by `film.js` without a library. It starts after the page has +loaded, stops in hidden tabs and once it has faded out, and draws at most at 1.5 times the CSS resolution. On +screens narrower than 900 px the shield sits behind the text and is drawn at half the strength. The scatter of the +packets comes from a fixed seed, so the page looks the same on every visit. The heading masks use CSS +scroll-driven animations (`animation-timeline: view()`) where the browser has them; elsewhere `film.js` raises them +when the heading comes into view, and without the script they stay where they are. Scrolling is the browser's own: +nothing takes over the wheel, keys, find in page or anchors. The film needs motion allowed +(`prefers-reduced-motion: no-preference`), scripting and a screen at least 30rem tall. Otherwise the same scenes +are a still sequence of full-screen frames, with nothing hidden. + +The chapter list in the header jumps to every scene, a skip link leads to the film, and focus is always visible. +Decoration (scene numbers on the splice line, the counter, the progress bar) is hidden from screen readers; the +headings, captions, lists and tables are plain HTML. + +Colour has one meaning: yellow marks what lss is doing right now, red what it refuses or what failed. The palette +is checked against WCAG AA in light and dark mode. The fonts are the system's own monospace and sans-serif. + +## Content Security Policy and headers + +The page loads nothing from other servers: no fonts, no analytics, no cookies. Its policy, set by a meta tag: + +```text +default-src 'none'; script-src 'self'; style-src 'sha256-'; img-src 'self'; +connect-src 'self'; base-uri 'none'; form-action 'none' +``` + +The page itself makes no requests after loading; `connect-src 'self'` is there for tools that fetch `robots.txt` +of the same site from within the page, such as Lighthouse. + +The stylesheet is inlined to save a request and allowed by its SHA-256, which the generator computes on every +build; there are no style attributes and no inline scripts. GitHub Pages cannot set response headers. A host that +can should send these as well (a meta tag cannot carry `frame-ancestors`): + +```text +Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'sha256-...'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' +X-Content-Type-Options: nosniff +Referrer-Policy: no-referrer +Permissions-Policy: camera=(), microphone=(), geolocation=() +Cross-Origin-Opener-Policy: same-origin +``` + +## Checks + +The Pages workflow builds the page and runs Lighthouse (three runs, mobile settings) on every pull request and push +to `main`. It fails below 100 in any of performance, accessibility, best practices and SEO. The unit tests in +`tests/unit/site.bats` check that every module, group and profile is on the page, that the policy hash matches the +stylesheet and that the page has no external resources. + +## Deploy + +Only a push to `main` deploys, through `actions/upload-pages-artifact` and `actions/deploy-pages` with +`pages: write` and `id-token: write` for that job alone. GitHub Pages has to be enabled once in the repository +settings with the source "GitHub Actions". diff --git a/scripts/build-site.sh b/scripts/build-site.sh new file mode 100755 index 0000000..97481ea --- /dev/null +++ b/scripts/build-site.sh @@ -0,0 +1,758 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Builds the website, a film of full-screen scenes that plays while scrolling: +# +# scripts/build-site.sh [OUT_DIR] write index.html, film.js, favicon.svg and robots.txt to OUT_DIR (default _site) +# scripts/build-site.sh --check only compare the README tables with data/systems.conf +# +# The text comes from the repository itself: MOD_NAME, MOD_GROUP, MOD_DESCRIPTION, MOD_REQUIRES, MOD_CONFLICTS and +# MOD_OPTIONS of every module, the profiles, data/systems.conf, the help text of lss and the menu items of lib/ui.sh. +# The Pages workflow runs this on every deploy and the page is never committed, so a new module or profile is on the +# site with the next deploy. The stylesheet is inlined and allowed by its SHA-256 in the Content Security Policy; +# film.js is the only script. +set -Eeuo pipefail +shopt -s inherit_errexit +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +SITE_SRC=site +SYSTEMS=data/systems.conf +REPO_URL=https://github.com/VectoDE/Linux-Server-Setup-Script +read -r LSS_SITE_VERSION &2 + exit 1 +} + +# The text helpers assign to a variable named by the caller instead of printing: every command substitution costs a +# fork, and the page has a few thousand values. + +# esc VAR TEXT: VAR is TEXT with the HTML special characters escaped. The quoted replacements keep "&" literal in +# bash 5.2. +esc() { + local -n _esc_out=$1 + local _esc_text=$2 + _esc_text=${_esc_text//'&'/'&'} + _esc_text=${_esc_text//'<'/'<'} + _esc_text=${_esc_text//'>'/'>'} + _esc_text=${_esc_text//'"'/'"'} + _esc_out=${_esc_text} + return +} + +# inline VAR TEXT: VAR is the escaped TEXT with `code` spans as . +inline() { + local -n _inline_out=$1 + local _inline_source=$2 + # shellcheck disable=SC2016 # literal backticks, nothing to expand + local _inline_text _inline_html="" _inline_re='^([^`]*)`([^`]*)`(.*)$' + esc _inline_text "${_inline_source}" + while [[ ${_inline_text} =~ ${_inline_re} ]]; do + _inline_html+="${BASH_REMATCH[1]}${BASH_REMATCH[2]}" + _inline_text=${BASH_REMATCH[3]} + done + _inline_out=${_inline_html}${_inline_text} + return +} + +# --- sources ------------------------------------------------------------------------------------------------------ + +# Prints the metadata of every module, read the way lss reads it (module_meta): this runs in a subshell, which sources +# the module files one after the other, so nothing of them reaches the rest of the script. Records end with \x1e, +# fields are separated by \x1f. +read_metadata() { + set +eu + export LSS_ROOT=${PWD} + local file + for file in modules/*/*.sh; do + unset MOD_NAME MOD_GROUP MOD_DESCRIPTION MOD_REQUIRES MOD_CONFLICTS MOD_OPTIONS + # shellcheck source=/dev/null + source "${file}" >/dev/null 2>&1 + printf '%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e' "${file}" "${MOD_NAME-}" "${MOD_GROUP-}" \ + "${MOD_DESCRIPTION-}" "${MOD_REQUIRES-}" "${MOD_CONFLICTS-}" "${MOD_OPTIONS-}" + done + return 0 +} + +load_modules() { + local file name group desc requires conflicts options expected + while IFS=$'\x1f' read -r -d $'\x1e' file name group desc requires conflicts options; do + expected=${file##*/} + [[ ${name} == "${expected%.sh}" ]] || fail "${file}: MOD_NAME is '${name}'." + expected=${file%/*} + [[ ${group} == "${expected##*/}" ]] || fail "${file}: MOD_GROUP is '${group}'." + [[ -n ${desc} ]] || fail "${file}: MOD_DESCRIPTION is empty." + MODULES+=("${name}") + M_GROUP[${name}]=${group} + M_DESC[${name}]=${desc} + M_REQUIRES[${name}]=${requires} + M_CONFLICTS[${name}]=${conflicts} + M_OPTIONS[${name}]=${options} + done < <(read_metadata) + [[ ${#MODULES[@]} -gt 0 ]] || fail "modules/ has an empty module list." + local -A present=() + for name in "${MODULES[@]}"; do + present[${M_GROUP[${name}]}]=1 + done + for group in ${GROUP_ORDER}; do + if [[ -n ${present[${group}]-} ]]; then + GROUPS_SORTED+=("${group}") + unset 'present[${group}]' + fi + done + mapfile -t -O "${#GROUPS_SORTED[@]}" GROUPS_SORTED < <(printf '%s\n' "${!present[@]}" | sed '/^$/d' | sort) + return +} + +# option_default VAR MODULE OPTION: VAR is the default value of OPTION from MOD_OPTIONS. +option_default() { + local -n _default_out=$1 + local _od_module=$2 _od_option=$3 _od_name _od_default _ + while IFS='|' read -r _od_name _od_default _; do + if [[ ${_od_name} == "${_od_option}" ]]; then + _default_out=${_od_default} + return 0 + fi + done <<<"${M_OPTIONS[${_od_module}]}" + fail "${_od_module} has the option '${_od_option}' missing from MOD_OPTIONS." +} + +# install_order VAR MODULE...: VAR is the list of the modules with their requirements first, like module_resolve +# (the first of alternatives). +install_order() { + local -n _order_out=$1 + shift + local -A _seen=() + local -a _order=() + _visit() { + local name=$1 requirement + [[ -z ${_seen[${name}]-} ]] || return 0 + _seen[${name}]=1 + for requirement in ${M_REQUIRES[${name}]-}; do + _visit "${requirement%%|*}" + done + _order+=("${name}") + return + } + local name + for name in "$@"; do + _visit "${name}" + done + _order_out=${_order[*]} + return +} + +# profiles: "name|description|modules" per profile, the smallest first. +profiles() { + local file description modules name + local -a words=() + for file in profiles/*.conf; do + description=$(sed -n 's/^# Profile: //p' "${file}") + modules=$(sed -n 's/^LSS_MODULES=//p' "${file}") + [[ -n ${description} && -n ${modules} ]] || fail "${file} needs '# Profile:' and LSS_MODULES." + read -ra words <<<"${modules}" + name=${file##*/} + printf '%s %s|%s|%s\n' "${#words[@]}" "${name%.conf}" "${description}" "${modules}" + done | sort -n | cut -d' ' -f2- + return +} + +# The help text of `lss --help`, from the usage function of lss. +help_text() { + local text + text=$(sed -n '/^usage() {/,/^}/p' lss | sed -n '/cat < 2) print; next } + inside && rows > 0 { exit } + ' README.md | sed -e 's/^|[[:space:]]*//' -e 's/[[:space:]]*|[[:space:]]*$//' -e 's/[[:space:]]*|[[:space:]]*/|/g' + return +} + +check_readme() { + local status=0 + if ! diff <(systems_rows system) <(readme_rows "Supported systems") >&2; then + printf 'build-site: README.md "Supported systems" differs from the system lines of %s (above).\n' "${SYSTEMS}" >&2 + status=1 + fi + if ! diff <(systems_rows requirement) <(readme_rows "System requirements") >&2; then + printf 'build-site: README.md "System requirements" differs from the requirement lines of %s (above).\n' "${SYSTEMS}" >&2 + status=1 + fi + return "${status}" +} + +# --- scenes ------------------------------------------------------------------------------------------------------- + +# list_close TAG: the end of an ol or ul. +list_close() { + local tag=$1 + printf '\n' "${tag}" + return +} + +# scene_open ID TITLE KIND LENGTH: starts a scene. KIND tells film.js what the progress drives (steps: lines appear, +# roll: the stage rolls past, switch: one item at a time, hero: the title steps back, still: the stage only rolls +# when it is taller than the screen). LENGTH is the scroll length in screens, 1 to 6. +scene_open() { + local id=$1 title=$2 kind=$3 length=$4 number html + if ((length > 6)); then + length=6 + fi + SCENE_IDS+=("${id}") + SCENE_TITLES+=("${title}") + printf -v number '%02d' "${#SCENE_IDS[@]}" + esc html "${title}" + printf '
\n' \ + "${length}" "${id}" "${kind}" "${html}" "${id}" + printf '
\n\n' \ + "${number}" "${html}" + return +} + +# scene_close CAPTION: the caption (subtitle) of the scene and the end of it. +scene_close() { + local caption=$1 + printf '

%s

\n
\n
\n' "${caption}" + return +} + +stage_open() { + printf '
\n
\n' + return +} + +stage_close() { + printf '
\n
\n' + return +} + +# heading ID TEXT [PREFIX]: the h2 of a scene, every word in a mask of its own so that it can rise into view. PREFIX +# is shown muted in front of the text, like the "modules/" of a group. +heading() { + local id=$1 text=$2 prefix=${3:-} word html + local -a words=() + printf '

' "${id}" + if [[ -n ${prefix} ]]; then + esc html "${prefix}" + printf '%s' "${html}" + fi + read -ra words <<<"${text}" + for word in "${words[@]}"; do + esc html "${word}" + printf '%s ' "${html}" + done + printf '

\n' + return +} + +# count_tested: the number of releases data/systems.conf lists as tested in CI. +count_tested() { + local _distribution _releases tested count=0 + local -a releases=() + while IFS='|' read -r _distribution _releases tested; do + IFS=',' read -ra releases <<<"${tested}" + count=$((count + ${#releases[@]})) + done < <(systems_rows system) + printf '%s' "${count}" + return +} + +# counter NUMBER LABEL: one figure of the opening. +counter() { + local number=$1 label=$2 + printf '
%s
%s
\n' "${label}" "${number}" "${number}" + return +} + +scene_hero() { + local -a profile_files=(profiles/*.conf) + scene_open top "lss" hero 2 + cat <<'EOF' +

lssLinux Server Setup

+EOF + stage_open + cat <<'EOF' +

sudo lss install --profile minimal-secure

+

Sets up and hardens Linux servers from modules. Install a whole profile in one run, or only the +modules you need, and come back later to add, change or remove one.

+EOF + # Read from the repository on every build. The order of dt and dd is swapped visually, the number comes first. + printf '
\n' + counter "${#MODULES[@]}" modules + counter "${#GROUPS_SORTED[@]}" groups + counter "${#profile_files[@]}" profiles + counter "$(count_tested)" "systems tested in CI" + printf '
\n' + stage_close + scene_close "Scroll to play the film. Every scene is plain text, so it reads the same with motion turned off." + return +} + +log_line() { + local source_level=$1 source_text=$2 class=${3:-} level text + esc level "${source_level}" + inline text "${source_text}" + printf '
  • %s%s
  • \n' "${class:+ ${class}}" "${level}" "${text}" + return +} + +scene_run() { + local order name last + install_order order firewall fail2ban + last=${order##* } + scene_open run "A run" steps 3 + heading run "One module at a time" + stage_open + printf '
      \n' + log_line '$' 'sudo lss install firewall fail2ban' cmdline + log_line INFO "Modules in order: ${order}" + for name in ${order}; do + log_line '==>' "${name}" + if [[ ${name} == "${last}" ]]; then + log_line OK "${name}: done (6 changes)" ok + elif [[ ${name} == system ]]; then + log_line OK "${name}: unchanged" ok + else + log_line OK "${name}: done (3 changes)" ok + fi + done + log_line OK 'Finished. Log: /var/log/lss/.log' ok + printf '
    \n

    When a step fails

    \n
      \n' + log_line '==>' "${last}" + log_line ERROR "${last}: verification failed; changed files were restored. Details: /var/log/lss/.log" deny + list_close ol + stage_close + scene_close "Each module checks its settings before it changes anything. When a step fails, lss puts back every +file that module touched in the run. Later, lss verify shows whether it still works." + return +} + +scene_profiles() { + local -a rows=() + local row name description modules html + local -a words=() + mapfile -t rows < <(profiles) + scene_open profiles "Profiles" switch "${#rows[@]}" + heading profiles "${#rows[@]} profiles" + stage_open + printf '
      \n\n
      \n' + for row in "${rows[@]}"; do + IFS='|' read -r name description modules <<<"${row}" + esc name "${name}" + inline description "${description}" + esc modules "${modules}" + read -ra words <<<"${modules}" + # One span per module keeps "kernel-hardening" on one line. + printf -v modules '%s ' "${words[@]}" + printf '
      \n

      %s

      \n

      %s

      \n' "${name}" "${description}" + printf '

      %s modules, in this order

      \n

      %s

      \n
      \n' \ + "${#words[@]}" "${modules}" + done + printf '
      \n
      \n' + stage_close + scene_close "A profile is a list of modules for one kind of server. Start from one, then add or remove single +modules with lss install and lss remove." + return +} + +group_caption() { + local group=$1 + case ${group} in + base) printf 'The ground floor: host name, locale and updates, time over NTS, swap, the administrator account, tools and a persistent journal.' ;; + security) printf 'The hardening and the checks that keep it in place. One of fail2ban and CrowdSec; ssh-2fa, clamav and usbguard are opt-in.' ;; + web) printf 'One web server owns ports 80 and 443, HTTPS only, with certificates from Let's Encrypt and sites from a single setting.' ;; + runtime) printf 'Language runtimes from the distribution or the vendor, each in the version you pick.' ;; + database) printf 'Every database listens on 127.0.0.1 with hardened authentication until you open it.' ;; + container) printf 'Docker or Podman, Portainer, or single-server Kubernetes with k3s. Published ports pass a firewall chain of their own.' ;; + backup) printf 'Encrypted daily backups with restic, database dumps before each run and a restore test every week.' ;; + monitoring) printf 'Metrics, logs and alerts with Prometheus, Grafana, Loki and Alloy, or Netdata on its own.' ;; + network) printf 'A WireGuard VPN for the admin interfaces, a validating DNS resolver and a time server.' ;; + mail) printf 'The server sends its own mail through a relay, or runs a mail server for your domains with webmail.' ;; + apps) printf 'Self-hosted apps as Docker Compose stacks behind the web server, each on its own host name.' ;; + *) printf 'The modules in modules/%s.' "${group}" ;; + esac + return +} + +scene_group() { + local group=$1 name option default _ count noun html text + local -a names=() + for name in "${MODULES[@]}"; do + [[ ${M_GROUP[${name}]} != "${group}" ]] || names+=("${name}") + done + count=${#names[@]} + scene_open "group-${group}" "modules/${group}" roll "$((2 + (count + 3) / 4))" + heading "group-${group}" "${group}" modules/ + noun=modules + if ((count == 1)); then + noun=module + fi + printf '

      %s %s

      \n' "${count}" "${noun}" + stage_open + printf '
        \n' + for name in "${names[@]}"; do + inline text "${M_DESC[${name}]}" + printf '
      • \n%s\n%s\n' "${name}" "${text}" + if [[ -n ${M_CONFLICTS[${name}]} ]]; then + esc text "${M_CONFLICTS[${name}]// /, }" + printf 'Instead of %s\n' "${text}" + fi + if [[ -n ${M_OPTIONS[${name}]} ]]; then + printf '' + while IFS='|' read -r option default _; do + [[ -n ${option} ]] || continue + text="LSS_${name}_${option}" + text=${text//-/_} + esc html "${default}" + printf '%s=%s ' "${text^^}" "${html}" + done <<<"${M_OPTIONS[${name}]}" + printf '\n' + fi + printf '
      • \n' + done + list_close ul + stage_close + scene_close "$(group_caption "${group}")" + return +} + +rule_row() { + local first=$1 second=$2 third=$3 class=${4:-} + printf '%s%s%s\n' "${class:+ ${class}}" "${first}" "${second}" "${third}" + return +} + +scene_security() { + scene_open security "Security model" steps 3 + heading security "Closed by default" + stage_open + cat <<'EOF' + + + + +EOF + rule_row 22/tcp allow "ssh-hardening: keys only, no root, no passwords" + rule_row "80/tcp 443/tcp" allow "nginx: HTTPS only, TLS 1.2 and 1.3" + rule_row 8090/tcp 127.0.0.1 "admin port of the web server, through an SSH tunnel or WireGuard" + rule_row 3306/tcp 127.0.0.1 "mariadb until LSS_MARIADB_ACCESS opens it" + rule_row "everything else" drop "firewall, with a timer that turns it off again if SSH stops answering" deny + printf '\n
        Inbound rules of a web server with the profile webhosting
        PortRuleWhy
        \n' + cat <<'EOF' +

        Modules never open ports themselves; they ask lss, which records the rule per module and closes it +when the module goes. The kernel is locked down, AppArmor or SELinux enforces, auditd records changes to accounts, +sudo and the network, and AIDE checks the files every day. The settings file is parsed and never run, and secrets +are files only root can read.

        +EOF + stage_close + scene_close "Inbound traffic stays closed except SSH and the ports of installed modules. Databases and admin +interfaces listen on 127.0.0.1 or the WireGuard network." + return +} + +scene_guard() { + local time threshold max_age + option_default time guard time + option_default threshold guard login_threshold + option_default max_age guard backup_max_age + [[ ${time} =~ ^[0-9:]+$ ]] || fail "guard: the default time '${time}' is not HH:MM." + scene_open guard "Guard" steps 3 + heading guard "Every morning at ${time}" + stage_open + printf '
        # systemctl cat lss-guard.timer\n[Timer]\nOnCalendar=*-*-* %s:00\nRandomizedDelaySec=10min
        \n' \ + "${time}" + printf '
          \n' + # shellcheck disable=SC2016 # backticks mark code + log_line verify 'every installed module, `lss verify`' ok + log_line repair 'nginx: a managed file was changed outside lss, configured again' + log_line report '3 security updates pending, reboot needed' + log_line report "71 failed SSH logins in 24 hours (more than ${threshold}), most from 203.0.113.0/24" deny + log_line report "last backup 6 hours ago (limit ${max_age} hours), restore test passed" + log_line sent 'webhook https://ntfy.sh/your-topic' + list_close ol + stage_close + scene_close "The guard module checks the server every day, repairs modules that drifted, and sends what +it found to a webhook or by e-mail. Failed services, full disks, vulnerable packages and container images are in the +report too." + return +} + +scene_install() { + scene_open install "Verified install" steps 3 + heading install "Verified before it runs" + stage_open + printf '
            \n' + log_line '$' 'gh release download -R VectoDE/Linux-Server-Setup-Script --pattern bootstrap.sh' cmdline + log_line '$' "gh attestation verify bootstrap.sh --repo VectoDE/Linux-Server-Setup-Script --cert-identity ${REPO_URL}/.github/workflows/release.yml@refs/heads/main" cmdline + log_line '$' 'sudo bash bootstrap.sh' cmdline + log_line OK 'SHA-256 of the release archive matches SHA256SUMS' ok + log_line OK 'attestation: built by release.yml from refs/heads/main on a GitHub-hosted runner' ok + log_line OK 'installed to /opt/lss/; /opt/lss/current points to it' ok + list_close ol + stage_close + scene_close "bootstrap.sh runs nothing it downloaded until the checksum matches and the Sigstore build +attestation proves the release workflow of this repository built it. lss update takes the same path, and +--rollback switches back." + return +} + +scene_cli() { + scene_open cli "Commands" roll 3 + heading cli "lss --help" + stage_open + local text + esc text "$(help_text)" + printf '
            %s
            \n' "${text}" + stage_close + scene_close "Settings are KEY=value lines in /etc/lss/lss.conf, named +LSS_<MODULE>_<OPTION>. --dry-run shows what a command would do." + return +} + +scene_menu() { + local -a items=() + local item tag text prompt + mapfile -t items < <(menu_items) + scene_open menu "Menu" switch "${#items[@]}" + heading menu "Or the menu" + stage_open + esc prompt "$(menu_prompt)" + esc text "${LSS_SITE_VERSION}" + printf '
            \n

            lss %s

            \n

            %s

            \n' "${text}" "${prompt}" + printf '
              \n' + for item in "${items[@]}"; do + esc tag "${item%%|*}" + esc text "${item#*|}" + printf '
            • %s%s
            • \n' "${tag}" "${text}" + done + printf '
            \n\n
            \n' + stage_close + scene_close "Without a command, sudo lss opens this menu. Choosing modules there runs the same install +as the command line." + return +} + +scene_systems() { + local distribution releases tested setup ram disk duration arch + scene_open systems "Systems" still 2 + heading systems "Where it runs" + stage_open + printf '\n\n' + printf '\n\n' + while IFS='|' read -r distribution releases tested; do + inline distribution "${distribution}" + inline releases "${releases}" + inline tested "${tested}" + printf '\n' "${distribution}" "${releases}" "${tested}" + done < <(systems_rows system) + inline arch "$(systems_rows arch)" + printf '\n
            Supported systems
            DistributionReleasesTested in CI
            %s%s%s
            \n

            %s

            \n' "${arch}" + printf '\n\n' + printf '\n\n' + while IFS='|' read -r setup ram disk duration; do + inline setup "${setup}" + inline ram "${ram}" + inline disk "${disk}" + inline duration "${duration}" + printf '\n' "${setup}" "${ram}" "${disk}" "${duration}" + done < <(systems_rows requirement) + printf '\n
            System requirements
            SetupRAMFree diskInstall time
            %s%s%s%s
            \n' + stage_close + scene_close "One CPU core is enough. The figures are the largest the integration tests measured on any tested +system; the README has them for every module." + return +} + +help_link() { + local path=$1 name=$2 what=$3 + printf '
          1. %s %s
          2. \n' "${REPO_URL}" "${path}" "${name}" "${what}" + return +} + +scene_help() { + scene_open help "Help" still 1 + heading help "Help" + stage_open + cat <<'EOF' +

            lss info guard

            +

            On the server, lss info MODULE shows what a module does, what it needs, its settings and +their defaults.

            +