From b630b736e9bfb95c43433e3ee66179d780b873f9 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Fri, 16 Oct 2026 17:57:44 +0200 Subject: [PATCH] feat(site): explain lss as a film that plays while scrolling The website for issue #10. scripts/build-site.sh writes one static page from the repository itself: a scene per module group from MOD_NAME, MOD_GROUP, MOD_DESCRIPTION, MOD_CONFLICTS and MOD_OPTIONS, the profiles, the guard defaults, the help text of lss, the whiptail menu and data/systems.conf. The Pages workflow runs it on every deploy and the page is never committed, so a module added later (Traefik, say) is on the site without anyone touching it. Scrolling scrubs each scene: log lines appear like a run of lss, module lists roll past, profiles and menu items switch one at a time. Every word is in the HTML from the start; with reduced motion, without scripting or on a short screen the same scenes are a still sequence. No framework, no fonts or requests from elsewhere, a strict Content Security Policy with the inlined stylesheet allowed by its hash. Lighthouse runs in CI and fails below 100 in any category; deploy runs only on main. data/systems.conf becomes the one source of the supported systems and the resource figures; the build fails when the README tables say something else. Refs #10 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/pages.yml | 68 ++++ .gitignore | 3 + .sonarcloud.properties | 2 +- CHANGELOG.md | 3 + README.md | 3 + REUSE.toml | 1 + data/systems.conf | 28 ++ docs/website.md | 89 ++++ scripts/build-site.sh | 685 +++++++++++++++++++++++++++++++ site/favicon.svg | 11 + site/film.js | 164 ++++++++ site/lighthouserc.json | 16 + site/robots.txt | 6 + site/styles.css | 788 ++++++++++++++++++++++++++++++++++++ tests/unit/site.bats | 125 ++++++ 15 files changed, 1991 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pages.yml create mode 100644 data/systems.conf create mode 100644 docs/website.md create mode 100755 scripts/build-site.sh create mode 100644 site/favicon.svg create mode 100644 site/film.js create mode 100644 site/lighthouserc.json create mode 100644 site/robots.txt create mode 100644 site/styles.css create mode 100644 tests/unit/site.bats 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..ee0acbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,9 @@ 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, with a 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..91b079d --- /dev/null +++ b/docs/website.md @@ -0,0 +1,89 @@ +# 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. 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..11265f2 --- /dev/null +++ b/scripts/build-site.sh @@ -0,0 +1,685 @@ +#!/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 + # shellcheck disable=SC2016 # literal backticks, nothing to expand + local _inline_text _inline_html="" _inline_re='^([^`]*)`([^`]*)`(.*)$' + esc _inline_text "$2" + 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_name _od_default _ + while IFS='|' read -r _od_name _od_default _; do + if [[ ${_od_name} == "$3" ]]; then + _default_out=${_od_default} + return 0 + fi + done <<<"${M_OPTIONS[$2]}" + fail "$2 has the option '$3' 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 ------------------------------------------------------------------------------------------------------- + +# 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, 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() { + printf '

%s

\n
\n
\n' "$1" + return +} + +stage_open() { + printf '
\n
\n' + return +} + +stage_close() { + printf '
\n
\n' + return +} + +scene_hero() { + scene_open top "lss" still 1 + 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 + 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 level text class=${3:-} + esc level "$1" + inline text "$2" + 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 + printf '

    One module at a time

    \n' + 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 + printf '
    \n' + 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[@]}" + printf '

    %s profiles

    \n' "${#rows[@]}" + 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() { + case $1 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.' "$1" ;; + 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))" + printf '

    modules/%s

    \n' "${group}" "${group}" + 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 + printf '
    \n' + stage_close + scene_close "$(group_caption "${group}")" + return +} + +rule_row() { + printf '%s%s%s\n' "${4:+ $4}" "$1" "$2" "$3" + return +} + +scene_security() { + scene_open security "Security model" steps 3 + printf '

    Closed by default

    \n' + 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 + printf '

    Every morning at %s

    \n' "${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' + printf '
    \n' + 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 + printf '

    Verified before it runs

    \n' + 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 + printf '
    \n' + 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 + printf '

    lss --help

    \n' + 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[@]}" + printf '\n' + 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 + printf '

    Where it runs

    \n' + 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() { + printf '
  • %s %s
  • \n' "${REPO_URL}" "$1" "$2" "$3" + return +} + +scene_help() { + scene_open help "Help" still 1 + printf '

    Help

    \n' + 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.

    +\n' + stage_close + scene_close "lss is free software under the MIT license, maintained in spare time." + return +} + +# --- page --------------------------------------------------------------------------------------------------------- + +chapters() { + local index html + printf '
      \n' + for index in "${!SCENE_IDS[@]}"; do + esc html "${SCENE_TITLES[${index}]}" + printf '
    1. %s
    2. \n' "${SCENE_IDS[${index}]}" "${html}" + done + printf '
    \n' + return +} + +build() { + local out=$1 body body_file css hash group + local file + for file in styles.css film.js favicon.svg robots.txt; do + [[ -f ${SITE_SRC}/${file} ]] || fail "${SITE_SRC}/${file} is missing." + done + load_modules + # The scenes run in this shell, not in a command substitution, so SCENE_IDS and SCENE_TITLES survive for the + # chapter list. + body_file=$(mktemp) + trap 'rm -f -- "${body_file}"' RETURN + { + scene_hero + scene_run + scene_profiles + for group in "${GROUPS_SORTED[@]}"; do + scene_group "${group}" + done + scene_security + scene_guard + scene_install + scene_cli + scene_menu + scene_systems + scene_help + } >"${body_file}" + body=$(<"${body_file}") + [[ $(grep -c '^
    + + + + + + + + + +lss: hardened Linux servers, one module at a time + + + + + + + +
    +lss + + +
    +
    +${body} +
    +
    +

    lss ${LSS_SITE_VERSION}, MIT license. Source on GitHub.

    +

    This page is built from the module files of the repository on every deploy. It loads nothing from other servers +and sets no cookies.

    +
    + + + +EOF + } >"${out}/index.html" + cp "${SITE_SRC}/film.js" "${SITE_SRC}/favicon.svg" "${SITE_SRC}/robots.txt" "${out}/" + printf 'build-site: %s/index.html with %s scenes and %s modules\n' "${out}" "${#SCENE_IDS[@]}" "${#MODULES[@]}" + return +} + +case ${1:-} in +--check) check_readme ;; +-h | --help) sed -n '7,9p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,3\}//' ;; +-*) fail "unknown option '$1'." ;; +*) + check_readme + build "${1:-_site}" + ;; +esac diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..695377c --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/site/film.js b/site/film.js new file mode 100644 index 0000000..fe306c3 --- /dev/null +++ b/site/film.js @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2025-2026 VectoDE +// +// SPDX-License-Identifier: MIT + +// Plays the film. Scrolling through a scene moves its progress from 0 to 1, and the progress decides which lines are +// shown (steps), how far the stage has rolled (roll) and which item is current (switch). Every word is in the page +// from the start; this script only moves and marks it. With reduced motion, without scripting or on a short screen +// the stylesheet shows the scenes as a still sequence and the script only keeps the scene counter up to date. +"use strict"; + +(() => { + const film = globalThis.matchMedia( + "(prefers-reduced-motion: no-preference) and (scripting: enabled) and (height >= 30rem)", + ); + const counter = document.getElementById("tc-n"); + const title = document.getElementById("tc-title"); + const bar = document.querySelector(".bar"); + const scenes = Array.from(document.querySelectorAll(".scene"), (element) => ({ + element, + kind: element.dataset.kind, + // A scene of one screen (l1) is not played; it scrolls past like a page. + played: !element.classList.contains("l1"), + win: element.querySelector(".win"), + stage: element.querySelector(".stage"), + steps: Array.from(element.querySelectorAll(".s")), + items: Array.from(element.querySelectorAll(".m")), + lists: Array.from(element.querySelectorAll("[data-switch]"), (list) => Array.from(list.children)), + shift: -1, + })); + let queued = false; + let shown = -1; + + const clamp = (value) => Math.min(1, Math.max(0, value)); + + // Bottom edge of an element, measured from the top of the stage, which is what a roll has to bring into view. + const bottomIn = (stage, element) => + element.getBoundingClientRect().bottom - stage.getBoundingClientRect().top; + + function roll(scene, shift) { + const value = Math.round(Math.max(0, shift)); + if (value !== scene.shift) { + scene.shift = value; + scene.stage.style.transform = value ? `translate3d(0, ${-value}px, 0)` : ""; + } + } + + // Lines appear one by one over the first 85 % of the scene, the newest is marked, and the stage rolls like a + // terminal once they no longer fit. + function playSteps(scene, progress) { + const count = scene.steps.length; + const visible = Math.max(1, Math.min(count, Math.ceil((progress / 0.85) * count))); + for (const [index, step] of scene.steps.entries()) { + step.classList.toggle("on", index < visible); + step.classList.toggle("now", index === visible - 1 && progress < 1); + } + roll(scene, bottomIn(scene.stage, scene.steps[visible - 1]) - scene.win.clientHeight); + } + + function playRoll(scene, progress) { + const count = scene.items.length; + const current = Math.round(progress * (count - 1)); + for (const [index, item] of scene.items.entries()) { + item.classList.toggle("cur", index === current); + } + roll(scene, progress * (scene.stage.scrollHeight - scene.win.clientHeight)); + } + + // One item per equal part of the scene; within its part, a tall item rolls to its end. + function playSwitch(scene, progress) { + const count = scene.lists[0].length; + const current = Math.min(count - 1, Math.floor(progress * count)); + for (const list of scene.lists) { + for (const [index, item] of list.entries()) { + item.classList.toggle("on", index === current); + } + } + const active = scene.lists.at(-1)[current]; + const local = clamp(progress * count - current); + roll(scene, local * (bottomIn(scene.stage, active) - scene.win.clientHeight)); + } + + function play(scene, progress) { + if (scene.kind === "steps" && scene.steps.length > 0) { + playSteps(scene, progress); + } else if (scene.kind === "roll" && scene.items.length > 0) { + playRoll(scene, progress); + } else if (scene.kind === "switch" && scene.lists.length > 0) { + playSwitch(scene, progress); + } else { + roll(scene, progress * (scene.stage.scrollHeight - scene.win.clientHeight)); + } + } + + function still() { + for (const scene of scenes) { + roll(scene, 0); + } + } + + function updateCounter(index) { + if (index === shown) { + return; + } + shown = index; + counter.textContent = String(index + 1).padStart(2, "0"); + title.textContent = scenes[index].element.dataset.title; + } + + function render() { + queued = false; + const height = globalThis.innerHeight; + let current = 0; + for (const [index, scene] of scenes.entries()) { + const box = scene.element.getBoundingClientRect(); + if (box.top <= height / 2) { + current = index; + } + const run = box.height - height; + if (film.matches && scene.played && run > 0 && box.bottom > 0 && box.top < height) { + play(scene, clamp(-box.top / run)); + } + } + updateCounter(current); + const total = document.documentElement.scrollHeight - height; + bar.style.transform = `scaleX(${total > 0 ? clamp(globalThis.scrollY / total) : 0})`; + } + + function queue() { + if (!queued) { + queued = true; + globalThis.requestAnimationFrame(render); + } + } + + // The chapter list closes when a chapter is chosen, and with Escape, which returns focus to its button. + const chapters = document.querySelector(".hud-nav details"); + chapters.addEventListener("click", (event) => { + if (event.target.closest("a")) { + chapters.open = false; + } + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && chapters.open) { + chapters.open = false; + chapters.querySelector("summary").focus(); + } + }); + + film.addEventListener("change", () => { + if (!film.matches) { + still(); + } + queue(); + }); + globalThis.addEventListener("scroll", queue, { passive: true }); + globalThis.addEventListener("resize", queue, { passive: true }); + // The first frame waits for the page to load: by then the browser has laid it out, so measuring the scenes costs + // nothing, and the page is complete without this frame anyway. + if (document.readyState === "complete") { + queue(); + } else { + globalThis.addEventListener("load", queue, { once: true }); + } +})(); diff --git a/site/lighthouserc.json b/site/lighthouserc.json new file mode 100644 index 0000000..b40381b --- /dev/null +++ b/site/lighthouserc.json @@ -0,0 +1,16 @@ +{ + "ci": { + "collect": { + "staticDistDir": "./_site", + "numberOfRuns": 3 + }, + "assert": { + "assertions": { + "categories:performance": ["error", { "minScore": 1, "aggregationMethod": "median-run" }], + "categories:accessibility": ["error", { "minScore": 1, "aggregationMethod": "median-run" }], + "categories:best-practices": ["error", { "minScore": 1, "aggregationMethod": "median-run" }], + "categories:seo": ["error", { "minScore": 1, "aggregationMethod": "median-run" }] + } + } + } +} diff --git a/site/robots.txt b/site/robots.txt new file mode 100644 index 0000000..e8f841d --- /dev/null +++ b/site/robots.txt @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +User-agent: * +Allow: / diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..0b25d97 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,788 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 VectoDE + * + * SPDX-License-Identifier: MIT + */ + +/* + * The page is a sequence of full-screen frames, readable as it is. With motion allowed, scripting on and a screen tall + * enough, every scene becomes longer than the screen, its frame sticks, and film.js plays it while it scrolls past + * (the block at the end). Colour code: yellow marks what lss is doing right now, red what it refuses or what failed. + * Fonts are the system's own; the page loads nothing from other servers. + */ + +:root { + color-scheme: light dark; + --bg: #f3f5f8; + --ink: #0f2747; + --muted: #475b7a; + --line: #c3cddb; + --mark: #ffd84d; + --on-mark: #0f2747; + --deny: #b3261e; + --hud: 3rem; + --gut: clamp(1rem, 4vw, 3.5rem); + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", "Liberation Mono", monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b1a30; + --ink: #e6ecf5; + --muted: #9fb1ca; + --line: #2a4266; + --mark: #f5c842; + --on-mark: #0b1a30; + --deny: #ff8f85; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 400 1.0625rem/1.55 var(--sans); + overflow-wrap: break-word; +} + +h1, +h2, +h3, +p, +pre { + margin: 0; +} + +a { + color: inherit; + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; +} + +a:hover { + text-decoration-thickness: 2px; +} + +:focus-visible { + outline: 3px solid var(--ink); + outline-offset: 2px; +} + +a:focus-visible, +summary:focus-visible { + background: var(--mark); + color: var(--on-mark); +} + +code { + font-family: var(--mono); + font-size: 0.92em; +} + +::selection { + background: var(--mark); + color: var(--on-mark); +} + +.skip { + position: absolute; + top: -10rem; + left: var(--gut); + z-index: 3; + padding: 0.6rem 1ch; + background: var(--mark); + color: var(--on-mark); + font-family: var(--mono); +} + +.skip:focus { + top: 0.5rem; +} + +/* Head-up display: the chapters and the scene that is playing. */ + +.hud { + position: fixed; + inset: 0 0 auto; + z-index: 2; + display: flex; + gap: 2.5ch; + align-items: center; + height: var(--hud); + /* Lines up with the frames, which stop growing at 96rem. */ + padding: 0 max(var(--gut), calc((100% - 96rem) / 2 + var(--gut))); + border-bottom: 1px solid var(--line); + background: var(--bg); + font: 500 0.875rem/1 var(--mono); +} + +.hud-mark { + padding: 0.6rem 0; + font-size: 1.125rem; + font-weight: 800; + letter-spacing: -0.04em; + text-decoration: none; +} + +.hud-nav { + position: relative; +} + +.hud-nav summary { + padding: 0.85rem 0; + cursor: pointer; + list-style: none; +} + +.hud-nav summary::-webkit-details-marker { + display: none; +} + +.hud-nav summary::after { + content: " +"; + color: var(--muted); +} + +.hud-nav details[open] summary::after { + content: " \2212"; +} + +.hud-nav ol { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + width: min(20rem, calc(100vw - 2 * var(--gut))); + max-height: calc(100svh - var(--hud) - 1.5rem); + margin: 0; + padding: 0.5rem 0; + overflow-y: auto; + border: 2px solid var(--ink); + background: var(--bg); + list-style: none; + counter-reset: chapter; +} + +.hud-nav li { + counter-increment: chapter; +} + +.hud-nav a { + display: flex; + gap: 1.5ch; + padding: 0.5rem 1rem; + text-decoration: none; +} + +.hud-nav a::before { + content: counter(chapter, decimal-leading-zero); + color: var(--muted); +} + +.hud-nav a:focus-visible::before { + color: inherit; +} + +.hud-tc { + display: flex; + gap: 1ch; + min-width: 0; + margin-left: auto; + white-space: nowrap; +} + +.hud-of { + margin-left: -1ch; + color: var(--muted); +} + +#tc-title { + overflow: hidden; + text-overflow: ellipsis; +} + +.bar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + height: 4px; + background: var(--mark); + transform: scaleX(0); + transform-origin: 0 50%; +} + +/* A frame: the splice line with the scene number, the title, the stage and the caption. */ + +/* Scenes below the first are laid out when they come near the screen, which keeps the first layout small. */ +.scene + .scene { + content-visibility: auto; + contain-intrinsic-size: auto 100svh; +} + +.frame { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 96rem; + min-height: 100svh; + margin: 0 auto; + padding: calc(var(--hud) + 1.25rem) var(--gut) 1.75rem; +} + +.splice { + display: flex; + gap: 2ch; + padding-top: 0.6rem; + border-top: 2px solid var(--ink); + color: var(--muted); + font: 500 0.8125rem/1 var(--mono); +} + +.splice span:first-child { + color: var(--ink); + font-weight: 700; +} + +h2 { + font: 700 clamp(1.75rem, 0.9rem + 5vw, 6rem)/1.02 var(--mono); + letter-spacing: -0.045em; + text-wrap: balance; + overflow-wrap: anywhere; +} + +h2 .path { + color: var(--muted); + font-weight: 400; +} + +.meta { + color: var(--muted); + font: 500 0.9375rem/1 var(--mono); +} + +.win { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +.stage { + position: relative; +} + +.cap { + max-width: 52ch; + margin: auto auto 0; + padding-top: 1rem; + font-size: clamp(1rem, 0.9rem + 0.45vw, 1.25rem); + font-weight: 500; + line-height: 1.45; + text-align: center; + text-wrap: balance; +} + +.lead, +.note { + max-width: 62ch; + text-wrap: pretty; +} + +.lead { + margin-top: 1.25rem; + font-size: clamp(1.0625rem, 1rem + 0.4vw, 1.3125rem); +} + +.note { + margin-top: 1.5rem; +} + +/* Opening scene */ + +h1 { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 2ch; + align-items: baseline; + margin-top: auto; +} + +.h1-word { + margin-left: -0.05em; + font: 800 clamp(6rem, 36vw, 22rem)/0.8 var(--mono); + letter-spacing: -0.07em; +} + +.h1-sub { + color: var(--muted); + font: 500 clamp(1rem, 0.8rem + 1vw, 1.5rem)/1.2 var(--mono); +} + +#top .win { + flex: 0 0 auto; +} + +#top .cap { + margin-top: 2rem; +} + +/* The command is sized so that its 43 characters fit one line from 352 px up; narrower screens wrap it. */ +.cmd { + width: fit-content; + max-width: 100%; + padding: 0.35em 0.6ch; + background: var(--mark); + color: var(--on-mark); + font: 600 clamp(0.75rem, 3.2vw, 1.75rem)/1.35 var(--mono); + overflow-wrap: anywhere; +} + +@media (width >= 22rem) { + .cmd { + white-space: nowrap; + } +} + +.t-hero { + display: inline-block; +} + +/* Logs in the format of lss: level, then the message */ + +.log { + max-width: 110ch; + margin: 0; + padding: 0; + font: 400 clamp(0.8125rem, 0.75rem + 0.35vw, 1.125rem)/1.6 var(--mono); + list-style: none; +} + +.log li { + display: grid; + grid-template-columns: 6ch minmax(0, 1fr); + column-gap: 1ch; + padding: 0 0.5ch; + overflow-wrap: anywhere; +} + +.lv { + color: var(--muted); +} + +.log .ok .lv { + color: var(--ink); + font-weight: 700; +} + +.log .cmdline { + margin-top: 0.35rem; + font-weight: 600; +} + +.log .deny, +.log .deny .lv { + color: var(--deny); + font-weight: 600; +} + +.log-head { + margin: 1.75rem 0 0.35rem; + color: var(--muted); + font: 600 0.9375rem/1.2 var(--sans); +} + +.report { + margin-top: 0.5rem; +} + +.report li { + grid-template-columns: 7ch minmax(0, 1fr); +} + +.unit { + margin-bottom: 1.5rem; + padding-left: 2ch; + border-left: 2px solid var(--ink); + font: 400 clamp(0.8125rem, 0.75rem + 0.35vw, 1.0625rem)/1.6 var(--mono); + white-space: pre-wrap; +} + +.u-c { + color: var(--muted); +} + +.help { + font: 400 clamp(0.75rem, 0.6rem + 0.45vw, 1rem)/1.5 var(--mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* Modules of a group */ + +.mods { + margin: 0; + padding: 0; + list-style: none; +} + +.m { + display: grid; + grid-template-columns: clamp(11rem, 24vw, 14rem) minmax(0, 1fr); + column-gap: 3ch; + padding: 0.9rem 0; + border-top: 1px solid var(--line); +} + +.m-name { + grid-row: span 3; + align-self: start; + justify-self: start; + font: 700 clamp(1rem, 0.9rem + 0.5vw, 1.375rem)/1.3 var(--mono); +} + +.m-desc { + max-width: 70ch; +} + +.m-alt { + color: var(--muted); + font-size: 0.9375rem; +} + +.m-opt { + color: var(--muted); + font: 400 0.8125rem/1.6 var(--mono); + overflow-wrap: anywhere; +} + +.m-opt code { + margin-right: 1.5ch; + font-size: 1em; +} + +/* Profiles: in the film one at a time, otherwise one after the other */ + +.sw-names { + display: none; +} + +.sw-items .o { + padding: 1rem 0; + border-top: 1px solid var(--line); +} + +.o h3 { + margin-bottom: 0.35rem; + font: 700 clamp(1.25rem, 1rem + 1.2vw, 2rem)/1.2 var(--mono); + letter-spacing: -0.02em; +} + +.o-desc { + max-width: 62ch; + margin-bottom: 0.5rem; +} + +.o-count { + margin-bottom: 0.35rem; + color: var(--muted); + font-size: 0.9375rem; +} + +.o-mods { + max-width: 92ch; + font: 400 clamp(0.8125rem, 0.75rem + 0.3vw, 1rem)/1.65 var(--mono); + word-spacing: 0.6ch; +} + +.o-mods span { + white-space: nowrap; +} + +/* The menu, drawn like whiptail draws it */ + +.tui { + max-width: 46rem; + padding-bottom: 1.25rem; + border: 2px solid var(--ink); + box-shadow: 0.6rem 0.6rem 0 var(--line); + font: 400 clamp(0.875rem, 0.8rem + 0.3vw, 1.0625rem)/1.4 var(--mono); +} + +.tui-title { + padding: 0.4rem 1ch; + background: var(--ink); + color: var(--bg); + font-weight: 700; + text-align: center; +} + +.tui-prompt { + margin: 1rem 2ch 0.75rem; +} + +.tui-items { + margin: 0 2ch; + padding: 0; + list-style: none; +} + +.tui-items li { + display: grid; + grid-template-columns: 8ch minmax(0, 1fr); + gap: 1ch; + padding: 0.3rem 1ch; +} + +.tui-tag { + font-weight: 700; +} + +.tui-buttons { + display: flex; + gap: 6ch; + justify-content: center; + margin-top: 1.25rem; +} + +/* Tables: firewall rules and systems */ + +table { + width: 100%; + max-width: 72rem; + border-collapse: collapse; +} + +caption { + padding-bottom: 0.6rem; + color: var(--muted); + font: 600 0.9375rem/1.3 var(--sans); + text-align: left; +} + +th, +td { + padding: 0.55rem 2ch 0.55rem 0; + border-top: 1px solid var(--line); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} + +th { + border-top: 0; + color: var(--muted); + font: 600 0.8125rem/1.2 var(--sans); +} + +.rules td:first-child, +.rules td:nth-child(2) { + font-family: var(--mono); +} + +.rules td:nth-child(2) { + font-weight: 700; + white-space: nowrap; +} + +.rules .deny td { + color: var(--deny); + font-weight: 600; +} + +.sys { + margin-bottom: 1.5rem; +} + +.sys + .note { + margin: -0.5rem 0 2rem; +} + +.sys td:first-child { + font-weight: 600; +} + +/* Help */ + +.links { + max-width: 60rem; + margin: 1.5rem 0 0; + padding: 0; + list-style: none; +} + +.links li { + padding: 0.45rem 0; + border-top: 1px solid var(--line); + color: var(--muted); +} + +.links a { + display: inline-block; + margin-right: 1.5ch; + padding: 0.2rem 0; + color: var(--ink); + font: 600 1.0625rem/1.4 var(--mono); +} + +.foot { + max-width: 96rem; + margin: 0 auto; + padding: 2.5rem var(--gut) 3.5rem; + border-top: 2px solid var(--ink); + color: var(--muted); + font-size: 0.9375rem; +} + +.foot p { + max-width: 70ch; + margin-bottom: 0.5rem; +} + +.foot a { + color: var(--ink); +} + +@media (width <= 40rem) { + .m { + grid-template-columns: minmax(0, 1fr); + } + + .m-name { + grid-row: auto; + } + + .hud { + gap: 1.5ch; + } +} + +/* The film. film.js uses the same media query. */ + +@media (prefers-reduced-motion: no-preference) and (scripting: enabled) and (height >= 30rem) { + .l2 { + height: 200svh; + } + + .l3 { + height: 300svh; + } + + .l4 { + height: 400svh; + } + + .l5 { + height: 500svh; + } + + .l6 { + height: 600svh; + } + + /* A scene of one screen (l1) is not played: it scrolls past like a page. */ + .scene:not(.l1) .frame { + position: sticky; + top: 0; + height: 100svh; + min-height: 0; + overflow: hidden; + } + + .scene:not(.l1) .win { + overflow: hidden; + } + + .s { + opacity: 0; + } + + .s.on { + opacity: 1; + } + + .log .now, + .log .now .lv, + .m.cur .m-name, + .sw-names .on, + .tui-items .on { + background: var(--mark); + color: var(--on-mark); + } + + .m-name { + margin-left: -0.5ch; + padding: 0 0.5ch; + } + + .sw { + display: grid; + grid-template-columns: minmax(12ch, 18ch) minmax(0, 1fr); + column-gap: 4ch; + align-items: start; + } + + .sw-names { + display: block; + margin: 0; + padding: 0; + color: var(--muted); + font: 600 1rem/1 var(--mono); + list-style: none; + } + + .sw-names li { + padding: 0.55rem 0.75ch; + } + + .sw-items { + display: grid; + } + + .sw-items .o { + grid-area: 1 / 1; + padding-top: 0; + border-top: 0; + opacity: 0; + } + + .sw-items .o.on { + opacity: 1; + } + + /* 41 characters, typed once when the page opens */ + .t-hero { + animation: type 1.1s steps(41, end) 0.2s both; + } + + @keyframes type { + from { + clip-path: inset(0 100% 0 0); + } + + to { + clip-path: inset(0); + } + } +} + +@media (prefers-reduced-motion: no-preference) and (scripting: enabled) and (height >= 30rem) and (width <= 40rem) { + /* One column: the heading of each profile names it, the index would only push it down. */ + .sw { + grid-template-columns: minmax(0, 1fr); + } + + .sw-names { + display: none; + } +} diff --git a/tests/unit/site.bats b/tests/unit/site.bats new file mode 100644 index 0000000..2f6efcd --- /dev/null +++ b/tests/unit/site.bats @@ -0,0 +1,125 @@ +#!/usr/bin/env bats +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# The website generator (scripts/build-site.sh): everything on the page comes from the repository, the Content +# Security Policy allows exactly the inlined stylesheet, and the README tables have to match data/systems.conf. + +setup_file() { + bats_require_minimum_version 1.5.0 + REPO_ROOT=$(cd "${BATS_TEST_DIRNAME}/../.." && pwd) + export REPO_ROOT + "${REPO_ROOT}/scripts/build-site.sh" "${BATS_FILE_TMPDIR}/site" >/dev/null + export PAGE=${BATS_FILE_TMPDIR}/site/index.html +} + +# copy_repo: the files the generator reads, in a directory of their own, so a test can change them. +copy_repo() { + ROOT=${BATS_TEST_TMPDIR}/repo + mkdir -p "${ROOT}/lib" + cp -r "${REPO_ROOT}/scripts" "${REPO_ROOT}/site" "${REPO_ROOT}/modules" "${REPO_ROOT}/profiles" "${ROOT}/" + mkdir -p "${ROOT}/data" + cp "${REPO_ROOT}/data/systems.conf" "${ROOT}/data/" + cp "${REPO_ROOT}/lib/ui.sh" "${ROOT}/lib/" + cp "${REPO_ROOT}/lss" "${REPO_ROOT}/VERSION" "${REPO_ROOT}/README.md" "${ROOT}/" +} + +@test "the page has every module, group and profile of the repository" { + local file name group + for file in "${REPO_ROOT}"/modules/*/*.sh; do + name=$(basename "${file}" .sh) + group=$(basename "$(dirname "${file}")") + grep -qF "${name}" "${PAGE}" + grep -qF "id=\"group-${group}\"" "${PAGE}" + done + for file in "${REPO_ROOT}"/profiles/*.conf; do + grep -qF "

    $(basename "${file}" .conf)

    " "${PAGE}" + done +} + +@test "every scene is in the chapter list" { + local scenes chapters + scenes=$(grep -c '^
    /p' "${PAGE}" | grep -c '^
  • /${scenes}" "${PAGE}" +} + +@test "the settings shown are the options of the modules with their defaults" { + grep -qF 'LSS_GUARD_TIME=07:00' "${PAGE}" + grep -qF 'LSS_KERNEL_HARDENING_BLOCK_USB_STORAGE=true' "${PAGE}" + grep -qF 'Instead of apache, caddy' "${PAGE}" + grep -qF '

    Every morning at 07:00

    ' "${PAGE}" +} + +@test "the help text comes from lss and the menu from lib/ui.sh" { + grep -qF 'install --profile NAME install a profile' "${PAGE}" + grep -qF "lss $(<"${REPO_ROOT}/VERSION"): modular, hardened Linux server setup" "${PAGE}" + grep -qF 'Install a profile (a ready-made set of modules)
  • ' "${PAGE}" +} + +@test "the policy allows exactly the inlined stylesheet and the page loads nothing from elsewhere" { + local css hash page + css=$(<"${REPO_ROOT}/site/styles.css") + hash=$(printf '%s' "${css}" | openssl dgst -sha256 -binary | openssl base64 -A) + page=$(<"${PAGE}") + [[ ${page} == *""* ]] + grep -qF "style-src 'sha256-${hash}'" "${PAGE}" + grep -qF "default-src 'none'; script-src 'self';" "${PAGE}" + [ "$(grep -c '' "${PAGE}" + run grep -E '(src|href)="(https?:)?//' "${PAGE}" + [ "${status}" -eq 0 ] + # Links to the repository are the only absolute URLs, and none of them is a resource the page loads. + run grep -E '<(script|link|img)[^>]+(src|href)="(https?:)?//' "${PAGE}" + [ "${status}" -eq 1 ] + [ -f "${BATS_FILE_TMPDIR}/site/film.js" ] + [ -f "${BATS_FILE_TMPDIR}/site/favicon.svg" ] +} + +@test "a module in a new group gets a scene of its own" { + copy_repo + mkdir -p "${ROOT}/modules/edge" + cat >"${ROOT}/modules/edge/gateway.sh" <<'EOF' +MOD_NAME="gateway" +MOD_GROUP="edge" +MOD_DESCRIPTION="Gateway in front of & more" +MOD_REQUIRES="system" +MOD_OPTIONS="port|8443|Port it listens on +mode||Optional mode" +EOF + run "${ROOT}/scripts/build-site.sh" "${ROOT}/out" + [ "${status}" -eq 0 ] + grep -qF 'id="group-edge"' "${ROOT}/out/index.html" + grep -qF 'Gateway in front of <everything> & more' "${ROOT}/out/index.html" + grep -qF 'LSS_GATEWAY_PORT=8443 LSS_GATEWAY_MODE=' "${ROOT}/out/index.html" + grep -qF '
  • modules/edge
  • ' "${ROOT}/out/index.html" + # Known groups keep their order; the new one comes after them. + [ "$(grep -n 'id="group-apps"' "${ROOT}/out/index.html" | cut -d: -f1)" -lt \ + "$(grep -n 'id="group-edge"' "${ROOT}/out/index.html" | cut -d: -f1)" ] +} + +@test "a module whose MOD_GROUP differs from its directory stops the build" { + copy_repo + sed -i 's/^MOD_GROUP="base"/MOD_GROUP="security"/' "${ROOT}/modules/base/swap.sh" + run "${ROOT}/scripts/build-site.sh" "${ROOT}/out" + [ "${status}" -eq 1 ] + [[ ${output} == *"modules/base/swap.sh: MOD_GROUP is 'security'."* ]] +} + +@test "the README tables have to match data/systems.conf" { + copy_repo + run "${ROOT}/scripts/build-site.sh" --check + [ "${status}" -eq 0 ] + sed -i 's/^system|Debian|12, 13|12, 13$/system|Debian|11, 12, 13|12, 13/' "${ROOT}/data/systems.conf" + sed -i 's/^requirement|Profile `devops`|1 GB; 1.5 GB on the RHEL family|7 GB|/requirement|Profile `devops`|1 GB|7 GB|/' \ + "${ROOT}/data/systems.conf" + run "${ROOT}/scripts/build-site.sh" --check + [ "${status}" -eq 1 ] + [[ ${output} == *'README.md "Supported systems" differs'* ]] + [[ ${output} == *'README.md "System requirements" differs'* ]] + run "${ROOT}/scripts/build-site.sh" "${ROOT}/out" + [ "${status}" -eq 1 ] + [ ! -e "${ROOT}/out/index.html" ] +}