diff --git a/.gitattributes b/.gitattributes index 7fb77a6..7d98a5e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,17 @@ * text=auto eol=lf *.png binary + +# Left out of the release archive (scripts/release/build.sh): tests, CI and tool settings. The Ansible collection is a +# release file of its own. +/.github export-ignore +/tests export-ignore +/scripts/dev export-ignore +/scripts/release export-ignore +/ansible export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.gitleaksignore export-ignore +/.shellcheckrc export-ignore +/.sonarcloud.properties export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 100c218..7f16447 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -99,15 +99,17 @@ and checks it against this list before approving: 4. **Security.** No weaker default than before; no path a local user controls is written, chowned or followed as root; settings are validated in `mod_check` before anything changes; nothing is downloaded without a check. 5. **Lockout.** Changes to SSH, the firewall, PAM or sudo keep a way in and are covered by the lockout checks. -6. **Compatibility.** A changed setting name or default is labelled `breaking-change` with migration notes, and - `lss update` migrates the state of existing installations. +6. **Compatibility.** A changed setting name or default is labelled `breaking-change` with migration notes, and a + module of the new version takes over the state an older version left (installed after `lss update`, applied with + `lss configure`). 7. **Documentation.** README, module guide, hardening guide and `CHANGELOG.md` match the change. ## Releases -From 2.0.0 on, releases are built by the release workflow on `main`: it tags the version, attaches the archive with a -signed build attestation and publishes the GitHub release. The version step comes from the pull request labels: -`breaking-change` makes a major release, `enhancement` a minor one, anything else a patch. +From 2.0.0 on, releases are built by the release workflow on `main`: it tags the version, attaches the archive, +`bootstrap.sh`, the Ansible collection, the SBOM and `SHA256SUMS` with a signed build attestation and publishes the +GitHub release. The version step comes from the pull request labels: `breaking-change` makes a major release, +`enhancement` a minor one, anything else a patch. Details: [releasing](../docs/releasing.md). ## License diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 29e55c0..8ce26d6 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -57,19 +57,21 @@ documented as an [OpenVEX](https://openvex.dev/) statement in `.vex/`, with the From 2.0.0 on, releases are built only by the release workflow of this repository, never from a laptop. Each release carries a signed build attestation (Sigstore, SLSA provenance) that names this repository, the workflow and the -commit. Verify a downloaded archive with the GitHub CLI: +commit. Verify a downloaded file with the GitHub CLI, naming the release workflow on `main` as the signer: ```bash -gh attestation verify lss-.tar.gz --repo VectoDE/Linux-Server-Setup-Script +gh attestation verify lss-.tar.gz --repo VectoDE/Linux-Server-Setup-Script \ + --cert-identity https://github.com/VectoDE/Linux-Server-Setup-Script/.github/workflows/release.yml@refs/heads/main ``` -`lss update` checks the attestation of every version before it installs it and refuses an archive that does not -verify. +`bootstrap.sh` and `lss update` check the checksum and the attestation of every version before they install it and +refuse a release that does not verify ([installing and upgrading](../docs/upgrading.md#what-gets-checked)). ## Secrets and credentials -- The project keeps no stored secrets: workflows use the short-lived `GITHUB_TOKEN`, attestations use the workflow's - OIDC identity. +- Workflows use the short-lived `GITHUB_TOKEN`, attestations the workflow's OIDC identity. The only stored secrets + are the deploy key and the SSH signing key of the release commit and tag, in the environment `release`, which only + `main` can use and only the job that commits and tags reads ([releasing](../docs/releasing.md)). - On a server, lss keeps generated secrets (passwords, 2FA enrolment) in `/etc/lss/secrets`, mode 0600 in a 0700 directory, and shows them only with `lss secrets show NAME`. They never go into logs. - Secrets are never committed. gitleaks in CI and GitHub secret scanning with push protection block leaks. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6bd8d85..0265a46 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -40,3 +40,16 @@ updates: groups: images: patterns: ['*'] + + # Python tools of the Ansible collection checks (tests/ansible/requirements.txt, pinned with hashes). + - package-ecosystem: pip + directory: /tests/ansible + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + labels: [dependencies, ci] + groups: + ansible: + patterns: ['*'] diff --git a/.github/workflows/distribution.yml b/.github/workflows/distribution.yml new file mode 100644 index 0000000..5150e3c --- /dev/null +++ b/.github/workflows/distribution.yml @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# How lss reaches servers, tested with real signatures: two test releases are built with scripts/release/build.sh, +# exactly as the release workflow builds a release, and get a real build attestation (Sigstore) from this workflow. +# bootstrap.sh and `lss update` then install, update, roll back and downgrade them on every supported system and +# refuse tampered copies (tests/integration/update.sh); the Ansible collection is linted and installs and updates lss +# over SSH (tests/integration/ansible.sh). Only the signer differs from a release: bootstrap.sh accepts these test +# builds because the tests name this workflow and ref in LSS_RELEASE_WORKFLOW and LSS_RELEASE_REF. +name: Release and update + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "47 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: distribution-${{ github.ref }} + cancel-in-progress: true + +env: + VERSION_A: 2.0.0-ci.1 + VERSION_B: 2.0.0-ci.2 + +jobs: + build: + name: Build and sign test releases + # Pull requests from forks and Dependabot get no OIDC token to sign with; the release workflow is unaffected. + if: github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write # Sigstore signature for the test attestations + attestations: write # stores them with the repository, as a release does + 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: Install ansible-core (hash-pinned) + run: | + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install --quiet --require-hashes -r tests/ansible/requirements.txt + echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH" + - name: Build two releases + run: | + for version in "$VERSION_A" "$VERSION_B"; do + scripts/release/build.sh --version "$version" "$RUNNER_TEMP/release/download/v$version" + done + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + id: attest-a + with: + subject-path: ${{ runner.temp }}/release/download/v${{ env.VERSION_A }}/* + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + id: attest-b + with: + subject-path: ${{ runner.temp }}/release/download/v${{ env.VERSION_B }}/* + - name: Lay the releases out like GitHub releases + env: + BUNDLE_A: ${{ steps.attest-a.outputs.bundle-path }} + BUNDLE_B: ${{ steps.attest-b.outputs.bundle-path }} + run: | + cp "$BUNDLE_A" "$RUNNER_TEMP/release/download/v$VERSION_A/lss-$VERSION_A.intoto.jsonl" + cp "$BUNDLE_B" "$RUNNER_TEMP/release/download/v$VERSION_B/lss-$VERSION_B.intoto.jsonl" + mkdir -p "$RUNNER_TEMP/release/latest/download" + cp "$RUNNER_TEMP/release/download/v$VERSION_B/SHA256SUMS" "$RUNNER_TEMP/release/latest/download/SHA256SUMS" + find "$RUNNER_TEMP/release" -type f | sort + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-release + path: ${{ runner.temp }}/release/ + retention-days: 3 + + lint: + name: Ansible collection lint + 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: Install ansible-core and ansible-lint (hash-pinned) + run: | + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install --quiet --require-hashes -r tests/ansible/requirements.txt + echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH" + - name: Build and install the collection, check the module documentation + run: | + scripts/release/build.sh "$RUNNER_TEMP/dist" + version="$(cat VERSION)" + ansible-galaxy collection install "$RUNNER_TEMP/dist/vectode-lss-$version.tar.gz" -p "$RUNNER_TEMP/collections" + echo "ANSIBLE_COLLECTIONS_PATH=$RUNNER_TEMP/collections" >> "$GITHUB_ENV" + ANSIBLE_COLLECTIONS_PATH="$RUNNER_TEMP/collections" ansible-doc vectode.lss.lss > /dev/null + ANSIBLE_COLLECTIONS_PATH="$RUNNER_TEMP/collections" ansible-doc -t role vectode.lss.lss > /dev/null + jq -e '.bomFormat == "CycloneDX" and (.components | length > 0)' "$RUNNER_TEMP/dist/lss-$version.cdx.json" > /dev/null + - name: ansible-lint + working-directory: ansible + run: ansible-lint --offline + + update: + name: ${{ matrix.system }}${{ matrix.arch == 'arm64' && ' arm64' || '' }} / bootstrap and update + needs: build + runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + system: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04, ubuntu-26.04, rocky-9, almalinux-10] + arch: [x64, arm64] + 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 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: test-release + path: ${{ runner.temp }}/release + - name: Install, update, roll back, downgrade, refuse + env: + SYSTEM: ${{ matrix.system }} + LSS_RELEASE_WORKFLOW: distribution.yml + LSS_RELEASE_REF: ${{ github.ref }} + run: tests/integration/update.sh "$SYSTEM" "$RUNNER_TEMP/release" "$VERSION_A" "$VERSION_B" + + ansible: + name: ${{ matrix.system }}${{ matrix.arch == 'arm64' && ' arm64' || '' }} / Ansible collection + needs: build + runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + system: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04, ubuntu-26.04, rocky-9, almalinux-10] + arch: [x64, arm64] + 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 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: test-release + path: ${{ runner.temp }}/release + - name: Install ansible-core (hash-pinned) and the collection + run: | + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install --quiet --require-hashes -r tests/ansible/requirements.txt + echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/venv/bin/ansible-galaxy" collection install \ + "$RUNNER_TEMP/release/download/v$VERSION_A/vectode-lss-$VERSION_A.tar.gz" -p "$RUNNER_TEMP/collections" + - name: Install lss and modules with the role, update, remove + env: + SYSTEM: ${{ matrix.system }} + LSS_RELEASE_WORKFLOW: distribution.yml + LSS_RELEASE_REF: ${{ github.ref }} + ANSIBLE_COLLECTIONS_PATH: ${{ runner.temp }}/collections + run: tests/integration/ansible.sh "$SYSTEM" "$RUNNER_TEMP/release" "$VERSION_A" "$VERSION_B" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c9446eb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,278 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Every push to main becomes a release (docs/releasing.md): scripts/release/prepare.sh picks the version from the +# pull request labels, the signed release commit and tag go to main, and scripts/release/build.sh builds the release +# once from the tagged commit: +# lss-V.tar.gz, bootstrap.sh, the Ansible collection vectode-lss-V.tar.gz, the CycloneDX SBOM lss-V.cdx.json and +# SHA256SUMS, all covered by one signed build attestation (Sigstore, SLSA provenance) kept as lss-V.intoto.jsonl. +# bootstrap.sh and `lss update` install a version only when its checksum and this attestation verify, signed by this +# workflow on refs/heads/main. Commits with "[skip release]" in the message are left out, and so is the release +# commit itself. +# +# The deploy key that may push past the rulesets of main and of the version tags is only handed to the "Commit and +# tag" job, which runs no code of the repository: it applies the version change prepared and checked without the key. +# Run it by hand to pick the version step, with a pre-release name (rc.1) for a test release or a candidate, or with +# "attach" to build and attach the files of an existing tag again. +# Every step skips what already exists, so a rerun is safe. +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + bump: + description: Version step + type: choice + options: [auto, patch, minor, major, attach] + default: auto + prerelease: + description: Pre-release name such as rc.1 (a test of the release workflow or a candidate; empty for a release) + type: string + required: false + tag: + description: Existing tag for attach (default the version in VERSION) + type: string + required: false + +permissions: + contents: read + +# One release at a time; a run that waits behind another sees its release commit and starts from there. +concurrency: + group: release + cancel-in-progress: false + +jobs: + prepare: + name: Pick the version and check + if: github.repository == 'VectoDE/Linux-Server-Setup-Script' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read # labels that decide the version step + outputs: + release: ${{ steps.version.outputs.release }} + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + sha: ${{ steps.version.outputs.sha }} + steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Skip when main moved on + id: current + run: | + main="$(git ls-remote origin refs/heads/main | cut -f1)" + if [ "$main" = "$GITHUB_SHA" ]; then + echo "current=true" >> "$GITHUB_OUTPUT" + else + echo "main is at ${main:0:7}, this run is for ${GITHUB_SHA:0:7}; the newer run releases both." + echo "current=false" >> "$GITHUB_OUTPUT" + fi + - name: Pick the version + id: version + if: steps.current.outputs.current == 'true' + env: + GH_TOKEN: ${{ github.token }} + BUMP: ${{ inputs.bump || 'auto' }} + RELEASE_PRERELEASE: ${{ inputs.prerelease }} + TAG: ${{ inputs.tag }} + RELEASE_NOTES: ${{ runner.temp }}/release/notes.md + run: | + mkdir -p "$RUNNER_TEMP/release" + if [ "$BUMP" = attach ]; then + tag="${TAG:-v$(cat VERSION)}" + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || + ! git rev-parse --verify --quiet "refs/tags/$tag" > /dev/null; then + echo "::error::$tag is not an existing version tag." + exit 1 + fi + # Only released code: the tag must point to a commit on main. Later jobs build exactly this commit. + sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")" + if ! git merge-base --is-ancestor "$sha" "$GITHUB_SHA"; then + echo "::error::$tag does not point to a commit on main." + exit 1 + fi + git tag --list "$tag" --format='%(contents)' > "$RELEASE_NOTES" + printf 'release=true\nversion=%s\ntag=%s\nsha=%s\n' "${tag#v}" "$tag" "$sha" | tee -a "$GITHUB_OUTPUT" + else + RELEASE_BUMP="$BUMP" scripts/release/prepare.sh + fi + git diff --binary > "$RUNNER_TEMP/release/version.patch" + # Saved before the checks run, so nothing they do can change what gets committed. + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.version.outputs.release == 'true' + with: + name: release + path: ${{ runner.temp }}/release/ + retention-days: 7 + - name: Check the release + if: steps.version.outputs.release == 'true' && inputs.bump != 'attach' + run: | + scripts/dev/install-bats.sh "$RUNNER_TEMP/bats" + "$RUNNER_TEMP/bats/bin/bats" tests/unit + + commit: + name: Commit and tag + needs: prepare + if: needs.prepare.outputs.release == 'true' && inputs.bump != 'attach' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: release # holds the deploy key and the signing key; only main can use it + permissions: + contents: read # the push uses the deploy key, not this token + outputs: + sha: ${{ steps.push.outputs.sha }} + steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The deploy key is the only actor that may push past the rulesets of main and of the version tags. Its push + # starts this workflow again, which finds nothing new to release. + ssh-key: ${{ secrets.RELEASE_DEPLOY_KEY }} + persist-credentials: true + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release + path: ${{ runner.temp }}/release + - name: Commit and tag + id: push + env: + TAG: ${{ needs.prepare.outputs.tag }} + # SSH key registered as a signing key on the maintainer's GitHub account, so release commits and tags show as + # verified. The public half is in .github/allowed_signers. + SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + run: | + patch="$RUNNER_TEMP/release/version.patch" + changed="$(git apply --numstat "$patch" | cut -f3 | LC_ALL=C sort | tr '\n' ' ')" + # A pre-release leaves CHANGELOG.md alone. + versioned="VERSION ansible/galaxy.yml ansible/roles/lss/defaults/main.yml " + if [ "$changed" != "CHANGELOG.md $versioned" ] && [ "$changed" != "$versioned" ]; then + echo "::error::The version change touches unexpected files: $changed" + exit 1 + fi + git apply "$patch" + key="$RUNNER_TEMP/signing-key" + (umask 077 && printf '%s\n' "$SIGNING_KEY" > "$key") + git config user.name "VectoDE" + git config user.email "157636760+VectoDE@users.noreply.github.com" + git config gpg.format ssh + git config user.signingkey "$key" + git config gpg.ssh.allowedSignersFile .github/allowed_signers + git commit --quiet --all --gpg-sign --message "Release $TAG" --message "[skip release]" + git tag --sign "$TAG" --file "$RUNNER_TEMP/release/notes.md" --cleanup=verbatim + git tag --verify "$TAG" + rm -f "$key" + git push --atomic origin HEAD:main "refs/tags/$TAG" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + package: + name: Build and sign the release + needs: [prepare, commit] + if: always() && needs.prepare.outputs.release == 'true' && contains(fromJSON('["success", "skipped"]'), needs.commit.result) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write # Sigstore signature for the build attestation + attestations: write # stores the attestation with the repository + outputs: + sha: ${{ steps.source.outputs.sha }} + steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - name: Pick the verified commit + id: source + env: + SHA: ${{ needs.commit.outputs.sha || needs.prepare.outputs.sha }} + run: | + if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::There is no verified commit to build." + exit 1 + fi + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.source.outputs.sha }} + persist-credentials: false + - name: Install ansible-core (hash-pinned) + run: | + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install --quiet --require-hashes -r tests/ansible/requirements.txt + echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH" + - name: Build + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + if [ "$(cat VERSION)" != "$VERSION" ]; then + echo "::error::The commit has version $(cat VERSION), the release is $VERSION." + exit 1 + fi + scripts/release/build.sh "$RUNNER_TEMP/dist" + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + id: attest + with: + subject-path: ${{ runner.temp }}/dist/* + - name: Keep the attestation next to the files + env: + BUNDLE: ${{ steps.attest.outputs.bundle-path }} + VERSION: ${{ needs.prepare.outputs.version }} + run: cp "$BUNDLE" "$RUNNER_TEMP/dist/lss-$VERSION.intoto.jsonl" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: ${{ runner.temp }}/dist/ + retention-days: 7 + + github: + name: GitHub release + needs: [prepare, package] + if: always() && needs.package.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write # GitHub release and its files + steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release + path: ${{ runner.temp }}/release + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: ${{ runner.temp }}/dist + - name: Create the release or attach the signed files + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.prepare.outputs.tag }} + SHA: ${{ needs.package.outputs.sha }} + run: | + # The tag must still point to the commit that was built and signed. + tagged="$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$TAG" --jq .sha)" + if [ "$tagged" != "$SHA" ]; then + echo "::error::$TAG points to $tagged, but $SHA was built." + exit 1 + fi + assets=("$RUNNER_TEMP"/dist/*) + if gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + gh release upload "$TAG" "${assets[@]}" -R "$GITHUB_REPOSITORY" --clobber + exit 0 + fi + prerelease="" + case "$TAG" in *-*) prerelease="--prerelease" ;; esac + gh release create "$TAG" "${assets[@]}" -R "$GITHUB_REPOSITORY" --verify-tag --title "$TAG" \ + --notes-file "$RUNNER_TEMP/release/notes.md" $prerelease diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 21173e3..f78ebef 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,bootstrap.sh,lib,libexec,modules,scripts,ansible sonar.tests=tests # Accepted findings, each with its reason. diff --git a/CHANGELOG.md b/CHANGELOG.md index e5aa3d0..e90e44c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ now or later. ### Added +- Signed releases: the release workflow builds `lss-.tar.gz`, `bootstrap.sh`, the Ansible collection, a + CycloneDX SBOM and `SHA256SUMS` and covers them with one build attestation (Sigstore, SLSA provenance). + `bootstrap.sh` installs lss to `/opt/lss/` behind the link `/opt/lss/current` only after the checksum and + the attestation verify; `lss update [VERSION]` updates, downgrades and, with `--rollback`, returns to the version + before, checked the same way ([installing and upgrading](docs/upgrading.md)). +- Ansible collection `vectode.lss` with every release: the role `vectode.lss.lss` installs lss from a verified release + and sets up modules, the module `vectode.lss.lss` installs and removes modules with check mode. - `lss` command with an interactive menu and the commands `list`, `info`, `install`, `configure`, `verify`, `status`, `remove`, `audit`, `secrets` and `profiles`; `--dry-run`, `--set KEY=VALUE` and a settings file in `/etc/lss/lss.conf`. @@ -91,7 +98,8 @@ now or later. ### Removed -- `install-configure-script.bash` will be removed when 2.0.0 is released. Its features move into modules. +- `install-configure-script.bash`, the 1.x install script. Its features are modules now; [upgrading from + 1.x](docs/upgrading.md#from-1x) maps its options to them. ## [1.0.1] - 2025-10-22 diff --git a/README.md b/README.md index 9c3ca81..4a3295f 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ modules you need, and come back later to add, change or remove one. Every module checks its settings before it touches the system, restores its files when something fails, and can prove with `lss verify` that it still works. -> Version 2.0 is in development on this branch. The 1.x install script (`install-configure-script.bash`) still works -> and is replaced when 2.0.0 is released. +> Version 2.0 is in development on this branch. It replaces the 1.x install script `install-configure-script.bash`; +> see [upgrading from 1.x](docs/upgrading.md#from-1x). ## Supported systems @@ -129,17 +129,20 @@ The integration jobs print these figures as `RESOURCES` lines, so every new modu ## Quick start ```bash -git clone https://github.com/VectoDE/Linux-Server-Setup-Script.git /opt/lss -sudo ln -s /opt/lss/lss /usr/local/sbin/lss +gh release download -R VectoDE/Linux-Server-Setup-Script --pattern bootstrap.sh +gh attestation verify bootstrap.sh --repo VectoDE/Linux-Server-Setup-Script \ + --cert-identity https://github.com/VectoDE/Linux-Server-Setup-Script/.github/workflows/release.yml@refs/heads/main +sudo bash bootstrap.sh sudo lss install --profile minimal-secure \ --set LSS_ADMIN_USER_NAME=alice \ --set LSS_ADMIN_USER_GITHUB_USER=alice-on-github ``` -That creates the administrator `alice` with the SSH keys of the GitHub account, switches SSH to keys only, turns on -the firewall, fail2ban, kernel hardening, AppArmor or SELinux, auditing, automatic security updates, integrity checks -and the daily guard. Keep a second SSH session open the first time. +`bootstrap.sh` installs the latest release to `/opt/lss/` once its checksum and its signed build +attestation verify ([installing and upgrading](docs/upgrading.md)). Then `lss install` creates the administrator +`alice` with the SSH keys of the GitHub account, switches SSH to keys only, turns on the firewall, fail2ban, kernel +hardening, AppArmor or SELinux, auditing, automatic security updates, integrity checks and the daily guard. Keep a second SSH session open the first time. Without a command, `sudo lss` opens a menu. On the RHEL family, sudo looks for commands only in `/usr/sbin` and `/usr/bin` until the `admin-user` module has added `/usr/local/sbin`; call `sudo /usr/local/sbin/lss` until then. @@ -158,6 +161,8 @@ Without a command, `sudo lss` opens a menu. On the RHEL family, sudo looks for c | `lss remove MODULE... [--purge]` | Remove modules; `--purge` also deletes their data | | `lss audit` | Lynis audit with the hardening index | | `lss secrets show NAME` | Print a generated secret, such as `admin-user-password` | +| `lss update [VERSION]` | Install the latest (or the named) release of lss after checking its signature | +| `lss update --rollback` | Switch back to the version of lss that was active before | Options: `--set KEY=VALUE` for one setting, `--config FILE` for another settings file, `--dry-run` to see what would happen, `--yes` for scripts, `--verbose` for more output. @@ -422,6 +427,9 @@ 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) +- [Installing and upgrading](docs/upgrading.md): `bootstrap.sh`, `lss update`, rollback, upgrading from 1.x +- [Ansible collection](ansible/README.md): the role and module `vectode.lss.lss` +- [Releasing](docs/releasing.md): the release workflow, signatures and the SBOM - [Changelog](CHANGELOG.md) ## Contributing and security diff --git a/REUSE.toml b/REUSE.toml index cac0518..3a8303e 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -12,14 +12,15 @@ path = [ ".github/allowed_signers", "templates/**", "tests/fixtures/os-release/**", + "scripts/release/tag-ruleset.json", ] SPDX-FileCopyrightText = "2025-2026 VectoDE" SPDX-License-Identifier = "MIT" -# The 1.x install script, kept until 2.0.0 replaces it. +# Python requirements with hashes, generated by uv pip compile (the command is in the file); a header would be lost. [[annotations]] -path = "install-configure-script.bash" -SPDX-FileCopyrightText = "2025 VectoDE" +path = "tests/ansible/requirements.txt" +SPDX-FileCopyrightText = "2025-2026 VectoDE" SPDX-License-Identifier = "MIT" # Public signing keys of vendor package repositories, copied unchanged from the vendor so lss can check its packages. diff --git a/ansible/.ansible-lint b/ansible/.ansible-lint new file mode 100644 index 0000000..f99c9f1 --- /dev/null +++ b/ansible/.ansible-lint @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT +--- +# The strictest profile; CI runs ansible-lint in this directory (.github/workflows/distribution.yml). +profile: production diff --git a/ansible/CHANGELOG.md b/ansible/CHANGELOG.md new file mode 100644 index 0000000..26a7301 --- /dev/null +++ b/ansible/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +The collection `vectode.lss` is released together with lss and has its version. Its changes are listed in the +[changelog of lss](https://github.com/VectoDE/Linux-Server-Setup-Script/blob/main/CHANGELOG.md). diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..0325530 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,64 @@ +# Ansible collection vectode.lss + +Sets up hardened Linux servers with [lss](https://github.com/VectoDE/Linux-Server-Setup-Script) from Ansible. The +collection is built and attached with every release of lss and has its version: collection 2.0.1 installs lss 2.0.1. + +- Role `vectode.lss.lss`: installs lss on the target, writes `/etc/lss/lss.conf` and installs or removes modules. +- Module `vectode.lss.lss`: `lss install` and `lss remove` as a task, with check mode and a change only when lss + changed something. + +## Install + +Download `vectode-lss-.tar.gz` from the [release](https://github.com/VectoDE/Linux-Server-Setup-Script/releases), +check it, and install it: + +```bash +gh attestation verify vectode-lss-2.0.1.tar.gz --repo VectoDE/Linux-Server-Setup-Script \ + --cert-identity https://github.com/VectoDE/Linux-Server-Setup-Script/.github/workflows/release.yml@refs/heads/main +ansible-galaxy collection install vectode-lss-2.0.1.tar.gz +``` + +The role copies `bootstrap.sh` to the target, which installs lss only after the SHA-256 and the GitHub build +attestation of the release verify (it downloads the GitHub CLI at a pinned version and checksum for that). Targets +need Python 3, `curl` and access to github.com. Supported: Debian 12 and 13, Ubuntu 22.04, 24.04 and 26.04, RHEL, +Rocky Linux and AlmaLinux 9 and 10, on x86_64 and aarch64. + +## Use + +```yaml +- name: Hardened web server + hosts: web + become: true + roles: + - role: vectode.lss.lss + vars: + lss_profile: minimal-secure + lss_modules: [nginx, tls, vhost] + lss_settings: + LSS_ADMIN_USER_NAME: alice + LSS_ADMIN_USER_GITHUB_USER: alice-on-github + LSS_TLS_DOMAINS: www.example.com + LSS_VHOST_SITES: "site:static:www.example.com" +``` + +| Variable | Default | Meaning | +| -------------------- | ------------------------- | ------------------------------------------------------------- | +| `lss_version` | version of the collection | lss version to install; a different one updates or downgrades | +| `lss_settings` | `{}` | `/etc/lss/lss.conf` as `KEY: value` (mode 0600) | +| `lss_profile` | `""` | profile to install | +| `lss_modules` | `[]` | modules to install | +| `lss_modules_absent` | `[]` | modules to remove | +| `lss_mode` | `""` | `native` or `docker` for services that support both | + +The module on its own, once lss is installed: + +```yaml +- name: Remove Adminer and its data + vectode.lss.lss: + modules: [adminer] + state: absent + purge: true +``` + +`ansible-doc vectode.lss.lss` describes every option. A second run with the same settings reports no change; check +mode runs `lss --dry-run install`, which checks every setting without changing anything. diff --git a/ansible/galaxy.yml b/ansible/galaxy.yml new file mode 100644 index 0000000..ec5bed1 --- /dev/null +++ b/ansible/galaxy.yml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# The collection vectode.lss. scripts/release/build.sh builds it with every release, at the version of lss it +# installs; the release workflow raises "version" here together with VERSION. +--- +namespace: vectode +name: lss +version: "2.0.0-dev" +readme: README.md +authors: + - VectoDE <157636760+VectoDE@users.noreply.github.com> +description: Installs lss from a verified release and sets up hardened Linux servers with its modules. +license: + - MIT +tags: + - linux + - security + - hardening + - server + - infrastructure +dependencies: {} +repository: https://github.com/VectoDE/Linux-Server-Setup-Script +documentation: https://github.com/VectoDE/Linux-Server-Setup-Script/blob/main/ansible/README.md +homepage: https://github.com/VectoDE/Linux-Server-Setup-Script +issues: https://github.com/VectoDE/Linux-Server-Setup-Script/issues +build_ignore: + - .ansible + - .ansible-lint + - "*.pyc" + - __pycache__ diff --git a/ansible/meta/runtime.yml b/ansible/meta/runtime.yml new file mode 100644 index 0000000..b5de753 --- /dev/null +++ b/ansible/meta/runtime.yml @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT +--- +requires_ansible: ">=2.18.0" diff --git a/ansible/plugins/modules/lss.py b/ansible/plugins/modules/lss.py new file mode 100644 index 0000000..4603835 --- /dev/null +++ b/ansible/plugins/modules/lss.py @@ -0,0 +1,224 @@ +#!/usr/bin/python +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +"""Ansible module vectode.lss.lss: installs and removes lss modules through the lss command.""" + +from __future__ import annotations + +import os +import re + +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = r""" +--- +module: lss +short_description: Install and remove lss modules +version_added: "2.0.0" +description: + - Runs C(lss install) or C(lss remove) on the target, the command that sets up and hardens the server. + - Install lss first with the role M(vectode.lss.lss) or with C(bootstrap.sh), both of which verify the release. + - A module counts as changed when lss reports changes for it, so a second run with the same settings reports no change. +author: + - VectoDE (@VectoDE) +options: + modules: + description: + - lss modules to install or remove, such as V(nginx) or V(ssh-hardening). C(lss list) shows them all. + - Modules they require are installed with them. + type: list + elements: str + default: [] + profile: + description: + - Profile to install, such as V(minimal-secure). Only with O(state=present). + type: str + state: + description: + - V(present) installs the modules and applies their settings, V(absent) removes them. + type: str + choices: [present, absent] + default: present + settings: + description: + - Settings for this run, each passed as C(--set KEY=VALUE), on top of C(/etc/lss/lss.conf). + - Keys are named C(LSS__