Remove the image build and consolidate operational code under scripts

This commit is contained in:
deytenit committed 2026-11-07 18:17:29 +03:00
1 parent 5f2d0b221f
commit d69ae202f6
77 files changed
+586 -260607

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
The MIT License (MIT)
Copyright (c) 2018 GitHub, Inc. and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -1,8 +0,0 @@
{
"name": "ci-action-gitea-download-artifact",
"source": "https://github.com/christopherhx/gitea-download-artifact.git",
"requested_ref": "v4",
"commit": "75635f32b4c1c41c4b3d64e8f85210112ed4c9c7",
"path": "/private/tmp/gitea-migration-20261105/mirrors/ci-action-gitea-download-artifact.git",
"destination": "https://git.ermnvldmr.com/deytenit/ci-action-gitea-download-artifact"
}
@@ -1,40 +0,0 @@
name: 'Download a Build Artifact'
description: 'Download a build artifact that was previously uploaded in the workflow by the upload-artifact action'
author: 'GitHub'
inputs:
name:
description: 'Name of the artifact to download. If unspecified, all artifacts for the run are downloaded.'
required: false
path:
description: 'Destination path. Supports basic tilde expansion. Defaults to $GITHUB_WORKSPACE'
required: false
pattern:
description: 'A glob pattern matching the artifacts that should be downloaded. Ignored if name is specified.'
required: false
merge-multiple:
description: 'When multiple artifacts are matched, this changes the behavior of the destination directories.
If true, the downloaded artifacts will be in the same directory specified by path.
If false, the downloaded artifacts will be extracted into individual named directories within the specified path.'
required: false
default: 'false'
github-token:
description: 'The GitHub token used to authenticate with the GitHub API.
This is required when downloading artifacts from a different repository or from a different workflow run.
If this is not specified, the action will attempt to download artifacts from the current repository and the current workflow run.'
required: false
repository:
description: 'The repository owner and the repository name joined together by "/".
If github-token is specified, this is the repository that artifacts will be downloaded from.'
required: false
default: ${{ github.repository }}
run-id:
description: 'The id of the workflow run where the desired download artifact was uploaded from.
If github-token is specified, this is the run that artifacts will be downloaded from.'
required: false
default: ${{ github.run_id }}
outputs:
download-path:
description: 'Path of artifact download'
runs:
using: 'node20'
main: 'dist/index.js'
File diff suppressed because it is too large. Load diff
@@ -1,47 +0,0 @@
{
"name": "download-artifact",
"version": "4.0.1",
"description": "Download an Actions Artifact from a workflow run",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"release": "ncc build src/download-artifact.ts && git add -f dist/",
"check-all": "concurrently \"npm:format-check\" \"npm:lint\" \"npm:build\"",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint **/*.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/actions/download-artifact.git"
},
"keywords": [
"Actions",
"GitHub",
"Artifacts",
"Download"
],
"author": "GitHub",
"license": "MIT",
"bugs": {
"url": "https://github.com/actions/download-artifact/issues"
},
"homepage": "https://github.com/actions/download-artifact#readme",
"dependencies": {
"@actions/artifact": "^2.0.1",
"@actions/core": "^1.10.0",
"@actions/github": "^5.1.1",
"minimatch": "^9.0.3"
},
"devDependencies": {
"@types/node": "^12.12.6",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@vercel/ncc": "^0.33.4",
"concurrently": "^5.2.0",
"eslint": "^8.55.0",
"eslint-plugin-github": "^4.10.1",
"eslint-plugin-prettier": "^5.0.1",
"prettier": "^3.1.1",
"typescript": "^5.3.3"
}
}
-22
View File
@@ -1,22 +0,0 @@
The MIT License (MIT)
Copyright (c) 2018 GitHub, Inc. and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -1,8 +0,0 @@
{
"name": "ci-action-gitea-upload-artifact",
"source": "https://github.com/christopherhx/gitea-upload-artifact.git",
"requested_ref": "v4",
"commit": "81f940d004763f986ba3582c007fd842dd5cb0d7",
"path": "/private/tmp/gitea-migration-20261105/mirrors/ci-action-gitea-upload-artifact.git",
"destination": "https://git.ermnvldmr.com/deytenit/ci-action-gitea-upload-artifact"
}
-61
View File
@@ -1,61 +0,0 @@
name: 'Upload a Build Artifact'
description: 'Upload a build artifact that can be used by subsequent workflow steps'
author: 'GitHub'
inputs:
name:
description: 'Artifact name'
default: 'artifact'
path:
description: 'A file, directory or wildcard pattern that describes what to upload'
required: true
if-no-files-found:
description: >
The desired behavior if no files are found using the provided path.
Available Options:
warn: Output a warning but do not fail the action
error: Fail the action with an error message
ignore: Do not output any warnings or errors, the action does not fail
default: 'warn'
retention-days:
description: >
Duration after which artifact will expire in days. 0 means using default retention.
Minimum 1 day.
Maximum 90 days unless changed from the repository settings page.
compression-level:
description: >
The level of compression for Zlib to be applied to the artifact archive.
The value can range from 0 to 9:
- 0: No compression
- 1: Best speed
- 6: Default compression (same as GNU Gzip)
- 9: Best compression
Higher levels will result in better compression, but will take longer to complete.
For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
default: '6'
overwrite:
description: >
If true, an artifact with a matching name will be deleted before a new one is uploaded.
If false, the action will fail if an artifact for the given name already exists.
Does not fail if the artifact does not exist.
default: 'false'
outputs:
artifact-id:
description: >
A unique identifier for the artifact that was just uploaded. Empty if the artifact upload failed.
This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts
artifact-url:
description: >
A download URL for the artifact that was just uploaded. Empty if the artifact upload failed.
This download URL only works for requests Authenticated with GitHub. Anonymous downloads will be prompted to first login.
If an anonymous download URL is needed than a short time restricted URL can be generated using the download artifact API: https://docs.github.com/en/rest/actions/artifacts#download-an-artifact
This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work.
Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues.
runs:
using: 'node20'
main: 'dist/index.js'
File diff suppressed because it is too large. Load diff
@@ -1,54 +0,0 @@
{
"name": "upload-artifact",
"version": "4.2.0",
"description": "Upload an Actions Artifact in a workflow run",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"release": "ncc build src/upload-artifact.ts && git add -f dist/index.js",
"check-all": "concurrently \"npm:format-check\" \"npm:lint\" \"npm:test\" \"npm:build\"",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint **/*.ts",
"test": "jest --testTimeout 10000"
},
"repository": {
"type": "git",
"url": "git+https://github.com/actions/upload-artifact.git"
},
"keywords": [
"Actions",
"GitHub",
"Artifacts",
"Upload"
],
"author": "GitHub",
"license": "MIT",
"bugs": {
"url": "https://github.com/actions/upload-artifact/issues"
},
"homepage": "https://github.com/actions/upload-artifact#readme",
"dependencies": {
"@actions/artifact": "^2.1.0",
"@actions/core": "^1.10.0",
"@actions/github": "^6.0.0",
"@actions/glob": "^0.3.0",
"@actions/io": "^1.1.2"
},
"devDependencies": {
"@types/jest": "^29.2.5",
"@types/node": "^18.11.18",
"@typescript-eslint/parser": "^5.48.0",
"@vercel/ncc": "^0.36.0",
"concurrently": "^7.6.0",
"eslint": "^8.31.0",
"eslint-plugin-github": "^4.6.0",
"eslint-plugin-jest": "^27.2.0",
"glob": "^8.0.3",
"jest": "^29.3.1",
"jest-circus": "^29.3.1",
"prettier": "^2.8.1",
"ts-jest": "^29.0.3",
"typescript": "^4.9.4"
}
}
@@ -1,179 +0,0 @@
name: ci/packer - main/manual-build-golden-image
'on':
workflow_dispatch:
inputs:
upload_s3:
description: Upload built image to specified static S3 Object Storage
type: boolean
default: true
concurrency:
group: ci-golden-image
cancel-in-progress: false
permissions:
contents: read
jobs:
validate-scripts:
name: 'Validate: Provisioner Scripts'
runs-on: apex-debian13
steps:
- name: Checkout repository
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- name: Setup Python
run: python3 -c "import sys; assert sys.version_info >= (3, 10)"
- name: Install Developer Dependencies
run: python3 -m pip install ruff==0.17.2 "PyYAML>=6.0,<7"
- name: Validate Shell Script Syntax
run: "for script in ci/packer/scripts/*.sh ci/scripts/init-node/provisioning/seed.sh;\
\ do\n bash -n \"$script\"\ndone\nmake check\n"
- name: Setup Packer
run: 'packer version
packer plugins installed | grep qemu'
- name: Validate Packer Template
run: 'packer plugins installed | grep qemu
packer validate ci/packer/apex-debian-13.pkr.hcl
'
build-image:
name: 'Build: Golden Image'
needs: validate-scripts
runs-on: apex-debian13
outputs:
image_name: ${{ steps.vars.outputs.image_name }}
steps:
- name: Checkout repository
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- name: Compute Image Metadata
id: vars
run: 'SHORT_SHA="$(echo ${{ github.sha }} | cut -c1-7)"
IMAGE_NAME="com-ermnvldmr-apex-debian-13-amd64-${SHORT_SHA}.qcow2"
echo "image_name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
'
- name: Install QEMU & Build Tools
run: 'sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-utils xorriso
sudo chmod 666 /dev/kvm 2>/dev/null || true
'
- name: Setup Packer
run: 'packer version
packer plugins installed | grep qemu'
- name: Build Debian 13 Image with Packer
run: 'packer plugins installed | grep qemu
packer build -var "vm_name=${{ steps.vars.outputs.image_name }}" -var "provision_revision=${{
github.sha }}" ci/packer/apex-debian-13.pkr.hcl
'
- name: Upload Image Artifact
uses: ./.gitea/actions/upload-artifact
with:
name: golden-image
path: output-qemu/${{ steps.vars.outputs.image_name }}
retention-days: 30
upload-s3:
name: 'Upload: S3 Storage'
needs: build-image
if: inputs.upload_s3 == true
runs-on: apex-debian13
steps:
- run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- name: Download Image Artifact
uses: ./.gitea/actions/download-artifact
with:
name: golden-image
path: output-qemu
- name: Upload to Static S3 (/dev/images/)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID
}}
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY
}}
AWS_DEFAULT_REGION: ${{ secrets.STATIC_AWS_REGION || secrets.COM_ERMNVLDMR_STATIC_S3_REGION
|| 'auto' }}
S3_ENDPOINT: ${{ secrets.STATIC_S3_ENDPOINT_URL || secrets.COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL
}}
S3_BUCKET: ${{ secrets.STATIC_S3_BUCKET || secrets.COM_ERMNVLDMR_STATIC_S3_BUCKET
}}
IMAGE_NAME: ${{ needs.build-image.outputs.image_name }}
run: "if [ -n \"${S3_BUCKET}\" ] && [ -n \"${AWS_ACCESS_KEY_ID}\" ]; then\n\
\ ENDPOINT_FLAG=\"\"\n if [ -n \"${S3_ENDPOINT}\" ]; then\n ENDPOINT_FLAG=\"\
--endpoint-url=${S3_ENDPOINT}\"\n fi\n echo \"Cleaning up older builds in\
\ dev/images/com-ermnvldmr-apex-debian-13-amd64-*.qcow2 to maintain quota...\"\
\n aws ${ENDPOINT_FLAG} s3 rm \"s3://${S3_BUCKET}/dev/images/\" --recursive\
\ --exclude \"*\" --include \"com-ermnvldmr-apex-debian-13-amd64-*.qcow2\"\
\ || true\n echo \"Uploading ${IMAGE_NAME}...\"\n aws ${ENDPOINT_FLAG} s3\
\ cp \"output-qemu/${IMAGE_NAME}\" \"s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}\"\
\n echo \"Golden image uploaded successfully to s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}\"\
\nelse\n echo \"S3 credentials not configured; skipping upload. Image artifact\
\ preserved in GitHub Actions run.\"\nfi\n"
+1 -1
View File
@@ -7,7 +7,7 @@ lint:
@if command -v ruff >/dev/null 2>&1; then \ @if command -v ruff >/dev/null 2>&1; then \
ruff check . scripts/bootstrap-node; \ ruff check . scripts/bootstrap-node; \
else \ else \
python3 -m compileall -q engine actions tests ci/scripts scripts && \ python3 -m compileall -q actions tests scripts && \
python3 -m py_compile scripts/bootstrap-node && \ python3 -m py_compile scripts/bootstrap-node && \
echo "ruff not installed in PATH; verified python bytecode compilation."; \ echo "ruff not installed in PATH; verified python bytecode compilation."; \
fi fi
+7 -23
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img alt="apex.ermnvldmr.com logo" src="./assets/logo.png" height="128" /> <img alt="apex.ermnvldmr.com logo" src="./etc/assets/logo.png" height="128" />
</p> </p>
# [apex.ermnvldmr.com](https://apex.ermnvldmr.com) # [apex.ermnvldmr.com](https://apex.ermnvldmr.com)
@@ -40,6 +40,8 @@ if [ -f ./init.sh ]; then ./init.sh && source ~/.bashrc; fi
./commons/apex --help ./commons/apex --help
``` ```
On existing checkouts that use the APEX Git hooks, run `git config --local core.hooksPath commons/scripts/githooks` from the node repository after updating the submodule.
Configure the node in its checkout. For example, on a stock Debian Docker host: Configure the node in its checkout. For example, on a stock Debian Docker host:
```sh ```sh
@@ -67,10 +69,8 @@ You should understand the following invariants before exploring the codebase:
- **[`actions/`](./actions)**: Built-in engine actions (`composition/`, `config/`, `repository/`, `tier/`, `util/`). - **[`actions/`](./actions)**: Built-in engine actions (`composition/`, `config/`, `repository/`, `tier/`, `util/`).
- **[`compositions/`](./compositions)**: The shared core Compose stack (`traefik`, `socket-proxy`, `alloy`, `adguard`, `xray`, `restic`). - **[`compositions/`](./compositions)**: The shared core Compose stack (`traefik`, `socket-proxy`, `alloy`, `adguard`, `xray`, `restic`).
- **[`engine/`](./engine)**: Framework engine — action discovery, overlay resolution, context injection, and logging. - **[`scripts/`](./scripts)**: Workstation bootstrap, the Python engine, host provisioning helpers, and Git hooks.
- **[`etc/`](./etc)**: Shared static configuration, including Alloy. - **[`etc/`](./etc)**: Shared configuration and static assets, including Alloy templates and the project logo.
- **[`ci/`](./ci)**: Host image build recipes, cloud-init templates, and image enrollment tooling.
- **[`assets/`](./assets)**: Repository branding assets (`logo.png`).
--- ---
@@ -141,21 +141,6 @@ On stock Debian the final policy allows only `adam` on port 2222 using a public
If a login fails, run `./commons/apex config/hardening rollback` from the checkout in the original session or let the timer restore the original SSH configuration five minutes after `begin`. Use `./commons/apex config/hardening status` to inspect the transition. [Action reference](https://docs.ermnvldmr.com/en/apex/reference/actions/#confighardening) If a login fails, run `./commons/apex config/hardening rollback` from the checkout in the original session or let the timer restore the original SSH configuration five minutes after `begin`. Use `./commons/apex config/hardening status` to inspect the transition. [Action reference](https://docs.ermnvldmr.com/en/apex/reference/actions/#confighardening)
### Infrastructure Administrator
Build Debian 13 amd64 golden images with Packer, then supply node identity,
operator keys, and the node repository through
[`ci/cloud-init/user-data.template.yaml`](./ci/cloud-init/user-data.template.yaml)
at first boot. Keep filled user-data and repository credentials outside Git.
```sh
packer init ci/packer/apex-debian-13.pkr.hcl
packer validate ci/packer/apex-debian-13.pkr.hcl
packer build ci/packer/apex-debian-13.pkr.hcl
```
Cloud-init runs the image's bundled enrollment helper. After enrollment, apply the individual `config/*` actions required by the node, then run `apex composition/run up`.
### Developer & Contributor ### Developer & Contributor
Adding shared actions or improving the engine. Adding shared actions or improving the engine.
@@ -166,11 +151,10 @@ Adding shared actions or improving the engine.
```sh ```sh
python3 -m venv .venv python3 -m venv .venv
. .venv/bin/activate . .venv/bin/activate
python3 -m pip install ruff==0.17.2 'PyYAML>=6.0,<7' python3 -m pip install ruff==0.17.2
``` ```
- **Verification**: `make check` runs lint and both the core and image provisioning suites. `make test` (or `bash tests/run.sh`) runs both suites; discovery limited to `tests/` omits provisioning. PyYAML is needed for the provisioning tests, not core execution. Environment integration tests use `docker compose` or standalone `docker-compose` with `config`; no daemon is needed. - **Verification**: `make check` runs lint and the core and host provisioning suites. `make test` (or `bash tests/run.sh`) runs both suites. Use `PYTHONPATH=scripts` when running individual test modules. Environment integration tests use `docker compose` or standalone `docker-compose` with `config`; no daemon is needed.
- **Linting**: Ruff checks correctness and security rules. Existing import-order, line-length, compact-statement, and annotation-style debt is explicitly deferred in `pyproject.toml` to avoid unrelated formatting changes. Without Ruff, `make lint` recursively checks Python compilation and fails on syntax errors; that fallback is not equivalent to Ruff. `make fmt` formats files and is a separate, deliberate operation. - **Linting**: Ruff checks correctness and security rules. Existing import-order, line-length, compact-statement, and annotation-style debt is explicitly deferred in `pyproject.toml` to avoid unrelated formatting changes. Without Ruff, `make lint` recursively checks Python compilation and fails on syntax errors; that fallback is not equivalent to Ruff. `make fmt` formats files and is a separate, deliberate operation.
- **Packer**: Run `packer init ci/packer/apex-debian-13.pkr.hcl` and `packer validate ci/packer/apex-debian-13.pkr.hcl` for template validation. A VM build or first-boot enrollment is a separate operation; unit tests and template validation do not demonstrate a deployed host.
--- ---
+2 -2
View File
@@ -7,6 +7,6 @@ METADATA = Meta(summary="Verify a working Docker toolchain or install it on stoc
def run(ctx, args): def run(ctx, args):
provisioning = Path(ctx.paths.commons) / "ci/scripts/init-node" provisioning = Path(ctx.paths.commons) / "scripts"
ctx.sys.sudo(["python3", "-E", "-s", "-m", "provisioning.baseline", "--docker-policy"], ctx.sys.sudo(["python3", "-E", "-s", "-m", "provisioning.docker"],
cwd=str(provisioning)) cwd=str(provisioning))
+1 -1
View File
@@ -174,7 +174,7 @@ def run(ctx, args):
if operation == "begin": if operation == "begin":
port, address = _original_endpoint(ctx, args) port, address = _original_endpoint(ctx, args)
options.extend(["--original-port", str(port), "--client-address", address, "--token", uuid.uuid4().hex]) options.extend(["--original-port", str(port), "--client-address", address, "--token", uuid.uuid4().hex])
installer = Path(__file__).resolve().parents[2] / "ci/scripts/init-node/provisioning/manual_bundle.py" installer = Path(__file__).resolve().parents[2] / "scripts/provisioning/manual_bundle.py"
bundle = ctx.sys.sudo(["/usr/bin/python3", "-I", "-B", str(installer)], capture=True).stdout.strip() bundle = ctx.sys.sudo(["/usr/bin/python3", "-I", "-B", str(installer)], capture=True).stdout.strip()
else: else:
if not state: if not state:
+2 -2
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""apex launcher — stdlib only. Puts commons/ (+ vendor/ + proprietaries/) on sys.path.""" """apex launcher — stdlib only. Loads the shared engine and optional checkout libraries."""
import os import os
import sys import sys
_here = os.path.dirname(os.path.realpath(__file__)) # commons dir (or standalone repo root) _here = os.path.dirname(os.path.realpath(__file__)) # commons dir (or standalone repo root)
sys.path.insert(0, _here) # make `engine` importable sys.path.insert(0, os.path.join(_here, "scripts"))
_vendor = os.path.join(_here, "vendor") _vendor = os.path.join(_here, "vendor")
if os.path.isdir(_vendor): if os.path.isdir(_vendor):
sys.path.insert(0, _vendor) sys.path.insert(0, _vendor)
-33
View File
@@ -1,33 +0,0 @@
#cloud-config
# Supply this file to cloud-init when booting the golden image.
# Replace the hostname, repository and public key. Keep filled files outside Git.
# SSH repositories additionally require approved deferred id_ed25519 (0600)
# and known_hosts (0644) writes under /home/adam/.ssh, owned by adam:adam.
# Supply trusted Git host keys; do not infer trust from ssh-keyscan alone.
preserve_hostname: false
hostname: node1.example.com
users:
- name: adam
sudo: ALL=(ALL) NOPASSWD:ALL
groups: [sudo, docker]
shell: /bin/bash
ssh_authorized_keys:
- "ssh-ed25519 REPLACE_WITH_OPERATOR_PUBLIC_KEY operator@workstation"
write_files:
- path: /etc/apex/bootstrap.env
owner: root:root
permissions: '0600'
content: |
APEX_REPO_NAME="node1.example.com"
APEX_REPO_URL="https://git.example.com/infrastructure/node1.example.com.git"
APEX_TIER1_MODE="directory"
APEX_TIER1_DEVICE=""
APEX_TIER2_MODE="directory"
APEX_TIER2_DEVICE=""
APEX_TIER3_MODE="directory"
APEX_TIER3_DEVICE=""
# For a verified empty disk, explicitly select MODE=create and DEVICE=/dev/disk/by-id/...
# For an existing single-device pool, select MODE=import, DEVICE and POOL_GUID.
# Occupied devices are never erased; missing devices never fall back to directories.
runcmd:
- /usr/local/bin/apex-bootstrap
-95
View File
@@ -1,95 +0,0 @@
packer {
required_plugins {
qemu = {
version = ">= 1.1.0"
source = "github.com/hashicorp/qemu"
}
}
}
variable "debian_cloud_image_url" {
type = string
default = "https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-13-genericcloud-amd64-daily.qcow2"
}
variable "debian_cloud_image_checksum" {
type = string
default = "none"
}
variable "vm_name" {
type = string
default = "com-ermnvldmr-apex-debian-13-amd64.qcow2"
}
variable "accelerator" {
type = string
default = "kvm"
}
variable "provision_revision" {
type = string
default = "working-tree"
}
source "qemu" "apex_debian" {
iso_url = var.debian_cloud_image_url
iso_checksum = var.debian_cloud_image_checksum
disk_image = true
disk_size = "10G"
format = "qcow2"
output_directory = "output-qemu"
shutdown_command = "sudo -n shutdown -P now"
accelerator = var.accelerator
cd_files = ["ci/packer/cidata/user-data", "ci/packer/cidata/meta-data"]
cd_label = "cidata"
ssh_username = "debian"
ssh_password = "packer"
ssh_timeout = "10m"
vm_name = var.vm_name
net_device = "virtio-net"
disk_interface = "virtio"
headless = true
qemuargs = [
["-m", "2048M"],
["-smp", "2"]
]
}
build {
sources = ["source.qemu.apex_debian"]
provisioner "file" {
source = "ci/scripts/init-node/provisioning"
destination = "/tmp/"
}
provisioner "shell" {
execute_command = "sudo -n sh -c '{{ .Vars }} {{ .Path }}'"
environment_vars = ["APEX_PROVISION_REVISION=${var.provision_revision}"]
inline = [
"bash /tmp/provisioning/seed.sh",
"cd /tmp && python3 -m provisioning.image install"
]
}
provisioner "shell" {
execute_command = "sudo -n sh -c '{{ .Vars }} {{ .Path }}'"
scripts = [
"ci/packer/scripts/01-base-system.sh",
"ci/packer/scripts/02-storage-docker.sh",
"ci/packer/scripts/02-alloy-package.sh",
"ci/packer/scripts/03-bootstrap-helper.sh",
"ci/packer/scripts/04-cleanup.sh"
]
}
post-processor "shell-local" {
inline = [
"echo 'Compressing and optimizing qcow2 image with 2M cluster size...'",
"qemu-img convert -f qcow2 -O qcow2 -o cluster_size=2M -c output-qemu/${var.vm_name} output-qemu/com-ermnvldmr-apex-debian-13-optimized.qcow2",
"mv output-qemu/com-ermnvldmr-apex-debian-13-optimized.qcow2 output-qemu/${var.vm_name}"
]
}
}
-2
View File
@@ -1,2 +0,0 @@
instance-id: apex-golden-image-build
local-hostname: apex-builder
-14
View File
@@ -1,14 +0,0 @@
#cloud-config
ssh_pwauth: true
disable_root: false
chpasswd:
list: |
debian:packer
root:packer
expire: false
users:
- default
- name: debian
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: false
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=60 update
apt-get -o DPkg::Lock::Timeout=60 install -y --no-upgrade --no-install-recommends qemu-guest-agent cloud-init
echo "=== [3/6] Configuring VirtIO Kernel Modules ==="
cat << 'EOF_VIRTIO' > /etc/modules-load.d/virtio.conf
virtio
virtio_net
virtio_blk
virtio_pci
virtio_scsi
virtiofs
EOF_VIRTIO
echo "=== [4/6] Configuring GRUB Serial Console & Predictable Interfaces ==="
sed -i 's/^GRUB_CMDLINE_LINUX_DEFAULT=.*/GRUB_CMDLINE_LINUX_DEFAULT="console=tty0 console=ttyS0,115200n8 net.ifnames=0 biosdevname=0"/' /etc/default/grub
sed -i 's/^#GRUB_TERMINAL=.*/GRUB_TERMINAL="console serial"/' /etc/default/grub
if ! grep -q "GRUB_SERIAL_COMMAND" /etc/default/grub; then
echo 'GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1"' >> /etc/default/grub
fi
update-grub
mkdir -p /etc/systemd/system/serial-getty@ttyS0.service.d
systemctl enable serial-getty@ttyS0.service || true
systemctl enable qemu-guest-agent 2>/dev/null || true
echo "=== [5/6] Configuring Cloud-Init Datasources & Service Enablement ==="
mkdir -p /etc/cloud /etc/cloud/cloud.cfg.d
cat << 'EOF_DS' > /etc/cloud/ds-identify.cfg
policy: enabled
EOF_DS
cat << 'EOF_CLOUD_CFG' > /etc/cloud/cloud.cfg.d/99-cloud-init.cfg
datasource_list: [ NoCloud, ConfigDrive, OpenStack, Ec2, None ]
disable_vm_template: false
EOF_CLOUD_CFG
# Cloud-init services are static and enabled by generator via ds-identify
systemctl enable cloud-init-local.service cloud-init.service cloud-config.service cloud-final.service 2>/dev/null || true
echo "=== [6/6] Configuring Predictable Network DHCP ==="
mkdir -p /etc/systemd/network
cat << 'EOF_NET' > /etc/systemd/network/10-eth.network
[Match]
Name=eth* en*
[Network]
DHCP=yes
EOF_NET
systemctl enable systemd-networkd systemd-resolved || true
mkdir -p /etc/network/interfaces.d
cat << 'EOF_IF' > /etc/network/interfaces.d/eth0
auto eth0
iface eth0 inet dhcp
EOF_IF
echo "=== Base System Configuration Complete ==="
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Service configuration and credentials are deployed later by apex config/alloy.
alloy_version=1.21.3
alloy_sha256=dbbd705de8ab37dec0cbd6361a3fc7e754d6b2854b57ce672a83e6366199ba73
[[ "$(dpkg --print-architecture)" == amd64 ]]
alloy_deb=$(mktemp --suffix=.deb)
trap 'rm -f "$alloy_deb"' EXIT
curl --fail --location --retry 3 --output "$alloy_deb" \
"https://github.com/grafana/alloy/releases/download/v${alloy_version}/alloy-${alloy_version}-1.amd64.deb"
printf '%s %s\n' "$alloy_sha256" "$alloy_deb" | sha256sum --check --status
systemctl mask alloy.service
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$alloy_deb"
systemctl disable --now alloy.service
systemctl unmask alloy.service
! systemctl is-active --quiet alloy.service
! systemctl is-enabled --quiet alloy.service
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
/usr/local/bin/apex-provision-image baseline
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
/usr/local/bin/apex-provision-image install-runner
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
/usr/local/bin/apex-provision-image sanitize
apt-get clean
cloud-init clean --logs --seed
sync
@@ -1,3 +0,0 @@
"""Shared initialization primitives; importing this package performs no host changes."""
ENROLLMENT_INTERFACE = 1
@@ -1,579 +0,0 @@
"""Shared Debian baseline. Callers own the exclusive initialization lock."""
from __future__ import annotations
from contextlib import contextmanager
import fcntl
from functools import wraps
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import tempfile
import urllib.request
import uuid
class BaselineError(RuntimeError):
"""A required baseline postcondition could not be established."""
def expected_errors(operation):
@wraps(operation)
def guarded(*args, **kwargs):
try:
return operation(*args, **kwargs)
except (OSError, ValueError) as exc:
raise BaselineError("Baseline filesystem or saved state is invalid; inspect the target before retrying.") from exc
return guarded
BASE_PACKAGES = ("curl", "wget", "git", "rsyslog", "ufw")
ADMIN_PACKAGES = (
"tcpdump", "socat", "rsync", "bind9-dnsutils", "iproute2", "net-tools",
"traceroute", "ca-certificates", "jq", "sed", "gawk", "tar", "gzip",
"bzip2", "xz-utils", "unzip", "htop", "iotop", "strace", "lsof",
"sysstat", "parted", "fdisk", "pciutils", "usbutils", "smartmontools",
"tmux", "vim", "less", "tree", "bash-completion", "python3",
"python3-minimal", "python3-yaml", "sudo", "cron", "iptables", "ipset",
"debian-archive-keyring", "kmod",
)
DOCKER_PACKAGES = ("docker-ce", "docker-ce-cli", "containerd.io",
"docker-buildx-plugin", "docker-compose-plugin")
SECURITY_PACKAGES = ("crowdsec", "crowdsec-firewall-bouncer-iptables")
DOCKER_CONFLICTS = ("docker.io", "docker-doc", "docker-compose", "docker-buildx",
"podman-docker", "containerd", "runc")
DOCKER_CONFIG = {"data-root": "/srv/docker", "log-driver": "json-file",
"log-opts": {"max-size": "20m", "max-file": "3"}}
HELPER_REVISION = "020a8699f95592561f254d8d4ad1bb40d401dfc7"
HELPER_URL = f"https://raw.githubusercontent.com/chaifeng/ufw-docker/{HELPER_REVISION}/ufw-docker"
HELPER_SHA256 = "643e56b080567c567b4aa28650196849b2a2da5dd0473fd3e5216b0886035ab0"
POLICY = Path("/usr/sbin/policy-rc.d")
POLICY_STATE = Path("/usr/sbin/.apex-policy-rc.d")
SECURITY_STATE = Path("/var/lib/apex/baseline-security.json")
SECURITY_SERVICES = {"ufw": "ufw", "crowdsec": "crowdsec",
"crowdsec-firewall-bouncer-iptables": "crowdsec-firewall-bouncer"}
SECURITY_CONDITION = "[Unit]\nConditionPathExists=/dev/null/apex-security-start-allowed\n"
APT = ["apt-get", "-o", "DPkg::Lock::Timeout=60", "-o",
"Acquire::Retries=2", "-o", "Acquire::http::Timeout=30", "-o",
"Acquire::https::Timeout=30"]
def run(args, *, check=True, timeout=300):
try:
result = subprocess.run(args, text=True, capture_output=True, timeout=timeout,
env={**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"})
except (OSError, subprocess.TimeoutExpired) as exc:
raise BaselineError(f"Cannot complete {args[0]}: {exc}") from exc
if check and result.returncode:
raise BaselineError(f"{args[0]} failed with exit status {result.returncode}; inspect target system logs.")
return result
def preflight(*, strict=True):
if os.geteuid() != 0:
raise BaselineError("Baseline requires root.")
if not strict:
if not Path("/etc/debian_version").is_file():
raise BaselineError("Base policy requires a Debian-based system.")
return
if not Path("/etc/os-release").is_file():
raise BaselineError("Supported target is Debian 13 amd64 only.")
release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines()
if "=" in line and not line.startswith("#"))
if (release.get("ID", "").strip('"') != "debian" or
release.get("VERSION_ID", "").strip('"') != "13" or
run(["dpkg", "--print-architecture"]).stdout.strip() != "amd64"):
raise BaselineError("Supported target is Debian 13 amd64 only.")
def package_versions():
architecture = run(["dpkg", "--print-architecture"]).stdout.strip()
result = run(["dpkg-query", "-W", "-f=${binary:Package}\t${Status}\t${Version}\n"])
installed = {}
for line in result.stdout.splitlines():
fields = line.split("\t")
if len(fields) == 3 and fields[1].split()[1:] == ["ok", "installed"]:
installed[fields[0].removesuffix(":" + architecture)] = fields[2]
return installed
def write_managed(path, content, mode=0o644):
path = Path(path)
for parent in (path, *path.parents):
if parent.is_symlink():
raise BaselineError(f"Refusing managed symlink: {parent}")
payload = content.encode() if isinstance(content, str) else content
if path.exists() and path.read_bytes() == payload:
if path.stat().st_mode & 0o777 != mode:
path.chmod(mode)
return False
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".apex-", dir=path.parent)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, mode)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return True
def restore_service_policy():
if not POLICY_STATE.exists():
return
backup = POLICY_STATE / "original"
if (POLICY_STATE.is_symlink() or POLICY_STATE.stat().st_uid != os.geteuid()):
raise BaselineError("Refusing untrusted service policy recovery state.")
if (POLICY_STATE / "ready").exists() and POLICY.exists():
original_matches = (backup.exists() and not POLICY.is_symlink() and
POLICY.read_bytes() == backup.read_bytes())
symlink_matches = (backup.is_symlink() and POLICY.is_symlink() and
os.readlink(backup) == os.readlink(POLICY))
guard_matches = not POLICY.is_symlink() and POLICY.read_bytes() == b"#!/bin/sh\nexit 101\n"
if not (original_matches or symlink_matches or guard_matches):
raise BaselineError("Service policy changed outside APEX; recovery requires explicit reconciliation.")
if backup.exists() or backup.is_symlink():
# Copy first: an interrupted restoration must retain its recovery copy.
temporary = POLICY_STATE / "restoring"
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
shutil.copy2(backup, temporary, follow_symlinks=False)
os.replace(temporary, POLICY)
elif (POLICY_STATE / "absent").exists():
POLICY.unlink(missing_ok=True)
elif (POLICY_STATE / "ready").exists():
raise BaselineError("Service policy recovery is incomplete; inspect /usr/sbin/.apex-policy-rc.d.")
shutil.rmtree(POLICY_STATE)
@contextmanager
def service_start_guard():
lock_path = Path("/run/lock/apex-package-policy.lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise BaselineError("Another APEX package operation is running.") from None
with _service_start_guard():
yield
@contextmanager
def _service_start_guard():
restore_service_policy()
POLICY_STATE.mkdir(mode=0o700)
try:
if POLICY.exists() or POLICY.is_symlink():
shutil.copy2(POLICY, POLICY_STATE / "original", follow_symlinks=False)
else:
(POLICY_STATE / "absent").touch()
(POLICY_STATE / "ready").touch()
# Replace the path itself, preserving any original symlink in the backup.
temporary = POLICY_STATE / "guard"
temporary.write_text("#!/bin/sh\nexit 101\n")
temporary.chmod(0o755)
os.replace(temporary, POLICY)
yield
finally:
restore_service_policy()
@contextmanager
def security_service_guard(packages):
"""Guard new security units without occupying package-owned unit paths."""
record = json.loads(SECURITY_STATE.read_text()) if SECURITY_STATE.exists() else {"version": 2, "services": {}}
if not isinstance(record, dict) or record.get("version") != 2:
raise BaselineError("Legacy security service guard state requires explicit reconciliation.")
managed = record.get("services")
if not isinstance(managed, dict) or any(SECURITY_SERVICES.get(key) != value for key, value in managed.items()):
raise BaselineError("Invalid baseline security service recovery state.")
installed = package_versions()
for package, service in SECURITY_SERVICES.items():
if package in packages and package not in installed and package not in managed:
if run(["systemctl", "is-enabled", service], check=False).stdout.strip() == "masked":
continue
managed[package] = service
write_managed(SECURITY_STATE, json.dumps({"version": 2, "services": managed}), 0o600)
for service in managed.values():
dropin = Path(f"/etc/systemd/system/{service}.service.d/99-apex-bootstrap-guard.conf")
if dropin.exists() and dropin.read_text() != SECURITY_CONDITION:
raise BaselineError("Security service guard changed outside APEX; reconcile it explicitly.")
# /dev/null cannot have children: this condition stays false after reboot.
write_managed(dropin, SECURITY_CONDITION)
if managed:
run(["systemctl", "daemon-reload"])
try:
yield
finally:
installed = package_versions()
for package, service in managed.items():
unit_exists = any(Path(f"{directory}/{service}.service").exists() for directory in
("/etc/systemd/system", "/usr/lib/systemd/system", "/lib/systemd/system"))
run(["systemctl", "disable", service], check=package in installed or unit_exists)
dropin = Path(f"/etc/systemd/system/{service}.service.d/99-apex-bootstrap-guard.conf")
if dropin.exists():
if dropin.is_symlink() or dropin.read_text() != SECURITY_CONDITION:
raise BaselineError("Security service guard changed outside APEX; reconcile it explicitly.")
dropin.unlink()
if managed:
run(["systemctl", "daemon-reload"])
SECURITY_STATE.unlink(missing_ok=True)
def install_missing(packages):
installed = package_versions()
missing = [package for package in packages if package not in installed]
if not missing:
return []
run([*APT, "update", "--error-on=any"])
command = [*APT, "install", "-y", "--no-install-recommends", "--no-upgrade", "--no-remove", *missing]
plan = run([*command, "--simulate"]).stdout
if any(line.startswith("Remv ") or re.match(r"^Inst \S+ \[", line)
for line in plan.splitlines()):
raise BaselineError("Missing packages require an upgrade/removal; reconcile packages explicitly before retrying.")
# Package writes must settle even after coordinator loss; stages gate later work.
run(command, timeout=None)
remaining = set(missing) - package_versions().keys()
if remaining:
raise BaselineError("Required packages not installed: " + ", ".join(sorted(remaining)))
return missing
def base_policy():
return {"packages": list(BASE_PACKAGES), "files": {
"/etc/apt/apt.conf.d/99-apex": 'APT::Keep-Downloaded-Packages "0";\n',
"/etc/systemd/journald.conf.d/99-apex.conf": "[Journal]\nSystemMaxUse=200M\n",
}, "helper": {"url": HELPER_URL, "sha256": HELPER_SHA256}}
def configure_common():
policy = base_policy()
for legacy in ("/etc/apt/apt.conf.d/01clean", "/etc/systemd/journald.conf.d/maxuse.conf"):
path = Path(legacy)
if path.exists() or path.is_symlink():
path.unlink()
for path, content in policy["files"].items():
changed = write_managed(path, content)
if changed and path.endswith("99-apex.conf"):
run(["systemctl", "restart", "systemd-journald"])
target = Path("/usr/local/bin/ufw-docker")
if (not target.is_file() or hashlib.sha256(target.read_bytes()).hexdigest() != HELPER_SHA256):
try:
with urllib.request.urlopen(HELPER_URL, timeout=30) as response:
data = response.read()
except OSError as exc:
raise BaselineError("Unable to download pinned ufw-docker helper.") from exc
if hashlib.sha256(data).hexdigest() != HELPER_SHA256:
raise BaselineError("Pinned ufw-docker checksum mismatch.")
write_managed(target, data, 0o755)
elif target.is_symlink():
raise BaselineError("Refusing a symlinked ufw-docker helper.")
else:
target.chmod(0o755)
def docker_preflight(installed):
conflicts = sorted(set(DOCKER_CONFLICTS) & installed.keys())
if conflicts:
raise BaselineError("Conflicting Docker packages require explicit reconciliation: " + ", ".join(conflicts))
path = Path("/etc/docker/daemon.json")
try:
config = json.loads(path.read_text()) if path.exists() else {}
except (OSError, ValueError) as exc:
raise BaselineError("Cannot validate existing Docker daemon configuration.") from exc
if not isinstance(config, dict):
raise BaselineError("Docker daemon configuration must be an object.")
for name, value in DOCKER_CONFIG.items():
if name in config and config[name] != value:
raise BaselineError(f"Conflicting Docker {name}; reconcile explicitly before initialization.")
old_data = Path("/var/lib/docker")
if old_data.exists() and any(old_data.iterdir()):
raise BaselineError("Existing /var/lib/docker data must be reconciled explicitly; no data migration is automatic.")
data = Path("/srv/docker")
if data.is_symlink() or data.parent.is_symlink():
raise BaselineError("Refusing a redirected Docker data-root.")
if data.exists() and any(data.iterdir()) and config.get("data-root") != "/srv/docker":
raise BaselineError("Unmanaged /srv/docker data requires explicit reconciliation.")
if "docker-ce" in installed:
active = run(["systemctl", "is-active", "--quiet", "docker"], check=False).returncode == 0
if active:
root = run(["docker", "info", "--format", "{{.DockerRootDir}}"] ).stdout.strip()
if root != "/srv/docker" or any(config.get(key) != value for key, value in DOCKER_CONFIG.items()):
raise BaselineError("Running Docker has incompatible data-root/log policy; reconcile it before retrying.")
return {**config, **DOCKER_CONFIG}
DOCKER_KEY_SHA256 = "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570"
LOCAL_DOCKER = ["docker", "--host", "unix:///var/run/docker.sock"]
def _docker_source(arch):
key = Path("/etc/apt/keyrings/apex-docker.asc")
source = Path("/etc/apt/sources.list.d/apex-docker.sources")
content = ("Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: trixie\n"
f"Components: stable\nArchitectures: {arch}\nSigned-By: {key}\n")
for path in (key, source):
if any(part.is_symlink() for part in (path, *path.parents)):
raise BaselineError(f"Refusing redirected APT path: {path}")
if source.exists() and source.read_text() != content:
raise BaselineError(f"Reconcile existing Docker repository configuration: {source}")
if key.exists():
data = key.read_bytes()
else:
url = "https://download.docker.com/linux/debian/gpg"
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
data = response.read(1024 * 1024)
if hashlib.sha256(data).hexdigest() != DOCKER_KEY_SHA256:
raise BaselineError("Docker repository signing key differs from the pinned release key.")
write_managed(key, data)
write_managed(source, content)
def _docker_capabilities(root, *, complete_plugins=False):
capabilities = {"docker_data_root": root}
plugins = {"docker-compose-plugin": ("compose", ["compose", "version", "--short"]),
"docker-buildx-plugin": ("buildx", ["buildx", "version"])}
missing = []
for package, (name, args) in plugins.items():
result = run([*LOCAL_DOCKER, *args], check=False)
if result.returncode or not result.stdout.strip():
missing.append(package)
else:
capabilities[name] = result.stdout.strip()
if missing:
if complete_plugins and "docker-ce" in package_versions():
with service_start_guard():
install_missing(missing)
return _docker_capabilities(root)
raise BaselineError("Existing Docker needs functional plugins: " + ", ".join(missing))
if not root:
raise BaselineError("Unable to verify the local Docker data-root.")
return capabilities
@expected_errors
def apply_docker_policy():
if Path("/usr/bin/pveversion").exists():
raise BaselineError("Docker installation on a Proxmox host requires separate administration.")
release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines()
if "=" in line and not line.startswith("#"))
arch = run(["dpkg", "--print-architecture"]).stdout.strip()
if (release.get("ID", "").strip('"') != "debian" or
release.get("VERSION_ID", "").strip('"') != "13" or arch not in ("amd64", "arm64")):
raise BaselineError("Docker installation supports stock Debian 13 amd64 or arm64.")
info = [*LOCAL_DOCKER, "info", "--format", "{{.DockerRootDir}}"]
if shutil.which("docker"):
current = run(info, check=False)
if current.returncode == 0:
return _docker_capabilities(current.stdout.strip(), complete_plugins=True)
installed = package_versions()
source = Path("/etc/apt/sources.list.d/apex-docker.sources")
if (not source.exists() and
(shutil.which("docker") or set(DOCKER_PACKAGES).intersection(installed))):
raise BaselineError("Existing Docker installation is inactive; reconcile it before retrying.")
config = docker_preflight(installed)
with service_start_guard():
install_missing(("ca-certificates",))
_docker_source(arch)
write_managed("/etc/docker/daemon.json", json.dumps(config, indent=2) + "\n")
install_missing(DOCKER_PACKAGES)
ensure_service("containerd")
ensure_service("docker")
root = run(info).stdout.strip()
if root != DOCKER_CONFIG["data-root"]:
raise BaselineError("Docker started with an unexpected data-root; inspect its configuration.")
return _docker_capabilities(root)
def configure_sources():
write_managed("/etc/apt/sources.list.d/apex-zfs.sources",
"Types: deb\nURIs: https://deb.debian.org/debian\nSuites: trixie\n"
"Components: contrib\nArchitectures: amd64\n"
"Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg\n")
for name, key_url, uri, suite, component in (
("docker", "https://download.docker.com/linux/debian/gpg",
"https://download.docker.com/linux/debian", "trixie", "stable"),
("crowdsec", "https://packagecloud.io/crowdsec/crowdsec/gpgkey",
"https://packagecloud.io/crowdsec/crowdsec/any", "any", "main"),
):
key = Path(f"/etc/apt/keyrings/apex-{name}.asc")
if not key.exists():
try:
# Both repository key URLs above are fixed HTTPS endpoints.
with urllib.request.urlopen(key_url, timeout=30) as response: # noqa: S310
data = response.read()
except OSError as exc:
raise BaselineError(f"Cannot download {name} repository signing key.") from exc
if not data.startswith(b"-----BEGIN PGP PUBLIC KEY BLOCK-----"):
raise BaselineError(f"Invalid {name} repository signing key.")
write_managed(key, data)
write_managed(f"/etc/apt/sources.list.d/apex-{name}.sources",
f"Types: deb\nURIs: {uri}\nSuites: {suite}\nComponents: {component}\n"
f"Architectures: amd64\nSigned-By: {key}\n")
def ensure_service(name):
if run(["systemctl", "is-enabled", "--quiet", name], check=False).returncode:
run(["systemctl", "enable", name])
if run(["systemctl", "is-active", "--quiet", name], check=False).returncode:
run(["systemctl", "start", name])
def repair_bouncer_credentials():
"""Repair package placeholders without rewriting administrator YAML settings."""
config = Path("/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml")
identity = config.with_suffix(".yaml.id")
for path in (config, identity):
if any(parent.is_symlink() for parent in (path, *path.parents)):
raise BaselineError("Refusing redirected CrowdSec bouncer credential paths.")
if path.exists():
metadata = path.stat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or
metadata.st_uid != os.geteuid()):
raise BaselineError("Refusing untrusted CrowdSec bouncer credential files.")
if not config.exists():
raise BaselineError("CrowdSec bouncer configuration is missing; reconcile the package installation.")
content = config.read_text()
entries = list(re.finditer(r"^(?:api_key|'api_key'|\"api_key\"):[^\r\n]*", content, re.MULTILINE))
if len(entries) != 1:
raise BaselineError("CrowdSec bouncer requires one explicit api_key; reconcile its configuration.")
entry = entries[0]
for following in content[entry.end():].splitlines():
if not following.strip() or following.lstrip().startswith("#"):
continue
if following[:1].isspace():
raise BaselineError("Multiline CrowdSec bouncer api_key requires explicit reconciliation.")
break
scalar = re.fullmatch(r"(?:api_key|'api_key'|\"api_key\"):[ \t]*(?P<value>\"[^\"]*\"|'[^']*'|[^ \t#'\"]*)(?:[ \t]+(?:#.*)?)?", entry.group())
if scalar is None:
raise BaselineError("Unsupported CrowdSec bouncer api_key; reconcile its configuration.")
value = scalar.group("value")
uninitialized = value in ("null", "Null", "NULL", "~")
if value[:1] in ("'", '"'):
value = value[1:-1]
if not uninitialized and value not in ("", "<API_KEY>", "$API_KEY", "${API_KEY}"):
return
metadata = config.stat()
registration = "cs-firewall-bouncer-apex-" + uuid.uuid4().hex
key = run(["cscli", "-oraw", "bouncers", "add", registration]).stdout.strip()
if not re.fullmatch(r"[A-Za-z0-9_+/=-]{24,256}", key):
raise BaselineError("CrowdSec bouncer registration did not return a usable API key.")
start, end = scalar.span("value")
separator = " " if entry.group()[start - 1] == ":" else ""
updated = content[:entry.start() + start] + separator + key + content[entry.start() + end:]
def publish(path, payload, mode, owner):
fd, temporary = tempfile.mkstemp(prefix=".apex-", dir=path.parent)
try:
with os.fdopen(fd, "w") as stream:
os.fchown(stream.fileno(), *owner)
os.fchmod(stream.fileno(), mode)
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
# Publish the key last: interruption can orphan a registration, but retries
# replace the placeholder and its package bookkeeping together in this order.
owner = (metadata.st_uid, metadata.st_gid)
publish(identity, registration + "\n", 0o600, owner)
publish(config, updated, (stat.S_IMODE(metadata.st_mode) & 0o640) | 0o600, owner)
if config.read_text() != updated or identity.read_text().strip() != registration:
raise BaselineError("CrowdSec bouncer credential publication did not converge.")
@expected_errors
def apply_base_policy():
"""Apply the base package, logging, and firewall policy."""
preflight(strict=False)
with service_start_guard(), security_service_guard(BASE_PACKAGES):
install_missing(BASE_PACKAGES)
configure_common()
ensure_service("rsyslog")
run(["/usr/local/bin/ufw-docker", "install"])
return {"packages": {key: value for key, value in package_versions().items() if key in BASE_PACKAGES}}
@expected_errors
def apply_baseline():
"""Converge baseline and return verified package versions and capabilities."""
preflight()
installed = package_versions()
docker_config = docker_preflight(installed)
kernel = run(["uname", "-r"]).stdout.strip()
if not re.fullmatch(r"[A-Za-z0-9.+_-]+", kernel):
raise BaselineError("Unsupported running kernel release.")
packages = (*BASE_PACKAGES, *ADMIN_PACKAGES, *DOCKER_PACKAGES,
*SECURITY_PACKAGES, "linux-headers-amd64", f"linux-headers-{kernel}",
"zfsutils-linux", "zfs-dkms")
with service_start_guard(), security_service_guard(packages):
install_missing((*BASE_PACKAGES, *ADMIN_PACKAGES))
configure_sources()
install_missing(("crowdsec",))
install_missing(packages)
repair_bouncer_credentials()
configure_common()
write_managed("/etc/docker/daemon.json", json.dumps(docker_config, indent=2) + "\n")
Path("/srv/docker").mkdir(parents=True, exist_ok=True)
ensure_service("containerd")
ensure_service("docker")
ensure_service("rsyslog")
ensure_service("cron")
root = run(["docker", "info", "--format", "{{.DockerRootDir}}"] ).stdout.strip()
if root != "/srv/docker":
raise BaselineError("Docker did not activate the required /srv/docker data-root.")
compose = run(["docker", "compose", "version", "--short"]).stdout.strip()
if not compose:
raise BaselineError("Docker Compose capability is unavailable.")
module = run(["modprobe", "zfs"], check=False)
if module.returncode:
raise BaselineError(f"ZFS unavailable for running kernel {kernel}; install matching headers/module or reboot into the supported kernel, then retry. No automatic reboot was performed.")
zfs = run(["zfs", "version"]).stdout.strip()
versions = package_versions()
if set(packages) - versions.keys():
raise BaselineError("Baseline package verification failed.")
return {"packages": {name: versions[name] for name in packages},
"capabilities": {"docker_data_root": root, "compose": compose, "zfs": zfs,
"kernel": kernel, "ufw_docker_sha256": HELPER_SHA256},
"sources": {"docker": "docker.com/debian trixie stable",
"crowdsec": "packagecloud.io/crowdsec/crowdsec/any any main",
"zfs": "Debian trixie contrib"}}
if __name__ == "__main__":
import argparse
from .state import ProvisionError, exclusive_lock
parser = argparse.ArgumentParser(description=__doc__)
scope = parser.add_mutually_exclusive_group()
scope.add_argument("--base-policy", action="store_true")
scope.add_argument("--docker-policy", action="store_true")
args = parser.parse_args()
try:
preflight(strict=not (args.base_policy or args.docker_policy))
with exclusive_lock("/var/lib/apex-init/init.lock"):
if args.docker_policy:
result = apply_docker_policy()
elif args.base_policy:
result = apply_base_policy()
else:
result = apply_baseline()
print(json.dumps(result, sort_keys=True))
except (BaselineError, OSError, ProvisionError) as exc:
parser.exit(1, f"Baseline failed: {exc}\n")
@@ -1,51 +0,0 @@
"""Deterministic provisioning payloads for golden-image builds."""
import hashlib
import io
from pathlib import Path
import re
import shutil
import subprocess
import tarfile
from .state import ProvisionError
def _bundle_from_source(root):
root = Path(root).resolve()
package = root / 'provisioning'
files = sorted(package.rglob('*.py'))
seed = package / 'seed.sh'
guard = package / 'profiles.sh'
if not seed.is_file() or not (package / '__init__.py').is_file():
raise ProvisionError('Provisioning package or seed is missing')
files.append(seed)
if guard.is_file():
files.append(guard)
output = io.BytesIO()
with tarfile.open(fileobj=output, mode='w') as archive:
for path in files:
if (not path.is_file() or '__pycache__' in path.parts
or any(part.is_symlink() for part in (path, *path.parents))):
raise ProvisionError('Provisioning bundle contains an unsafe source path')
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(root).as_posix())
info.size, info.mode, info.mtime = len(data), (0o755 if path == seed else 0o644), 0
info.uid = info.gid = 0
archive.addfile(info, io.BytesIO(data))
data = output.getvalue()
return data, hashlib.sha256(data).hexdigest()
def build_bundle(commons):
root = Path(commons).resolve()
data, digest = _bundle_from_source(root / 'ci/scripts/init-node')
revision = 'unversioned-modified'
if shutil.which('git'):
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
revision = result.stdout.strip()
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'ci/scripts/init-node'], capture_output=True, text=True, timeout=30)
if dirty.returncode or dirty.stdout.strip():
revision += '-modified'
return data, digest, revision
@@ -1,236 +0,0 @@
"""Pure validation for the supported APEX enrollment contract."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import PurePosixPath
from typing import Any, Dict, Mapping, Optional, Tuple
from urllib.parse import urlsplit
class ContractError(ValueError):
"""A safe diagnostic that never includes a supplied value."""
@dataclass(frozen=True)
class Repository:
name: str
url: str
ref: Optional[str]
@dataclass(frozen=True)
class Tier:
number: int
mode: str
device: Optional[str]
pool_guid: Optional[str]
@dataclass(frozen=True)
class ManagedFile:
path: str
owner: str
mode: int
content: str = field(repr=False)
@dataclass(frozen=True)
class Enrollment:
hostname: str
repository: Repository
tiers: Tuple[Tier, ...]
authorized_keys: Tuple[str, ...] = field(repr=False)
files: Tuple[ManagedFile, ...] = field(repr=False)
def identity(self) -> Dict[str, Any]:
return {
'hostname': self.hostname,
'repository': {
'name': self.repository.name,
'url': self.repository.url,
'ref': self.repository.ref,
},
'tiers': [
{'number': t.number, 'mode': t.mode,
'device': t.device, 'pool_guid': t.pool_guid}
for t in self.tiers
],
}
_ENV_KEYS = {'APEX_REPO_NAME', 'APEX_REPO_URL', 'APEX_REPO_REF'} | {
f'APEX_TIER{number}_{suffix}'
for number in (1, 2, 3)
for suffix in ('DEVICE', 'MODE', 'POOL_GUID')
}
_ASSIGNMENT = re.compile(
r'''\s*([A-Z][A-Z0-9_]*)\s*=\s*(?:"([^"\\]*)"|'([^'\\]*)'|([^\s'"\\()#]*))\s*'''
)
_FILE_POLICY = {
'/etc/apex/bootstrap.env': ('root:root', 0o600, False),
'/home/adam/.ssh/id_ed25519': ('adam:adam', 0o600, True),
'/home/adam/.ssh/known_hosts': ('adam:adam', 0o644, True),
}
def _require(condition: bool, diagnostic: str) -> None:
if not condition:
raise ContractError(diagnostic)
def _mapping(value: Any, required: set, optional: set, diagnostic: str) -> Mapping:
_require(isinstance(value, Mapping), diagnostic)
_require(set(value) >= required and set(value) <= required | optional, diagnostic)
return value
def _plain(value: Any, diagnostic: str, allow_empty: bool = False) -> str:
_require(type(value) is str, diagnostic)
_require(allow_empty or bool(value), diagnostic)
_require(not any(ord(c) < 32 or ord(c) == 127 for c in value), diagnostic)
return value
def parse_bootstrap_env(text: str) -> Dict[str, str]:
_require(type(text) is str, 'bootstrap.env must contain literal assignments')
result = {}
for line in text.splitlines():
if not line.strip() or line.lstrip().startswith('#'):
continue
match = _ASSIGNMENT.fullmatch(line)
_require(match is not None, 'bootstrap.env must contain literal assignments')
key = match.group(1)
_require(key in _ENV_KEYS, 'bootstrap.env contains an unsupported assignment')
_require(key not in result, 'bootstrap.env contains a duplicate assignment')
value = next(part for part in match.groups()[1:] if part is not None)
_require(not any(c in value for c in '$`;|&<>'),
'bootstrap.env contains unsupported shell syntax')
result[key] = _plain(value, 'bootstrap.env contains an invalid literal', True)
return result
def _repository(values: Mapping[str, str]) -> Repository:
name = values.get('APEX_REPO_NAME', '')
_require(bool(re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', name))
and name not in ('.', '..'), 'repository name must be one safe directory name')
url = values.get('APEX_REPO_URL', '')
_require(bool(url) and not any(c.isspace() for c in url), 'repository URL is invalid')
if '://' in url:
try:
parsed = urlsplit(url)
port = parsed.port
except ValueError:
raise ContractError('repository URL is invalid') from None
_require(parsed.scheme in ('https', 'ssh') and bool(parsed.hostname)
and parsed.password is None and not parsed.query and not parsed.fragment
and bool(parsed.path.strip('/')) and (port is None or port > 0),
'repository URL is invalid or contains credentials')
_require(parsed.scheme != 'https' or parsed.username is None,
'HTTPS repository URL must not contain credentials')
else:
_require(bool(re.fullmatch(
r'[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9_./-]+', url)),
'repository URL must use HTTPS or SSH')
remote_path = url.split(':', 1)[1]
_require(not remote_path.startswith('-') and remote_path not in ('.', '..'),
'repository URL is invalid')
ref = values.get('APEX_REPO_REF') or None
if ref is not None:
_require(not ref.startswith('-') and not any(c.isspace() for c in ref),
'repository ref is invalid')
return Repository(name, url, ref)
def _tiers(values: Mapping[str, str]) -> Tuple[Tier, ...]:
result = []
devices = set()
guids = set()
for number in (1, 2, 3):
prefix = f'APEX_TIER{number}_'
device = values.get(prefix + 'DEVICE') or None
mode = values.get(prefix + 'MODE')
guid = values.get(prefix + 'POOL_GUID') or None
_require(mode is not None or device is None,
'a mapped tier device requires an explicit mode')
mode = mode or 'directory'
_require(mode in ('directory', 'create', 'import'), 'tier mode is invalid')
if mode == 'directory':
_require(device is None and guid is None,
'directory tier must not select a device or pool')
else:
_require(device is not None and device.startswith('/dev/')
and not any(c.isspace() for c in device)
and '..' not in PurePosixPath(device).parts
and str(PurePosixPath(device)) == device,
'block-backed tier requires a normalized device path')
_require(device not in devices, 'a device is selected by multiple tiers')
devices.add(device)
if mode == 'create':
_require(guid is None, 'create tier must not name an existing pool')
else:
_require(guid is not None and bool(re.fullmatch(r'[1-9][0-9]{0,19}', guid))
and int(guid) < 2**64, 'import tier requires a valid pool GUID')
_require(guid not in guids, 'a pool is selected by multiple tiers')
guids.add(guid)
result.append(Tier(number, mode, device, guid))
return tuple(result)
def normalize_user_data(document: Any) -> Enrollment:
doc = _mapping(document,
{'preserve_hostname', 'hostname', 'users', 'write_files', 'runcmd'},
set(), 'unsupported cloud-config structure')
_require(doc['preserve_hostname'] is False, 'preserve_hostname must be false')
hostname = _plain(doc['hostname'], 'hostname is invalid')
labels = hostname.split('.')
_require(len(hostname) <= 253 and len(labels) >= 2
and all(re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?', x)
for x in labels), 'hostname must be a valid FQDN')
users = doc['users']
_require(type(users) is list and len(users) == 1, 'exactly one administrator is required')
user = _mapping(users[0],
{'name', 'sudo', 'groups', 'shell', 'ssh_authorized_keys'},
set(), 'unsupported administrator configuration')
_require(user['name'] == 'adam' and user['shell'] == '/bin/bash',
'administrator must be adam with Bash')
_require(type(user['sudo']) is str and ''.join(user['sudo'].split()) in
('ALL=(ALL)NOPASSWD:ALL', 'ALL=(ALL:ALL)NOPASSWD:ALL'),
'administrator requires passwordless sudo')
groups = user['groups']
_require(type(groups) is list and len(groups) == 2
and all(type(g) is str for g in groups) and set(groups) == {'sudo', 'docker'},
'administrator requires sudo and docker groups')
keys = user['ssh_authorized_keys']
_require(type(keys) is list and len(keys) > 0, 'administrator requires public SSH keys')
authorized_keys = tuple(_plain(k, 'administrator SSH key is invalid') for k in keys)
_require(all(k.strip() for k in authorized_keys), 'administrator SSH key is empty')
commands = doc['runcmd']
_require(commands in (['/usr/local/bin/apex-bootstrap'],
[['/usr/local/bin/apex-bootstrap']]),
'only the standard APEX bootstrap command is supported')
writes = doc['write_files']
_require(type(writes) is list, 'write_files must be a list')
files = []
seen = set()
for write in writes:
item = _mapping(write, {'path', 'owner', 'permissions', 'content'}, {'defer'},
'unsupported write_files entry')
path = _plain(item['path'], 'managed file path is invalid')
_require(path in _FILE_POLICY and path not in seen,
'managed file path is unsupported or repeated')
seen.add(path)
owner, mode, deferred = _FILE_POLICY[path]
permission = item['permissions']
_require(type(permission) is str and bool(re.fullmatch(r'0?[0-7]{3}', permission))
and int(permission, 8) == mode and item['owner'] == owner,
'managed file ownership or permissions are invalid')
_require(item.get('defer', False) is deferred, 'managed file defer setting is invalid')
content = item['content']
_require(type(content) is str and bool(content.strip()) and '\x00' not in content,
'managed file content is invalid')
files.append(ManagedFile(path, owner, mode, content))
bootstrap = next((f for f in files if f.path == '/etc/apex/bootstrap.env'), None)
_require(bootstrap is not None, 'bootstrap.env is required')
values = parse_bootstrap_env(bootstrap.content)
return Enrollment(hostname, _repository(values), _tiers(values), authorized_keys, tuple(files))
-124
View File
@@ -1,124 +0,0 @@
"""Image-only installation and sanitization; for golden-image builds."""
from __future__ import annotations
import argparse
import io
import json
import os
from pathlib import Path
import shutil
import tarfile
import uuid
from . import ENROLLMENT_INTERFACE
from .state import ProvisionError, atomic_write, exclusive_lock, safe_path
from .target import preflight, run
def install():
from .bundle import _bundle_from_source
source = Path(__file__).resolve().parents[1]
data, digest = _bundle_from_source(source)
destination = safe_path(Path('/usr/local/lib/apex-provisioner') / digest)
destination.mkdir(mode=0o755, parents=True, exist_ok=False)
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
archive.extractall(destination, filter='data')
revision = os.environ.get('APEX_PROVISION_REVISION', 'unversioned-modified')
metadata = {'bundle_digest': digest, 'revision': revision, 'path': str(destination),
'baseline_revision': revision, 'enrollment_interface': ENROLLMENT_INTERFACE}
atomic_write('/etc/apex/provisioner.json', json.dumps(metadata) + '\n')
for executable, operation in (('apex-provision-image', ''), ('apex-bootstrap', 'cloud-enroll ')):
content = ('#!/bin/sh\nset -eu\ncd ' + str(destination) + '\n'
'exec /usr/bin/python3 -m provisioning.image ' + operation + '"$@"\n')
atomic_write('/usr/local/bin/' + executable, content, 0o755)
return metadata
def cloud_enroll():
from .access import Transition
from .target import enroll
from .userdata import decode_user_data
metadata = json.loads(Path('/etc/apex/provisioner.json').read_text())
if metadata.get('enrollment_interface') != ENROLLMENT_INTERFACE:
raise ProvisionError('image enrollment interface is missing or unsupported; rebuild the image')
instance = Path('/var/lib/cloud/instance').resolve(strict=True)
try:
instance.relative_to('/var/lib/cloud/instances')
except ValueError:
raise RuntimeError('cloud-init instance path is outside its managed directory') from None
path = safe_path(instance / 'user-data.txt')
if path.stat().st_uid != 0 or path.stat().st_size > 1024 * 1024:
raise RuntimeError('cloud-init user-data is not a supported root-owned file')
spec = decode_user_data(path.read_bytes())
result = enroll(spec, metadata['revision'], metadata['bundle_digest'])
token = uuid.uuid4().hex
transition = Transition()
try:
current = transition.execute('begin', token, 22, '127.0.0.1')
run(['runuser', '-u', 'adam', '--', 'sudo', '-n', 'true'])
if current['status'] not in ('existing', 'committed'):
transition.execute('finalize', token)
if current['status'] != 'committed':
transition.execute('commit', token)
except Exception:
transition.execute('rollback', token)
raise
result['external_access'] = 'not-verified-cloud-init'
atomic_write('/var/lib/apex-init/enrollment.json', json.dumps(result, sort_keys=True) + '\n')
return result
def sanitize_files(root=Path('/')):
root = Path(root)
for home in ('root', 'home/debian'):
for name in ('.ssh/authorized_keys', '.ssh/authorized_keys2', '.bash_history'):
path = root / home / name
if path.exists() or path.is_symlink():
path.unlink()
for path in (root / 'etc/ssh').glob('ssh_host_*'):
if path.is_file() or path.is_symlink():
path.unlink()
atomic_write(root / 'etc/machine-id', '', 0o444)
dbus = root / 'var/lib/dbus/machine-id'
if dbus.exists() or dbus.is_symlink():
dbus.unlink()
dbus.parent.mkdir(parents=True, exist_ok=True)
dbus.symlink_to('/etc/machine-id')
for name in ('var/lib/apex-init', 'tmp/provisioning', 'var/lib/cloud'):
path = root / name
if path.is_symlink():
path.unlink()
elif path.exists():
shutil.rmtree(path)
autologin = root / 'etc/systemd/system/serial-getty@ttyS0.service.d/autologin.conf'
autologin.unlink(missing_ok=True)
atomic_write(root / 'etc/systemd/system/ssh.service.d/10-generate-host-keys.conf',
'[Service]\nExecStartPre=\nExecStartPre=/usr/bin/ssh-keygen -A\n'
'ExecStartPre=/usr/sbin/sshd -t\n', 0o644)
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument('operation', choices=('install', 'baseline', 'install-runner', 'sanitize', 'cloud-enroll'))
args = parser.parse_args(argv)
preflight()
if args.operation == 'install':
print(json.dumps(install()))
elif args.operation == 'baseline':
from .baseline import apply_baseline
with exclusive_lock('/var/lib/apex-init/init.lock'):
result = apply_baseline()
atomic_write('/etc/apex/image-baseline.json', json.dumps(result, sort_keys=True) + '\n')
elif args.operation == 'install-runner':
run(['test', '-x', '/usr/local/bin/apex-bootstrap'])
elif args.operation == 'sanitize':
run(['usermod', '--password', '!', 'root'])
run(['usermod', '--password', '!', 'debian'])
sanitize_files()
else:
print(json.dumps(cloud_enroll(), sort_keys=True))
return 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -1,121 +0,0 @@
"""Pinned, non-destructive checkout enrollment as the node administrator."""
from __future__ import annotations
import os
from pathlib import Path
import pwd
import subprocess
from engine.identity import read_env
from . import ENROLLMENT_INTERFACE
from .state import ProvisionError, safe_path
def as_adam(args, cwd=None):
environment = dict(os.environ, GIT_TERMINAL_PROMPT='0',
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
LC_ALL='C')
result = subprocess.run(['runuser', '-u', 'adam', '--', *args], cwd=cwd,
env=environment, text=True, capture_output=True, timeout=300)
if result.returncode:
# Git/hook stderr may contain credential files or remote URL credentials.
raise ProvisionError('repository operation failed; check administrator Git access and the pinned checkout')
return result.stdout.strip()
def verify_checkout_setup(destination, account):
if as_adam(['git', 'config', '--get', 'core.hooksPath'], destination) != 'commons/githooks':
raise ProvisionError('checkout hook configuration is missing or incompatible')
home = safe_path(account.pw_dir)
bashrc = safe_path(home / '.bashrc')
entry = 'export PATH="' + str(destination / 'commons') + ':$PATH"'
if not bashrc.is_file() or bashrc.read_text().splitlines().count(entry) != 1:
raise ProvisionError('checkout PATH setup is missing or duplicated; reconcile init.sh before retrying')
vim = home / '.vimrc'
if not vim.is_symlink() or vim.resolve() != (destination / 'commons/etc/.vimrc').resolve():
raise ProvisionError('checkout editor link is missing or incompatible')
def reconcile_repository(spec, state, save, srv=Path('/srv')):
repository = spec.repository
destination = safe_path(Path(srv) / repository.name)
staging = safe_path(Path(srv) / ('.apex-clone-' + repository.name))
expected = {'name': repository.name, 'url': repository.url, 'ref': repository.ref}
account = pwd.getpwnam('adam')
record = state.setdefault('repository', {})
if record and record.get('request') != expected:
raise ProvisionError('repository enrollment conflicts with its recorded request')
if not record:
if destination.exists() or staging.exists():
raise ProvisionError('unrecorded repository path exists; explicit adoption is required')
record.update(request=expected, clone_intent=True)
save()
if not destination.exists():
if staging.exists():
# An interrupted clone may be incomplete: never erase or guess its state.
raise ProvisionError('an interrupted clone remains; inspect the staging checkout before retrying')
staging.mkdir(mode=0o755)
os.chown(staging, account.pw_uid, account.pw_gid)
as_adam(['git', 'clone', '--no-checkout', '--', repository.url, str(staging)])
os.rename(staging, destination)
safe_path(destination / '.git')
origin = as_adam(['git', 'remote', 'get-url', 'origin'], destination)
if origin != repository.url:
raise ProvisionError('existing checkout has a different origin; it was preserved')
if 'commit' not in record:
reference = repository.ref or 'HEAD'
if repository.ref:
as_adam(['git', 'fetch', '--no-tags', 'origin', reference], destination)
reference = 'FETCH_HEAD'
commit = as_adam(['git', 'rev-parse', '--verify', reference + '^{commit}'], destination)
record['commit'] = commit
record['checkout_intent'] = True
save()
commit = record['commit']
if record.get('checkout_intent'):
# --no-checkout index intentionally looks dirty before the first checkout.
if any(p.name != '.git' for p in destination.iterdir()):
head = as_adam(['git', 'rev-parse', 'HEAD'], destination)
dirty = as_adam(['git', 'status', '--porcelain'], destination)
if head != commit or dirty:
raise ProvisionError('interrupted checkout has unexpected contents; it was preserved')
else:
as_adam(['git', 'checkout', '--detach', commit], destination)
record.pop('checkout_intent')
save()
if as_adam(['git', 'rev-parse', 'HEAD'], destination) != commit:
raise ProvisionError('checkout revision changed; initialization will not reset it')
if as_adam(['git', 'status', '--porcelain'], destination):
raise ProvisionError('checkout contains local changes; initialization will not reset it')
node_env = safe_path(destination / 'node.env')
node_identity = read_env(str(node_env))
if node_identity.get('APEX_NODE_FQDN') != spec.hostname:
raise ProvisionError('repository node.env does not declare the requested hostname')
if node_identity.get('APEX_ENROLLMENT_INTERFACE') != str(ENROLLMENT_INTERFACE):
raise ProvisionError('node checkout enrollment interface is missing or unsupported; update its init.sh and node.env together')
as_adam(['git', 'submodule', 'update', '--init', '--recursive'], destination)
commons = safe_path(destination / 'commons')
if not commons.is_dir():
raise ProvisionError('node checkout does not contain its pinned commons submodule')
commons_commit = as_adam(['git', 'rev-parse', 'HEAD'], commons)
gitlink = as_adam(['git', 'ls-tree', 'HEAD', '--', 'commons'], destination).split()
if gitlink != ['160000', 'commit', commons_commit, 'commons']:
raise ProvisionError('commons is not the exact pinned Git submodule')
if Path(as_adam(['git', 'rev-parse', '--show-toplevel'], commons)).resolve() != commons.resolve():
raise ProvisionError('commons does not have its own Git worktree')
if record.get('commons_commit', commons_commit) != commons_commit:
raise ProvisionError('pinned commons revision changed unexpectedly')
hook = safe_path(destination / 'init.sh')
if not hook.is_file():
raise ProvisionError('node repository is missing its checkout initializer')
if not record.get('hook_complete'):
as_adam(['bash', './init.sh'], destination)
verify_checkout_setup(destination, account)
record['hook_complete'] = True
else:
verify_checkout_setup(destination, account)
if as_adam(['git', 'status', '--porcelain'], destination):
raise ProvisionError('checkout initializer changed tracked files; inspect before resuming')
record['commons_commit'] = commons_commit
save()
return {'path': str(destination), 'commit': commit, 'commons_commit': commons_commit}
@@ -1,591 +0,0 @@
"""Security activation owned by the protected access transaction."""
from __future__ import annotations
import base64
import hashlib
import ipaddress
import json
import os
import re
import shlex
import tempfile
import uuid
from .access import atomic
from .baseline import HELPER_SHA256
SERVICES = ('ufw', 'crowdsec', 'crowdsec-firewall-bouncer')
FILES = ('/etc/default/ufw', '/etc/ufw/ufw.conf', '/etc/ufw/before.rules',
'/etc/ufw/before6.rules', '/etc/ufw/after.rules', '/etc/ufw/after6.rules',
'/etc/ufw/user.rules', '/etc/ufw/user6.rules')
HELPER = '/usr/local/bin/ufw-docker'
BLOCK = re.compile(r'^# BEGIN UFW AND DOCKER\n.*?^# END UFW AND DOCKER\n?', re.M | re.S)
def _helper_lines(ipv6):
prefix = 'ufw6' if ipv6 else 'ufw'
subnets = ('fd00::/8',) if ipv6 else ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')
return ['# BEGIN UFW AND DOCKER', '*filter', ':' + prefix + '-user-forward - [0:0]',
':' + prefix + '-docker-logging-deny - [0:0]', ':DOCKER-USER - [0:0]',
'-A DOCKER-USER -j ' + prefix + '-user-forward',
'-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN',
'-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP',
'-A DOCKER-USER -i docker0 -o docker0 -j ACCEPT',
*['-A DOCKER-USER -j RETURN -s ' + subnet for subnet in subnets],
*['-A DOCKER-USER -j ' + prefix + '-docker-logging-deny -m conntrack --ctstate NEW -d ' + subnet
for subnet in subnets],
'-A DOCKER-USER -j RETURN',
'-A ' + prefix + '-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "',
'-A ' + prefix + '-docker-logging-deny -j DROP', 'COMMIT', '# END UFW AND DOCKER']
def _runtime_rule(line):
tokens = shlex.split(line)
if len(tokens) % 2:
raise SecurityError('Unsupported runtime firewall rule')
pairs = []
for key, value in zip(tokens[::2], tokens[1::2], strict=True):
if key in ('-s', '-d'):
value = str(ipaddress.ip_network(value, strict=False))
elif key == '--ctstate':
value = ','.join(sorted(value.split(',')))
pairs.append((key, value))
return sorted(pairs)
class SecurityError(RuntimeError):
pass
class Security:
def __init__(self, host):
self.host = host
def preflight(self):
services = {name: self._service(name) for name in SERVICES}
for name, state in services.items():
if (state.get('LoadState') != 'loaded' or state.get('UnitFileState') not in ('enabled', 'disabled')
or state.get('ActiveState') not in ('active', 'inactive')
or state.get('DropInPaths') or not self._supported_unit(name, state.get('FragmentPath'))):
raise SecurityError('Unsupported or masked security service: ' + name)
if self._digest(self._path(HELPER).read_bytes()) != HELPER_SHA256:
raise SecurityError('The ufw-docker helper does not match the pinned baseline')
files = self._files()
active = self._active()
pristine = False
if not active:
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}', 'ufw']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M))
pristine = (hashlib.md5(base64.b64decode(files['/etc/default/ufw']['data']), usedforsecurity=False).hexdigest()
== conffiles.get('/etc/default/ufw'))
manifest = dict((name, digest) for digest, name in re.findall(
r'^([a-f0-9]{32})\s+(\S+)\s*$', self._text('/var/lib/dpkg/info/ufw.md5sums'), re.M))
# Debian's postinst copies/ucf-installs these files; they are not dpkg conffiles.
for name in FILES[1:]:
filename = name.rsplit('/', 1)[1]
template = '/usr/share/ufw/' + ('' if filename == 'ufw.conf' else 'iptables/') + filename
data = self._path(template).read_bytes()
pristine = pristine and (hashlib.md5(data, usedforsecurity=False).hexdigest() == manifest.get(template.lstrip('/'))
and data == base64.b64decode(files[name]['data']))
if not pristine:
raise SecurityError('Inactive UFW is not provably pristine; reconcile its existing policy first')
texts = [self._text('/etc/ufw/' + name) for name in ('after.rules', 'after6.rules')]
present = any('UFW AND DOCKER' in text for text in texts)
if present:
self._check_helper_files()
runtime = None
else:
runtime = {executable: self._helper_runtime(executable) for executable in ('iptables', 'ip6tables')}
for chains in runtime.values():
self._validate_helper_before(chains)
return {'version': 1, 'pristine': pristine, 'active': active, 'services': services,
'files': files, 'helper_present': present, 'helper_runtime_before': runtime,
'ipv6': bool(re.search(r'^IPV6\s*=\s*[\"\x27]?yes', self._text('/etc/default/ufw'), re.M))}
def _supported_unit(self, name, path):
if path in ('/usr/lib/systemd/system/' + name + '.service', '/lib/systemd/system/' + name + '.service'):
return True
if name != 'crowdsec-firewall-bouncer' or path != '/etc/systemd/system/crowdsec-firewall-bouncer.service':
return False
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}',
'crowdsec-firewall-bouncer-iptables']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M))
return hashlib.md5(self._path(path).read_bytes(), usedforsecurity=False).hexdigest() == conffiles.get(path)
def _path(self, name):
path = self.host.path(name)
for parent in (path, *path.parents):
if parent.is_symlink():
raise SecurityError('Refusing redirected security path: ' + name)
if not path.is_file():
raise SecurityError('Required security file is missing: ' + name)
return path
def _text(self, name):
return self._path(name).read_text()
@staticmethod
def _digest(data):
return hashlib.sha256(data).hexdigest()
def _files(self):
return {name: self._file(name) for name in FILES}
def _file(self, name):
path = self._path(name)
data, metadata = path.read_bytes(), path.stat()
result = {'data': base64.b64encode(data).decode(), 'digest': self._digest(data),
'mode': metadata.st_mode & 0o777, 'uid': metadata.st_uid, 'gid': metadata.st_gid}
result['fingerprint'] = self._digest(json.dumps(result, sort_keys=True).encode())
return result
def _service(self, name):
output = self.host.command(['systemctl', 'show', name, '-p',
'LoadState,ActiveState,UnitFileState,FragmentPath,DropInPaths']).stdout
return dict(line.split('=', 1) for line in output.splitlines() if '=' in line)
def _active(self):
output = self.host.command(['ufw', 'status']).stdout
if 'Status: inactive' in output:
return False
if 'Status: active' in output:
return True
raise SecurityError('Cannot determine UFW runtime state')
def snapshot(self, plan, original_port, client_address):
if not isinstance(original_port, int) or not 1 <= original_port <= 65535:
raise SecurityError('Invalid bootstrap port')
try:
address = str(ipaddress.ip_address(client_address))
except ValueError:
raise SecurityError('Invalid bootstrap peer address') from None
if plan != self.preflight():
raise SecurityError('Security configuration changed after preflight')
if ipaddress.ip_address(address).version == 6 and not plan['ipv6']:
raise SecurityError('IPv6 bootstrap access requires enabled UFW IPv6 support')
if plan['services']['crowdsec']['ActiveState'] == 'active':
self._check_decisions(address)
return {'version': 1, 'plan': plan, 'original_port': original_port,
'client_address': address, 'token': uuid.uuid4().hex, 'rules': [],
'expected': {name: value['fingerprint'] for name, value in plan['files'].items()},
'service_changes': [], 'inflight': None, 'phase': 'prepared',
'access_recovered': False, 'cleanup': 'not-started', 'helper_blocks': {},
'helper_runtime': {'introduced': False, 'restored': []}}
def activate(self, state, persist):
if state['phase'] != 'prepared' or state['inflight']:
raise SecurityError('Security activation requires a fresh prepared snapshot')
self._assert_files(state)
state['phase'] = 'activating'
persist(state)
self._add_rule(state, state['original_port'], state['client_address'], True, persist)
self._add_rule(state, 2222, '0.0.0.0/0', False, persist)
if state['plan']['ipv6']:
self._add_rule(state, 2222, '::/0', False, persist)
if state['plan']['pristine']:
for policy, direction in (('deny', 'incoming'), ('allow', 'outgoing'), ('allow', 'routed')):
self._mutate(state, ['ufw', 'default', policy, direction], ['/etc/default/ufw'], persist)
self._mutate(state, ['ufw', '--force', 'enable'],
['/etc/ufw/ufw.conf', '/etc/ufw/user.rules', '/etc/ufw/user6.rules'], persist)
if not state['plan']['helper_present']:
if any(self._helper_runtime(executable) != before for executable, before in
state['plan']['helper_runtime_before'].items()):
raise SecurityError('Docker firewall rules changed before helper installation')
state['helper_runtime']['introduced'] = True
persist(state)
names = ['/etc/ufw/after.rules', '/etc/ufw/after6.rules']
self._mutate(state, [HELPER, 'install'], names, persist)
for name in names:
blocks = BLOCK.findall(self._text(name))
if len(blocks) != 1:
raise SecurityError('ufw-docker did not install a verifiable integration block')
state['helper_blocks'][name] = blocks[0]
persist(state)
self._check_helper_files()
self._mutate(state, ['ufw', 'reload'], [], persist)
self._enable(state, 'ufw', persist)
self.host.command(['crowdsec', '-t', '-c', '/etc/crowdsec/config.yaml'])
self._enable(state, 'crowdsec', persist)
self.host.command(['cscli', 'lapi', 'status'])
self._check_decisions(state['client_address'])
self._enable(state, 'crowdsec-firewall-bouncer', persist)
state['phase'] = 'active'
persist(state)
self.verify(state)
def finalize(self, state, persist):
self.verify(state)
for rule in state['rules']:
if rule['temporary'] and rule['owned']:
self._delete_rule(state, rule, persist)
state['phase'] = 'final'
persist(state)
self.verify(state)
def verify(self, state):
if state['phase'] not in ('active', 'final') or state['inflight']:
raise SecurityError('Security activation is incomplete')
self._assert_files(state)
if not self._active():
raise SecurityError('UFW is not active')
for name in SERVICES:
service = self._service(name)
if service.get('ActiveState') != 'active' or service.get('UnitFileState') != 'enabled':
raise SecurityError('Security service is not active and enabled: ' + name)
self.host.command(['cscli', 'lapi', 'status'])
self._check_decisions(state['client_address'])
self._check_helper_files()
self._check_helper_runtime(state['plan']['ipv6'])
for rule in state['rules']:
if not (rule['temporary'] and state['phase'] == 'final') and self._rule_comment(rule) is None:
raise SecurityError('Required SSH firewall allowance is missing')
def _check_helper_files(self):
for ipv6, name in ((False, 'after.rules'), (True, 'after6.rules')):
text = self._text('/etc/ufw/' + name)
blocks = BLOCK.findall(text)
if (len(blocks) != 1 or text.count('UFW AND DOCKER') != 2
or [line.strip() for line in blocks[0].splitlines() if line.strip()] != _helper_lines(ipv6)):
raise SecurityError('Existing ufw-docker integration is custom or incomplete')
def _check_helper_runtime(self, ipv6):
for version in (False, True) if ipv6 else (False,):
executable = 'ip6tables' if version else 'iptables'
prefix = 'ufw6' if version else 'ufw'
for chain in ('DOCKER-USER', prefix + '-docker-logging-deny'):
result = self.host.command([executable, '-S', chain], False)
expected = ['-N ' + chain] + [line for line in _helper_lines(version)
if line.startswith('-A ' + chain + ' ')]
try:
matches = (not result.returncode and [_runtime_rule(line) for line in result.stdout.splitlines()]
== [_runtime_rule(line) for line in expected])
except ValueError:
matches = False
if not matches:
raise SecurityError('ufw-docker runtime chain is missing or changed: ' + executable + ' ' + chain)
if self.host.command([executable, '-C', 'FORWARD', '-j', 'DOCKER-USER'], False).returncode:
raise SecurityError('Docker forwarding hook is missing: ' + executable)
def _helper_runtime(self, executable):
logging = ('ufw6' if executable == 'ip6tables' else 'ufw') + '-docker-logging-deny'
chains = {'DOCKER-USER': None, logging: None}
for line in self.host.command([executable, '-S']).stdout.splitlines():
tokens = shlex.split(line)
if len(tokens) < 2 or tokens[1] not in chains:
continue
if tokens[0] == '-N' and len(tokens) == 2 and chains[tokens[1]] is None:
chains[tokens[1]] = [line]
elif tokens[0] == '-A' and chains[tokens[1]] is not None:
chains[tokens[1]].append(line)
else:
raise SecurityError('Unsupported Docker firewall chain declaration')
return chains
@staticmethod
def _validate_helper_before(chains):
if (not isinstance(chains, dict) or len(chains) != 2 or 'DOCKER-USER' not in chains
or chains['DOCKER-USER'] not in (None, ['-N DOCKER-USER'],
['-N DOCKER-USER', '-A DOCKER-USER -j RETURN'])
or any(value is not None for name, value in chains.items() if name != 'DOCKER-USER')):
raise SecurityError('Unmanaged Docker firewall rules require reconciliation')
def _restore_helper_runtime(self, state, persist):
if state['plan']['helper_present']:
return
before = state['plan'].get('helper_runtime_before')
progress = state.get('helper_runtime')
if (not isinstance(before, dict) or set(before) != {'iptables', 'ip6tables'}
or not isinstance(progress, dict) or not isinstance(progress.get('introduced'), bool)
or not isinstance(progress.get('restored'), list)):
raise SecurityError('Missing Docker firewall recovery snapshot; reconcile explicitly')
for executable, original in before.items():
self._validate_helper_before(original)
current = self._helper_runtime(executable)
if set(original) != set(current):
raise SecurityError('Unsupported Docker firewall recovery snapshot')
if current != original:
ipv6 = executable == 'ip6tables'
expected = {name: ['-N ' + name] + [line for line in _helper_lines(ipv6)
if line.startswith('-A ' + name + ' ')] for name in current}
try:
owned = (progress['introduced'] and (not ipv6 or state['plan']['ipv6'])
and all(current[name] is not None and
[_runtime_rule(line) for line in current[name]] ==
[_runtime_rule(line) for line in expected[name]] for name in current))
except (ValueError, SecurityError):
owned = False
if not owned:
raise SecurityError('Docker firewall runtime changed; preserve it for reconciliation')
logging = ('ufw6' if ipv6 else 'ufw') + '-docker-logging-deny'
commands = ['*filter', '-F DOCKER-USER', *(original['DOCKER-USER'] or [])[1:],
'-F ' + logging, '-X ' + logging]
if original['DOCKER-USER'] is None:
commands.append('-X DOCKER-USER')
commands.append('COMMIT')
progress['restoring'] = executable
persist(state)
with tempfile.NamedTemporaryFile(mode='w', prefix='apex-firewall-', suffix='.rules') as stream:
stream.write('\n'.join(commands) + '\n')
stream.flush()
if self._helper_runtime(executable) != current:
raise SecurityError('Docker firewall runtime changed immediately before restoration')
self.host.command([executable + '-restore', '--wait', '10', '--noflush', stream.name])
if self._helper_runtime(executable) != original:
raise SecurityError('Docker firewall restoration did not converge')
if executable not in progress['restored']:
progress['restored'].append(executable)
progress.pop('restoring', None)
persist(state)
def restore(self, state, persist):
state['cleanup'] = 'pending'
state['access_recovered'] = False
persist(state)
errors = []
for change in reversed(state['service_changes']):
name, field = change['service'], change['field']
if name == 'ufw' and field == 'ActiveState' and state['plan']['active']:
continue
try:
current = self._service(name)
if any(current.get(key) != change['metadata'].get(key) for key in change['metadata']):
raise SecurityError('Service unit changed during activation')
owned_start = (field == 'ActiveState' and change['before'] == 'inactive'
and change['after'] == 'active' and current[field] in
('active', 'inactive', 'activating', 'deactivating', 'failed', 'reloading'))
# Stop even when inactive: systemd may still have a queued start job.
if current[field] == change['after'] or owned_start:
actions = {'active': 'start', 'inactive': 'stop', 'enabled': 'enable', 'disabled': 'disable'}
change['status'] = 'restoring'
persist(state)
self.host.command(['systemctl', actions[change['before']], name])
stopped = self._service(name)
if any(stopped.get(key) != change['metadata'].get(key) for key in change['metadata']):
raise SecurityError('Service unit changed during restoration')
if owned_start and stopped[field] == 'failed':
self.host.command(['systemctl', 'reset-failed', name])
elif current[field] != change['before']:
raise SecurityError('Service state changed outside this transition')
if self._service(name)[field] != change['before']:
raise SecurityError('Service state restoration did not converge')
change['status'] = 'restored'
persist(state)
except Exception:
errors.append('service:' + name + ':' + field)
ambiguous = []
for name in FILES:
try:
if self._file(name)['fingerprint'] not in (state['plan']['files'][name]['fingerprint'], state['expected'][name]):
ambiguous.append(name)
except Exception:
ambiguous.append(name)
pending = bool(ambiguous or state['inflight'] or errors)
original_active = state['plan']['active']
# Recover runtime access even when an unrelated edit prevents file cleanup.
try:
if not original_active:
try:
self._path('/etc/ufw/ufw.conf')
except SecurityError:
# A service stop removes runtime rules without following the replaced enable file.
self.host.command(['systemctl', 'stop', 'ufw'])
else:
self.host.command(['ufw', 'disable'])
state['access_recovered'] = not self._active()
elif pending:
self._retain_bootstrap(state, persist)
if not self._active():
self.host.command(['ufw', '--force', 'enable'])
state['access_recovered'] = self._active()
except Exception:
errors.append('bootstrap-firewall')
for name, previous in state['plan']['files'].items():
try:
path = self._path(name)
content = path.read_bytes()
fingerprint = self._file(name)['fingerprint']
if fingerprint == previous['fingerprint']:
continue
if fingerprint == state['expected'][name] and not (original_active and pending and name.endswith(('user.rules', 'user6.rules'))):
atomic(path, base64.b64decode(previous['data']), previous['mode'])
os.chown(path, previous['uid'], previous['gid'])
continue
block = state['helper_blocks'].get(name)
if block and content.decode().count(block) == 1:
metadata = path.stat()
atomic(path, content.replace(block.encode(), b'', 1), metadata.st_mode & 0o777)
os.chown(path, metadata.st_uid, metadata.st_gid)
else:
errors.append('file:' + name)
except Exception:
errors.append('file:' + name)
# Rule removal is ownership-checked independently of whole-file snapshots.
for rule in state['rules']:
if rule['owned'] and not (original_active and pending and rule['temporary']):
try:
if self._rule_comment(rule) == rule['comment']:
self.host.command(self._rule_command(rule, delete=True))
elif self._rule_comment(rule) is not None:
errors.append('rule-ownership')
except Exception:
errors.append('rule-cleanup')
try:
self._restore_helper_runtime(state, persist)
except Exception:
errors.append('docker-firewall-runtime')
try:
if original_active:
if errors or pending:
self._retain_bootstrap(state, persist)
else:
self.host.command(['ufw', 'reload'])
state['access_recovered'] = self._active()
else:
# Restoring the former ufw.conf must not reactivate the runtime.
state['access_recovered'] = not self._active()
except Exception:
errors.append('bootstrap-firewall')
if not state['access_recovered']:
errors.append('bootstrap-firewall')
if not state['plan']['helper_present']:
try:
if any(self._helper_runtime(executable) != before for executable, before in
state['plan']['helper_runtime_before'].items()):
raise SecurityError('Docker firewall restoration changed before cleanup completed')
except Exception:
errors.append('docker-firewall-runtime')
for change in state['service_changes']:
name, field = change['service'], change['field']
if name == 'ufw' and field == 'ActiveState' and original_active:
continue
try:
current = self._service(name)
if (current[field] != change['before'] or any(current.get(key) != value
for key, value in change['metadata'].items())):
raise SecurityError('Service restoration changed before cleanup completed')
except Exception:
errors.append('service:' + name + ':' + field)
if errors or pending:
state['diagnostics'] = sorted(set(errors + ['file:' + name for name in ambiguous]
+ (['interrupted-mutation'] if state['inflight'] else [])))
persist(state)
raise SecurityError('Security cleanup pending; bootstrap firewall recovery=' + str(state['access_recovered'])
+ '; inspect the root-owned recovery record')
state.update(cleanup='complete', phase='restored', inflight=None, diagnostics=[])
persist(state)
def _assert_files(self, state):
if any(value['fingerprint'] != state['expected'][name] for name, value in self._files().items()):
raise SecurityError('Security files changed outside this transition; recovery remains required')
def _mutate(self, state, args, changed, persist):
self._assert_files(state)
state['inflight'] = {'command': args[:2], 'files': changed}
persist(state)
self.host.command(args)
current = self._files()
for name in changed:
state['expected'][name] = current[name]['fingerprint']
if any(value['fingerprint'] != state['expected'][name] for name, value in current.items()):
raise SecurityError('Concurrent security file change during activation')
state['inflight'] = None
persist(state)
def _enable(self, state, name, persist):
for field, before, after, operation in (
('UnitFileState', 'disabled', 'enabled', 'enable'),
('ActiveState', 'inactive', 'active', 'start')):
current = self._service(name)
if current[field] == after:
continue
if current[field] != before:
raise SecurityError('Security service changed before activation: ' + name)
metadata = {key: current.get(key) for key in ('LoadState', 'FragmentPath', 'DropInPaths')}
if any(value != state['plan']['services'][name].get(key) for key, value in metadata.items()):
raise SecurityError('Security service unit changed before activation: ' + name)
change = {'service': name, 'field': field, 'before': before, 'after': after,
'metadata': metadata, 'status': 'pending'}
state['service_changes'].append(change)
persist(state)
self.host.command(['systemctl', operation, name])
if self._service(name)[field] != after:
raise SecurityError('Security service did not activate: ' + name)
change['status'] = 'applied'
persist(state)
def _check_decisions(self, peer):
self.host.command(['cscli', 'lapi', 'status'])
try:
alerts = json.loads(self.host.command(
['cscli', 'decisions', 'list', '--all', '--limit', '0', '--no-simu', '-o', 'json']).stdout)
if not isinstance(alerts, list):
raise ValueError()
address = ipaddress.ip_address(peer)
for alert in alerts:
if not isinstance(alert, dict) or not isinstance(alert.get('decisions'), list):
raise ValueError()
for decision in alert['decisions']:
if (not isinstance(decision, dict) or any(not isinstance(decision.get(key), str)
or not decision[key] for key in ('scope', 'value', 'type'))
or decision.get('simulated', False) is not False):
raise ValueError()
if decision['scope'].lower() not in ('ip', 'range'):
continue
network = ipaddress.ip_network(decision['value'], strict=False)
if address in network:
raise SecurityError('An existing CrowdSec decision covers the bootstrap peer')
except (ValueError, TypeError, KeyError):
raise SecurityError('Cannot verify CrowdSec decisions safely') from None
@staticmethod
def _rule_file(rule):
return '/etc/ufw/user6.rules' if ipaddress.ip_network(rule['source'], strict=False).version == 6 else '/etc/ufw/user.rules'
def _rule_comment(self, rule):
source = ipaddress.ip_network(rule['source'], strict=False)
any_address = '::/0' if source.version == 6 else '0.0.0.0/0'
for line in self._text(self._rule_file(rule)).splitlines():
if not line.startswith('### tuple ### '):
continue
parts = line[len('### tuple ### '):].split()
if len(parts) < 7 or parts[:5] != ['allow', 'tcp', str(rule['port']), any_address, 'any'] or parts[6] != 'in':
continue
try:
if ipaddress.ip_network(parts[5], strict=False) != source:
continue
comment = next((p[8:] for p in parts[7:] if p.startswith('comment=')), '')
return bytes.fromhex(comment).decode() if comment else ''
except (ValueError, UnicodeError):
raise SecurityError('Cannot prove ownership of an existing SSH firewall rule') from None
return None
@staticmethod
def _rule_command(rule, delete=False):
command = ['ufw', '--force', 'delete'] if delete else (['ufw', 'prepend'] if rule['temporary'] else ['ufw'])
return command + ['allow', 'proto', 'tcp', 'from', rule['source'], 'to', 'any', 'port', str(rule['port']),
'comment', rule['comment']]
def _add_rule(self, state, port, source, temporary, persist):
rule = {'port': port, 'source': source, 'temporary': temporary,
'comment': 'apex-init-' + state['token'] + ('-bootstrap' if temporary else '-final')}
rule['owned'] = self._rule_comment(rule) is None
state['rules'].append(rule)
persist(state)
if rule['owned']:
self._mutate(state, self._rule_command(rule), ['/etc/ufw/user.rules', '/etc/ufw/user6.rules'], persist)
def _delete_rule(self, state, rule, persist):
if self._rule_comment(rule) != rule['comment']:
raise SecurityError('Owned SSH rule changed; preserve it for explicit reconciliation')
self._mutate(state, self._rule_command(rule, delete=True), ['/etc/ufw/user.rules', '/etc/ufw/user6.rules'], persist)
def _retain_bootstrap(self, state, persist):
rule = next((rule for rule in state['rules'] if rule['temporary']), None)
if rule is None:
raise SecurityError('No recorded bootstrap allowance exists')
if self._rule_comment(rule) is None:
rule['owned'] = True
state['bootstrap_retained'] = True
persist(state)
self.host.command(self._rule_command(rule))
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
# Only the parser runtime may be installed before semantic input validation.
set -euo pipefail
export LC_ALL=C DEBIAN_FRONTEND=noninteractive
[[ $(id -u) == 0 ]] || { echo 'APEX seed requires root.' >&2; exit 1; }
[[ -r /etc/os-release ]] || { echo 'APEX seed requires Debian 13 amd64.' >&2; exit 1; }
. /etc/os-release
[[ ${ID:-} == debian && ${VERSION_ID:-} == 13 && $(dpkg --print-architecture) == amd64 ]] || {
echo 'APEX seed requires Debian 13 amd64.' >&2; exit 1;
}
policy=/usr/sbin/policy-rc.d
state=/usr/sbin/.apex-policy-rc.d
for path in /var /var/lib /var/lib/apex-init; do
[[ ! -L $path ]] || { echo 'Refusing redirected initialization state.' >&2; exit 1; }
done
mkdir -p -m 0700 /var/lib/apex-init
[[ $(stat -c %u /var/lib/apex-init) == 0 ]] || {
echo 'Initialization state must be root-owned.' >&2; exit 1;
}
(( (8#$(stat -c %a /var/lib/apex-init) & 022) == 0 )) || {
echo 'Initialization state must not be writable by other users.' >&2; exit 1;
}
[[ ! -L /var/lib/apex-init/init.lock ]] || { echo 'Refusing redirected initialization lock.' >&2; exit 1; }
exec 8>/var/lib/apex-init/init.lock
flock -n 8 || { echo 'Another APEX initialization is running.' >&2; exit 1; }
mkdir -p /run/lock
exec 9>/run/lock/apex-package-policy.lock
flock -n 9 || { echo 'Another APEX package operation is running.' >&2; exit 1; }
restore_policy() {
[[ -d $state ]] || return 0
[[ ! -L $state && $(stat -c %u "$state") == 0 ]] || {
echo 'Refusing untrusted service policy recovery state.' >&2; return 1;
}
if [[ -e $state/ready && ( -e $policy || -L $policy ) ]]; then
if ! cmp -s "$policy" <(printf '#!/bin/sh\nexit 101\n') &&
! cmp -s "$policy" "$state/original"; then
echo 'Service policy changed outside APEX; reconcile it explicitly.' >&2
return 1
fi
fi
if [[ -e $state/original || -L $state/original ]]; then
rm -f -- "$state/restoring"
cp -a -- "$state/original" "$state/restoring"
mv -Tf -- "$state/restoring" "$policy"
elif [[ -e $state/absent ]]; then
rm -f -- "$policy"
elif [[ -e $state/ready ]]; then
echo 'APEX service policy recovery is incomplete; inspect /usr/sbin/.apex-policy-rc.d.' >&2
return 1
fi
rm -rf -- "$state"
}
# Release the shared host lock on exit; the target runner reacquires it later.
restore_policy
missing=()
for package in python3 python3-yaml; do
status=$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)
[[ $status == *' ok installed' ]] || missing+=("$package")
done
if (( ${#missing[@]} )); then
mkdir -m 0700 -- "$state"
trap restore_policy EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if [[ -e $policy || -L $policy ]]; then
cp -a -- "$policy" "$state/original"
else
touch "$state/absent"
fi
touch "$state/ready"
printf '#!/bin/sh\nexit 101\n' > "$state/guard"
chmod 0755 "$state/guard"
mv -Tf -- "$state/guard" "$policy"
apt=(apt-get -o DPkg::Lock::Timeout=60 -o Acquire::Retries=2
-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30)
timeout 300 "${apt[@]}" update --error-on=any
install=("${apt[@]}" install -y --no-install-recommends --no-upgrade --no-remove "${missing[@]}")
plan=$(timeout 300 "${install[@]}" --simulate)
if grep -Eq '^Remv |^Inst [^ ]+ \[' <<< "$plan"; then
echo 'Parser installation requires a package upgrade/removal; reconcile it explicitly.' >&2
exit 1
fi
"${install[@]}"
fi
python3 -c 'import yaml'
-128
View File
@@ -1,128 +0,0 @@
"""Durable enrollment records and writes that refuse redirected paths."""
from __future__ import annotations
import contextlib
import fcntl
import json
import os
from pathlib import Path
import stat
import tempfile
import time
from . import ENROLLMENT_INTERFACE
class ProvisionError(RuntimeError):
pass
def safe_path(path):
path = Path(path).absolute()
for part in (*reversed(path.parents), path):
try:
info = part.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(info.st_mode):
raise ProvisionError('managed path contains a symbolic link')
return path
def atomic_write(path, content, mode=0o600, uid=None, gid=None):
path = safe_path(path)
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() and (not path.is_file() or path.stat().st_nlink != 1):
raise ProvisionError('managed file is not an ordinary unlinked file')
data = content.encode() if isinstance(content, str) else content
fd, temporary = tempfile.mkstemp(prefix='.apex-', dir=path.parent)
try:
with os.fdopen(fd, 'wb') as stream:
os.fchmod(stream.fileno(), mode)
if uid is not None:
os.fchown(stream.fileno(), uid, gid)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
@contextlib.contextmanager
def exclusive_lock(path):
path = safe_path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ProvisionError('another initialization is running; inspect status before retrying') from None
yield
finally:
os.close(fd)
def check_heartbeat(path, now=None):
if path is None:
return
try:
age = (time.time() if now is None else now) - safe_path(path).stat().st_mtime
except FileNotFoundError:
raise ProvisionError('coordinator heartbeat is missing; stopping before the next stage') from None
if not 0 <= age < 90:
raise ProvisionError('coordinator heartbeat expired; stopping before the next stage')
class Journal:
def __init__(self, path, identity, revision, bundle_digest):
self.path = safe_path(path)
if self.path.exists():
try:
self.data = json.loads(self.path.read_text())
except (ValueError, OSError):
raise ProvisionError('invalid enrollment journal; reconcile it before retrying') from None
if self.data.get('enrollment_interface') != ENROLLMENT_INTERFACE:
raise ProvisionError('recorded enrollment interface is missing or unsupported')
if self.data.get('identity') != identity:
raise ProvisionError('enrollment identity conflicts with the recorded target')
if self.data.get('bundle_digest') != bundle_digest:
raise ProvisionError('provisioner bundle changed; resume using the recorded bundle')
else:
self.data = dict(identity=identity, revision=revision, bundle_digest=bundle_digest,
baseline_revision=revision, enrollment_interface=ENROLLMENT_INTERFACE,
status='running', stages={})
def save(self):
atomic_write(self.path, json.dumps(self.data, sort_keys=True, indent=2) + '\n')
def stage(self, name, operation, heartbeat=None):
try:
check_heartbeat(heartbeat)
except ProvisionError:
self.data['status'] = 'stopped'
self.data['failed_stage'] = name
self.save()
raise
self.data['status'] = 'running'
self.data['stages'][name] = {'status': 'running'}
self.save()
try:
result = operation()
except Exception:
self.data['status'] = 'failed'
self.data['failed_stage'] = name
self.data['stages'][name] = {'status': 'failed'}
self.save()
raise
self.data['stages'][name] = {'status': 'done', 'result': result}
self.data.pop('failed_stage', None)
self.save()
return result
@@ -1,509 +0,0 @@
"""Conservative target-side tier and optional swap reconciliation.
The caller holds the initialization lock and supplies a zero-argument atomic
journal writer. Ambiguous device or pool ownership always requires remediation.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import tempfile
from .contract import Enrollment
SRV = Path('/srv')
SWAPFILE = Path('/swapfile')
FSTAB = Path('/etc/fstab')
MEMINFO = Path('/proc/meminfo')
SWAPS = Path('/proc/swaps')
_OWNER = 'org.apex:enrollment'
_GIB = 1024 ** 3
class StorageError(RuntimeError):
"""Storage could not be positively identified or safely reconciled."""
def _require(condition, message):
if not condition:
raise StorageError(message)
def _run(argv, *, full_vdev_paths=False):
environment = {**os.environ, 'LC_ALL': 'C'}
if full_vdev_paths:
environment.update(ZPOOL_VDEV_NAME_PATH='1', ZPOOL_VDEV_NAME_FOLLOW_LINKS='1')
environment.pop('ZPOOL_VDEV_NAME_GUID', None)
try:
result = subprocess.run(argv, check=False, capture_output=True, text=True,
timeout=300, env=environment)
except (OSError, subprocess.TimeoutExpired):
raise StorageError('required storage tool is unavailable or timed out: ' + argv[0]) from None
_require(result.returncode == 0,
'storage inspection or operation failed: ' + ' '.join(argv[:2]))
return result.stdout
def _json(argv, key):
try:
value = json.loads(_run(argv))[key]
_require(isinstance(value, list), 'storage inspection returned an invalid list')
return value
except (ValueError, KeyError, TypeError):
raise StorageError('storage inspection returned unsupported JSON') from None
def _flatten(items):
for item in items:
yield item
yield from _flatten(item.get('children', []))
def _mounts():
return list(_flatten(_json(['findmnt', '--json', '--output', 'TARGET,SOURCE,FSTYPE,MAJ:MIN'],
'filesystems')))
def _device_number(path):
try:
info = os.stat(path)
except OSError:
raise StorageError('selected device is missing or is not a block device') from None
_require(stat.S_ISBLK(info.st_mode), 'selected device is not a block device')
return f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
def _inventory():
roots = _json(['lsblk', '--json', '--bytes', '--paths', '--output',
'NAME,MAJ:MIN,TYPE,PKNAME,MOUNTPOINTS,FSTYPE,WWN,SERIAL,SIZE,PARTUUID'], 'blockdevices')
nodes, edges = {}, {}
def visit(items, parent=None):
for item in items:
number = item['maj:min']
nodes[number] = item
edges.setdefault(number, set())
if parent:
edges[number].add(parent)
edges[parent].add(number)
visit(item.get('children', []), number)
try:
visit(roots)
by_name = {item['name']: number for number, item in nodes.items()}
for number, item in nodes.items():
parent = by_name.get(item.get('pkname'))
if parent:
edges[number].add(parent)
edges[parent].add(number)
except (KeyError, TypeError):
raise StorageError('block-device topology could not be established') from None
return nodes, edges
def _connected(number, edges):
found, pending = set(), [number]
while pending:
item = pending.pop()
if item not in found:
found.add(item)
pending.extend(edges.get(item, ()))
return found
def _pools():
result = {}
for line in _run(['zpool', 'list', '-H', '-o', 'name,guid,health']).splitlines():
fields = line.split('\t')
_require(len(fields) == 3 and fields[1].isdigit(), 'active pool identity is ambiguous')
result[fields[0]] = {'guid': fields[1], 'health': fields[2]}
return result
def _members(text, name, imported=True):
"""Accept only a plain single-device ONLINE pool, never infer topology."""
marker = re.search(r'^\s*config:\s*$', text, re.M)
_require(marker is not None, 'pool membership is unavailable')
lines = text[marker.end():].splitlines()
rows = []
for line in lines:
fields = line.split()
if not fields or fields[0] == 'NAME':
continue
if fields[0] == 'errors:':
_require(line.strip() == 'errors: No known data errors', 'pool reports data errors')
break
_require(len(fields) >= 2 and fields[1] == 'ONLINE',
'pool is not a healthy single-device topology')
if imported:
_require(len(fields) == 5 and fields[2:] == ['0', '0', '0'],
'pool reports device errors')
rows.append(fields[0])
_require(len(rows) == 2 and rows[0] == name,
'pool must contain exactly one identifiable device')
leaf = rows[1]
if not imported and re.fullmatch(r'[A-Za-z0-9_.-]+', leaf):
leaf = '/dev/' + leaf
_require(leaf.startswith('/dev/'), 'pool device path cannot be positively resolved')
return leaf
def _pool_device(name):
return _members(_run(['zpool', 'status', '-P', name], full_vdev_paths=True), name)
def _inspect_device(device, mounts, allowed_pool=None, allowed_path=None):
number = _device_number(device)
nodes, edges = _inventory()
_require(number in nodes, 'selected device is absent from the block topology')
node = nodes[number]
hardware = node.get('wwn') or node.get('serial')
partition = None
if node.get('type') == 'part':
parents = [item for item in nodes.values() if item['name'] == node.get('pkname')]
_require(len(parents) == 1 and parents[0]['type'] == 'disk' and node.get('partuuid'),
'selected partition needs a stable partition UUID and identifiable parent disk')
hardware = parents[0].get('wwn') or parents[0].get('serial')
partition = node['partuuid']
_require(isinstance(hardware, str) and bool(hardware.strip()),
'selected device needs a stable WWN or hardware serial')
_require(node.get('type') in ('disk', 'part') and int(node.get('size') or 0) > 0,
'selected device type or capacity is unsupported')
related = _connected(number, edges)
protected = set()
for other, data in nodes.items():
for mount in data.get('mountpoints') or []:
if mount and mount != allowed_path:
protected.add(other)
for mount in mounts:
if mount['target'] != allowed_path:
protected.add(mount.get('maj:min'))
active = _pools()
for pool in active:
leaf = _device_number(_pool_device(pool))
if pool != allowed_pool or any(
m['source'].split('/')[0] == pool and m['target'] != allowed_path for m in mounts):
protected.add(leaf)
_require(not (related & protected),
'selected device intersects mounted, root, boot, swap, or active pool storage')
identity = {'hardware': hardware, 'size': int(node['size']), 'type': node['type']}
if partition:
identity['partition'] = partition
return {'number': number, 'identity': identity, 'related': related, 'node': node, 'pools': active}
def _safe_path(path):
for parent in reversed((path, *path.parents)):
try:
info = parent.lstat()
except FileNotFoundError:
_require(parent == path, 'storage parent directory is missing')
continue
_require(stat.S_ISDIR(info.st_mode), 'storage path contains a symlink or non-directory')
def _directory(path, mounts, pool=None):
_safe_path(path)
exact = [m for m in mounts if m['target'] == str(path)]
_require(not exact or (len(exact) == 1 and pool is not None and
exact[0]['fstype'] == 'zfs' and exact[0]['source'] == pool),
'tier path is occupied by an unexpected mount')
_require(not any(m['target'].startswith(str(path) + '/') for m in mounts),
'tier path contains an unexpected nested mount')
created = not path.exists()
if created:
path.mkdir(mode=0o755)
path.chmod(0o755)
return created
def _directory_identity(path):
_safe_path(path)
try:
info = path.lstat()
except FileNotFoundError:
raise StorageError('managed directory identity is missing') from None
return {'device': info.st_dev, 'inode': info.st_ino}
def _verify_ownership_identity(record, path):
ownership = record.get('ownership')
if ownership:
_require(ownership['identity'] == _directory_identity(path),
'managed directory identity changed before ownership handoff')
return ownership
def _record_pending_ownership(record, path):
if not _verify_ownership_identity(record, path):
record['ownership'] = {'status': 'pending', 'identity': _directory_identity(path)}
def _verify_pool(name, guid, device_info, path, owner, require_owner=True):
pools = _pools()
_require(name in pools and pools[name] == {'guid': guid, 'health': 'ONLINE'},
'pool name, GUID, or health does not match enrollment')
_require(_member_matches(_pool_device(name), device_info),
'pool device membership does not match the selected device')
rows = [line.split('\t') for line in _run(
['zfs', 'list', '-H', '-r', '-o', 'name,mountpoint,type,mounted', name]).splitlines()]
_require(len(rows) == 1 and len(rows[0]) == 4 and rows[0][:3] == [name, str(path), 'filesystem'],
'pool dataset or mountpoint requires explicit reconciliation')
if require_owner:
actual = _run(['zfs', 'get', '-H', '-o', 'value', _OWNER, name]).strip()
_require(actual == owner, 'pool lacks matching enrollment ownership')
return rows[0][3] == 'yes'
def _member_matches(member, info):
number = _device_number(member)
return number == info['number'] or (info['node']['type'] == 'disk' and number in info['related'])
def _import_candidate(device, guid):
text = _run(['zpool', 'import', '-d', device], full_vdev_paths=True)
names = re.findall(r'^\s*pool:\s*(\S+)\s*$', text, re.M)
guids = re.findall(r'^\s*id:\s*(\d+)\s*$', text, re.M)
states = re.findall(r'^\s*state:\s*(\S+)\s*$', text, re.M)
_require(len(names) == 1 and guids == [guid] and states == ['ONLINE'],
'import discovery does not match the declared healthy pool GUID')
actions = re.findall(r'^\s*action:\s*(.+)$', text, re.M)
_require(not re.search(r'^\s*status:', text, re.M) and
all(action == 'The pool can be imported using its name or numeric identifier.' for action in actions),
'pool import reports ownership or recovery conditions; reconcile manually')
return names[0], _members(text, names[0], imported=False)
def _block_tier(tier, path, journal, owner, save):
record = journal.get(str(tier.number))
created = False
pool = record.get('pool') if record else None
info = _inspect_device(tier.device, _mounts(), pool, str(path) if pool else None)
if record:
_require(record['device'] == info['identity'], 'selected device identity changed since enrollment')
_require(record['status'] in ('intent', 'complete'), 'tier journal status is unsupported')
if pool and pool in info['pools']:
guid = record.get('guid') or info['pools'][pool]['guid']
_verify_pool(pool, guid, info, path, owner)
else:
_require(not record or record['status'] == 'intent',
'previously enrolled pool is not active; explicit reconciliation is required')
if tier.mode == 'create':
pool = f'tier-{tier.number}'
_require(pool not in info['pools'], 'existing pool name does not prove enrollment ownership')
_require(info['node']['type'] == 'disk', 'pool creation requires an unused whole disk')
_require(len(info['related']) == 1, 'selected disk has partitions or dependent devices')
signatures = _json(['wipefs', '--json', '--output', 'TYPE,UUID,LABEL', tier.device], 'signatures')
_require(not signatures and not info['node'].get('fstype'), 'selected device contains existing signatures')
else:
pool, member = _import_candidate(tier.device, tier.pool_guid)
_require(_member_matches(member, info), 'import membership differs from the selected device')
_require(pool not in info['pools'], 'pool is already active without matching enrollment ownership')
created = _directory(path, _mounts())
_require(not path.exists() or not any(path.iterdir()), 'pool mountpoint contains existing data')
record = {'status': 'intent', 'device': info['identity'], 'pool': pool,
'ownership_intent': tier.mode == 'create'}
journal[str(tier.number)] = record
save()
fresh = _inspect_device(tier.device, _mounts())
_require(fresh['identity'] == info['identity'] and fresh['number'] == info['number'],
'selected device identity changed before pool operation')
_directory(path, _mounts())
_require(not any(path.iterdir()), 'pool mountpoint acquired existing data before operation')
if tier.mode == 'create':
_require(not _json(['wipefs', '--json', '--output', 'TYPE,UUID,LABEL', tier.device], 'signatures'),
'selected device acquired signatures before creation')
_run(['zpool', 'create', '-o', 'ashift=12', '-O', f'mountpoint={path}',
'-O', f'{_OWNER}={owner}', pool, tier.device])
else:
_require(_import_candidate(tier.device, tier.pool_guid) == (pool, member),
'pool identity changed before import')
_run(['zpool', 'import', '-N', '-d', tier.device, tier.pool_guid])
pools = _pools()
_require(pool in pools, 'pool operation did not establish an active pool')
guid = pools[pool]['guid']
_require(tier.mode != 'import' or guid == tier.pool_guid, 'imported pool GUID differs from intent')
refreshed = _inspect_device(tier.device, _mounts(), pool, str(path))
_require(refreshed['identity'] == info['identity'], 'selected device changed during pool operation')
info = refreshed
_verify_pool(pool, guid, info, path, owner, require_owner=tier.mode == 'create')
if tier.mode == 'import':
previous_owner = _run(['zfs', 'get', '-H', '-o', 'value', _OWNER, pool]).strip()
_require(previous_owner in ('-', owner), 'imported pool belongs to a different enrollment')
_run(['zfs', 'set', f'{_OWNER}={owner}', pool])
created = _directory(path, _mounts(), pool) or created
if not _verify_pool(pool, guid, info, path, owner):
_require(not any(path.iterdir()), 'refusing to hide existing files beneath a pool mount')
_run(['zfs', 'mount', pool])
_require(any(m['target'] == str(path) and m['source'] == pool and m['fstype'] == 'zfs'
for m in _mounts()), 'pool is not mounted at its enrolled path')
record.update(status='complete', guid=guid)
if tier.mode == 'create' and record.get('ownership_intent'):
_record_pending_ownership(record, path)
save()
return {'path': str(path), 'mode': tier.mode, 'created': created and tier.mode == 'create',
'pool': pool, 'guid': guid}
def _persist_swap():
info = FSTAB.lstat()
_require(stat.S_ISREG(info.st_mode) and info.st_uid == 0, 'fstab must be a root-owned regular file')
original = FSTAB.read_text()
lines = [line.split() for line in original.splitlines() if line.strip() and not line.lstrip().startswith('#')]
matches = [line for line in lines if line[0] == str(SWAPFILE)]
if matches:
_require(len(matches) == 1 and len(matches[0]) >= 4 and matches[0][2] == 'swap'
and 'noauto' not in matches[0][3].split(','),
'existing swap fstab entry is incompatible')
return
fd, temporary = tempfile.mkstemp(prefix='.apex-swap-', dir=FSTAB.parent)
try:
with os.fdopen(fd, 'w') as output:
os.fchmod(output.fileno(), stat.S_IMODE(info.st_mode))
os.fchown(output.fileno(), info.st_uid, info.st_gid)
output.write(original + ('' if original.endswith('\n') else '\n') +
f'{SWAPFILE} none swap sw 0 0\n')
output.flush()
os.fsync(output.fileno())
current = FSTAB.lstat()
_require(all(getattr(current, key) == getattr(info, key) for key in
('st_dev', 'st_ino', 'st_size', 'st_mtime_ns', 'st_ctime_ns')),
'fstab changed during swap setup')
os.replace(temporary, FSTAB)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def _active_swaps():
lines = SWAPS.read_bytes().splitlines()
_require(bool(lines) and lines[0].split() == [b'Filename', b'Type', b'Size', b'Used', b'Priority'],
'kernel swap inventory header is invalid')
paths = []
for line in lines[1:]:
fields = line.split()
_require(len(fields) == 5 and fields[0].startswith(b'/') and fields[1] in (b'file', b'partition')
and fields[2].isdigit() and fields[3].isdigit()
and re.fullmatch(rb'-?[0-9]+', fields[4]) is not None,
'kernel swap inventory entry is invalid')
_require(re.search(rb'\\(?![0-3][0-7]{2})', fields[0]) is None,
'kernel swap inventory path escaping is invalid')
decoded = re.sub(rb'\\([0-3][0-7]{2})', lambda match: bytes([int(match.group(1), 8)]), fields[0])
paths.append(os.fsdecode(decoded))
return paths
def _swap(journal, save):
try:
active = _active_swaps()
if active:
return {'status': 'skipped', 'reason': 'existing active swap preserved'}
if os.path.lexists(SWAPFILE):
return {'status': 'skipped', 'reason': 'existing swapfile preserved; no overwrite or resize'}
mounts = _mounts()
covering = [m for m in mounts if str(SWAPFILE).startswith(m['target'].rstrip('/') + '/')]
_require(bool(covering), 'swap filesystem could not be identified')
filesystem = max(covering, key=lambda m: len(m['target']))['fstype']
_require(filesystem in ('ext4', 'xfs'), 'optional swap requires a supported ext4 or XFS filesystem')
match = re.search(r'^MemTotal:\s+(\d+)\s+kB$', MEMINFO.read_text(), re.M)
_require(match is not None, 'RAM size could not be established')
size = (4 if int(match.group(1)) * 1024 > 4 * _GIB else 2) * _GIB
space = os.statvfs(SWAPFILE.parent)
_require(space.f_bavail * space.f_frsize >= size + _GIB,
'insufficient free space for optional swap and reserve')
_require(hasattr(os, 'posix_fallocate'), 'safe swap allocation is unavailable')
journal['swap'] = {'status': 'intent', 'size': size}
save()
fd = os.open(SWAPFILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
os.posix_fallocate(fd, 0, size)
os.fsync(fd)
finally:
os.close(fd)
_run(['mkswap', str(SWAPFILE)])
_run(['swapon', str(SWAPFILE)])
active = _active_swaps()
_require(str(SWAPFILE) in active,
'optional swap activation did not become observable')
_persist_swap()
return {'status': 'complete', 'size': size}
except (StorageError, OSError) as error:
return {'status': 'skipped', 'reason': str(error) if isinstance(error, StorageError)
else 'optional swap allocation or persistence failed; existing resources preserved'}
def _enrollment_identity(spec):
return hashlib.sha256(json.dumps(spec.identity(), sort_keys=True,
separators=(',', ':')).encode()).hexdigest()
def acknowledge_storage_ownership(spec: Enrollment, state: dict, paths, save) -> None:
"""Persist the caller's successful, nonrecursive ownership handoff for paths."""
journal = state.get('storage', {})
_require(journal.get('identity') == _enrollment_identity(spec),
'storage enrollment identity conflicts with ownership acknowledgement')
allowed = {str(SRV / f'tier-{tier.number}.{spec.repository.name}'): tier for tier in spec.tiers}
acknowledged = []
for path in paths:
_require(path in allowed, 'ownership acknowledgement path is outside enrollment')
tier = allowed[path]
record = journal.get('tiers', {}).get(str(tier.number), {})
ownership = _verify_ownership_identity(record, Path(path))
_require(tier.mode != 'import' and ownership is not None,
'ownership acknowledgement requires a directory created by enrollment')
_require(ownership['status'] in ('pending', 'applied'), 'ownership journal status is unsupported')
acknowledged.append(ownership)
for ownership in acknowledged:
ownership['status'] = 'applied'
save()
def reconcile_storage(spec: Enrollment, state: dict, save) -> dict:
"""Return ownership_pending paths until the caller acknowledges their chown."""
identity = _enrollment_identity(spec)
journal = state.setdefault('storage', {'identity': identity, 'tiers': {}})
_require(journal.get('identity') == identity, 'storage enrollment identity conflicts with journal')
_safe_path(SRV)
_require(SRV.exists(), 'storage parent directory is missing')
selected = set()
for tier in spec.tiers:
if tier.device:
number = _device_number(tier.device)
_require(number not in selected, 'multiple tiers select aliases of the same device')
selected.add(number)
result = {'tiers': [], 'ownership_pending': []}
for tier in spec.tiers:
path = SRV / f'tier-{tier.number}.{spec.repository.name}'
if tier.mode == 'directory':
record = journal['tiers'].get(str(tier.number), {})
_verify_ownership_identity(record, path)
_safe_path(path)
_require(not (record.get('ownership_intent') and not record.get('ownership') and path.exists()),
'directory creation was interrupted before identity was recorded; reconcile manually')
if not path.exists():
record = {'status': 'intent', 'path': str(path), 'ownership_intent': True}
journal['tiers'][str(tier.number)] = record
save()
created = _directory(path, _mounts())
if created:
_record_pending_ownership(record, path)
entry = {'path': str(path), 'mode': tier.mode, 'created': created}
record.update(status='complete', path=str(path))
journal['tiers'][str(tier.number)] = record
save()
else:
entry = _block_tier(tier, path, journal['tiers'], identity, save)
ownership = journal['tiers'][str(tier.number)].get('ownership')
if ownership and ownership['status'] == 'pending':
result['ownership_pending'].append(str(path))
result['tiers'].append(entry)
result['swap'] = _swap(journal, save)
journal['swap'] = result['swap']
save()
return result
-170
View File
@@ -1,170 +0,0 @@
"""Shared root runner for remote and cloud-init enrollment."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import platform
import pwd
import subprocess
import sys
import tempfile
from .state import Journal, ProvisionError, atomic_write, exclusive_lock, safe_path
from .userdata import decode_user_data
STATE = Path('/var/lib/apex-init')
def run(args, **kwargs):
result = subprocess.run(args, text=True, capture_output=True, timeout=300, **kwargs)
if result.returncode:
raise ProvisionError('required target operation failed: ' + args[0])
return result.stdout.strip()
def preflight():
if os.geteuid() != 0:
raise ProvisionError('target enrollment requires root privileges')
release = {}
for line in Path('/etc/os-release').read_text().splitlines():
key, separator, value = line.partition('=')
if separator:
release[key] = value.strip('"')
if release.get('ID') != 'debian' or release.get('VERSION_ID') != '13' or platform.machine() != 'x86_64':
raise ProvisionError('initialization supports Debian 13 amd64 only')
def validate_keys(spec):
for key in spec.authorized_keys:
with tempfile.NamedTemporaryFile(mode='w') as stream:
stream.write(key + '\n')
stream.flush()
try:
run(['ssh-keygen', '-l', '-f', stream.name])
except ProvisionError:
raise ProvisionError('administrator public key failed cryptographic validation') from None
def administrator(spec):
try:
account = pwd.getpwnam('adam')
except KeyError:
run(['useradd', '--create-home', '--shell', '/bin/bash', '--groups', 'sudo,docker', 'adam'])
account = pwd.getpwnam('adam')
if account.pw_dir != '/home/adam' or account.pw_uid == 0:
raise ProvisionError('existing administrator has incompatible home or UID')
safe_path(account.pw_dir)
if account.pw_shell != '/bin/bash':
run(['usermod', '--shell', '/bin/bash', 'adam'])
run(['usermod', '--append', '--groups', 'sudo,docker', 'adam'])
directory = safe_path(Path(account.pw_dir) / '.ssh')
directory.mkdir(mode=0o700, exist_ok=True)
os.chown(directory, account.pw_uid, account.pw_gid)
os.chmod(directory, 0o700)
authorized = safe_path(directory / 'authorized_keys')
existing = authorized.read_text().splitlines() if authorized.exists() else []
keys = existing + [key for key in spec.authorized_keys if key not in existing]
atomic_write(authorized, '\n'.join(keys) + '\n', uid=account.pw_uid, gid=account.pw_gid)
policy = 'adam ALL=(ALL:ALL) NOPASSWD: ALL\n'
with tempfile.NamedTemporaryFile(mode='w') as stream:
stream.write(policy)
stream.flush()
run(['visudo', '-cf', stream.name])
atomic_write('/etc/sudoers.d/99-apex-adam', policy, 0o440)
run(['visudo', '-c'])
run(['runuser', '-u', 'adam', '--', 'sudo', '-n', 'true'])
for item in spec.files:
uid, gid = (0, 0) if item.owner == 'root:root' else (account.pw_uid, account.pw_gid)
atomic_write(item.path, item.content, item.mode, uid=uid, gid=gid)
if run(['hostname']) != spec.hostname:
run(['hostnamectl', 'set-hostname', spec.hostname])
groups = run(['id', '-nG', 'adam']).split()
if not {'sudo', 'docker'} <= set(groups):
raise ProvisionError('administrator groups did not converge')
return {'administrator': 'adam', 'hostname': spec.hostname, 'sudo': 'verified'}
def enroll(spec, revision, bundle_digest, heartbeat=None):
from .baseline import apply_baseline
from .storage import acknowledge_storage_ownership, reconcile_storage
from .repository import reconcile_repository
preflight()
validate_keys(spec)
safe_path(STATE).mkdir(mode=0o700, exist_ok=True)
if STATE.stat().st_uid != 0:
raise ProvisionError('enrollment state directory must be root-owned')
os.chmod(STATE, 0o700)
with exclusive_lock(STATE / 'init.lock'):
journal = Journal(STATE / 'enrollment.json', spec.identity(), revision, bundle_digest)
journal.save()
journal.stage('baseline', apply_baseline, heartbeat)
journal.stage('administrator', lambda: administrator(spec), heartbeat)
result = journal.stage('storage', lambda: reconcile_storage(spec, journal.data, journal.save), heartbeat)
account = pwd.getpwnam('adam')
for path in result['ownership_pending']:
os.chown(safe_path(path), account.pw_uid, account.pw_gid)
acknowledge_storage_ownership(spec, journal.data, [path], journal.save)
journal.stage('repository', lambda: reconcile_repository(spec, journal.data, journal.save), heartbeat)
journal.data['status'] = 'enrolled'
journal.data['external_access'] = 'pending'
journal.save()
return journal.data
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument('operation', choices=('validate', 'enroll', 'status', 'complete'))
parser.add_argument('--user-data')
parser.add_argument('--revision', default='unknown')
parser.add_argument('--bundle-digest', default='unknown')
parser.add_argument('--heartbeat')
args = parser.parse_args(argv)
try:
preflight()
if args.operation == 'status':
path = safe_path(STATE / 'enrollment.json')
print(path.read_text() if path.exists() else json.dumps({'status': 'not-started'}))
return 0
if args.operation == 'complete':
with exclusive_lock(STATE / 'init.lock'):
access = json.loads(safe_path(STATE / 'access/state.json').read_text())
path = safe_path(STATE / 'enrollment.json')
record = json.loads(path.read_text())
if access.get('status') != 'committed' or record.get('status') not in ('enrolled', 'complete'):
raise ProvisionError('enrollment and access must be verified before completion')
record['external_access'] = 'verified'
record['status'] = 'complete'
atomic_write(path, json.dumps(record, sort_keys=True) + '\n')
print(json.dumps(record, sort_keys=True))
return 0
if not args.user_data:
raise ProvisionError('--user-data is required')
path = safe_path(args.user_data)
if path.stat().st_uid != 0 or path.stat().st_mode & 0o077:
raise ProvisionError('user-data must be root-owned and private')
if not path.is_file() or path.stat().st_size > 1024 * 1024:
raise ProvisionError('user-data must be a regular file no larger than 1 MiB')
spec = decode_user_data(path.read_bytes())
validate_keys(spec)
if args.operation == 'validate':
print(json.dumps({'status': 'valid', 'hostname': spec.hostname}))
else:
print(json.dumps(enroll(spec, args.revision, args.bundle_digest, args.heartbeat), sort_keys=True))
return 0
except Exception as error:
from .contract import ContractError
from .baseline import BaselineError
from .storage import StorageError
if isinstance(error, (ProvisionError, ContractError, BaselineError, StorageError)):
print(str(error), file=sys.stderr)
else:
# Unexpected dependency failures must not render raw credential input.
print('initialization failed (' + type(error).__name__ + '); inspect stage status', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
@@ -1,30 +0,0 @@
"""Target-only decoding; importing the CLI never requires a YAML dependency."""
from .contract import ContractError, normalize_user_data
def decode_user_data(content):
import yaml
class Loader(yaml.SafeLoader):
def compose_node(self, parent, index):
if self.check_event(yaml.AliasEvent):
raise ContractError('YAML aliases are not supported')
return super().compose_node(parent, index)
def construct_mapping(self, node, deep=False):
result = {}
for key_node, value_node in node.value:
key = self.construct_object(key_node, deep=deep)
if not isinstance(key, str) or key in result:
raise ContractError('YAML mapping keys must be unique strings')
result[key] = self.construct_object(value_node, deep=deep)
return result
if len(content) > 1024 * 1024:
raise ContractError('user-data exceeds the supported size')
try:
# Loader extends SafeLoader only to reject aliases and duplicate keys.
document = yaml.load(content, Loader=Loader) # noqa: S506
except (yaml.YAMLError, UnicodeError, RecursionError):
raise ContractError('invalid or unsupported YAML user-data') from None
return normalize_user_data(document)
@@ -1,38 +0,0 @@
from provisioning.security import SecurityError
class SecurityBoundary:
"""Isolate firewall/service effects while exercising the durable coordinator."""
def __init__(self):
self.active = False
self.fail_activation = False
self.fail_restore = False
def preflight(self):
return {}
def snapshot(self, plan, port, address):
return {'phase': 'prepared', 'was_active': self.active}
def activate(self, state, persist):
self.active = True
state['phase'] = 'active'
persist(state)
if self.fail_activation:
raise SecurityError('activation failed')
def finalize(self, state, persist):
self.verify(state)
state['phase'] = 'final'
persist(state)
def verify(self, state):
if not self.active:
raise SecurityError('security stopped')
def restore(self, state, persist):
self.active = state['was_active']
state['cleanup'] = 'pending' if self.fail_restore else 'complete'
persist(state)
if self.fail_restore:
raise SecurityError('cleanup pending')
@@ -1,510 +0,0 @@
"""Exercise baseline decisions with a temporary host filesystem and fake commands."""
import hashlib
import importlib.util
import io
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from provisioning import baseline
class Host:
def __init__(self, root):
self.root = root
self.commands = []
self.installed = {}
self.partial = {}
self.active = set()
self.enabled = set()
self.masked = set()
self.fail_install = False
self.plan = ''
self.zfs_available = True
self.docker_root = '/srv/docker'
self.bouncer_key = 'testOnlyGeneratedCredential1234567890'
self.registration_failure = False
self.registrations = {}
self.path('/etc/os-release').parent.mkdir(parents=True)
self.path('/etc/os-release').write_text('ID=debian\nVERSION_ID="13"\n')
self.path('/etc/debian_version').touch()
self.path('/usr/sbin').mkdir(parents=True)
def path(self, path):
path = Path(path)
if str(path).startswith(str(self.root)):
return path
return self.root / str(path).lstrip('/')
def command(self, args, **kwargs):
self.commands.append(args)
code, output = 0, ''
if args[:2] == ['dpkg', '--print-architecture']:
output = 'amd64\n'
elif args[0] == 'dpkg-query':
output = ''.join(f'{name}\tinstall ok installed\t{version}\n' for name, version in self.installed.items())
output += ''.join(f'{name}\t{status}\t1.0\n' for name, status in self.partial.items())
elif args == ['uname', '-r']:
output = '6.12.0-amd64\n'
elif args[0] == 'apt-get' and 'install' in args:
if '--simulate' in args:
output = self.plan
elif self.fail_install:
code = 100
else:
start = args.index('--no-remove') + 1
# Reproduce dpkg configuring the bouncer before CrowdSec in one batch.
if 'crowdsec-firewall-bouncer-iptables' in args[start:]:
config = self.path('/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml')
config.parent.mkdir(parents=True, exist_ok=True)
if not config.exists():
key = 'packageGeneratedCredential123456789' if 'crowdsec' in self.installed else '<API_KEY>'
config.write_text(f'api_url: http://127.0.0.1:8080/\napi_key: {key}\nmode: iptables\n')
config.chmod(0o640)
for name in args[start:]:
self.installed[name] = '1.0'
self.partial.pop(name, None)
elif args[:4] == ['cscli', '-oraw', 'bouncers', 'add']:
if self.registration_failure:
code, output = 1, 'sensitive diagnostic'
else:
output = self.bouncer_key + '\n'
self.registrations[args[4]] = self.bouncer_key
elif args[0] == 'systemctl':
service = args[-1]
operation = args[1]
if operation == 'is-active':
code = 0 if service in self.active else 3
elif operation == 'is-enabled':
code = 0 if service in self.enabled else 1
output = 'masked\n' if service in self.masked else ''
elif operation == 'start':
conditions = []
for dropin in self.path(f'/etc/systemd/system/{service}.service.d').glob('*.conf'):
conditions.extend(line.partition('=')[2] for line in dropin.read_text().splitlines()
if line.startswith('ConditionPathExists='))
if service not in self.masked and all(self.path(path).exists() for path in conditions):
self.active.add(service)
elif operation == 'enable':
self.enabled.add(service)
elif operation == 'disable':
self.enabled.discard(service)
elif operation == 'mask':
self.masked.add(service)
unit = self.path(f'/etc/systemd/system/{service}.service')
unit.parent.mkdir(parents=True, exist_ok=True)
unit.symlink_to('/dev/null')
elif operation == 'unmask':
self.masked.discard(service)
unit = self.path(f'/etc/systemd/system/{service}.service')
if unit.is_symlink():
unit.unlink()
elif args[:2] == ['docker', 'info']:
output = self.docker_root + '\n'
elif args[:3] == ['docker', 'compose', 'version']:
output = '2.39.2\n'
elif args == ['modprobe', 'zfs']:
code = 0 if self.zfs_available else 1
elif args == ['zfs', 'version']:
output = 'zfs-2.3.2\nzfs-kmod-2.3.2\n'
return subprocess.CompletedProcess(args, code, output, 'installation failed' if code else '')
class BaselineTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.host = Host(Path(self.temp.name).resolve())
self.helper = b'#!/bin/bash\necho pinned test helper\n'
patches = [
patch.object(baseline, 'Path', self.host.path),
patch.object(baseline, 'POLICY', self.host.path('/usr/sbin/policy-rc.d')),
patch.object(baseline, 'POLICY_STATE', self.host.path('/usr/sbin/.apex-policy-rc.d')),
patch.object(baseline, 'SECURITY_STATE', self.host.path('/var/lib/apex/baseline-security.json')),
patch.object(baseline.os, 'geteuid', return_value=Path(self.temp.name).stat().st_uid),
patch.object(baseline.subprocess, 'run', side_effect=self.host.command),
patch.object(baseline.urllib.request, 'urlopen', side_effect=self.download),
patch.object(baseline, 'HELPER_SHA256', hashlib.sha256(self.helper).hexdigest()),
]
for item in patches:
item.start()
self.addCleanup(item.stop)
def download(self, url, **kwargs):
if url == baseline.HELPER_URL:
return io.BytesIO(self.helper)
return io.BytesIO(b'-----BEGIN PGP PUBLIC KEY BLOCK-----\nfixture\n')
def apply(self):
# The filesystem remains owned by the test uid; only root preflight is isolated.
with patch.object(baseline, 'preflight'):
return baseline.apply_baseline()
def test_package_database_drives_installation_and_repeat_does_not_upgrade(self):
self.host.installed['curl'] = '7.0'
self.host.partial['rsyslog'] = 'deinstall ok config-files'
self.assertEqual(baseline.install_missing(['curl', 'rsyslog']), ['rsyslog'])
self.assertEqual(self.host.installed['curl'], '7.0')
self.assertEqual(self.host.installed['rsyslog'], '1.0')
count = len(self.host.commands)
self.assertEqual(baseline.install_missing(['curl', 'rsyslog']), [])
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands[count:]))
def test_dependency_upgrade_is_rejected_before_install(self):
self.host.plan = 'Inst libc6 [2.0] (3.0 Debian:13)\n'
with self.assertRaisesRegex(baseline.BaselineError, 'upgrade/removal'):
baseline.install_missing(['git'])
self.assertNotIn('git', self.host.installed)
def test_held_installed_packages_are_preserved(self):
self.host.partial['git'] = 'hold ok installed'
self.assertEqual(baseline.install_missing(['git']), [])
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands))
def test_conflicting_docker_config_is_rejected_without_host_mutation(self):
path = self.host.path('/etc/docker/daemon.json')
path.parent.mkdir(parents=True)
path.write_text('{"data-root": "/data/docker"}')
before = sorted(str(path.relative_to(self.host.root)) for path in self.host.root.rglob('*'))
with self.assertRaisesRegex(baseline.BaselineError, 'Conflicting Docker'):
self.apply()
self.assertEqual(before, sorted(str(path.relative_to(self.host.root)) for path in self.host.root.rglob('*')))
self.assertFalse(any(command[0] in ('apt-get', 'systemctl') for command in self.host.commands))
def test_existing_docker_data_is_preserved(self):
data = self.host.path('/var/lib/docker/volumes/user-data')
data.parent.mkdir(parents=True)
data.write_text('valuable')
with self.assertRaisesRegex(baseline.BaselineError, 'Existing /var/lib/docker'):
self.apply()
self.assertEqual(data.read_text(), 'valuable')
def test_conflicting_distribution_docker_package_is_never_removed(self):
self.host.installed['docker.io'] = '26.1.5'
with self.assertRaisesRegex(baseline.BaselineError, 'Conflicting Docker packages'):
self.apply()
self.assertEqual(self.host.installed['docker.io'], '26.1.5')
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands))
def test_unmanaged_destination_data_is_not_adopted(self):
data = self.host.path('/srv/docker/private-data')
data.parent.mkdir(parents=True)
data.write_text('preserve me')
with self.assertRaisesRegex(baseline.BaselineError, 'Unmanaged /srv/docker'):
self.apply()
self.assertEqual(data.read_text(), 'preserve me')
def test_package_failure_restores_existing_service_policy(self):
policy = self.host.path('/usr/sbin/policy-rc.d')
policy.write_text('#!/bin/sh\nexit 42\n')
policy.chmod(0o751)
self.host.fail_install = True
with self.assertRaises(baseline.BaselineError):
self.apply()
self.assertEqual(policy.read_text(), '#!/bin/sh\nexit 42\n')
self.assertEqual(policy.stat().st_mode & 0o777, 0o751)
self.assertFalse(self.host.path('/usr/sbin/.apex-policy-rc.d').exists())
self.assertFalse(self.host.masked)
def test_service_policy_symlink_is_restored_after_failure(self):
policy = self.host.path('/usr/sbin/policy-rc.d')
original = self.host.path('/usr/sbin/provider-policy')
original.write_text('provider policy')
policy.symlink_to(original)
with self.assertRaisesRegex(RuntimeError, 'boom'):
with baseline.service_start_guard():
self.assertFalse(policy.is_symlink())
raise RuntimeError('boom')
self.assertTrue(policy.is_symlink())
self.assertEqual(original.read_text(), 'provider policy')
def test_interrupted_guard_is_recovered_before_next_attempt(self):
state = self.host.path('/usr/sbin/.apex-policy-rc.d')
state.mkdir()
(state / 'original').write_text('original policy')
(state / 'ready').touch()
self.host.path('/usr/sbin/policy-rc.d').write_text('#!/bin/sh\nexit 101\n')
with baseline.service_start_guard():
pass
self.assertEqual(self.host.path('/usr/sbin/policy-rc.d').read_text(), 'original policy')
def test_external_policy_change_is_not_overwritten(self):
policy = self.host.path('/usr/sbin/policy-rc.d')
with self.assertRaisesRegex(baseline.BaselineError, 'changed outside APEX'):
with baseline.service_start_guard():
policy.write_text('external policy')
self.assertEqual(policy.read_text(), 'external policy')
def test_baseline_reports_capabilities_and_repeats_without_restarts(self):
result = self.apply()
self.assertEqual(result['capabilities']['docker_data_root'], '/srv/docker')
self.assertEqual(result['capabilities']['compose'], '2.39.2')
self.assertIn('zfs-kmod-2.3.2', result['capabilities']['zfs'])
self.assertEqual(result['packages']['zfs-dkms'], '1.0')
self.assertEqual(self.host.path('/etc/apt/apt.conf.d/99-apex').read_text(), 'APT::Keep-Downloaded-Packages "0";\n')
self.assertFalse(self.host.path('/usr/sbin/policy-rc.d').exists())
self.assertFalse(self.host.masked)
self.assertNotIn('ufw', self.host.active)
self.assertNotIn('crowdsec-firewall-bouncer', self.host.enabled)
self.assertFalse(any(command[0] in ('ufw', '/usr/local/bin/ufw-docker') for command in self.host.commands))
count = len(self.host.commands)
again = self.apply()
self.assertEqual(again, result)
repeated = self.host.commands[count:]
self.assertFalse(any(command[0] == 'apt-get' or command[:2] in
(['systemctl', 'restart'], ['systemctl', 'start']) for command in repeated))
def bouncer_config(self, key='<API_KEY>'):
self.host.installed.update({'crowdsec': '1.8.1', 'crowdsec-firewall-bouncer-iptables': '0.0.36'})
config = self.host.path('/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml')
config.parent.mkdir(parents=True, exist_ok=True)
config.write_text(f'# custom settings\napi_url: http://127.0.0.1:9090/\napi_key: {key} # keep comment\nmode: iptables\nblacklists_ipv4: [custom]\n')
config.chmod(0o640)
return config
def test_fresh_crowdsec_is_configured_before_bouncer_install(self):
self.apply()
installs = [args for args in self.host.commands if args[0] == 'apt-get' and 'install' in args and '--simulate' not in args]
crowdsec = next(index for index, args in enumerate(installs) if 'crowdsec' in args)
bouncer = next(index for index, args in enumerate(installs) if 'crowdsec-firewall-bouncer-iptables' in args)
self.assertLess(crowdsec, bouncer)
self.assertFalse(self.host.registrations)
self.assertFalse(self.host.active & {'crowdsec', 'crowdsec-firewall-bouncer'})
def test_broken_installed_bouncer_is_repaired_and_repeat_preserves_registration(self):
config = self.bouncer_config()
before = config.read_text()
owner = (config.stat().st_uid, config.stat().st_gid)
self.apply()
self.assertEqual(config.read_text(), before.replace('<API_KEY>', self.host.bouncer_key))
registration = config.with_suffix('.yaml.id').read_text().strip()
self.assertEqual(self.host.registrations[registration], self.host.bouncer_key)
self.assertEqual((config.stat().st_uid, config.stat().st_gid), owner)
self.assertEqual(config.stat().st_mode & 0o777, 0o640)
count = len(self.host.registrations)
self.apply()
self.assertEqual(len(self.host.registrations), count)
self.assertFalse(self.host.active & {'crowdsec', 'crowdsec-firewall-bouncer'})
def test_existing_bouncer_key_and_custom_settings_are_preserved(self):
config = self.bouncer_config('existing-custom-key')
before = config.read_bytes()
self.apply()
self.assertEqual(config.read_bytes(), before)
self.assertFalse(self.host.registrations)
def test_registration_failure_leaves_placeholder_without_exposing_output(self):
config = self.bouncer_config()
before = config.read_bytes()
self.host.registration_failure = True
with self.assertRaises(baseline.BaselineError) as failure:
self.apply()
self.assertEqual(config.read_bytes(), before)
self.assertFalse(config.with_suffix('.yaml.id').exists())
self.assertNotIn('sensitive diagnostic', str(failure.exception))
def test_invalid_registered_key_is_not_published(self):
for key in ('', '<API_KEY>', 'not a key\nsensitive diagnostic'):
with self.subTest(key=key):
config = self.bouncer_config()
before = config.read_bytes()
self.host.bouncer_key = key
with self.assertRaises(baseline.BaselineError):
self.apply()
self.assertEqual(config.read_bytes(), before)
self.assertFalse(config.with_suffix('.yaml.id').exists())
def test_bouncer_config_symlink_is_rejected_before_registration(self):
config = self.bouncer_config()
destination = config.with_suffix('.provider')
config.rename(destination)
config.symlink_to(destination)
with self.assertRaises(baseline.BaselineError):
self.apply()
self.assertIn('<API_KEY>', destination.read_text())
self.assertFalse(self.host.registrations)
def test_quoted_null_is_an_existing_key_not_an_uninitialized_yaml_value(self):
config = self.bouncer_config('"null"')
before = config.read_bytes()
self.apply()
self.assertEqual(config.read_bytes(), before)
self.assertFalse(self.host.registrations)
def test_package_placeholder_variants_are_repaired(self):
for placeholder in ('"<API_KEY>"', "'$API_KEY'", '${API_KEY}', 'null', '~', '', '""'):
with self.subTest(placeholder=placeholder):
config = self.bouncer_config(placeholder)
self.apply()
self.assertIn('api_key: ' + self.host.bouncer_key, config.read_text())
def test_ambiguous_or_missing_api_key_is_not_mutated(self):
for content in ('api_key: <API_KEY>\n"api_key": existing-key\n', 'api_url: http://localhost:8080/\n',
'api_key: "<API_KEY>x\n'):
with self.subTest(content=content):
config = self.bouncer_config()
config.write_text(content)
with self.assertRaises(baseline.BaselineError):
self.apply()
self.assertEqual(config.read_text(), content)
self.assertFalse(self.host.registrations)
def test_multiline_custom_credentials_are_preserved_without_registration(self):
for credential in ('\n existing-custom-credential', ' # comment\n\n existing-custom-credential',
' <API_KEY>\n existing-custom-credential', '\n # comment\n existing-custom-credential'):
with self.subTest(credential=credential):
config = self.bouncer_config()
config.write_text('api_key:' + credential + '\napi_url: http://localhost:9090/\n')
identity = config.with_suffix('.yaml.id')
identity.write_text('existing-registration\n')
before = config.read_bytes()
with self.assertRaises(baseline.BaselineError):
self.apply()
self.assertEqual(config.read_bytes(), before)
self.assertEqual(identity.read_text(), 'existing-registration\n')
self.assertFalse(self.host.registrations)
@unittest.skipUnless(importlib.util.find_spec('yaml'), 'target-only python3-yaml is unavailable')
def test_empty_credential_replacement_parses_as_the_generated_key(self):
import yaml
for entry in ('api_key:', 'api_key: # comment', 'api_key: ',
'api_key:\n # next-line comment'):
with self.subTest(entry=entry):
config = self.bouncer_config()
config.write_text(entry + '\napi_url: http://localhost:9090/\n')
self.assertIsNone(yaml.safe_load(config.read_text())['api_key'])
self.apply()
parsed = yaml.safe_load(config.read_text())
self.assertEqual(parsed['api_key'], self.host.bouncer_key)
self.assertEqual(parsed['api_url'], 'http://localhost:9090/')
if '# ' in entry:
self.assertIn(entry[entry.index('# '):], config.read_text())
def test_redirected_bookkeeping_is_rejected_before_registration(self):
config = self.bouncer_config()
destination = config.with_suffix('.provider')
destination.write_text('provider-data')
config.with_suffix('.yaml.id').symlink_to(destination)
with self.assertRaises(baseline.BaselineError):
self.apply()
self.assertEqual(destination.read_text(), 'provider-data')
self.assertFalse(self.host.registrations)
def test_interrupted_credential_publication_can_retry_without_reusing_wrong_identity(self):
config = self.bouncer_config()
replace = baseline.os.replace
def interrupt_config(source, destination):
if destination == config:
raise OSError('interrupted publication')
return replace(source, destination)
with patch.object(baseline.os, 'replace', side_effect=interrupt_config):
with self.assertRaises(baseline.BaselineError):
self.apply()
self.assertIn('<API_KEY>', config.read_text())
orphan = config.with_suffix('.yaml.id').read_text().strip()
self.assertIn(orphan, self.host.registrations)
self.apply()
current = config.with_suffix('.yaml.id').read_text().strip()
self.assertNotEqual(orphan, current)
self.assertIn(self.host.registrations[current], config.read_text())
self.assertFalse(list(config.parent.glob('.apex-*')))
def test_zfs_missing_for_running_kernel_is_an_explicit_incomplete_result(self):
self.host.zfs_available = False
with self.assertRaisesRegex(baseline.BaselineError, 'reboot.*retry'):
self.apply()
def test_narrow_base_policy_preserves_scope_and_shared_configuration(self):
with patch.object(baseline, 'preflight'):
result = baseline.apply_base_policy()
self.assertEqual(set(result['packages']), {'curl', 'wget', 'git', 'rsyslog', 'ufw'})
self.assertFalse(self.host.path('/etc/docker/daemon.json').exists())
self.assertEqual(self.host.path('/etc/systemd/journald.conf.d/99-apex.conf').read_text(),
'[Journal]\nSystemMaxUse=200M\n')
self.assertIn(['/usr/local/bin/ufw-docker', 'install'], self.host.commands)
def test_unverified_download_is_not_installed_or_executed(self):
with patch.object(baseline.urllib.request, 'urlopen', return_value=io.BytesIO(b'tampered')):
with self.assertRaisesRegex(baseline.BaselineError, 'checksum mismatch'):
baseline.configure_common()
self.assertFalse(self.host.path('/usr/local/bin/ufw-docker').exists())
self.assertFalse(any(command[0] == '/usr/local/bin/ufw-docker' for command in self.host.commands))
def test_recovery_does_not_remove_preexisting_service_masks(self):
self.host.masked.add('ufw')
with baseline.security_service_guard(['ufw']):
self.host.installed['ufw'] = '0.36'
self.assertIn('ufw', self.host.masked)
def test_package_owned_etc_service_file_can_be_unpacked_under_guard(self):
unit = self.host.path('/etc/systemd/system/crowdsec-firewall-bouncer.service')
with baseline.security_service_guard(['crowdsec-firewall-bouncer-iptables']):
self.assertFalse(unit.is_symlink(), 'dpkg must be able to install its own conffile')
unit.parent.mkdir(parents=True, exist_ok=True)
unit.write_text('[Service]\nExecStart=/usr/bin/crowdsec-firewall-bouncer\n')
self.host.installed['crowdsec-firewall-bouncer-iptables'] = '0.0.36'
self.host.enabled.add('crowdsec-firewall-bouncer')
baseline.run(['systemctl', 'start', 'crowdsec-firewall-bouncer'])
self.assertNotIn('crowdsec-firewall-bouncer', self.host.active)
self.assertTrue(unit.is_file())
self.assertIn('ExecStart=/usr/bin/crowdsec-firewall-bouncer', unit.read_text())
self.assertNotIn('crowdsec-firewall-bouncer', self.host.enabled)
baseline.run(['systemctl', 'start', 'crowdsec-firewall-bouncer'])
self.assertIn('crowdsec-firewall-bouncer', self.host.active)
def test_disable_failure_retains_guard_and_retry_recovers_it(self):
original_command = self.host.command
def fail_disable(args, **kwargs):
if args == ['systemctl', 'disable', 'crowdsec-firewall-bouncer']:
return subprocess.CompletedProcess(args, 1, '', '')
return original_command(args, **kwargs)
with patch.object(baseline.subprocess, 'run', side_effect=fail_disable):
with self.assertRaises(baseline.BaselineError):
with baseline.security_service_guard(['crowdsec-firewall-bouncer-iptables']):
self.host.installed['crowdsec-firewall-bouncer-iptables'] = '0.0.36'
self.host.enabled.add('crowdsec-firewall-bouncer')
baseline.run(['systemctl', 'start', 'crowdsec-firewall-bouncer'])
self.assertNotIn('crowdsec-firewall-bouncer', self.host.active)
with baseline.security_service_guard(['crowdsec-firewall-bouncer-iptables']):
pass
self.assertNotIn('crowdsec-firewall-bouncer', self.host.enabled)
baseline.run(['systemctl', 'start', 'crowdsec-firewall-bouncer'])
self.assertIn('crowdsec-firewall-bouncer', self.host.active)
def test_generic_package_failure_does_not_suggest_zfs_reboot(self):
original_command = self.host.command
def fail_security_packages(args, **kwargs):
if args[0] == 'apt-get' and 'crowdsec-firewall-bouncer-iptables' in args and '--simulate' not in args:
return subprocess.CompletedProcess(args, 100, '', 'secret source diagnostic')
return original_command(args, **kwargs)
with patch.object(baseline.subprocess, 'run', side_effect=fail_security_packages):
with self.assertRaises(baseline.BaselineError) as failure:
self.apply()
self.assertNotIn('reboot', str(failure.exception))
self.assertNotIn('secret source diagnostic', str(failure.exception))
def test_unsupported_os_fails_before_package_commands(self):
self.host.path('/etc/os-release').write_text('ID=ubuntu\nVERSION_ID=24.04\n')
with patch.object(baseline.os, 'geteuid', return_value=0):
with self.assertRaisesRegex(baseline.BaselineError, 'Debian 13 amd64'):
baseline.apply_baseline()
self.assertEqual(self.host.commands, [])
def test_non_root_fails_before_any_command(self):
with patch.object(baseline.os, 'geteuid', return_value=1000):
with self.assertRaisesRegex(baseline.BaselineError, 'root'):
baseline.apply_baseline()
self.assertEqual(self.host.commands, [])
def test_unsupported_architecture_fails_before_apt_mutation(self):
with patch.object(baseline.os, 'geteuid', return_value=0), patch.object(
baseline.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'arm64\n', '')) as command:
with self.assertRaisesRegex(baseline.BaselineError, 'Debian 13 amd64'):
baseline.apply_baseline()
self.assertEqual([call.args[0] for call in command.call_args_list], [['dpkg', '--print-architecture']])
if __name__ == '__main__':
unittest.main()
@@ -1,230 +0,0 @@
import copy
import unittest
from provisioning.contract import ContractError, normalize_user_data, parse_bootstrap_env
def example():
return {
'preserve_hostname': False,
'hostname': 'node1.a1.example.com',
'users': [{
'name': 'adam',
'sudo': 'ALL=(ALL) NOPASSWD:ALL',
'groups': ['sudo', 'docker'],
'shell': '/bin/bash',
'ssh_authorized_keys': ['ssh-ed25519 PUBLIC_TEST_KEY operator'],
}],
'write_files': [{
'path': '/etc/apex/bootstrap.env',
'owner': 'root:root',
'permissions': '0600',
'content': 'APEX_REPO_NAME="node1.example.com"\n'
'APEX_REPO_URL="https://example.com/team/node1.git"\n',
}],
'runcmd': ['/usr/local/bin/apex-bootstrap'],
}
def add_env(doc, text):
doc['write_files'][0]['content'] += text
return doc
class TestBootstrapAssignments(unittest.TestCase):
def test_literal_quotes_comments_and_empty_values(self):
values = parse_bootstrap_env(
'# enrollment\nAPEX_REPO_NAME=node1.example.com\n'
"APEX_REPO_REF='release-1'\nAPEX_TIER1_DEVICE=\n")
self.assertEqual(values['APEX_REPO_REF'], 'release-1')
self.assertEqual(values['APEX_TIER1_DEVICE'], '')
def test_rejects_shell_forms_without_repeating_input(self):
payloads = [
'APEX_REPO_NAME=$(touch /tmp/SECRET_SENTINEL)',
'APEX_REPO_NAME="${SECRET_SENTINEL}"',
'APEX_REPO_NAME=`SECRET_SENTINEL`',
'APEX_REPO_NAME=x;SECRET_SENTINEL',
'export APEX_REPO_NAME=SECRET_SENTINEL',
]
for payload in payloads:
with self.subTest(payload=payload):
with self.assertRaises(ContractError) as caught:
parse_bootstrap_env(payload)
self.assertNotIn('SECRET_SENTINEL', str(caught.exception))
def test_rejects_duplicate_and_unknown_assignments(self):
for text in ('APEX_REPO_NAME=a\nAPEX_REPO_NAME=b\n', 'OTHER_SECRET=value\n'):
with self.subTest(text=text):
with self.assertRaises(ContractError):
parse_bootstrap_env(text)
def test_rejects_unquoted_parentheses_and_inline_comments(self):
for value in ('(main)', 'main(', 'main)', ' #comment', 'main #comment'):
with self.subTest(value=value):
with self.assertRaises(ContractError):
parse_bootstrap_env('APEX_REPO_REF=' + value)
def test_parentheses_and_hashes_remain_quoted_literals(self):
for quote in ('"', "'"):
for value in ('(main)', '#comment', 'main #comment'):
with self.subTest(quote=quote, value=value):
values = parse_bootstrap_env('APEX_REPO_REF=' + quote + value + quote)
self.assertEqual(values['APEX_REPO_REF'], value)
class TestUserDataContract(unittest.TestCase):
def test_public_repository_and_directory_defaults(self):
spec = normalize_user_data(example())
self.assertEqual(spec.hostname, 'node1.a1.example.com')
self.assertEqual(spec.repository.name, 'node1.example.com')
self.assertIsNone(spec.repository.ref)
self.assertEqual([tier.mode for tier in spec.tiers], ['directory'] * 3)
self.assertEqual(spec.tiers[0].number, 1)
def test_ref_and_explicit_storage_intent(self):
doc = add_env(example(),
'APEX_REPO_REF="refs/tags/v1"\n'
'APEX_TIER1_MODE=create\nAPEX_TIER1_DEVICE=/dev/disk/by-id/disk-a\n'
'APEX_TIER2_MODE=import\nAPEX_TIER2_DEVICE=/dev/disk/by-id/disk-b\n'
'APEX_TIER2_POOL_GUID=1234567\n')
spec = normalize_user_data(doc)
self.assertEqual(spec.repository.ref, 'refs/tags/v1')
self.assertEqual(spec.tiers[0].mode, 'create')
self.assertEqual(spec.tiers[1].pool_guid, '1234567')
def test_legacy_device_requires_explicit_mode(self):
with self.assertRaisesRegex(ContractError, 'explicit mode'):
normalize_user_data(add_env(example(), 'APEX_TIER1_DEVICE=/dev/vdb\n'))
def test_storage_conflicts_are_rejected(self):
cases = [
'APEX_TIER1_MODE=create\n',
'APEX_TIER1_MODE=import\nAPEX_TIER1_DEVICE=/dev/vdb\n',
'APEX_TIER1_MODE=directory\nAPEX_TIER1_DEVICE=/dev/vdb\n',
'APEX_TIER1_MODE=create\nAPEX_TIER1_DEVICE=/dev/vdb\nAPEX_TIER1_POOL_GUID=1\n',
'APEX_TIER1_MODE=erase\n',
'APEX_TIER1_MODE=create\nAPEX_TIER1_DEVICE=/dev/vdb\n'
'APEX_TIER2_MODE=create\nAPEX_TIER2_DEVICE=/dev/vdb\n',
]
for assignment in cases:
with self.subTest(assignment=assignment):
with self.assertRaises(ContractError):
normalize_user_data(add_env(example(), assignment))
def test_arbitrary_cloud_init_operations_are_rejected(self):
for field in ('packages', 'bootcmd', 'disk_setup'):
doc = example()
doc[field] = []
with self.subTest(field=field):
with self.assertRaises(ContractError):
normalize_user_data(doc)
doc = example()
doc['runcmd'] = ['touch /tmp/unapproved']
with self.assertRaises(ContractError):
normalize_user_data(doc)
def test_standard_list_form_of_bootstrap_command(self):
doc = example()
doc['runcmd'] = [['/usr/local/bin/apex-bootstrap']]
self.assertEqual(normalize_user_data(doc).hostname, doc['hostname'])
def test_invalid_administrator_and_missing_keys(self):
changes = [
('name', 'root'), ('shell', '/bin/sh'), ('groups', ['sudo']),
('sudo', 'ALL=(ALL) ALL'), ('ssh_authorized_keys', []),
]
for key, value in changes:
doc = example()
doc['users'][0][key] = value
with self.subTest(key=key):
with self.assertRaises(ContractError):
normalize_user_data(doc)
def test_unapproved_or_duplicate_file_writes(self):
for mutate in ('path', 'owner', 'permissions', 'append', 'duplicate'):
doc = example()
item = doc['write_files'][0]
if mutate == 'path':
item['path'] = '/etc/shadow'
elif mutate == 'owner':
item['owner'] = 'adam:adam'
elif mutate == 'permissions':
item['permissions'] = '0644'
elif mutate == 'append':
item['append'] = True
else:
doc['write_files'].append(copy.deepcopy(item))
with self.subTest(mutate=mutate):
with self.assertRaises(ContractError):
normalize_user_data(doc)
def test_rejects_wrong_types_and_missing_required_fields(self):
cases = [None, [], {'hostname': 'x.example.com'}]
for value in cases:
with self.subTest(value=value):
with self.assertRaises(ContractError):
normalize_user_data(value)
doc = example()
doc['preserve_hostname'] = 'false'
with self.assertRaises(ContractError):
normalize_user_data(doc)
def test_repository_paths_and_embedded_passwords(self):
for name, url in [
('../escape', 'https://example.com/repo.git'),
('node1', 'https://user:SECRET_SENTINEL@example.com/repo.git'),
('node1', 'file:///etc/shadow'),
('node1', '-uSECRET_SENTINEL'),
]:
doc = example()
doc['write_files'][0]['content'] = (
f'APEX_REPO_NAME="{name}"\nAPEX_REPO_URL="{url}"\n')
with self.subTest(name=name, url=url):
with self.assertRaises(ContractError) as caught:
normalize_user_data(doc)
self.assertNotIn('SECRET_SENTINEL', str(caught.exception))
def test_runtime_secrets_are_excluded_from_repr_and_identity(self):
doc = example()
for path, content, permission in [
('/home/adam/.ssh/id_ed25519', 'SECRET_SENTINEL', '0600'),
('/home/adam/.ssh/known_hosts', 'HOST_TRUST_SENTINEL', '0644'),
]:
doc['write_files'].append({
'path': path, 'owner': 'adam:adam', 'permissions': permission,
'defer': True, 'content': content,
})
spec = normalize_user_data(doc)
text = repr(spec) + repr(spec.identity())
self.assertNotIn('SECRET_SENTINEL', text)
self.assertNotIn('PUBLIC_TEST_KEY', text)
self.assertNotIn('HOST_TRUST_SENTINEL', text)
self.assertEqual(spec.files[1].content, 'SECRET_SENTINEL')
def test_administrator_files_require_defer(self):
doc = example()
doc['write_files'].append({
'path': '/home/adam/.ssh/known_hosts', 'owner': 'adam:adam',
'permissions': '0644', 'content': 'example.com ssh-ed25519 VALUE',
})
with self.assertRaises(ContractError):
normalize_user_data(doc)
def test_contract_does_not_mutate_caller_input(self):
doc = example()
before = copy.deepcopy(doc)
normalize_user_data(doc)
self.assertEqual(doc, before)
def test_identity_changes_for_target_but_not_credentials(self):
doc = example()
before = normalize_user_data(doc).identity()
doc['users'][0]['ssh_authorized_keys'] = ['ssh-ed25519 ANOTHER_PUBLIC_KEY operator']
self.assertEqual(normalize_user_data(doc).identity(), before)
doc['hostname'] = 'other.a1.example.com'
self.assertNotEqual(normalize_user_data(doc).identity(), before)
if __name__ == '__main__':
unittest.main()
@@ -1,112 +0,0 @@
import io
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from provisioning import access, image
from provisioning.image import cloud_enroll, sanitize_files
from provisioning.bundle import build_bundle
from provisioning.state import atomic_write, safe_path
from provisioning.state import ProvisionError
class ImageTests(unittest.TestCase):
def test_uploaded_package_installs_runnable_wrappers_and_watchdog(self):
commons = Path(__file__).resolve().parents[4]
data, digest, _ = build_bundle(commons)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
upload = root / 'tmp'
upload.mkdir()
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
archive.extractall(upload, filter='data')
def mapped(path):
return safe_path(root / str(path).lstrip('/'))
def write(path, content, mode=0o600):
return atomic_write(mapped(path), content, mode)
with patch.object(image, '__file__', str(upload / 'provisioning/image.py')), \
patch.object(image, 'safe_path', side_effect=mapped), \
patch.object(image, 'atomic_write', side_effect=write):
metadata = image.install()
installed = root / 'usr/local/lib/apex-provisioner' / digest
self.assertEqual(metadata['path'], str(installed))
self.assertTrue((installed / 'provisioning/seed.sh').is_file())
environment = dict(os.environ)
environment.pop('PYTHONPATH', None)
for executable in ('apex-provision-image', 'apex-bootstrap'):
wrapper = root / 'usr/local/bin' / executable
self.assertEqual(wrapper.stat().st_mode & 0o777, 0o755)
script = wrapper.read_text().replace('/usr/bin/python3', shlex.quote(sys.executable) + ' -S')
result = subprocess.run(['/bin/sh', '-c', script, executable, '--help'],
cwd=root, env=environment, capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('usage:', result.stdout)
commands = []
def runner(args, **kwargs):
commands.append(args)
return subprocess.CompletedProcess(args, 0, '', '')
canonical = '/usr/local/lib/apex-provisioner/' + digest
with patch.object(access, '__file__', canonical + '/provisioning/access.py'):
access.Host(root, runner=runner).arm()
service = (root / 'etc/systemd/system/apex-access-rollback.service').read_text()
fields = dict(line.split('=', 1) for line in service.splitlines() if '=' in line)
self.assertEqual(fields['WorkingDirectory'], canonical)
argv = shlex.split(fields['ExecStart'])
self.assertEqual(argv[-1], 'tick')
result = subprocess.run([sys.executable, '-S', *argv[1:-1], '--help'],
cwd=installed, env=environment, capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(['systemctl', 'enable', '--now', 'apex-access-rollback.timer'], commands)
sanitize_files(root)
self.assertFalse((upload / 'provisioning').exists())
self.assertTrue((installed / 'provisioning/image.py').is_file())
def test_watchdog_rejects_uninstalled_package_before_writing_units(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
with self.assertRaisesRegex(access.AccessError, 'installed root-owned bundle'):
access.Host(root).arm()
self.assertEqual(list(root.iterdir()), [])
def test_incompatible_image_stops_before_any_enrollment(self):
for metadata in ('{}', '{"enrollment_interface":999}'):
with self.subTest(metadata=metadata), patch.object(Path, 'read_text', return_value=metadata), \
patch('provisioning.target.enroll') as enroll:
with self.assertRaisesRegex(ProvisionError, 'image enrollment interface'):
cloud_enroll()
enroll.assert_not_called()
def test_sanitization_removes_cloned_identity_and_enrollment_but_preserves_runner(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
for relative in ('etc/ssh/ssh_host_ed25519_key', 'etc/ssh/ssh_host_ed25519_key.pub',
'root/.ssh/authorized_keys', 'home/debian/.ssh/authorized_keys',
'etc/machine-id', 'var/lib/dbus/machine-id',
'var/lib/apex-init/enrollment.json', 'var/lib/cloud/instance/user-data.txt',
'usr/local/lib/apex-provisioner/digest/provisioning/target.py',
'tmp/provisioning/image.py'):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('build-specific')
sanitize_files(root)
self.assertEqual(list((root / 'etc/ssh').glob('ssh_host_*')), [])
self.assertEqual((root / 'etc/machine-id').read_text(), '')
self.assertFalse((root / 'root/.ssh/authorized_keys').exists())
self.assertFalse((root / 'var/lib/apex-init').exists())
self.assertFalse((root / 'var/lib/cloud').exists())
self.assertFalse((root / 'tmp/provisioning').exists())
self.assertTrue((root / 'usr/local/lib/apex-provisioner/digest/provisioning/target.py').is_file())
self.assertTrue((root / 'etc/systemd/system/ssh.service.d/10-generate-host-keys.conf').is_file())
@@ -1,132 +0,0 @@
import os
from pathlib import Path
from types import SimpleNamespace
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from provisioning.contract import Enrollment, Repository
from provisioning.repository import reconcile_repository
from provisioning.state import ProvisionError
class RepositoryTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
self.upstream = self.root / 'upstream'
self.commons = self.root / 'commons'
self.srv = self.root / 'srv'
self.srv.mkdir()
self.home = self.root / 'adam'
self.home.mkdir()
for path in (self.upstream, self.commons):
path.mkdir()
self.git(['init', '-q'], path)
self.git(['config', 'user.name', 'Test'], path)
self.git(['config', 'user.email', 'test@example.invalid'], path)
(self.commons / 'marker').write_text('commons')
(self.commons / 'etc').mkdir()
(self.commons / 'etc/.vimrc').write_text('set number\n')
self.git(['add', '.'], self.commons)
self.git(['commit', '-qm', 'initial'], self.commons)
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=node1.example.com\nAPEX_ENROLLMENT_INTERFACE=1\n')
(self.upstream / 'init.sh').write_text(
'#!/bin/sh\nset -eu\ngit config core.hooksPath commons/githooks\n'
'printf \'export PATH="%s/commons:$PATH"\\n\' "$PWD" > "$HOME/.bashrc"\n'
'ln -sf "$PWD/commons/etc/.vimrc" "$HOME/.vimrc"\n')
self.git(['-c', 'protocol.file.allow=always', 'submodule', 'add', str(self.commons), 'commons'], self.upstream)
self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'initial'], self.upstream)
self.spec = Enrollment('node1.example.com', Repository('node1', str(self.upstream), None), (), (), ())
self.state = {}
self.saved = []
self.account = patch('provisioning.repository.pwd.getpwnam',
return_value=SimpleNamespace(pw_uid=os.getuid(), pw_gid=os.getgid(), pw_dir=str(self.home)))
self.account.start()
self.addCleanup(self.account.stop)
self.runner = patch('provisioning.repository.as_adam', side_effect=self.local_run)
self.runner.start()
self.addCleanup(self.runner.stop)
@staticmethod
def git(args, path):
return subprocess.run(['git', *args], cwd=path, text=True, capture_output=True, check=True).stdout.strip()
def local_run(self, args, cwd=None):
if args[0] == 'git':
args = ['git', '-c', 'protocol.file.allow=always', *args[1:]]
return subprocess.run(args, cwd=cwd, text=True, capture_output=True, check=True,
env={**os.environ, 'HOME': str(self.home)}).stdout.strip()
def enroll(self):
return reconcile_repository(self.spec, self.state, lambda: self.saved.append(True), self.srv)
def test_repeat_does_not_follow_new_upstream_commit(self):
first = self.enroll()
(self.upstream / 'new').write_text('upstream advanced')
self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'advance'], self.upstream)
second = self.enroll()
self.assertEqual(first['commit'], second['commit'])
self.assertFalse((self.srv / 'node1' / 'new').exists())
self.assertTrue(self.state['repository']['hook_complete'])
def test_local_changes_are_preserved_and_stop_initialization(self):
self.enroll()
path = self.srv / 'node1' / 'node.env'
path.write_text('local changes')
with self.assertRaisesRegex(ProvisionError, 'local changes'):
self.enroll()
self.assertEqual(path.read_text(), 'local changes')
def test_unrecorded_checkout_is_not_adopted(self):
(self.srv / 'node1').mkdir()
with self.assertRaisesRegex(ProvisionError, 'adoption'):
self.enroll()
def test_noop_hook_cannot_claim_success(self):
(self.upstream / 'init.sh').write_text('true\n')
self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'noop'], self.upstream)
with self.assertRaises((ProvisionError, subprocess.CalledProcessError)):
self.enroll()
self.assertFalse(self.state['repository'].get('hook_complete', False))
def test_missing_or_unsupported_interface_stops_before_hook(self):
for version in ('', 'APEX_ENROLLMENT_INTERFACE=999\n'):
with self.subTest(version=version):
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=node1.example.com\n' + version)
self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'unsupported interface'], self.upstream)
self.spec = Enrollment('node1.example.com', Repository('missing-' + str(len(version)), str(self.upstream), None), (), (), ())
self.state = {}
with self.assertRaisesRegex(ProvisionError, 'enrollment interface'):
self.enroll()
self.assertFalse((self.home / '.bashrc').exists())
self.assertNotIn('hook_complete', self.state['repository'])
def test_retry_checks_hook_state_instead_of_completion_marker(self):
self.enroll()
self.git(['config', 'core.hooksPath', 'wrong-path'], self.srv / 'node1')
with self.assertRaisesRegex(ProvisionError, 'hook configuration'):
self.enroll()
def test_ordinary_commons_directory_is_not_a_pinned_submodule(self):
self.git(['rm', '-f', 'commons'], self.upstream)
(self.upstream / 'commons').mkdir()
(self.upstream / 'commons/ordinary').write_text('no submodule')
self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'ordinary directory'], self.upstream)
with self.assertRaisesRegex(ProvisionError, 'pinned Git submodule'):
self.enroll()
def test_mismatched_node_hostname_stops_before_hook(self):
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=wrong.example.com\n')
self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'wrong'], self.upstream)
with self.assertRaisesRegex(ProvisionError, 'hostname'):
self.enroll()
self.assertNotIn('hook_complete', self.state['repository'])
@@ -1,878 +0,0 @@
import base64
import copy
import hashlib
import json
import ipaddress
import re
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from provisioning.access import Host
from provisioning import security
# Pinned helper default output, kept independent of the adapter's validation code.
HELPER_V4 = """# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP
-A DOCKER-USER -i docker0 -o docker0 -j ACCEPT
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 172.16.0.0/12
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 192.168.0.0/16
-A DOCKER-USER -j RETURN
-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP
COMMIT
# END UFW AND DOCKER
"""
HELPER_V6 = """# BEGIN UFW AND DOCKER
*filter
:ufw6-user-forward - [0:0]
:ufw6-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw6-user-forward
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP
-A DOCKER-USER -i docker0 -o docker0 -j ACCEPT
-A DOCKER-USER -j RETURN -s fd00::/8
-A DOCKER-USER -j ufw6-docker-logging-deny -m conntrack --ctstate NEW -d fd00::/8
-A DOCKER-USER -j RETURN
-A ufw6-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw6-docker-logging-deny -j DROP
COMMIT
# END UFW AND DOCKER
"""
def native_alert(value, scope='Ip', origin='cscli', identifier=1):
return {'id': identifier, 'created_at': '2026-10-10T12:00:00Z', 'events_count': 1,
'events': [], 'message': 'manual decision', 'scenario': 'manual', 'simulated': False,
'source': {'scope': scope, 'value': value},
'decisions': [{'id': identifier, 'duration': '4h', 'origin': origin, 'scenario': 'manual',
'scope': scope, 'simulated': False, 'type': 'ban', 'value': value}]}
class SecurityHost(Host):
"""Keep filesystem effects real; substitute Debian commands and services."""
def __init__(self, root):
super().__init__(root)
self.active = False
self.services = {name: {'LoadState': 'loaded', 'ActiveState': 'inactive',
'UnitFileState': 'disabled', 'DropInPaths': '',
'FragmentPath': '/usr/lib/systemd/system/' + name + '.service'}
for name in ('ufw', 'crowdsec', 'crowdsec-firewall-bouncer')}
self.commands = []
self.decisions = []
self.runtime_overrides = {}
self.runtime_chains = {name: {'DOCKER-USER': ['-N DOCKER-USER'],
'ADMIN-SENTINEL': ['-N ADMIN-SENTINEL', '-A ADMIN-SENTINEL -j RETURN']}
for name in ('iptables', 'ip6tables')}
self.forward_hook = True
self.fail = None
self.after_command = None
self.pending_starts = set()
contents = {
'/etc/default/ufw': 'IPV6=yes\nDEFAULT_INPUT_POLICY="ACCEPT"\nDEFAULT_OUTPUT_POLICY="ACCEPT"\nDEFAULT_FORWARD_POLICY="DROP"\n',
'/etc/ufw/ufw.conf': 'ENABLED=no\n',
'/etc/ufw/user.rules': '*filter\n-A ufw-after-logging-forward -j LOG --log-prefix "[UFW BLOCK] " -m limit --limit 3/min --limit-burst 10\nCOMMIT\n',
'/etc/ufw/user6.rules': '*filter\n-A ufw6-after-logging-forward -j LOG --log-prefix "[UFW BLOCK] " -m limit --limit 3/min --limit-burst 10\nCOMMIT\n',
'/etc/ufw/before.rules': '*filter\nCOMMIT\n',
'/etc/ufw/before6.rules': '*filter\nCOMMIT\n',
'/etc/ufw/after.rules': '*filter\nCOMMIT\n',
'/etc/ufw/after6.rules': '*filter\nCOMMIT\n',
'/usr/local/bin/ufw-docker': '#!/bin/sh\n',
}
for name, content in contents.items():
path = self.path(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
self.conffiles = {'/etc/default/ufw': hashlib.md5(contents['/etc/default/ufw'].encode(), usedforsecurity=False).hexdigest()}
self.bouncer_conffiles = {}
checksums = []
for name, content in contents.items():
if not name.startswith('/etc/ufw/'):
continue
filename = name.rsplit('/', 1)[1]
template = '/usr/share/ufw/' + ('' if filename == 'ufw.conf' else 'iptables/') + filename
path = self.path(template)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
checksums.append(hashlib.md5(content.encode(), usedforsecurity=False).hexdigest() + ' ' + template.lstrip('/'))
manifest = self.path('/var/lib/dpkg/info/ufw.md5sums')
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text('\n'.join(checksums) + '\n')
def load_helper_runtime(self):
for executable, filename, fixture in (('iptables', 'after.rules', HELPER_V4),
('ip6tables', 'after6.rules', HELPER_V6)):
if executable == 'ip6tables' and 'IPV6=yes' not in self.path('/etc/default/ufw').read_text():
continue
if '# BEGIN UFW AND DOCKER' not in self.path('/etc/ufw/' + filename).read_text():
continue
logging_chain = 'ufw6-docker-logging-deny' if executable == 'ip6tables' else 'ufw-docker-logging-deny'
for chain in ('DOCKER-USER', logging_chain):
self.runtime_chains[executable][chain] = ['-N ' + chain] + [
line for line in fixture.splitlines() if line.startswith('-A ' + chain + ' ')]
def assert_restore_scope(self, args):
if '--noflush' not in args:
raise AssertionError('whole-table restore is forbidden')
if Path(args[-1]).stat().st_mode & 0o777 != 0o600:
raise AssertionError('restore input must be private')
def command(self, args, check=True):
self.commands.append(args)
output, code = '', 0
if args[:2] == ['systemctl', 'show']:
output = '\n'.join(k + '=' + v for k, v in self.services[args[2]].items())
elif args[:2] == ['ufw', 'status']:
output = 'Status: ' + ('active' if self.active else 'inactive')
elif args[0] == 'dpkg-query':
conffiles = self.bouncer_conffiles if args[-1] == 'crowdsec-firewall-bouncer-iptables' else self.conffiles
output = '\n'.join(' ' + name + ' ' + digest for name, digest in conffiles.items())
elif args[0] in ('iptables', 'ip6tables'):
if args[1] == '-C':
code = 0 if self.forward_hook else 1
elif args[1] == '-S':
chains = self.runtime_chains[args[0]]
if len(args) == 3:
chain = args[2]
code = 0 if chain in chains else 1
output = self.runtime_overrides.get((args[0], chain), '\n'.join(chains.get(chain, [])) + '\n')
else:
output = '-P FORWARD ACCEPT\n' + ('-A FORWARD -j DOCKER-USER\n' if self.forward_hook else '')
output += ''.join(self.runtime_overrides.get((args[0], chain), '\n'.join(lines) + '\n')
for chain, lines in chains.items())
else:
raise AssertionError(args)
elif args[0] in ('iptables-restore', 'ip6tables-restore'):
self.assert_restore_scope(args)
executable = args[0].removesuffix('-restore')
chains = copy.deepcopy(self.runtime_chains[executable])
for line in Path(args[-1]).read_text().splitlines():
if line in ('*filter', 'COMMIT'):
continue
operation, chain, *rest = line.split()
if operation == '-F':
chains[chain] = ['-N ' + chain]
elif operation == '-A':
chains[chain].append(line)
elif operation == '-X':
del chains[chain]
else:
raise AssertionError(line)
self.runtime_chains[executable] = chains
elif args[0] == '/usr/local/bin/ufw-docker':
if args[1] == 'check':
code = 0 if '# BEGIN UFW AND DOCKER' in self.path('/etc/ufw/after.rules').read_text() else 1
elif args[1] == 'install':
if not self.active:
raise RuntimeError('UFW is disabled')
for name in ('after.rules', 'after6.rules'):
path = self.path('/etc/ufw/' + name)
path.write_text(path.read_text() + (HELPER_V6 if name == 'after6.rules' else HELPER_V4))
elif args[0] == 'ufw':
if 'allow' in args and 'proto' in args:
if args[1:3] == ['insert', '1'] and not any(
'### tuple ###' in self.path('/etc/ufw/' + name).read_text()
for name in ('user.rules', 'user6.rules')):
if check:
raise RuntimeError("ERROR: Invalid position '1'")
return subprocess.CompletedProcess(args, 1, '', "ERROR: Invalid position '1'")
source, port = args[args.index('from') + 1], args[args.index('port') + 1]
v6 = ipaddress.ip_network(source, strict=False).version == 6
path = self.path('/etc/ufw/user6.rules' if v6 else '/etc/ufw/user.rules')
line = '### tuple ### allow tcp ' + port + (' ::/0' if v6 else ' 0.0.0.0/0') + ' any ' + source + ' in comment=' + args[-1].encode().hex() + '\n'
path.write_text(path.read_text().replace(line, '') if 'delete' in args else path.read_text() + line)
elif args[1] == 'default':
key = {'incoming': 'INPUT', 'outgoing': 'OUTPUT', 'routed': 'FORWARD'}[args[3]]
path = self.path('/etc/default/ufw')
path.write_text(re.sub('DEFAULT_' + key + '_POLICY="[A-Z]+"', 'DEFAULT_' + key + '_POLICY="' + ('DROP' if args[2] == 'deny' else 'ACCEPT') + '"', path.read_text()))
elif args[-1] in ('enable', 'disable'):
self.active = args[-1] == 'enable'
self.path('/etc/ufw/ufw.conf').write_text('ENABLED=' + ('yes' if self.active else 'no') + '\n')
if self.active and 'DEFAULT_FORWARD_POLICY="ACCEPT"' in self.path('/etc/default/ufw').read_text():
for name, prefix in (('user.rules', 'ufw'), ('user6.rules', 'ufw6')):
path = self.path('/etc/ufw/' + name)
path.write_text(''.join(line for line in path.read_text().splitlines(keepends=True)
if not line.startswith('-A ' + prefix + '-after-logging-forward -j LOG ')))
elif args[-1] != 'reload':
raise AssertionError(args)
if self.active and args[-1] in ('enable', 'reload'):
self.load_helper_runtime()
elif args[0] == 'systemctl':
field, value = {'enable': ('UnitFileState', 'enabled'), 'disable': ('UnitFileState', 'disabled'),
'start': ('ActiveState', 'active'), 'stop': ('ActiveState', 'inactive'),
'reset-failed': ('ActiveState', 'inactive')}[args[1]]
if args[1] == 'reset-failed' and self.services[args[2]]['ActiveState'] != 'failed':
raise AssertionError('reset-failed must only follow an observed failure')
self.services[args[2]][field] = value
if args[1] == 'stop':
self.pending_starts.discard(args[2])
if args[1:3] == ['stop', 'ufw']:
self.active = False
elif args[0] == 'crowdsec':
pass
elif args[:2] == ['cscli', 'lapi']:
code = 0 if self.services['crowdsec']['ActiveState'] == 'active' else 1
elif args[:2] == ['cscli', 'decisions']:
alerts = self.decisions
if isinstance(alerts, list) and all(isinstance(a, dict) and isinstance(a.get('decisions'), list) for a in alerts):
if '--all' not in args:
alerts = [a for a in alerts if all(d['origin'] != 'CAPI' for d in a['decisions'])]
limit = int(args[args.index('--limit') + 1]) if '--limit' in args else 100
if limit:
alerts = alerts[:limit]
output = json.dumps(alerts)
else:
raise AssertionError('Unexpected command: ' + repr(args))
result = subprocess.CompletedProcess(args, code, output, '')
if self.after_command:
self.after_command(args)
if check and code:
raise RuntimeError('simulated command failure')
return result
class SecurityTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.host = SecurityHost(Path(self.temp.name))
self.adapter = security.Security(self.host)
self.addCleanup(patch.stopall)
patch.object(security, 'HELPER_SHA256', hashlib.sha256(b'#!/bin/sh\n').hexdigest(), create=True).start()
def test_masked_security_service_refuses_before_mutation(self):
self.host.services['crowdsec']['UnitFileState'] = 'masked'
before = self.host.path('/etc/default/ufw').read_bytes()
with self.assertRaises(security.SecurityError):
self.adapter.preflight()
self.assertEqual(self.host.path('/etc/default/ufw').read_bytes(), before)
self.assertFalse(self.host.active)
def test_inactive_custom_policy_is_not_mistaken_for_pristine(self):
path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text() + 'MANAGED_BY_ADMIN=yes\n')
with self.assertRaises(security.SecurityError):
self.adapter.preflight()
self.assertFalse(self.host.active)
def test_packaged_bouncer_unit_in_etc_can_activate_and_restore(self):
name = '/etc/systemd/system/crowdsec-firewall-bouncer.service'
path = self.host.path(name)
path.parent.mkdir(parents=True)
path.write_text('[Service]\nExecStart=/usr/bin/crowdsec-firewall-bouncer\n')
self.host.bouncer_conffiles[name] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
self.host.services['crowdsec-firewall-bouncer']['FragmentPath'] = name
state = self.prepared()
self.adapter.activate(state, self.persist)
self.assertEqual(self.host.services['crowdsec-firewall-bouncer']['ActiveState'], 'active')
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec-firewall-bouncer']['ActiveState'], 'inactive')
self.assertEqual(state['cleanup'], 'complete')
def test_modified_or_unmanaged_bouncer_unit_in_etc_refuses_before_activation(self):
name = '/etc/systemd/system/crowdsec-firewall-bouncer.service'
path = self.host.path(name)
path.parent.mkdir(parents=True)
path.write_text('[Service]\nExecStart=/usr/bin/crowdsec-firewall-bouncer\n')
self.host.services['crowdsec-firewall-bouncer']['FragmentPath'] = name
for conffiles in ({}, {name: '0' * 32}):
with self.subTest(conffiles=conffiles):
self.host.bouncer_conffiles = conffiles
with self.assertRaises(security.SecurityError):
self.adapter.preflight()
self.assertFalse(self.host.active)
def test_native_generated_ufw_files_are_verified_against_package_templates(self):
self.assertEqual(set(self.host.conffiles), {'/etc/default/ufw'})
self.assertTrue(self.adapter.preflight()['pristine'])
def test_modified_generated_file_or_package_template_is_not_pristine(self):
for name in ('/etc/ufw/user.rules', '/usr/share/ufw/iptables/after.rules'):
with self.subTest(path=name):
path = self.host.path(name)
before = path.read_bytes()
path.write_bytes(before + b'# modified\n')
with self.assertRaises(security.SecurityError):
self.adapter.preflight()
path.write_bytes(before)
def test_active_custom_policy_and_ipv6_rules_are_preserved_in_plan(self):
self.host.active = True
self.host.path('/etc/default/ufw').write_text('IPV6=yes\nDEFAULT_FORWARD_POLICY="DROP"\n')
self.host.path('/etc/ufw/user6.rules').write_text('# existing administrator sentinel\n')
plan = self.adapter.preflight()
self.assertFalse(plan['pristine'])
self.assertTrue(plan['ipv6'])
self.assertIn('sentinel', base64.b64decode(plan['files']['/etc/ufw/user6.rules']['data']).decode())
def test_incomplete_helper_is_rejected(self):
self.host.active = True
self.host.path('/etc/ufw/after.rules').write_text('# BEGIN UFW AND DOCKER\ncustom\n# END UFW AND DOCKER\n')
with self.assertRaises(security.SecurityError):
self.adapter.preflight()
def prepared(self):
self.saved = []
state = self.adapter.snapshot(self.adapter.preflight(), 22, '192.0.2.10')
self.saved.append(copy.deepcopy(state))
return state
def persist(self, state):
self.saved.append(copy.deepcopy(state))
def test_pristine_activation_establishes_security_without_losing_bootstrap(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.assertTrue(self.host.active)
self.assertIn('DEFAULT_INPUT_POLICY="DROP"', self.host.path('/etc/default/ufw').read_text())
self.assertIn('DEFAULT_FORWARD_POLICY="ACCEPT"', self.host.path('/etc/default/ufw').read_text())
rules = self.host.path('/etc/ufw/user.rules').read_text()
self.assertIn('192.0.2.10', rules)
self.assertIn('2222', rules)
for service in self.host.services.values():
self.assertEqual(service['ActiveState'], 'active')
self.assertEqual(service['UnitFileState'], 'enabled')
self.adapter.verify(state)
def test_bootstrap_allowance_exists_when_pristine_firewall_first_activates(self):
enabled = []
def inspect_enable(args):
if args == ['ufw', '--force', 'enable']:
rules = self.host.path('/etc/ufw/user.rules').read_text()
self.assertIn('192.0.2.10', rules)
self.assertIn('2222', rules)
self.assertIn('2222', self.host.path('/etc/ufw/user6.rules').read_text())
enabled.append(True)
self.host.after_command = inspect_enable
state = self.prepared()
self.adapter.activate(state, self.persist)
self.assertEqual(enabled, [True])
def test_active_policy_survives_activation_and_rollback(self):
self.host.active = True
self.host.services['ufw'].update(ActiveState='active', UnitFileState='enabled')
self.host.path('/etc/ufw/user6.rules').write_text('# IPv6 administrator sentinel\n')
before = {name: self.host.path(name).read_bytes() for name in security.FILES}
state = self.prepared()
self.adapter.activate(state, self.persist)
self.assertEqual(self.host.path('/etc/default/ufw').read_bytes(), before['/etc/default/ufw'])
self.adapter.restore(state, self.persist)
self.assertTrue(self.host.active)
for name, content in before.items():
self.assertEqual(self.host.path(name).read_bytes(), content)
def test_peer_range_ban_refuses_bouncer_and_can_restore_access(self):
self.host.decisions = [native_alert('192.0.2.0/24', 'Range')]
state = self.prepared()
with self.assertRaises(security.SecurityError):
self.adapter.activate(state, self.persist)
self.assertEqual(self.host.services['crowdsec-firewall-bouncer']['ActiveState'], 'inactive')
self.adapter.restore(state, self.persist)
self.assertFalse(self.host.active)
self.assertEqual(self.host.services['crowdsec']['ActiveState'], 'inactive')
def test_concurrent_edit_is_preserved_and_inactive_access_recovers(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text() + 'ADMIN_SENTINEL=yes\n')
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertFalse(self.host.active)
self.assertIn('ADMIN_SENTINEL=yes', path.read_text())
self.assertTrue(state['access_recovered'])
self.assertEqual(state['cleanup'], 'pending')
self.assertEqual(self.host.services['crowdsec-firewall-bouncer']['ActiveState'], 'inactive')
def test_finalization_removes_only_temporary_bootstrap_allowance(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.adapter.finalize(state, self.persist)
rules = self.host.path('/etc/ufw/user.rules').read_text()
self.assertNotIn('192.0.2.10', rules)
self.assertIn('2222', rules)
self.adapter.verify(state)
def test_clean_rollback_restores_inactive_service_states(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.adapter.restore(state, self.persist)
for service in self.host.services.values():
self.assertEqual(service['ActiveState'], 'inactive')
self.assertEqual(service['UnitFileState'], 'disabled')
self.assertEqual(state['cleanup'], 'complete')
def test_rollback_restores_live_helper_chains_and_allows_next_preflight(self):
for original in (['-N DOCKER-USER'], ['-N DOCKER-USER', '-A DOCKER-USER -j RETURN']):
with self.subTest(original=original):
for executable in ('iptables', 'ip6tables'):
self.host.runtime_chains[executable]['DOCKER-USER'] = original[:]
before = copy.deepcopy(self.host.runtime_chains)
state = self.prepared()
self.adapter.activate(state, self.persist)
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.runtime_chains, before)
self.assertTrue(self.host.forward_hook)
self.assertTrue(self.adapter.preflight()['pristine'])
def test_preexisting_helper_specific_chain_is_rejected_before_mutation(self):
self.host.runtime_chains['ip6tables']['ufw6-docker-logging-deny'] = ['-N ufw6-docker-logging-deny']
before = copy.deepcopy(self.host.runtime_chains)
with self.assertRaises(security.SecurityError):
self.adapter.preflight()
self.assertEqual(self.host.runtime_chains, before)
self.assertFalse(self.host.active)
def test_docker_runtime_must_match_preflight_at_capture(self):
plan = self.adapter.preflight()
self.host.runtime_chains['iptables']['DOCKER-USER'].append('-A DOCKER-USER -j RETURN')
with self.assertRaises(security.SecurityError):
self.adapter.snapshot(plan, 22, '192.0.2.10')
self.assertFalse(self.host.active)
def test_changed_helper_runtime_is_preserved_while_access_recovers(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.host.runtime_chains['iptables']['DOCKER-USER'].append('-A DOCKER-USER -j ACCEPT')
before = copy.deepcopy(self.host.runtime_chains)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.runtime_chains, before)
self.assertTrue(state['access_recovered'])
self.assertEqual(state['cleanup'], 'pending')
self.assertIn('docker-firewall-runtime', state['diagnostics'])
def test_interrupted_helper_runtime_cleanup_resumes_each_family(self):
state = self.prepared()
before = copy.deepcopy(self.host.runtime_chains)
self.adapter.activate(state, self.persist)
def interrupt(args):
if args[0] == 'iptables-restore':
raise KeyboardInterrupt()
self.host.after_command = interrupt
with self.assertRaises(KeyboardInterrupt):
self.adapter.restore(state, self.persist)
self.host.after_command = None
self.assertEqual(self.host.runtime_chains['iptables'], before['iptables'])
self.assertNotEqual(self.host.runtime_chains['ip6tables'], before['ip6tables'])
resumed = copy.deepcopy(self.saved[-1])
self.adapter.restore(resumed, self.persist)
self.assertEqual(self.host.runtime_chains, before)
self.assertEqual(resumed['cleanup'], 'complete')
self.assertEqual(sum(args[0] == 'iptables-restore' for args in self.host.commands), 1)
def test_successful_restore_command_requires_runtime_postcondition(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
native = self.host.command
def no_change(args, check=True):
if args[0].endswith('tables-restore'):
return subprocess.CompletedProcess(args, 0, '', '')
return native(args, check)
with patch.object(self.host, 'command', side_effect=no_change):
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertTrue(state['access_recovered'])
self.assertEqual(state['cleanup'], 'pending')
def test_runtime_drift_after_cleanup_persistence_is_not_overwritten(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
changed = []
def change_during_persist(value):
self.persist(value)
if value['helper_runtime'].get('restoring') == 'iptables' and not changed:
self.host.runtime_chains['iptables']['DOCKER-USER'].append('-A DOCKER-USER -j ACCEPT')
changed.append(True)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, change_during_persist)
self.assertEqual(self.host.runtime_chains['iptables']['DOCKER-USER'][-1], '-A DOCKER-USER -j ACCEPT')
self.assertTrue(state['access_recovered'])
self.assertFalse(any(args[0].endswith('tables-restore') for args in self.host.commands))
def test_runtime_drift_during_final_reload_prevents_false_complete_cleanup(self):
self.host.active = True
self.host.services['ufw'].update(ActiveState='active', UnitFileState='enabled')
state = self.prepared()
self.adapter.activate(state, self.persist)
def change_after_reload(args):
if args == ['ufw', 'reload']:
self.host.runtime_chains['iptables']['DOCKER-USER'].append('-A DOCKER-USER -j ACCEPT')
self.host.after_command = change_after_reload
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.runtime_chains['iptables']['DOCKER-USER'][-1], '-A DOCKER-USER -j ACCEPT')
self.assertEqual(state['cleanup'], 'pending')
self.assertTrue(state['access_recovered'])
def test_legacy_runtime_snapshot_requires_reconciliation_but_recovers_access(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
del state['plan']['helper_runtime_before']
del state['helper_runtime']
before = copy.deepcopy(self.host.runtime_chains)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.runtime_chains, before)
self.assertTrue(state['access_recovered'])
self.assertEqual(state['cleanup'], 'pending')
def test_disabled_ipv6_runtime_is_preserved_during_helper_cleanup(self):
path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text().replace('IPV6=yes', 'IPV6=no'))
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
before = copy.deepcopy(self.host.runtime_chains)
state = self.prepared()
self.adapter.activate(state, self.persist)
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.runtime_chains, before)
self.assertFalse(any(args[0] == 'ip6tables-restore' for args in self.host.commands))
def test_rollback_does_not_restart_preexisting_service_stopped_by_administrator(self):
self.host.services['crowdsec'].update(ActiveState='active', UnitFileState='enabled')
state = self.prepared()
self.adapter.activate(state, self.persist)
self.host.services['crowdsec']['ActiveState'] = 'inactive'
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec']['ActiveState'], 'inactive')
self.assertEqual(self.host.services['crowdsec']['UnitFileState'], 'enabled')
self.assertNotIn(['systemctl', 'start', 'crowdsec'], self.host.commands)
def test_owned_enable_does_not_claim_preexisting_activity_field(self):
self.host.services['crowdsec']['ActiveState'] = 'active'
state = self.prepared()
self.adapter.activate(state, self.persist)
self.host.services['crowdsec']['ActiveState'] = 'inactive'
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec']['ActiveState'], 'inactive')
self.assertEqual(self.host.services['crowdsec']['UnitFileState'], 'disabled')
def test_rollback_stops_owned_services_that_are_restarting_or_failed(self):
for activity in ('activating', 'deactivating', 'failed', 'reloading'):
with self.subTest(activity=activity):
self.host = SecurityHost(Path(self.temp.name) / activity)
self.adapter = security.Security(self.host)
state = self.prepared()
self.adapter.activate(state, self.persist)
self.host.services['crowdsec-firewall-bouncer']['ActiveState'] = activity
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec-firewall-bouncer']['ActiveState'], 'inactive')
self.assertEqual(state['cleanup'], 'complete')
def test_owned_inactive_service_stop_cancels_queued_start(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.host.services['crowdsec-firewall-bouncer']['ActiveState'] = 'inactive'
self.host.pending_starts.add('crowdsec-firewall-bouncer')
self.adapter.restore(state, self.persist)
self.assertFalse(self.host.pending_starts)
self.assertEqual(state['cleanup'], 'complete')
def test_failed_owned_stop_is_reset_and_verified_inactive(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
def failed_stop(args):
if args == ['systemctl', 'stop', 'crowdsec']:
self.host.services['crowdsec']['ActiveState'] = 'failed'
self.host.after_command = failed_stop
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec']['ActiveState'], 'inactive')
self.assertIn(['systemctl', 'reset-failed', 'crowdsec'], self.host.commands)
self.assertNotIn(['systemctl', 'reset-failed', 'crowdsec-firewall-bouncer'], self.host.commands)
self.assertEqual(state['cleanup'], 'complete')
def test_previously_restored_owned_service_is_rechecked_and_stopped_again(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.adapter.restore(state, self.persist)
self.host.services['crowdsec-firewall-bouncer']['ActiveState'] = 'activating'
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec-firewall-bouncer']['ActiveState'], 'inactive')
self.assertEqual(state['cleanup'], 'complete')
def test_late_owned_service_start_prevents_false_complete_cleanup(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
def late_start(args):
if args == ['ufw', 'disable']:
self.host.services['crowdsec-firewall-bouncer']['ActiveState'] = 'activating'
self.host.after_command = late_start
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(state['cleanup'], 'pending')
self.assertTrue(state['access_recovered'])
self.host.after_command = None
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec-firewall-bouncer']['ActiveState'], 'inactive')
self.assertEqual(state['cleanup'], 'complete')
def test_failed_preexisting_service_is_not_stopped_or_reset(self):
self.host.services['crowdsec'].update(ActiveState='active', UnitFileState='enabled')
state = self.prepared()
self.adapter.activate(state, self.persist)
self.host.services['crowdsec']['ActiveState'] = 'failed'
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec']['ActiveState'], 'failed')
self.assertNotIn(['systemctl', 'stop', 'crowdsec'], self.host.commands)
self.assertNotIn(['systemctl', 'reset-failed', 'crowdsec'], self.host.commands)
def test_failed_reset_does_not_claim_service_recovery(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
def remains_failed(args):
if args in (['systemctl', 'stop', 'crowdsec'], ['systemctl', 'reset-failed', 'crowdsec']):
self.host.services['crowdsec']['ActiveState'] = 'failed'
self.host.after_command = remains_failed
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(state['cleanup'], 'pending')
self.assertTrue(state['access_recovered'])
def test_changed_previously_restored_service_unit_is_preserved(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.adapter.restore(state, self.persist)
self.host.services['crowdsec-firewall-bouncer'].update(
ActiveState='activating', DropInPaths='/etc/systemd/system/bouncer.service.d/custom.conf')
count = len(self.host.commands)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec-firewall-bouncer']['ActiveState'], 'activating')
self.assertNotIn(['systemctl', 'stop', 'crowdsec-firewall-bouncer'], self.host.commands[count:])
self.assertTrue(state['access_recovered'])
def test_changed_service_unit_is_preserved_and_reported_pending(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.host.services['crowdsec']['ActiveState'] = 'failed'
self.host.services['crowdsec']['DropInPaths'] = '/etc/systemd/system/crowdsec.service.d/administrator.conf'
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.services['crowdsec']['ActiveState'], 'failed')
self.assertTrue(state['access_recovered'])
self.assertIn('service:crowdsec:ActiveState', state['diagnostics'])
def test_redirected_file_does_not_prevent_runtime_access_recovery(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
target = self.host.path('/etc/ufw/after.rules')
sentinel = self.host.path('/administrator-owned')
sentinel.write_text('do not touch\n')
target.unlink()
target.symlink_to(sentinel)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertFalse(self.host.active)
self.assertTrue(state['access_recovered'])
self.assertEqual(sentinel.read_text(), 'do not touch\n')
def test_metadata_change_is_not_silently_overwritten(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
path = self.host.path('/etc/default/ufw')
path.chmod(0o600)
with self.assertRaises(security.SecurityError):
self.adapter.verify(state)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertFalse(self.host.active)
def test_process_death_after_native_write_preserves_unknown_bytes(self):
state = self.prepared()
def die(args):
if args[:2] == ['ufw', 'default']:
raise KeyboardInterrupt()
self.host.after_command = die
with self.assertRaises(KeyboardInterrupt):
self.adapter.activate(state, self.persist)
self.host.after_command = None
recovered = copy.deepcopy(self.saved[-1])
with self.assertRaises(security.SecurityError):
security.Security(self.host).restore(recovered, self.persist)
self.assertFalse(self.host.active)
self.assertTrue(recovered['access_recovered'])
self.assertEqual(recovered['cleanup'], 'pending')
self.assertIn('interrupted-mutation', recovered['diagnostics'])
def test_active_firewall_keeps_source_limited_bootstrap_on_ambiguous_recovery(self):
self.host.active = True
self.host.services['ufw'].update(ActiveState='active', UnitFileState='enabled')
state = self.prepared()
self.adapter.activate(state, self.persist)
self.adapter.finalize(state, self.persist)
path = self.host.path('/etc/ufw/user6.rules')
path.write_text(path.read_text() + '# concurrent IPv6 sentinel\n')
before_restore = len(self.host.commands)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertTrue(self.host.active)
self.assertTrue(state['access_recovered'])
self.assertIn('192.0.2.10', self.host.path('/etc/ufw/user.rules').read_text())
self.assertIn('# concurrent IPv6 sentinel', path.read_text())
self.assertNotIn(['ufw', 'disable'], self.host.commands)
self.assertNotIn(['ufw', 'reload'], self.host.commands[before_restore:])
def test_preexisting_ssh_rule_is_never_relabelled_or_deleted(self):
self.host.active = True
self.host.services['ufw'].update(ActiveState='active', UnitFileState='enabled')
path = self.host.path('/etc/ufw/user.rules')
sentinel = '### tuple ### allow tcp 2222 0.0.0.0/0 any 0.0.0.0/0 in comment=' + b'administrator'.hex() + '\n'
path.write_text(path.read_text() + sentinel)
state = self.prepared()
self.adapter.activate(state, self.persist)
self.adapter.finalize(state, self.persist)
self.assertIn(sentinel, path.read_text())
self.adapter.restore(state, self.persist)
self.assertIn(sentinel, path.read_text())
def test_retry_verification_detects_stopped_bouncer(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
self.host.services['crowdsec-firewall-bouncer']['ActiveState'] = 'inactive'
with self.assertRaises(security.SecurityError):
self.adapter.verify(state)
def test_redirected_ufw_enable_file_is_not_written_during_recovery(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
path = self.host.path('/etc/ufw/ufw.conf')
sentinel = self.host.path('/administrator-config')
sentinel.write_text('do not change\n')
path.unlink()
path.symlink_to(sentinel)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertEqual(sentinel.read_text(), 'do not change\n')
self.assertFalse(self.host.active)
self.assertTrue(state['access_recovered'])
def test_compatible_existing_helper_is_preserved(self):
self.host.active = True
for name in ('after.rules', 'after6.rules'):
path = self.host.path('/etc/ufw/' + name)
path.write_text(path.read_text() + (HELPER_V6 if name == 'after6.rules' else HELPER_V4))
self.host.load_helper_runtime()
before = copy.deepcopy(self.host.runtime_chains)
state = self.prepared()
self.adapter.activate(state, self.persist)
self.assertNotIn([security.HELPER, 'install'], self.host.commands)
self.adapter.restore(state, self.persist)
self.assertEqual(self.host.runtime_chains, before)
self.assertFalse(any(args[0].endswith('tables-restore') for args in self.host.commands))
def test_helper_check_success_does_not_accept_permissive_marker_block(self):
self.host.active = True
for name in ('after.rules', 'after6.rules'):
self.host.path('/etc/ufw/' + name).write_text(
'# BEGIN UFW AND DOCKER\n*filter\n-A DOCKER-USER -j ACCEPT\nCOMMIT\n# END UFW AND DOCKER\n')
self.assertEqual(self.host.command([security.HELPER, 'check']).returncode, 0)
with self.assertRaises(security.SecurityError):
self.adapter.preflight()
def test_valid_helper_files_require_live_rules_and_forward_hook(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
for executable, chain, contents in (
('iptables', 'DOCKER-USER', '-N DOCKER-USER\n-A DOCKER-USER -j RETURN\n'),
('ip6tables', 'DOCKER-USER', '-N DOCKER-USER\n-A DOCKER-USER -j ACCEPT\n'),
('iptables', 'DOCKER-USER', '-N DOCKER-USER\n-A DOCKER-USER -j ACCEPT\n' + '\n'.join(
line for line in HELPER_V4.splitlines() if line.startswith('-A DOCKER-USER ')) + '\n'),
('iptables', 'ufw-docker-logging-deny', '-N ufw-docker-logging-deny\n')):
with self.subTest(executable=executable, chain=chain):
self.host.runtime_overrides[(executable, chain)] = contents
with self.assertRaises(security.SecurityError):
self.adapter.verify(state)
self.host.runtime_overrides.clear()
self.host.forward_hook = False
with self.assertRaises(security.SecurityError):
self.adapter.verify(state)
def test_native_runtime_option_order_is_accepted_without_reordering_rules(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
output = self.host.command(['iptables', '-S', 'DOCKER-USER']).stdout
output = output.replace('RELATED,ESTABLISHED', 'ESTABLISHED,RELATED').replace(
'-j RETURN -s 10.0.0.0/8', '-s 10.0.0.0/8 -j RETURN')
self.host.runtime_overrides[('iptables', 'DOCKER-USER')] = output
self.adapter.verify(state)
lines = output.splitlines()
lines[1], lines[-1] = lines[-1], lines[1]
self.host.runtime_overrides[('iptables', 'DOCKER-USER')] = '\n'.join(lines) + '\n'
with self.assertRaises(security.SecurityError):
self.adapter.verify(state)
def test_existing_live_ban_is_rejected_before_firewall_changes(self):
self.host.services['crowdsec'].update(ActiveState='active', UnitFileState='enabled')
self.host.decisions = [native_alert('192.0.2.10')]
with self.assertRaises(security.SecurityError):
self.prepared()
self.assertFalse(self.host.active)
self.assertNotIn('2222', self.host.path('/etc/ufw/user.rules').read_text())
def test_ipv6_peer_requires_ipv6_firewall_support(self):
path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text().replace('IPV6=yes', 'IPV6=no'))
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
with self.assertRaises(security.SecurityError):
self.adapter.snapshot(self.adapter.preflight(), 22, '2001:db8::1')
def test_decision_check_covers_alerts_after_default_limit_and_central_api(self):
self.host.services['crowdsec'].update(ActiveState='active', UnitFileState='enabled')
harmless = [native_alert('198.51.100.' + str(i), identifier=i) for i in range(1, 101)]
for origin in ('cscli', 'CAPI'):
with self.subTest(origin=origin):
self.host.decisions = harmless + [native_alert('192.0.2.0/24', 'Range', origin, 101)]
with self.assertRaises(security.SecurityError):
self.prepared()
self.assertFalse(self.host.active)
def test_non_native_decision_json_is_rejected_instead_of_skipped(self):
self.host.services['crowdsec'].update(ActiveState='active', UnitFileState='enabled')
for malformed in ([{'scope': 'Ip', 'value': '192.0.2.10'}], [{}], {'decisions': []}, None):
with self.subTest(value=malformed):
self.host.decisions = malformed
with self.assertRaises(security.SecurityError):
self.prepared()
def test_optional_simulated_field_is_not_required_for_native_decisions(self):
self.host.services['crowdsec'].update(ActiveState='active', UnitFileState='enabled')
alert = native_alert('198.51.100.3')
del alert['decisions'][0]['simulated']
self.host.decisions = [alert]
self.prepared()
alert['decisions'][0]['value'] = '192.0.2.0/24'
alert['decisions'][0]['scope'] = 'Range'
with self.assertRaises(security.SecurityError):
self.prepared()
def test_surgical_helper_cleanup_preserves_concurrent_metadata_and_text(self):
state = self.prepared()
self.adapter.activate(state, self.persist)
path = self.host.path('/etc/ufw/after.rules')
path.write_text(path.read_text() + '# administrator sentinel\n')
path.chmod(0o600)
with self.assertRaises(security.SecurityError):
self.adapter.restore(state, self.persist)
self.assertIn('# administrator sentinel', path.read_text())
self.assertNotIn('# BEGIN UFW AND DOCKER', path.read_text())
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
if __name__ == '__main__':
unittest.main()
@@ -1,105 +0,0 @@
import tempfile
import unittest
from pathlib import Path
from provisioning.access import AccessError, Transition
from provisioning.security import SecurityError
from test_provision_access import FakeHost, TOKEN
from provision_fakes import SecurityBoundary
class SecurityAccessTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.host = FakeHost(Path(self.temp.name))
self.security = SecurityBoundary()
self.clock = 1000
self.transition = Transition(self.host, now=lambda: self.clock)
self.transition.security = self.security
def begin(self, port=22):
return self.transition.execute('begin', TOKEN, port, '192.0.2.10')
def test_activation_failure_restores_original_access_and_security(self):
self.security.fail_activation = True
with self.assertRaises((AccessError, SecurityError)):
self.begin()
self.assertEqual(self.host.ports, [22])
self.assertFalse(self.security.active)
self.assertFalse(self.host.armed)
self.assertEqual(self.transition.load()['status'], 'rolled_back')
def test_security_cleanup_failure_does_not_prevent_ssh_recovery(self):
self.begin()
self.transition.execute('finalize', TOKEN)
self.security.fail_restore = True
self.clock += 301
with self.assertRaises(AccessError):
self.transition.execute('tick')
self.assertEqual(self.host.ports, [22])
self.assertTrue(self.host.armed)
self.assertEqual(self.transition.load()['status'], 'recovery_pending')
self.security.fail_restore = False
self.transition.execute('tick')
self.assertEqual(self.transition.load()['status'], 'rolled_back')
self.assertFalse(self.host.armed)
def test_committed_retry_cannot_accept_stopped_security(self):
self.begin()
self.transition.execute('finalize', TOKEN)
self.transition.execute('commit', TOKEN)
self.security.active = False
with self.assertRaises((AccessError, SecurityError)):
self.begin(2222)
self.assertEqual(self.host.ports, [2222])
def test_existing_final_ssh_still_activates_security_with_watchdog(self):
self.host.existing_final = True
self.host.ports = [2222]
self.assertEqual(self.begin(2222)['status'], 'transition')
self.assertTrue(self.security.active)
self.assertTrue(self.host.armed)
self.assertEqual(self.host.ports, [2222])
self.transition.execute('finalize', TOKEN)
self.transition.execute('commit', TOKEN)
self.assertFalse(self.host.path('/etc/systemd/system/ssh.service.d/apex-init.conf').exists())
self.assertFalse(self.host.armed)
def test_legacy_committed_access_gains_security_without_reopening_root(self):
self.begin()
self.transition.execute('finalize', TOKEN)
self.transition.execute('commit', TOKEN)
state = self.transition.load()
state.pop('security')
self.transition.save(state)
self.security.active = False
self.assertEqual(self.begin(2222)['status'], 'transition')
self.assertEqual(self.host.ports, [2222])
self.assertTrue(self.security.active)
self.assertTrue(self.host.armed)
def test_failed_ssh_restore_still_restores_security_and_retries(self):
self.begin()
restore = self.host.restore
self.host.restore = lambda state: (_ for _ in ()).throw(AccessError('restart failed'))
self.clock += 301
with self.assertRaises(AccessError):
self.transition.execute('tick')
self.assertFalse(self.security.active)
self.assertTrue(self.host.armed)
self.host.restore = restore
self.transition.execute('tick')
self.assertEqual(self.host.ports, [22])
self.assertEqual(self.transition.load()['status'], 'rolled_back')
def test_security_failure_at_commit_keeps_rollback_armed(self):
self.begin()
self.transition.execute('finalize', TOKEN)
self.security.active = False
with self.assertRaises(SecurityError):
self.transition.execute('commit', TOKEN)
self.assertTrue(self.host.armed)
self.assertEqual(self.transition.load()['status'], 'final')
@@ -1,80 +0,0 @@
import json
import os
from pathlib import Path
import tempfile
import unittest
from provisioning.state import (Journal, ProvisionError, atomic_write,
check_heartbeat, exclusive_lock)
class StateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
def test_atomic_private_file_and_symlink_rejection(self):
path = self.root / 'state' / 'record'
atomic_write(path, 'first')
atomic_write(path, 'second')
self.assertEqual(path.read_text(), 'second')
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
link = self.root / 'redirect'
link.symlink_to(path.parent)
with self.assertRaises(ProvisionError):
atomic_write(link / 'record', 'corruption')
self.assertEqual(path.read_text(), 'second')
def test_lock_rejects_competing_initializer_and_releases(self):
path = self.root / 'lock'
with exclusive_lock(path):
with self.assertRaises(ProvisionError):
with exclusive_lock(path):
self.fail('competing lock acquired')
with exclusive_lock(path):
pass
def test_failed_stage_is_retryable_and_success_is_reverified(self):
path = self.root / 'journal'
record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
with self.assertRaises(RuntimeError):
record.stage('package', lambda: (_ for _ in ()).throw(RuntimeError('SECRET')))
self.assertNotIn('SECRET', path.read_text())
self.assertEqual(json.loads(path.read_text())['failed_stage'], 'package')
record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
results = iter(({'version': 'one'}, {'version': 'two'}))
record.stage('package', lambda: next(results))
record.stage('package', lambda: next(results))
self.assertEqual(record.data['stages']['package']['result']['version'], 'two')
def test_identity_or_bundle_change_cannot_retarget_partial_run(self):
path = self.root / 'journal'
Journal(path, {'hostname': 'node1'}, 'rev', 'digest').save()
for identity, digest in (({'hostname': 'other'}, 'digest'), ({'hostname': 'node1'}, 'other')):
with self.assertRaises(ProvisionError):
Journal(path, identity, 'rev', digest)
def test_unsupported_interface_cannot_resume_and_revisions_survive_retry(self):
path = self.root / 'journal'
Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest').save()
resumed = Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
self.assertEqual(resumed.data['baseline_revision'], 'source-rev')
for version in (None, 999):
resumed.data['enrollment_interface'] = version
resumed.save()
with self.assertRaisesRegex(ProvisionError, 'enrollment interface'):
Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
def test_expired_heartbeat_prevents_next_operation(self):
beat = self.root / 'heartbeat'
beat.touch()
os.utime(beat, (100, 100))
check_heartbeat(beat, now=110)
with self.assertRaises(ProvisionError):
check_heartbeat(beat, now=200)
record = Journal(self.root / 'journal', {}, 'rev', 'digest')
called = []
with self.assertRaises(ProvisionError):
record.stage('next', lambda: called.append(True), beat)
self.assertEqual(called, [])
@@ -1,471 +0,0 @@
import copy
import json
import os
from pathlib import Path
import subprocess
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from provisioning.contract import Enrollment, Repository, Tier
from provisioning import storage
from provisioning.storage import StorageError, _persist_swap, reconcile_storage
def enrollment(*tiers):
return Enrollment('node.example.com', Repository('node', 'https://example.com/node', None),
tuple(tiers) or (Tier(1, 'directory', None, None),), (), ())
class TestStorage(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
self.srv = self.root / 'srv'
self.srv.mkdir()
self.swap = self.root / 'swapfile'
self.fstab = self.root / 'fstab'
self.fstab.write_text('# existing entries\n')
self.meminfo = self.root / 'meminfo'
self.meminfo.write_text('MemTotal: 8388608 kB\n')
self.swaps = self.root / 'swaps'
self.calls = []
self.state = {}
self.saved = []
self.mounts = [{'target': '/', 'source': '/dev/root', 'fstype': 'ext4', 'maj:min': '8:0'}]
self.devices = [self.device('/dev/root', '8:0', 'ROOT', ['/']),
self.device('/dev/spare', '8:16', 'SPARE', [])]
self.signatures = []
self.pool = None
self.pool_member = '/dev/spare'
self.pool_mountpoint = self.srv / 'tier-1.node'
self.pool_owner = None
self.import_listing = ''
self.import_full_path_listing = None
self.set_active_swaps(['/dev/existing-swap'])
self.activate_swap = True
self.create_hook = None
for name, value in [('SRV', self.srv), ('SWAPFILE', self.swap),
('FSTAB', self.fstab), ('MEMINFO', self.meminfo), ('SWAPS', self.swaps)]:
patcher = patch('provisioning.storage.' + name, value, create=True)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch('provisioning.storage.subprocess.run', side_effect=self.run_command)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch('provisioning.storage._device_number', side_effect=self.device_number)
patcher.start()
self.addCleanup(patcher.stop)
@staticmethod
def device(name, number, serial, mounts):
return {'name': name, 'maj:min': number, 'type': 'disk', 'pkname': None,
'mountpoints': mounts, 'fstype': None, 'wwn': None,
'serial': serial, 'size': 1024 ** 4}
def device_number(self, path):
aliases = {'/dev/alias': '/dev/spare'}
path = aliases.get(str(path), str(path))
for item in self.devices:
if item['name'] == path:
return item['maj:min']
raise StorageError('selected device is missing or is not a block device')
def save(self):
self.saved.append(copy.deepcopy(self.state))
def set_active_swaps(self, paths):
rows = ['Filename\tType\tSize\tUsed\tPriority']
for path in paths:
escaped = path.replace('\\', r'\134').replace(' ', r'\040').replace('\t', r'\011').replace('\n', r'\012')
rows.append(f'{escaped}\tfile\t2097148\t0\t-2')
self.swaps.write_text('\n'.join(rows) + '\n')
def run_command(self, argv, **kwargs):
self.calls.append(argv)
output = ''
if argv[0] == 'findmnt':
output = json.dumps({'filesystems': self.mounts})
elif argv[0] == 'lsblk':
output = json.dumps({'blockdevices': self.devices})
elif argv[0] == 'wipefs':
output = json.dumps({'signatures': self.signatures})
elif argv[:2] == ['zpool', 'list']:
if self.pool:
output = f"tier-1\t{self.pool['guid']}\tONLINE\n"
elif argv[:2] == ['zpool', 'status']:
output = (' pool: tier-1\n state: ONLINE\nconfig:\n\n'
'\tNAME STATE READ WRITE CKSUM\n'
'\ttier-1 ONLINE 0 0 0\n'
f'\t {self.pool_member} ONLINE 0 0 0\n\nerrors: No known data errors\n')
elif argv[:2] == ['zpool', 'create']:
self.assertTrue(self.saved)
self.assertEqual(self.saved[-1]['storage']['tiers']['1']['status'], 'intent')
if self.create_hook:
self.create_hook()
self.pool = {'guid': '12345'}
elif argv[:2] == ['zpool', 'import']:
if '-N' in argv:
self.pool = {'guid': argv[-1]}
else:
output = self.import_listing
if self.import_full_path_listing is not None and kwargs['env'].get('ZPOOL_VDEV_NAME_PATH') == '1' \
and kwargs['env'].get('ZPOOL_VDEV_NAME_FOLLOW_LINKS') == '1' \
and 'ZPOOL_VDEV_NAME_GUID' not in kwargs['env']:
output = self.import_full_path_listing
elif argv[:2] == ['zfs', 'list']:
mounted = 'yes' if any(m['source'] == 'tier-1' for m in self.mounts) else 'no'
output = f'tier-1\t{self.pool_mountpoint}\tfilesystem\t{mounted}\n'
elif argv[:2] == ['zfs', 'get']:
output = (self.pool_owner or self.state['storage']['identity']) + '\n'
elif argv[:2] == ['zfs', 'set']:
self.pool_owner = argv[2].split('=', 1)[1]
elif argv[:2] == ['zfs', 'mount']:
self.mounts.append({'target': str(self.srv / 'tier-1.node'), 'source': 'tier-1',
'fstype': 'zfs', 'maj:min': '0:77'})
elif argv[0] == 'mkswap':
self.assertTrue(self.swap.exists())
self.assertEqual(self.saved[-1]['storage']['swap']['status'], 'intent')
elif argv == ['swapon', str(self.swap)]:
if self.activate_swap:
self.set_active_swaps([str(self.swap)])
else:
self.fail('Unexpected command: ' + repr(argv))
return subprocess.CompletedProcess(argv, 0, output, '')
def test_directory_preserves_contents_and_only_reports_new_directory(self):
path = self.srv / 'tier-1.node'
path.mkdir()
(path / 'keep').write_text('existing data')
result = reconcile_storage(enrollment(Tier(1, 'directory', None, None),
Tier(2, 'directory', None, None)), self.state, self.save)
self.assertEqual((path / 'keep').read_text(), 'existing data')
self.assertFalse(result['tiers'][0]['created'])
self.assertTrue(result['tiers'][1]['created'])
self.assertEqual(result['swap']['status'], 'skipped')
def test_new_directory_mode_is_exact_under_restrictive_umask(self):
self.srv.chmod(0o710)
previous_umask = os.umask(0o077)
try:
reconcile_storage(enrollment(), self.state, self.save)
finally:
os.umask(previous_umask)
self.assertEqual((self.srv / 'tier-1.node').stat().st_mode & 0o777, 0o755)
self.assertEqual(self.srv.stat().st_mode & 0o777, 0o710)
def test_preexisting_directory_keeps_restrictive_mode(self):
path = self.srv / 'tier-1.node'
path.mkdir()
path.chmod(0o700)
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertFalse(result['tiers'][0]['created'])
self.assertEqual(path.stat().st_mode & 0o777, 0o700)
def test_directory_symlink_and_unexpected_mount_are_rejected(self):
path = self.srv / 'tier-1.node'
path.symlink_to(self.root)
with self.assertRaises(StorageError):
reconcile_storage(enrollment(), self.state, self.save)
path.unlink()
path.mkdir()
self.mounts.append({'target': str(path), 'source': '/dev/other', 'fstype': 'ext4', 'maj:min': '8:32'})
with self.assertRaises(StorageError):
reconcile_storage(enrollment(), self.state, self.save)
def test_pending_directory_ownership_survives_later_tier_failure_until_ack(self):
spec = enrollment(Tier(1, 'directory', None, None), Tier(2, 'directory', None, None))
first = self.srv / 'tier-1.node'
second = self.srv / 'tier-2.node'
second.symlink_to(self.root)
with self.assertRaises(StorageError):
reconcile_storage(spec, self.state, self.save)
self.assertTrue(first.is_dir())
self.state = copy.deepcopy(self.saved[-1])
second.unlink()
second.mkdir()
result = reconcile_storage(spec, self.state, self.save)
self.assertEqual(result['ownership_pending'], [str(first)])
self.assertEqual(reconcile_storage(spec, self.state, self.save)['ownership_pending'], [str(first)])
storage.acknowledge_storage_ownership(spec, self.state, [str(first)], self.save)
self.state = copy.deepcopy(self.saved[-1])
self.assertEqual(reconcile_storage(spec, self.state, self.save)['ownership_pending'], [])
def test_replaced_pending_directory_is_never_adopted_or_acknowledged(self):
spec = enrollment()
path = self.srv / 'tier-1.node'
reconcile_storage(spec, self.state, self.save)
path.rename(self.srv / 'original')
path.mkdir()
with self.assertRaisesRegex(StorageError, 'identity'):
reconcile_storage(spec, self.state, self.save)
with self.assertRaisesRegex(StorageError, 'identity'):
storage.acknowledge_storage_ownership(spec, self.state, [str(path)], self.save)
def test_preexisting_directory_cannot_be_acknowledged(self):
path = self.srv / 'tier-1.node'
path.mkdir()
spec = enrollment()
result = reconcile_storage(spec, self.state, self.save)
self.assertEqual(result['ownership_pending'], [])
with self.assertRaises(StorageError):
storage.acknowledge_storage_ownership(spec, self.state, [str(path)], self.save)
def test_ownership_acknowledgement_rejects_retargeted_enrollment(self):
spec = enrollment()
result = reconcile_storage(spec, self.state, self.save)
changed = Enrollment('other.example.com', spec.repository, spec.tiers, (), ())
with self.assertRaisesRegex(StorageError, 'identity'):
storage.acknowledge_storage_ownership(changed, self.state, result['ownership_pending'], self.save)
self.assertEqual(reconcile_storage(spec, self.state, self.save)['ownership_pending'],
result['ownership_pending'])
def test_missing_root_and_occupied_device_never_create_pool(self):
for device in ('/dev/missing', '/dev/root', '/dev/spare'):
with self.subTest(device=device):
self.signatures = [{'type': 'ext4'}] if device == '/dev/spare' else []
with self.assertRaises(StorageError):
reconcile_storage(enrollment(Tier(1, 'create', device, None)), {}, lambda: None)
self.assertFalse(any(c[:2] == ['zpool', 'create'] for c in self.calls))
def test_aliases_selected_by_two_tiers_are_rejected_before_creation(self):
with self.assertRaises(StorageError):
reconcile_storage(enrollment(Tier(1, 'create', '/dev/spare', None),
Tier(2, 'create', '/dev/alias', None)), self.state, self.save)
self.assertFalse(any(c[:2] == ['zpool', 'create'] for c in self.calls))
def test_create_records_guid_and_retry_verifies_without_recreation(self):
spec = enrollment(Tier(1, 'create', '/dev/spare', None))
reconcile_storage(spec, self.state, self.save)
self.assertEqual(self.state['storage']['tiers']['1']['guid'], '12345')
reconcile_storage(spec, self.state, self.save)
self.assertEqual(sum(c[:2] == ['zpool', 'create'] for c in self.calls), 1)
self.devices[1]['serial'] = 'REPLACED'
with self.assertRaises(StorageError):
reconcile_storage(spec, self.state, self.save)
def test_existing_pool_name_without_journal_is_not_ownership(self):
self.pool = {'guid': '12345'}
with self.assertRaises(StorageError):
reconcile_storage(enrollment(Tier(1, 'create', '/dev/spare', None)), self.state, self.save)
def test_import_requires_exact_guid_and_single_device(self):
self.import_listing = (' pool: tier-1\n id: 54321\n state: ONLINE\n'
' config:\n\n\ttier-1 ONLINE\n\t /dev/spare ONLINE\n')
with self.assertRaises(StorageError):
reconcile_storage(enrollment(Tier(1, 'import', '/dev/spare', '12345')), self.state, self.save)
self.assertFalse(any(c[:2] == ['zpool', 'import'] and '-N' in c for c in self.calls))
def test_unknown_swapfile_is_preserved(self):
self.set_active_swaps([])
self.swap.write_text('do not overwrite')
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(self.swap.read_text(), 'do not overwrite')
self.assertEqual(result['swap']['status'], 'skipped')
self.assertIn('existing', result['swap']['reason'])
def test_root_disk_descendant_is_protected(self):
child = self.device('/dev/root1', '8:1', 'ROOT', ['/boot'])
child.update(type='part', pkname='/dev/root', partuuid='root-partition')
self.devices.append(child)
for device in ('/dev/root', '/dev/root1'):
with self.subTest(device=device):
with self.assertRaisesRegex(StorageError, 'intersects'):
reconcile_storage(enrollment(Tier(1, 'create', device, None)), self.state, self.save)
self.state.clear()
self.assertFalse(any(c[:2] == ['zpool', 'create'] for c in self.calls))
def test_missing_hardware_identity_stops_before_creation(self):
self.devices[1]['serial'] = None
with self.assertRaisesRegex(StorageError, 'stable'):
reconcile_storage(enrollment(Tier(1, 'create', '/dev/spare', None)), self.state, self.save)
self.assertFalse(any(c[:2] == ['zpool', 'create'] for c in self.calls))
def test_identity_rechecked_after_intent_is_saved(self):
def changed_device():
self.save()
self.devices[1]['serial'] = 'REPLACED'
with self.assertRaisesRegex(StorageError, 'changed before'):
reconcile_storage(enrollment(Tier(1, 'create', '/dev/spare', None)), self.state, changed_device)
self.assertFalse(any(c[:2] == ['zpool', 'create'] for c in self.calls))
def test_created_whole_disk_pool_can_use_a_new_partition(self):
def partition_created():
child = self.device('/dev/spare1', '8:17', 'SPARE', [])
child.update(type='part', pkname='/dev/spare')
self.devices.append(child)
self.pool_member = '/dev/spare1'
self.create_hook = partition_created
result = reconcile_storage(enrollment(Tier(1, 'create', '/dev/spare', None)), self.state, self.save)
self.assertEqual(result['tiers'][0]['guid'], '12345')
def importable(self, members='\t spare ONLINE\n'):
self.import_listing = (' pool: tier-1\n id: 12345\n state: ONLINE\n'
' action: The pool can be imported using its name or numeric identifier.\n'
' config:\n\n\ttier-1 ONLINE\n' + members)
def test_imports_declared_guid_without_force_or_mountpoint_rewrite(self):
self.importable()
result = reconcile_storage(enrollment(Tier(1, 'import', '/dev/spare', '12345')), self.state, self.save)
self.assertEqual(result['tiers'][0]['guid'], '12345')
self.assertFalse(result['tiers'][0]['created'])
self.assertEqual(result['ownership_pending'], [])
with self.assertRaises(StorageError):
storage.acknowledge_storage_ownership(
enrollment(Tier(1, 'import', '/dev/spare', '12345')), self.state,
[str(self.pool_mountpoint)], self.save)
self.assertIn(['zpool', 'import', '-N', '-d', '/dev/spare', '12345'], self.calls)
self.assertFalse(any('-f' in command for command in self.calls))
self.assertFalse(any('mountpoint=' in arg for command in self.calls for arg in command))
def test_multidevice_import_is_rejected(self):
self.importable('\t spare ONLINE\n\t other ONLINE\n')
with self.assertRaisesRegex(StorageError, 'exactly one'):
reconcile_storage(enrollment(Tier(1, 'import', '/dev/spare', '12345')), self.state, self.save)
self.assertFalse(any(c[:2] == ['zpool', 'import'] and '-N' in c for c in self.calls))
def partitioned_import_fixture(self):
for suffix, number in (('1', '8:17'), ('9', '8:25')):
child = self.device('/dev/spare' + suffix, number, None, [])
child.update(type='part', pkname='/dev/spare', partuuid='partition-' + suffix)
self.devices.append(child)
self.pool_member = '/dev/spare1'
self.importable()
self.import_full_path_listing = self.import_listing.replace('spare ONLINE', '/dev/spare1 ONLINE')
def test_import_discovery_preserves_partition_path_instead_of_whole_disk_display(self):
self.partitioned_import_fixture()
with patch.dict(os.environ, {'ZPOOL_VDEV_NAME_GUID': '1'}):
result = reconcile_storage(enrollment(Tier(1, 'import', '/dev/spare1', '12345')),
self.state, self.save)
self.assertEqual(result['tiers'][0]['guid'], '12345')
self.assertIn(['zpool', 'import', '-N', '-d', '/dev/spare1', '12345'], self.calls)
def test_import_discovery_never_accepts_a_sibling_partition(self):
self.partitioned_import_fixture()
with self.assertRaisesRegex(StorageError, 'membership differs'):
reconcile_storage(enrollment(Tier(1, 'import', '/dev/spare9', '12345')), self.state, self.save)
self.assertFalse(any(c[:2] == ['zpool', 'import'] and '-N' in c for c in self.calls))
def test_import_discovery_parent_only_output_is_not_partition_proof(self):
self.partitioned_import_fixture()
self.import_full_path_listing = self.import_listing.replace('spare ONLINE', '/dev/spare ONLINE')
with self.assertRaisesRegex(StorageError, 'membership differs'):
reconcile_storage(enrollment(Tier(1, 'import', '/dev/spare1', '12345')), self.state, self.save)
self.assertFalse(any(c[:2] == ['zpool', 'import'] and '-N' in c for c in self.calls))
def test_import_does_not_mount_or_reassign_unrelated_mountpoint(self):
self.importable()
self.pool_mountpoint = '/important-data'
with self.assertRaisesRegex(StorageError, 'mountpoint'):
reconcile_storage(enrollment(Tier(1, 'import', '/dev/spare', '12345')), self.state, self.save)
self.assertFalse(any(c[:2] in (['zfs', 'mount'], ['zfs', 'set']) for c in self.calls))
def test_swap_skip_reports_low_space_and_unsupported_filesystems(self):
self.set_active_swaps([])
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=1, f_frsize=4096)):
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertIn('insufficient', result['swap']['reason'])
self.mounts[0]['fstype'] = 'btrfs'
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertIn('supported', result['swap']['reason'])
self.assertFalse(self.swap.exists())
def test_swap_sizes_allocation_and_records_verified_result(self):
self.set_active_swaps([])
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 4 * 1024 ** 3))
self.assertEqual(result['swap'], {'status': 'complete', 'size': 4 * 1024 ** 3})
persist.assert_called_once()
def test_existing_active_swap_preserved_without_cli_inspection(self):
before = self.swaps.read_text()
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(result['swap'], {'status': 'skipped', 'reason': 'existing active swap preserved'})
self.assertEqual(self.swaps.read_text(), before)
self.assertFalse(any(command[0] in ('swapon', 'mkswap') for command in self.calls))
self.assertFalse(self.swap.exists())
def test_proc_swaps_empty_and_escaped_paths(self):
self.set_active_swaps([])
self.assertEqual(storage._active_swaps(), [])
self.swaps.write_text('Filename\tType\tSize\tUsed\tPriority\n'
'/swap\\040file\\011name\\012with\\134slash\tfile\t2097148\t0\t-2\n')
self.assertEqual(storage._active_swaps(), ['/swap file\tname\nwith\\slash'])
def test_unobserved_swap_activation_is_not_persisted_or_reported_complete(self):
self.set_active_swaps([])
self.activate_swap = False
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True), \
patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(result['swap']['status'], 'skipped')
self.assertIn('activation did not become observable', result['swap']['reason'])
persist.assert_not_called()
def test_malformed_proc_swaps_stops_optional_allocation(self):
for text in ('', 'wrong header\n', 'Filename Type Size Used Priority\n/swapfile file broken 0 -2\n'):
with self.subTest(text=text):
self.swaps.write_text(text)
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(result['swap']['status'], 'skipped')
self.assertIn('swap inventory', result['swap']['reason'])
self.assertFalse(self.swap.exists())
def test_small_memory_swap_is_two_gib(self):
self.set_active_swaps([])
self.meminfo.write_text('MemTotal: 4194304 kB\n')
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('provisioning.storage._persist_swap'):
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 2 * 1024 ** 3))
self.assertEqual(result['swap']['size'], 2 * 1024 ** 3)
def test_fstab_preserves_existing_content_and_does_not_duplicate_swap(self):
actual_lstat = Path.lstat
def root_owned(path, *args, **kwargs):
info = actual_lstat(path, *args, **kwargs)
if path == self.fstab:
fields = list(info)
fields[4] = 0
return os.stat_result(fields)
return info
with patch('provisioning.storage.Path.lstat', root_owned), \
patch('provisioning.storage.os.fchown'):
_persist_swap()
_persist_swap()
self.assertEqual(self.fstab.read_text(), '# existing entries\n' +
f'{self.swap} none swap sw 0 0\n')
def test_pool_guid_change_on_retry_is_rejected(self):
spec = enrollment(Tier(1, 'create', '/dev/spare', None))
reconcile_storage(spec, self.state, self.save)
self.pool['guid'] = '67890'
with self.assertRaisesRegex(StorageError, 'GUID'):
reconcile_storage(spec, self.state, self.save)
def test_interrupted_create_recovers_only_matching_pool_ownership(self):
spec = enrollment(Tier(1, 'create', '/dev/spare', None))
reconcile_storage(spec, self.state, self.save)
record = self.state['storage']['tiers']['1']
record['status'] = 'intent'
del record['guid']
reconcile_storage(spec, self.state, self.save)
self.assertEqual(record['guid'], '12345')
self.assertEqual(sum(c[:2] == ['zpool', 'create'] for c in self.calls), 1)
self.pool_owner = 'other-enrollment'
with self.assertRaisesRegex(StorageError, 'ownership'):
reconcile_storage(spec, self.state, self.save)
if __name__ == '__main__':
unittest.main()
@@ -1,87 +0,0 @@
import contextlib
import io
import json
import os
from pathlib import Path
from types import SimpleNamespace
import tempfile
import unittest
from unittest.mock import patch
from provisioning import target
from provisioning.contract import normalize_user_data
from provisioning.state import ProvisionError, atomic_write, safe_path
from test_provision_contract import example
class TargetTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
def test_invalid_public_key_rejected_before_host_enrollment(self):
with self.assertRaisesRegex(ProvisionError, 'cryptographic validation'):
target.validate_keys(normalize_user_data(example()))
def test_administrator_preserves_existing_keys_and_uses_narrow_writes(self):
home = self.root / 'home/adam'
(home / '.ssh').mkdir(parents=True)
(home / '.ssh/authorized_keys').write_text('existing-key\n')
account = SimpleNamespace(pw_dir='/home/adam', pw_uid=os.getuid(), pw_gid=os.getgid(), pw_shell='/bin/bash')
commands = []
def command(args, **kwargs):
commands.append(args)
if args == ['hostname']:
return 'provider.example.com'
if args == ['id', '-nG', 'adam']:
return 'adam sudo docker'
return ''
def mapped(path):
path = Path(path)
return safe_path(path if path.is_relative_to(self.root) else self.root / str(path).lstrip('/'))
def write(path, content, mode=0o600, **kwargs):
# Keep real file writes; privileged ownership is the isolated boundary.
kwargs.pop('uid', None)
kwargs.pop('gid', None)
atomic_write(mapped(path), content, mode, **kwargs)
with patch.object(target.pwd, 'getpwnam', return_value=account), \
patch.object(target, 'safe_path', side_effect=mapped), \
patch.object(target, 'atomic_write', side_effect=write), \
patch.object(target, 'run', side_effect=command):
spec = normalize_user_data(example())
target.administrator(spec)
target.administrator(spec)
content = (home / '.ssh/authorized_keys').read_text().splitlines()
self.assertEqual(content, ['existing-key', 'ssh-ed25519 PUBLIC_TEST_KEY operator'])
self.assertIn(['hostnamectl', 'set-hostname', spec.hostname], commands)
self.assertEqual((self.root / 'etc/sudoers.d/99-apex-adam').stat().st_mode & 0o777, 0o440)
self.assertFalse((self.root / 'etc/machine-id').exists())
self.assertFalse((self.root / 'etc/network').exists())
def test_completion_requires_committed_access_and_preserves_revisions(self):
state = self.root / 'state'
(state / 'access').mkdir(parents=True)
record = {'status': 'enrolled', 'repository': {'commit': 'nodecommit', 'commons_commit': 'commonscommit'}}
atomic_write(state / 'enrollment.json', json.dumps(record))
atomic_write(state / 'access/state.json', json.dumps({'status': 'final'}))
with patch.object(target, 'STATE', state), patch.object(target, 'preflight'), \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(target.main(['complete']), 1)
self.assertEqual(json.loads((state / 'enrollment.json').read_text())['status'], 'enrolled')
atomic_write(state / 'access/state.json', json.dumps({'status': 'committed'}))
self.assertEqual(target.main(['complete']), 0)
actual = json.loads((state / 'enrollment.json').read_text())
self.assertEqual(actual['external_access'], 'verified')
self.assertEqual(actual['repository'], record['repository'])
def test_preflight_rejects_wrong_os_before_any_enrollment(self):
with patch.object(target.os, 'geteuid', return_value=0), \
patch.object(target.platform, 'machine', return_value='x86_64'), \
patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="24.04"\n'):
with self.assertRaisesRegex(ProvisionError, 'Debian 13'):
target.preflight()
@@ -1,30 +0,0 @@
import importlib.util
import unittest
from provisioning.contract import ContractError
from provisioning.userdata import decode_user_data
@unittest.skipUnless(importlib.util.find_spec('yaml'), 'target-only python3-yaml is unavailable')
class UserDataTests(unittest.TestCase):
def test_duplicate_mapping_keys_rejected_before_normalization(self):
with self.assertRaisesRegex(ContractError, 'unique'):
decode_user_data(b'hostname: one.example.com\nhostname: two.example.com\n')
def test_alias_and_unsafe_yaml_do_not_execute_or_echo_content(self):
for content in (b'a: &a [*a]', b'!!python/object/apply:os.system [SECRET_SENTINEL]',
b'a: [SECRET_SENTINEL'):
with self.subTest(content=content):
with self.assertRaises(ContractError) as error:
decode_user_data(content)
self.assertNotIn('SECRET_SENTINEL', str(error.exception))
def test_normal_supported_document(self):
import yaml
from test_provision_contract import example
spec = decode_user_data(yaml.safe_dump(example()).encode())
self.assertEqual(spec.hostname, 'node1.a1.example.com')
def test_yaml_boolean_mapping_key_rejected(self):
with self.assertRaisesRegex(ContractError, 'unique strings'):
decode_user_data(b'true: value\n')
File renamed without changes.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

+1 -1
View File
@@ -7,7 +7,7 @@ license = { text = "BSD-2-Clause" }
requires-python = ">=3.10" requires-python = ">=3.10"
[project.optional-dependencies] [project.optional-dependencies]
dev = ["ruff==0.17.2", "PyYAML>=6.0,<7"] dev = ["ruff==0.17.2"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
File renamed without changes.
+3 -2
View File
@@ -1,9 +1,10 @@
# engine/cli.py # scripts/engine/cli.py
"""apex CLI: list / dispatch / help. Acts on the local checkout (no <node> arg).""" """apex CLI: list / dispatch / help. Acts on the local checkout (no <node> arg)."""
from __future__ import annotations from __future__ import annotations
import os import os
import socket import socket
import sys import sys
from pathlib import Path
from engine import overlay from engine import overlay
from engine.identity import resolve from engine.identity import resolve
@@ -12,7 +13,7 @@ from engine.descriptor import build_parser, Meta
def _resolve_layout(): def _resolve_layout():
commons_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) commons_dir = str(Path(__file__).resolve().parents[2])
parent = os.path.dirname(commons_dir) parent = os.path.dirname(commons_dir)
cwd = os.getcwd() cwd = os.getcwd()
@@ -1,4 +1,4 @@
# engine/context.py # scripts/engine/context.py
"""Context and filesystem paths available to actions.""" """Context and filesystem paths available to actions."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -1,4 +1,4 @@
# engine/descriptor.py # scripts/engine/descriptor.py
"""Declarative action descriptors — successor to the bash action_metadata() heredoc. """Declarative action descriptors — successor to the bash action_metadata() heredoc.
Meta drives BOTH the help text and argparse parsing. Exit code 64 (EX_USAGE) on a Meta drives BOTH the help text and argparse parsing. Exit code 64 (EX_USAGE) on a
@@ -1,4 +1,4 @@
# engine/identity.py # scripts/engine/identity.py
"""Node identity — fully explicit, from node.env. The framework assumes no domain """Node identity — fully explicit, from node.env. The framework assumes no domain
and no clustering: a node declares its real FQDN and its public host name, and the and no clustering: a node declares its real FQDN and its public host name, and the
short `name` is just the first label. The OS hostname is only a fallback for the FQDN. short `name` is just the first label. The OS hostname is only a fallback for the FQDN.
File renamed without changes.
@@ -1,4 +1,4 @@
# engine/lib/crowdsec.py # scripts/engine/lib/crowdsec.py
"""ctx.crowdsec — install + config deploy + collections (port of config/crowdsec).""" """ctx.crowdsec — install + config deploy + collections (port of config/crowdsec)."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -1,4 +1,4 @@
# engine/lib/host.py # scripts/engine/lib/host.py
"""ctx.host — idempotent host mutation: backup, write_file, inject_block, install_crontab. """ctx.host — idempotent host mutation: backup, write_file, inject_block, install_crontab.
Ports root_template_crontab (install + rollback) and the ufw block-injection logic Ports root_template_crontab (install + rollback) and the ufw block-injection logic
@@ -1,4 +1,4 @@
# engine/lib/log.py # scripts/engine/lib/log.py
"""ctx.log — [node] [action] [level] message (port of root_log_*).""" """ctx.log — [node] [action] [level] message (port of root_log_*)."""
from __future__ import annotations from __future__ import annotations
import sys import sys
@@ -1,4 +1,4 @@
# engine/lib/notify.py # scripts/engine/lib/notify.py
"""ctx.notify — Telegram notifications. """ctx.notify — Telegram notifications.
The alert `instance` is the node's public host (APEX_NODE_HOST), passed in from the The alert `instance` is the node's public host (APEX_NODE_HOST), passed in from the
@@ -1,4 +1,4 @@
# engine/lib/sys.py # scripts/engine/lib/sys.py
"""ctx.sys — command execution + systemd service management (port of root_sys.sh).""" """ctx.sys — command execution + systemd service management (port of root_sys.sh)."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -1,4 +1,4 @@
# engine/lib/systemd.py # scripts/engine/lib/systemd.py
"""ctx.systemd — render + install unit files, daemon-reload, enable+start.""" """ctx.systemd — render + install unit files, daemon-reload, enable+start."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -1,4 +1,4 @@
# engine/lib/tpl.py # scripts/engine/lib/tpl.py
"""ctx.tpl — stdlib string.Template rendering. """ctx.tpl — stdlib string.Template rendering.
safe_substitute replaces known $VAR/${VAR} and leaves unknown ones literal. safe_substitute replaces known $VAR/${VAR} and leaves unknown ones literal.
@@ -1,4 +1,4 @@
# engine/lib/ufw.py # scripts/engine/lib/ufw.py
"""Apply ufw and ufw-docker rules. """Apply ufw and ufw-docker rules.
Replace existing supported marker blocks before inserting current rules. Replace existing supported marker blocks before inserting current rules.
@@ -1,4 +1,4 @@
# engine/overlay.py # scripts/engine/overlay.py
"""Resolve action names across commons + proprietaries. """Resolve action names across commons + proprietaries.
proprietaries first (override), commons second. Listing uses static ast scanning proprietaries first (override), commons second. Listing uses static ast scanning
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
"""Host-side Docker installation and SSH handover."""
@@ -335,13 +335,6 @@ class Host:
if any(values.get(key) != value for key, value in expected.items()): if any(values.get(key) != value for key, value in expected.items()):
raise AccessError('Effective SSH authentication policy is inconsistent') raise AccessError('Effective SSH authentication policy is inconsistent')
def current_final(self, address):
try:
self.verify_active([2222], True, address)
return True
except AccessError:
return False
def install_policy(self, ports, final, address): def install_policy(self, ports, final, address):
args = self.policy(ports, final, address) args = self.policy(ports, final, address)
path = self.path('/etc/systemd/system/ssh.service.d/apex-init.conf') path = self.path('/etc/systemd/system/ssh.service.d/apex-init.conf')
@@ -354,21 +347,6 @@ class Host:
self.command(['systemctl', 'restart', 'ssh.service']) self.command(['systemctl', 'restart', 'ssh.service'])
self.verify_active(ports, final, address) self.verify_active(ports, final, address)
def firewall_needed(self):
result = self.command(['ufw', 'status'], False)
if result.returncode or 'Status: active' not in result.stdout:
return False
if re.search(r'^2222/tcp(?:\s|\(v6\)).*ALLOW', result.stdout, re.MULTILINE):
return False
return True
def open_firewall(self):
# Existing allows remain owned by their administrator. Only our commented rule is removed.
self.command(['ufw', 'allow', '2222/tcp', 'comment', 'apex-init-access'])
def close_firewall(self):
self.command(['ufw', '--force', 'delete', 'allow', '2222/tcp', 'comment', 'apex-init-access'])
def arm(self): def arm(self):
bundle = Path(__file__).resolve().parents[1] bundle = Path(__file__).resolve().parents[1]
if not re.fullmatch(r'/usr/local/lib/apex-provisioner/[a-f0-9]{64}', str(bundle)): if not re.fullmatch(r'/usr/local/lib/apex-provisioner/[a-f0-9]{64}', str(bundle)):
@@ -396,8 +374,6 @@ class Host:
self.command(['/usr/sbin/sshd', '-t']) self.command(['/usr/sbin/sshd', '-t'])
self.command(['systemctl', 'daemon-reload']) self.command(['systemctl', 'daemon-reload'])
self.command(['systemctl', 'restart', 'ssh.service']) self.command(['systemctl', 'restart', 'ssh.service'])
if state.get('firewall_added'):
self.close_firewall()
def atomic(path, data, mode=0o600): def atomic(path, data, mode=0o600):
@@ -425,30 +401,24 @@ def atomic(path, data, mode=0o600):
class Transition: class Transition:
def __init__(self, host=None, now=time.monotonic, security=None, profile=None): def __init__(self, host=None, now=time.monotonic, profile=None):
from .security import Security
if profile is not None and profile not in MANUAL_PROFILES: if profile is not None and profile not in MANUAL_PROFILES:
raise AccessError('Unknown SSH access profile') raise AccessError('Unknown SSH access profile')
self.profile = profile self.profile = profile
self.host = host or Host() self.host = host or Host()
self.host.profile = profile self.host.profile = profile
self.security = security if security is not None else Security(self.host)
self.now = now self.now = now
self.directory = self.host.path('/var/lib/apex-init/access') self.directory = self.host.path('/var/lib/apex-init/access')
def load(self): def load(self):
return self._read_record(bool(self.profile)) path = self.directory / 'manual-state.json'
def _read_record(self, manual):
path = self.directory / ('manual-state.json' if manual else 'state.json')
return json.loads(path.read_text()) if path.exists() else None return json.loads(path.read_text()) if path.exists() else None
def save(self, state): def save(self, state):
path = self.directory / ('manual-state.json' if state.get('profile') else 'state.json') atomic(self.directory / 'manual-state.json', json.dumps(state, sort_keys=True).encode())
atomic(path, json.dumps(state, sort_keys=True).encode())
def execute(self, operation, token=None, original_port=None, client_address=None): def execute(self, operation, token=None, original_port=None, client_address=None):
# Rollback must never wait for a stalled enrollment stage holding init.lock. # The watchdog must recover SSH even when another APEX action holds init.lock.
if operation in ('tick', 'rollback'): if operation in ('tick', 'rollback'):
return self._execute(operation, token, original_port, client_address) return self._execute(operation, token, original_port, client_address)
from .state import ProvisionError, exclusive_lock from .state import ProvisionError, exclusive_lock
@@ -472,19 +442,17 @@ class Transition:
with os.fdopen(descriptor, 'w') as lock: with os.fdopen(descriptor, 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX) fcntl.flock(lock, fcntl.LOCK_EX)
state = self.load() state = self.load()
other = self._read_record(not self.profile)
active = ('transition', 'final', 'recovery_pending') active = ('transition', 'final', 'recovery_pending')
if other and other['status'] in active: # A historical bundle owns its pending rollback and the shared timer.
if operation == 'tick': legacy_path = self.directory / 'state.json'
state = other if legacy_path.exists():
else: legacy = json.loads(legacy_path.read_text())
if legacy['status'] in active or legacy.get('disarm_pending'):
raise AccessError('Another access workflow is pending; wait for rollback before retrying') raise AccessError('Another access workflow is pending; wait for rollback before retrying')
if state and self.profile and state.get('profile') != self.profile and operation != 'tick': if state and self.profile and state.get('profile') != self.profile and operation != 'tick':
if operation != 'begin' or state['status'] in active: if operation != 'begin' or state['status'] in active:
raise AccessError('Access transition belongs to a different platform') raise AccessError('Access transition belongs to a different platform')
state = None state = None
if operation == 'tick' and other and other.get('disarm_pending') and not (state and state['status'] in active):
state = other
if state and state.get('disarm_pending') and state['status'] in ('committed', 'rolled_back'): if state and state.get('disarm_pending') and state['status'] in ('committed', 'rolled_back'):
self._disarm(state) self._disarm(state)
if (state and token and token == state.get('token') if (state and token and token == state.get('token')
@@ -514,45 +482,9 @@ class Transition:
raise AccessError('Invalid SSH client address') from None raise AccessError('Invalid SSH client address') from None
self.host.preflight() self.host.preflight()
path = self.host.path('/etc/systemd/system/ssh.service.d/apex-init.conf') path = self.host.path('/etc/systemd/system/ssh.service.d/apex-init.conf')
if self.profile: return self._begin_manual(token, original_port, client_address, path)
return self._begin_manual(token, original_port, client_address, path) if not pending or token != state['token']:
preserve_policy = False
if state and state['status'] == 'committed':
if state['fingerprint'] != self.host.fingerprint():
raise AccessError('Committed access configuration changed; reconcile before retrying')
self.host.verify_active([2222], True, client_address)
if state.get('security'):
self.security.verify(state['security'])
return {'status': 'committed'}
preserve_policy = True
if original_port == 2222 and not path.exists() and self.host.current_final(client_address):
preserve_policy = True
security_state = self.security.snapshot(self.security.preflight(), original_port, client_address)
self.host.arm()
state = {'status': 'transition', 'token': token, 'deadline': self.now() + 300,
'boot_id': self.host.boot_id(), 'client_address': client_address,
'original_port': original_port, 'firewall_added': False,
'preserve_policy': preserve_policy, 'security': security_state,
'previous': base64.b64encode(path.read_bytes()).decode() if path.exists() else None}
self.save(state)
try:
self.security.activate(state['security'], lambda value: self._save_security(state, value))
if not preserve_policy:
self.host.install_policy([original_port, 2222], False, client_address)
state['fingerprint'] = self.host.fingerprint()
self.save(state)
except Exception:
self._rollback(state)
raise
return {'status': 'transition'}
if not state or (not pending and state['status'] != 'existing') or token != state['token']:
raise AccessError('No matching pending access transition') raise AccessError('No matching pending access transition')
if state['status'] == 'existing':
if operation == 'rollback':
state['status'] = 'rolled_back'
self.save(state)
return {'status': 'rolled_back'}
raise AccessError('Legacy access verification lacks security activation; retry initialization')
if operation == 'rollback': if operation == 'rollback':
self._rollback(state) self._rollback(state)
return {'status': 'rolled_back'} return {'status': 'rolled_back'}
@@ -563,14 +495,10 @@ class Transition:
raise AccessError('Access transition is not ready for final policy') raise AccessError('Access transition is not ready for final policy')
try: try:
if not state.get('preserve_policy'): if not state.get('preserve_policy'):
if state.get('profile'): state['ssh_changed'] = True
state['ssh_changed'] = True self.save(state)
self.save(state)
self.host.install_policy(self.host.final_ports, True, state['client_address']) self.host.install_policy(self.host.final_ports, True, state['client_address'])
if state.get('profile'): state['authenticated_after_us'] = time.monotonic_ns() // 1000
state['authenticated_after_us'] = time.monotonic_ns() // 1000
else:
self.security.finalize(state['security'], lambda value: self._save_security(state, value))
state['status'] = 'final' state['status'] = 'final'
state['fingerprint'] = self.host.fingerprint() state['fingerprint'] = self.host.fingerprint()
self.save(state) self.save(state)
@@ -580,8 +508,6 @@ class Transition:
return {'status': 'final'} return {'status': 'final'}
if operation == 'commit' and state['status'] == 'final': if operation == 'commit' and state['status'] == 'final':
self.host.verify_active(self.host.final_ports, True, state['client_address']) self.host.verify_active(self.host.final_ports, True, state['client_address'])
if not state.get('profile'):
self.security.verify(state['security'])
state['status'] = 'committed' state['status'] = 'committed'
state['disarm_pending'] = True state['disarm_pending'] = True
self.save(state) # Timer observes this before it can restore anything. self.save(state) # Timer observes this before it can restore anything.
@@ -620,17 +546,8 @@ class Transition:
raise raise
return {'status': 'transition'} return {'status': 'transition'}
def _save_security(self, state, value):
state['security'] = value
self.save(state)
def _rollback(self, state): def _rollback(self, state):
errors = [] errors = []
if state.get('security'):
try:
self.security.restore(state['security'], lambda value: self._save_security(state, value))
except Exception:
errors.append('security')
if not state.get('ssh_restored') and state.get('ssh_changed', True): if not state.get('ssh_restored') and state.get('ssh_changed', True):
try: try:
self.host.restore(state) self.host.restore(state)
@@ -656,9 +573,8 @@ class Transition:
def main(argv=None): def main(argv=None):
from .security import SecurityError
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('operation', choices=['preflight', 'begin', 'finalize', 'commit', 'rollback', 'tick']) parser.add_argument('operation', choices=['begin', 'finalize', 'commit', 'rollback', 'tick'])
parser.add_argument('--profile', choices=MANUAL_PROFILES) parser.add_argument('--profile', choices=MANUAL_PROFILES)
parser.add_argument('--token') parser.add_argument('--token')
parser.add_argument('--original-port', type=int) parser.add_argument('--original-port', type=int)
@@ -667,14 +583,12 @@ def main(argv=None):
if os.geteuid() != 0: if os.geteuid() != 0:
parser.error('Access handover requires root') parser.error('Access handover requires root')
try: try:
if args.operation == 'preflight': if args.operation != 'tick' and not args.profile:
Host(profile=args.profile).bootstrap_preflight() parser.error('A host profile is required')
result = {'status': 'prerequisites-verified'} result = Transition(profile=args.profile).execute(args.operation, args.token, args.original_port, args.client_address)
else:
result = Transition(profile=args.profile).execute(args.operation, args.token, args.original_port, args.client_address)
print(json.dumps(result)) print(json.dumps(result))
return 0 return 0
except (AccessError, SecurityError) as exc: except AccessError as exc:
print(str(exc), file=sys.stderr) print(str(exc), file=sys.stderr)
return 1 return 1
+301
View File
@@ -0,0 +1,301 @@
"""Verify or install a local Docker Engine and CLI plugins."""
from __future__ import annotations
from contextlib import contextmanager
import fcntl
from functools import wraps
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import urllib.request
class DockerError(RuntimeError):
"""A required Docker postcondition could not be established."""
def expected_errors(operation):
@wraps(operation)
def guarded(*args, **kwargs):
try:
return operation(*args, **kwargs)
except (OSError, ValueError) as exc:
raise DockerError("Docker filesystem or saved state is invalid; inspect the target before retrying.") from exc
return guarded
DOCKER_PACKAGES = ("docker-ce", "docker-ce-cli", "containerd.io",
"docker-buildx-plugin", "docker-compose-plugin")
DOCKER_CONFLICTS = ("docker.io", "docker-doc", "docker-compose", "docker-buildx",
"podman-docker", "containerd", "runc")
DOCKER_CONFIG = {"data-root": "/srv/docker", "log-driver": "json-file",
"log-opts": {"max-size": "20m", "max-file": "3"}}
POLICY = Path("/usr/sbin/policy-rc.d")
POLICY_STATE = Path("/usr/sbin/.apex-policy-rc.d")
APT = ["apt-get", "-o", "DPkg::Lock::Timeout=60", "-o",
"Acquire::Retries=2", "-o", "Acquire::http::Timeout=30", "-o",
"Acquire::https::Timeout=30"]
def run(args, *, check=True, timeout=300):
try:
result = subprocess.run(args, text=True, capture_output=True, timeout=timeout,
env={**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"})
except (OSError, subprocess.TimeoutExpired) as exc:
raise DockerError(f"Cannot complete {args[0]}: {exc}") from exc
if check and result.returncode:
raise DockerError(f"{args[0]} failed with exit status {result.returncode}; inspect target system logs.")
return result
def package_versions():
architecture = run(["dpkg", "--print-architecture"]).stdout.strip()
result = run(["dpkg-query", "-W", "-f=${binary:Package}\t${Status}\t${Version}\n"])
installed = {}
for line in result.stdout.splitlines():
fields = line.split("\t")
if len(fields) == 3 and fields[1].split()[1:] == ["ok", "installed"]:
installed[fields[0].removesuffix(":" + architecture)] = fields[2]
return installed
def write_managed(path, content, mode=0o644):
path = Path(path)
for parent in (path, *path.parents):
if parent.is_symlink():
raise DockerError(f"Refusing managed symlink: {parent}")
payload = content.encode() if isinstance(content, str) else content
if path.exists() and path.read_bytes() == payload:
if path.stat().st_mode & 0o777 != mode:
path.chmod(mode)
return False
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".apex-", dir=path.parent)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, mode)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return True
def restore_service_policy():
if not POLICY_STATE.exists():
return
backup = POLICY_STATE / "original"
if (POLICY_STATE.is_symlink() or POLICY_STATE.stat().st_uid != os.geteuid()):
raise DockerError("Refusing untrusted service policy recovery state.")
if (POLICY_STATE / "ready").exists() and POLICY.exists():
original_matches = (backup.exists() and not POLICY.is_symlink() and
POLICY.read_bytes() == backup.read_bytes())
symlink_matches = (backup.is_symlink() and POLICY.is_symlink() and
os.readlink(backup) == os.readlink(POLICY))
guard_matches = not POLICY.is_symlink() and POLICY.read_bytes() == b"#!/bin/sh\nexit 101\n"
if not (original_matches or symlink_matches or guard_matches):
raise DockerError("Service policy changed outside APEX; recovery requires explicit reconciliation.")
if backup.exists() or backup.is_symlink():
# Copy first: an interrupted restoration must retain its recovery copy.
temporary = POLICY_STATE / "restoring"
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
shutil.copy2(backup, temporary, follow_symlinks=False)
os.replace(temporary, POLICY)
elif (POLICY_STATE / "absent").exists():
POLICY.unlink(missing_ok=True)
elif (POLICY_STATE / "ready").exists():
raise DockerError("Service policy recovery is incomplete; inspect /usr/sbin/.apex-policy-rc.d.")
shutil.rmtree(POLICY_STATE)
@contextmanager
def service_start_guard():
lock_path = Path("/run/lock/apex-package-policy.lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise DockerError("Another APEX package operation is running.") from None
with _service_start_guard():
yield
@contextmanager
def _service_start_guard():
restore_service_policy()
POLICY_STATE.mkdir(mode=0o700)
try:
if POLICY.exists() or POLICY.is_symlink():
shutil.copy2(POLICY, POLICY_STATE / "original", follow_symlinks=False)
else:
(POLICY_STATE / "absent").touch()
(POLICY_STATE / "ready").touch()
# Replace the path itself, preserving any original symlink in the backup.
temporary = POLICY_STATE / "guard"
temporary.write_text("#!/bin/sh\nexit 101\n")
temporary.chmod(0o755)
os.replace(temporary, POLICY)
yield
finally:
restore_service_policy()
def install_missing(packages):
installed = package_versions()
missing = [package for package in packages if package not in installed]
if not missing:
return []
run([*APT, "update", "--error-on=any"])
command = [*APT, "install", "-y", "--no-install-recommends", "--no-upgrade", "--no-remove", *missing]
plan = run([*command, "--simulate"]).stdout
if any(line.startswith("Remv ") or re.match(r"^Inst \S+ \[", line)
for line in plan.splitlines()):
raise DockerError("Missing packages require an upgrade/removal; reconcile packages explicitly before retrying.")
# Let dpkg finish before restoring the service-start policy.
run(command, timeout=None)
remaining = set(missing) - package_versions().keys()
if remaining:
raise DockerError("Required packages not installed: " + ", ".join(sorted(remaining)))
return missing
def docker_preflight(installed):
conflicts = sorted(set(DOCKER_CONFLICTS) & installed.keys())
if conflicts:
raise DockerError("Conflicting Docker packages require explicit reconciliation: " + ", ".join(conflicts))
path = Path("/etc/docker/daemon.json")
try:
config = json.loads(path.read_text()) if path.exists() else {}
except (OSError, ValueError) as exc:
raise DockerError("Cannot validate existing Docker daemon configuration.") from exc
if not isinstance(config, dict):
raise DockerError("Docker daemon configuration must be an object.")
for name, value in DOCKER_CONFIG.items():
if name in config and config[name] != value:
raise DockerError(f"Conflicting Docker {name}; reconcile explicitly before initialization.")
old_data = Path("/var/lib/docker")
if old_data.exists() and any(old_data.iterdir()):
raise DockerError("Existing /var/lib/docker data must be reconciled explicitly; no data migration is automatic.")
data = Path("/srv/docker")
if data.is_symlink() or data.parent.is_symlink():
raise DockerError("Refusing a redirected Docker data-root.")
if data.exists() and any(data.iterdir()) and config.get("data-root") != "/srv/docker":
raise DockerError("Unmanaged /srv/docker data requires explicit reconciliation.")
if "docker-ce" in installed:
active = run(["systemctl", "is-active", "--quiet", "docker"], check=False).returncode == 0
if active:
root = run(["docker", "info", "--format", "{{.DockerRootDir}}"] ).stdout.strip()
if root != "/srv/docker" or any(config.get(key) != value for key, value in DOCKER_CONFIG.items()):
raise DockerError("Running Docker has incompatible data-root/log policy; reconcile it before retrying.")
return {**config, **DOCKER_CONFIG}
DOCKER_KEY_SHA256 = "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570"
LOCAL_DOCKER = ["docker", "--host", "unix:///var/run/docker.sock"]
def _docker_source(arch):
key = Path("/etc/apt/keyrings/apex-docker.asc")
source = Path("/etc/apt/sources.list.d/apex-docker.sources")
content = ("Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: trixie\n"
f"Components: stable\nArchitectures: {arch}\nSigned-By: {key}\n")
for path in (key, source):
if any(part.is_symlink() for part in (path, *path.parents)):
raise DockerError(f"Refusing redirected APT path: {path}")
if source.exists() and source.read_text() != content:
raise DockerError(f"Reconcile existing Docker repository configuration: {source}")
if key.exists():
data = key.read_bytes()
else:
url = "https://download.docker.com/linux/debian/gpg"
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
data = response.read(1024 * 1024)
if hashlib.sha256(data).hexdigest() != DOCKER_KEY_SHA256:
raise DockerError("Docker repository signing key differs from the pinned release key.")
write_managed(key, data)
write_managed(source, content)
def _docker_capabilities(root, *, complete_plugins=False):
capabilities = {"docker_data_root": root}
plugins = {"docker-compose-plugin": ("compose", ["compose", "version", "--short"]),
"docker-buildx-plugin": ("buildx", ["buildx", "version"])}
missing = []
for package, (name, args) in plugins.items():
result = run([*LOCAL_DOCKER, *args], check=False)
if result.returncode or not result.stdout.strip():
missing.append(package)
else:
capabilities[name] = result.stdout.strip()
if missing:
if complete_plugins and "docker-ce" in package_versions():
with service_start_guard():
install_missing(missing)
return _docker_capabilities(root)
raise DockerError("Existing Docker needs functional plugins: " + ", ".join(missing))
if not root:
raise DockerError("Unable to verify the local Docker data-root.")
return capabilities
@expected_errors
def apply_docker_policy():
if Path("/usr/bin/pveversion").exists():
raise DockerError("Docker installation on a Proxmox host requires separate administration.")
release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines()
if "=" in line and not line.startswith("#"))
arch = run(["dpkg", "--print-architecture"]).stdout.strip()
if (release.get("ID", "").strip('"') != "debian" or
release.get("VERSION_ID", "").strip('"') != "13" or arch not in ("amd64", "arm64")):
raise DockerError("Docker installation supports stock Debian 13 amd64 or arm64.")
info = [*LOCAL_DOCKER, "info", "--format", "{{.DockerRootDir}}"]
if shutil.which("docker"):
current = run(info, check=False)
if current.returncode == 0:
return _docker_capabilities(current.stdout.strip(), complete_plugins=True)
installed = package_versions()
source = Path("/etc/apt/sources.list.d/apex-docker.sources")
if (not source.exists() and
(shutil.which("docker") or set(DOCKER_PACKAGES).intersection(installed))):
raise DockerError("Existing Docker installation is inactive; reconcile it before retrying.")
config = docker_preflight(installed)
with service_start_guard():
install_missing(("ca-certificates",))
_docker_source(arch)
write_managed("/etc/docker/daemon.json", json.dumps(config, indent=2) + "\n")
install_missing(DOCKER_PACKAGES)
ensure_service("containerd")
ensure_service("docker")
root = run(info).stdout.strip()
if root != DOCKER_CONFIG["data-root"]:
raise DockerError("Docker started with an unexpected data-root; inspect its configuration.")
return _docker_capabilities(root)
def ensure_service(name):
if run(["systemctl", "is-enabled", "--quiet", name], check=False).returncode:
run(["systemctl", "enable", name])
if run(["systemctl", "is-active", "--quiet", name], check=False).returncode:
run(["systemctl", "start", name])
if __name__ == "__main__":
from .state import ProvisionError, exclusive_lock
try:
if os.geteuid() != 0 or not Path("/etc/debian_version").is_file():
raise DockerError("Docker setup requires root on Debian.")
with exclusive_lock("/var/lib/apex-init/init.lock"):
print(json.dumps(apply_docker_policy(), sort_keys=True))
except (DockerError, OSError, ProvisionError) as exc:
raise SystemExit(f"Docker setup failed: {exc}") from None
@@ -1,14 +1,32 @@
"""Install an immutable watchdog bundle from the operator's checkout.""" """Install an immutable watchdog bundle from the operator's checkout."""
import hashlib
import io import io
import os import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import stat import stat
import sys
import tarfile import tarfile
import tempfile import tempfile
def _bundle_from_source(root):
root = Path(root).resolve()
files = [root / 'provisioning' / name for name in ('__init__.py', 'access.py', 'state.py')]
output = io.BytesIO()
with tarfile.open(fileobj=output, mode='w') as archive:
for path in files:
if (not path.is_file() or '__pycache__' in path.parts
or any(part.is_symlink() for part in (path, *path.parents))):
raise RuntimeError('Provisioning bundle contains an unsafe source path')
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(root).as_posix())
info.size, info.mode, info.mtime = len(data), 0o644, 0
info.uid = info.gid = 0
archive.addfile(info, io.BytesIO(data))
data = output.getvalue()
return data, hashlib.sha256(data).hexdigest()
def _trusted(path): def _trusted(path):
info = path.lstat() info = path.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o022: if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o022:
@@ -17,9 +35,6 @@ def _trusted(path):
def install(root=Path('/')): def install(root=Path('/')):
source = Path(__file__).resolve().parents[1] source = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(source))
from provisioning.bundle import _bundle_from_source
data, digest = _bundle_from_source(source) data, digest = _bundle_from_source(source)
# All ancestors from the filesystem root must be protected before publishing code. # All ancestors from the filesystem root must be protected before publishing code.
parent = root parent = root
+37
View File
@@ -0,0 +1,37 @@
"""Serialize host configuration changes across provisioning commands."""
import contextlib
import fcntl
import os
from pathlib import Path
import stat
class ProvisionError(RuntimeError):
pass
def safe_path(path):
path = Path(path).absolute()
for part in (*reversed(path.parents), path):
try:
info = part.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(info.st_mode):
raise ProvisionError('managed path contains a symbolic link')
return path
@contextlib.contextmanager
def exclusive_lock(path):
path = safe_path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ProvisionError('another initialization is running; inspect status before retrying') from None
yield
finally:
os.close(fd)
@@ -8,7 +8,7 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from provisioning import baseline from provisioning import docker
class ManualDockerTests(unittest.TestCase): class ManualDockerTests(unittest.TestCase):
@@ -44,12 +44,12 @@ class ManualDockerTests(unittest.TestCase):
path = Path(path) path = Path(path)
return path if self.root in path.parents else self.root / str(path).lstrip("/") return path if self.root in path.parents else self.root / str(path).lstrip("/")
with (patch.object(baseline, "Path", side_effect=target), with (patch.object(docker, "Path", side_effect=target),
patch.object(baseline, "run", side_effect=self.run_command), patch.object(docker, "run", side_effect=self.run_command),
patch.object(baseline, "package_versions", return_value=self.versions), patch.object(docker, "package_versions", return_value=self.versions),
patch.object(baseline.shutil, "which", return_value="/usr/bin/docker" if self.running else None), patch.object(docker.shutil, "which", return_value="/usr/bin/docker" if self.running else None),
patch.object(baseline, "install_missing", side_effect=self.installations.append)): patch.object(docker, "install_missing", side_effect=self.installations.append)):
return baseline.apply_docker_policy() return docker.apply_docker_policy()
def test_working_engine_keeps_local_data_root_without_writes(self): def test_working_engine_keeps_local_data_root_without_writes(self):
self.running = True self.running = True
@@ -62,19 +62,19 @@ class ManualDockerTests(unittest.TestCase):
binary = self.root / "usr/bin/pveversion" binary = self.root / "usr/bin/pveversion"
binary.parent.mkdir(parents=True) binary.parent.mkdir(parents=True)
binary.touch() binary.touch()
with self.assertRaisesRegex(baseline.BaselineError, "Proxmox"): with self.assertRaisesRegex(docker.DockerError, "Proxmox"):
self.apply() self.apply()
self.assertEqual(self.commands, []) self.assertEqual(self.commands, [])
def test_unsupported_architecture_refused(self): def test_unsupported_architecture_refused(self):
self.arch = "i386" self.arch = "i386"
with self.assertRaisesRegex(baseline.BaselineError, "amd64|arm64"): with self.assertRaisesRegex(docker.DockerError, "amd64|arm64"):
self.apply() self.apply()
self.assertEqual(self.installations, []) self.assertEqual(self.installations, [])
def test_existing_runtime_packages_not_replaced(self): def test_existing_runtime_packages_not_replaced(self):
self.versions = {"containerd": "1.7"} self.versions = {"containerd": "1.7"}
with self.assertRaisesRegex(baseline.BaselineError, "containerd"): with self.assertRaisesRegex(docker.DockerError, "containerd"):
self.apply() self.apply()
self.assertEqual(self.installations, []) self.assertEqual(self.installations, [])
@@ -86,10 +86,10 @@ class ManualDockerTests(unittest.TestCase):
if service == "docker": if service == "docker":
self.running = True self.running = True
with (patch.object(baseline, "service_start_guard", return_value=nullcontext()), with (patch.object(docker, "service_start_guard", return_value=nullcontext()),
patch.object(baseline.urllib.request, "urlopen", return_value=io.BytesIO(key)), patch.object(docker.urllib.request, "urlopen", return_value=io.BytesIO(key)),
patch.object(baseline, "DOCKER_KEY_SHA256", hashlib.sha256(key).hexdigest()), patch.object(docker, "DOCKER_KEY_SHA256", hashlib.sha256(key).hexdigest()),
patch.object(baseline, "ensure_service", side_effect=start)): patch.object(docker, "ensure_service", side_effect=start)):
self.assertEqual(self.apply()["docker_data_root"], "/srv/docker") self.assertEqual(self.apply()["docker_data_root"], "/srv/docker")
source = (self.root / "etc/apt/sources.list.d/apex-docker.sources").read_text() source = (self.root / "etc/apt/sources.list.d/apex-docker.sources").read_text()
self.assertIn("Architectures: arm64", source) self.assertIn("Architectures: arm64", source)
@@ -102,9 +102,9 @@ class ManualDockerTests(unittest.TestCase):
self.assertNotIn("ufw", self.installations[-1]) self.assertNotIn("ufw", self.installations[-1])
def test_untrusted_signing_key_cannot_publish_apt_source_or_install_docker(self): def test_untrusted_signing_key_cannot_publish_apt_source_or_install_docker(self):
with (patch.object(baseline, "service_start_guard", return_value=nullcontext()), with (patch.object(docker, "service_start_guard", return_value=nullcontext()),
patch.object(baseline.urllib.request, "urlopen", return_value=io.BytesIO(b"wrong key"))): patch.object(docker.urllib.request, "urlopen", return_value=io.BytesIO(b"wrong key"))):
with self.assertRaisesRegex(baseline.BaselineError, "signing key"): with self.assertRaisesRegex(docker.DockerError, "signing key"):
self.apply() self.apply()
self.assertFalse((self.root / "etc/apt/sources.list.d/apex-docker.sources").exists()) self.assertFalse((self.root / "etc/apt/sources.list.d/apex-docker.sources").exists())
self.assertFalse((self.root / "etc/docker/daemon.json").exists()) self.assertFalse((self.root / "etc/docker/daemon.json").exists())
@@ -114,7 +114,7 @@ class ManualDockerTests(unittest.TestCase):
self.running = True self.running = True
self.missing_buildx = True self.missing_buildx = True
self.versions = {"docker-ce": "1"} self.versions = {"docker-ce": "1"}
with patch.object(baseline, "service_start_guard", return_value=nullcontext()): with patch.object(docker, "service_start_guard", return_value=nullcontext()):
self.assertEqual(self.apply()["docker_data_root"], "/custom/data") self.assertEqual(self.apply()["docker_data_root"], "/custom/data")
self.assertEqual(self.installations, [["docker-buildx-plugin"]]) self.assertEqual(self.installations, [["docker-buildx-plugin"]])
self.assertFalse((self.root / "etc/docker").exists()) self.assertFalse((self.root / "etc/docker").exists())
+138
View File
@@ -0,0 +1,138 @@
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from provisioning import docker
class Host:
def __init__(self, root):
self.root = root
self.commands = []
self.installed = {}
self.partial = {}
self.plan = ''
self.path('/usr/sbin').mkdir(parents=True)
def path(self, path):
path = Path(path)
if str(path).startswith(str(self.root)):
return path
return self.root / str(path).lstrip('/')
def command(self, args, **kwargs):
self.commands.append(args)
code, output = 0, ''
if args[:2] == ['dpkg', '--print-architecture']:
output = 'amd64\n'
elif args[0] == 'dpkg-query':
output = ''.join(f'{name}\tinstall ok installed\t{version}\n' for name, version in self.installed.items())
output += ''.join(f'{name}\t{status}\t1.0\n' for name, status in self.partial.items())
elif args[0] == 'apt-get' and 'install' in args:
if '--simulate' in args:
output = self.plan
else:
for name in args[args.index('--no-remove') + 1:]:
self.installed[name] = '1.0'
self.partial.pop(name, None)
return subprocess.CompletedProcess(args, code, output, '')
class DockerPackageTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.host = Host(Path(self.temp.name).resolve())
for item in (
patch.object(docker, 'Path', self.host.path),
patch.object(docker, 'POLICY', self.host.path('/usr/sbin/policy-rc.d')),
patch.object(docker, 'POLICY_STATE', self.host.path('/usr/sbin/.apex-policy-rc.d')),
patch.object(docker.subprocess, 'run', side_effect=self.host.command),
):
item.start()
self.addCleanup(item.stop)
def test_package_database_drives_installation_and_repeat_does_not_upgrade(self):
self.host.installed['curl'] = '7.0'
self.host.partial['rsyslog'] = 'deinstall ok config-files'
self.assertEqual(docker.install_missing(['curl', 'rsyslog']), ['rsyslog'])
self.assertEqual(self.host.installed['curl'], '7.0')
self.assertEqual(self.host.installed['rsyslog'], '1.0')
count = len(self.host.commands)
self.assertEqual(docker.install_missing(['curl', 'rsyslog']), [])
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands[count:]))
def test_dependency_upgrade_is_rejected_before_install(self):
self.host.plan = 'Inst libc6 [2.0] (3.0 Debian:13)\n'
with self.assertRaisesRegex(docker.DockerError, 'upgrade/removal'):
docker.install_missing(['git'])
self.assertNotIn('git', self.host.installed)
def test_held_installed_packages_are_preserved(self):
self.host.partial['git'] = 'hold ok installed'
self.assertEqual(docker.install_missing(['git']), [])
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands))
def test_conflicting_docker_config_is_rejected_without_host_mutation(self):
path = self.host.path('/etc/docker/daemon.json')
path.parent.mkdir(parents=True)
path.write_text('{"data-root": "/data/docker"}')
before = sorted(str(path.relative_to(self.host.root)) for path in self.host.root.rglob('*'))
with self.assertRaisesRegex(docker.DockerError, 'Conflicting Docker'):
docker.docker_preflight(self.host.installed)
self.assertEqual(before, sorted(str(path.relative_to(self.host.root)) for path in self.host.root.rglob('*')))
self.assertFalse(any(command[0] in ('apt-get', 'systemctl') for command in self.host.commands))
def test_existing_docker_data_is_preserved(self):
data = self.host.path('/var/lib/docker/volumes/user-data')
data.parent.mkdir(parents=True)
data.write_text('valuable')
with self.assertRaisesRegex(docker.DockerError, 'Existing /var/lib/docker'):
docker.docker_preflight(self.host.installed)
self.assertEqual(data.read_text(), 'valuable')
def test_conflicting_distribution_docker_package_is_never_removed(self):
self.host.installed['docker.io'] = '26.1.5'
with self.assertRaisesRegex(docker.DockerError, 'Conflicting Docker packages'):
docker.docker_preflight(self.host.installed)
self.assertEqual(self.host.installed['docker.io'], '26.1.5')
self.assertFalse(any(command[0] == 'apt-get' for command in self.host.commands))
def test_unmanaged_destination_data_is_not_adopted(self):
data = self.host.path('/srv/docker/private-data')
data.parent.mkdir(parents=True)
data.write_text('preserve me')
with self.assertRaisesRegex(docker.DockerError, 'Unmanaged /srv/docker'):
docker.docker_preflight(self.host.installed)
self.assertEqual(data.read_text(), 'preserve me')
def test_service_policy_symlink_is_restored_after_failure(self):
policy = self.host.path('/usr/sbin/policy-rc.d')
original = self.host.path('/usr/sbin/provider-policy')
original.write_text('provider policy')
policy.symlink_to(original)
with self.assertRaisesRegex(RuntimeError, 'boom'):
with docker.service_start_guard():
self.assertFalse(policy.is_symlink())
raise RuntimeError('boom')
self.assertTrue(policy.is_symlink())
self.assertEqual(original.read_text(), 'provider policy')
def test_interrupted_guard_is_recovered_before_next_attempt(self):
state = self.host.path('/usr/sbin/.apex-policy-rc.d')
state.mkdir()
(state / 'original').write_text('original policy')
(state / 'ready').touch()
self.host.path('/usr/sbin/policy-rc.d').write_text('#!/bin/sh\nexit 101\n')
with docker.service_start_guard():
pass
self.assertEqual(self.host.path('/usr/sbin/policy-rc.d').read_text(), 'original policy')
def test_external_policy_change_is_not_overwritten(self):
policy = self.host.path('/usr/sbin/policy-rc.d')
with self.assertRaisesRegex(docker.DockerError, 'changed outside APEX'):
with docker.service_start_guard():
policy.write_text('external policy')
self.assertEqual(policy.read_text(), 'external policy')
@@ -7,9 +7,7 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from provisioning.access import AccessError, Host, Transition, atomic, main from provisioning.access import AccessError, Host, Transition, atomic
from provision_fakes import SecurityBoundary
TOKEN = 'a' * 32 TOKEN = 'a' * 32
@@ -18,15 +16,12 @@ class FakeHost(Host):
"""Replace system service effects; keep snapshots and transition files real.""" """Replace system service effects; keep snapshots and transition files real."""
def __init__(self, root): def __init__(self, root):
super().__init__(root) super().__init__(root)
self.security = SecurityBoundary()
self.boot = 'first-boot' self.boot = 'first-boot'
self.armed = False self.armed = False
self.restored = 0 self.restored = 0
self.ports = [22] self.ports = [22]
self.firewall = False
self.bad_sudo = False self.bad_sudo = False
self.fail_final = False self.fail_final = False
self.existing_final = False
atomic(self.path('/etc/ssh/sshd_config'), b'Port 22\n') atomic(self.path('/etc/ssh/sshd_config'), b'Port 22\n')
def boot_id(self): def boot_id(self):
@@ -42,19 +37,10 @@ class FakeHost(Host):
def disarm(self): def disarm(self):
self.armed = False self.armed = False
def firewall_needed(self):
return True
def open_firewall(self):
self.firewall = True
def policy(self, ports, final, address): def policy(self, ports, final, address):
if final and self.fail_final: if final and self.fail_final:
raise AccessError('policy failed') raise AccessError('policy failed')
def current_final(self, address):
return self.existing_final
def verify_active(self, ports, final, address): def verify_active(self, ports, final, address):
self.policy(ports, final, address) self.policy(ports, final, address)
if self.ports != sorted(set(ports)): if self.ports != sorted(set(ports)):
@@ -69,7 +55,6 @@ class FakeHost(Host):
def restore(self, state): def restore(self, state):
self.restored += 1 self.restored += 1
self.ports = [state['original_port']] self.ports = [state['original_port']]
self.firewall = False
path = self.path('/etc/systemd/system/ssh.service.d/apex-init.conf') path = self.path('/etc/systemd/system/ssh.service.d/apex-init.conf')
if state['previous'] is None: if state['previous'] is None:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
@@ -84,7 +69,7 @@ class AccessTests(unittest.TestCase):
self.addCleanup(self.temp.cleanup) self.addCleanup(self.temp.cleanup)
self.host = FakeHost(Path(self.temp.name)) self.host = FakeHost(Path(self.temp.name))
self.clock = 1000 self.clock = 1000
self.transition = Transition(self.host, now=lambda: self.clock, security=self.host.security) self.transition = self.manual('debian')
def begin(self): def begin(self):
return self.transition.execute('begin', TOKEN, 22, '192.0.2.10') return self.transition.execute('begin', TOKEN, 22, '192.0.2.10')
@@ -110,17 +95,8 @@ class AccessTests(unittest.TestCase):
self.clock += 301 self.clock += 301
self.transition.execute('tick') self.transition.execute('tick')
self.assertEqual(self.host.ports, [22]) self.assertEqual(self.host.ports, [22])
self.assertFalse(self.host.firewall)
self.assertEqual(self.transition.load()['status'], 'rolled_back') self.assertEqual(self.transition.load()['status'], 'rolled_back')
def test_reboot_recovers_persisted_transition_with_fresh_runner(self):
self.begin()
self.host.boot = 'second-boot'
fresh = Transition(self.host, now=lambda: 0, security=self.host.security)
fresh.execute('tick')
self.assertEqual(self.host.ports, [22])
self.assertEqual(self.host.restored, 1)
def test_watchdog_restores_access_while_enrollment_lock_is_held(self): def test_watchdog_restores_access_while_enrollment_lock_is_held(self):
from provisioning.state import exclusive_lock from provisioning.state import exclusive_lock
self.begin() self.begin()
@@ -163,35 +139,6 @@ class AccessTests(unittest.TestCase):
self.transition.execute('commit', 'b' * 32) self.transition.execute('commit', 'b' * 32)
self.assertTrue(self.host.armed) self.assertTrue(self.host.armed)
def test_committed_retry_does_not_reopen_root_listener(self):
self.begin()
self.transition.execute('finalize', TOKEN)
self.transition.execute('commit', TOKEN)
result = self.transition.execute('begin', 'b' * 32, 2222, '192.0.2.10')
self.assertEqual(result['status'], 'committed')
self.assertEqual(self.host.ports, [2222])
self.assertFalse(self.host.armed)
def test_committed_retry_rejects_live_listener_drift(self):
self.begin()
self.transition.execute('finalize', TOKEN)
self.transition.execute('commit', TOKEN)
self.host.ports = [22, 2222]
with self.assertRaises(AccessError):
self.transition.execute('begin', 'b' * 32, 2222, '192.0.2.10')
def test_preexisting_final_policy_is_preserved_during_security_activation(self):
self.host.existing_final = True
self.host.ports = [2222]
result = self.transition.execute('begin', TOKEN, 2222, '192.0.2.10')
self.assertEqual(result['status'], 'transition')
self.assertTrue(self.host.armed)
self.assertFalse(self.host.firewall)
self.transition.execute('finalize', TOKEN)
self.transition.execute('commit', TOKEN)
self.assertEqual(self.transition.load()['status'], 'committed')
self.assertFalse(self.host.path('/etc/systemd/system/ssh.service.d/apex-init.conf').exists())
def manual(self, profile): def manual(self, profile):
self.host.live_arguments = lambda: (['/usr/sbin/sshd', '-D'], '755') self.host.live_arguments = lambda: (['/usr/sbin/sshd', '-D'], '755')
self.host.listener_ports = lambda pid: set(self.host.ports) self.host.listener_ports = lambda pid: set(self.host.ports)
@@ -203,27 +150,36 @@ class AccessTests(unittest.TestCase):
manual.execute('begin', TOKEN, 2200, '192.0.2.1') manual.execute('begin', TOKEN, 2200, '192.0.2.1')
self.assertEqual(self.host.ports, [22, 2200, 2222]) self.assertEqual(self.host.ports, [22, 2200, 2222])
boundary = manual.load()['authenticated_after_us'] boundary = manual.load()['authenticated_after_us']
with self.assertRaisesRegex(AccessError, 'workflow is pending'):
self.begin()
manual.execute('finalize', TOKEN) manual.execute('finalize', TOKEN)
self.assertEqual(self.host.ports, [2222]) self.assertEqual(self.host.ports, [2222])
self.assertGreaterEqual(manual.load()['authenticated_after_us'], boundary) self.assertGreaterEqual(manual.load()['authenticated_after_us'], boundary)
manual.execute('commit', TOKEN) manual.execute('commit', TOKEN)
self.assertIsNone(self.transition.load()) self.assertEqual(manual.load()['status'], 'committed')
self.assertNotIn('security', manual.load())
def test_manual_proxmox_installed_policy_never_restarts_and_preserves_image_state(self): def test_manual_proxmox_installed_policy_never_restarts_and_preserves_image_state(self):
self.host.ports = [22, 2222] self.host.ports = [22, 2222]
self.transition.save({'status': 'committed', 'token': 'b' * 32}) legacy = self.host.path('/var/lib/apex-init/access/state.json')
manual = self.manual('proxmox') manual = self.manual('proxmox')
with patch.object(self.host, 'install_policy', side_effect=AssertionError('restarted sshd')): with patch.object(self.host, 'install_policy', side_effect=AssertionError('restarted sshd')):
for round_number in range(2): for round_number, status in enumerate(('committed', 'existing')):
record = {'status': status}
atomic(legacy, json.dumps(record).encode())
token = str(round_number) * 32 token = str(round_number) * 32
manual.execute('begin', token, 22, '192.0.2.1') manual.execute('begin', token, 22, '192.0.2.1')
manual.execute('finalize', token) manual.execute('finalize', token)
manual.execute('commit', token) manual.execute('commit', token)
self.assertEqual(json.loads(legacy.read_text()), record)
self.assertEqual(self.host.ports, [22, 2222]) self.assertEqual(self.host.ports, [22, 2222])
self.assertEqual(self.transition.load(), {'status': 'committed', 'token': 'b' * 32})
def test_inflight_legacy_handover_retains_its_timer_and_state(self):
legacy = self.host.path('/var/lib/apex-init/access/state.json')
for record in ({'status': 'final'}, {'status': 'committed', 'disarm_pending': True}):
atomic(legacy, json.dumps(record).encode())
for operation in ('begin', 'tick'):
with self.assertRaisesRegex(AccessError, 'workflow is pending'):
self.transition.execute(operation, TOKEN, 22, '192.0.2.1')
self.assertEqual(json.loads(legacy.read_text()), record)
self.assertIsNone(self.transition.load())
def test_plain_watchdog_restores_manual_override_on_reboot(self): def test_plain_watchdog_restores_manual_override_on_reboot(self):
override = self.host.path('/etc/systemd/system/ssh.service.d/apex-init.conf') override = self.host.path('/etc/systemd/system/ssh.service.d/apex-init.conf')
@@ -345,22 +301,6 @@ class LivePolicyTests(unittest.TestCase):
self.host.verify_active([2222], True, '192.0.2.1') self.host.verify_active([2222], True, '192.0.2.1')
self.effective = original self.effective = original
def test_bootstrap_prerequisites_do_not_require_adam_or_sudo_or_write_state(self):
self.arguments = self.running_arguments = ['/usr/sbin/sshd', '-D']
self.cmdline()
def unseeded(args, **kwargs):
if args[0] in ('visudo', 'runuser', 'sudo'):
raise AssertionError('administrator tools do not exist before baseline')
return self.runner(args, **kwargs)
self.host.runner = unseeded
before = sorted(self.root.rglob('*'))
with patch('provisioning.access.Host', return_value=self.host), \
patch('provisioning.access.os.geteuid', return_value=0):
self.assertEqual(main(['preflight']), 0)
self.assertEqual(sorted(self.root.rglob('*')), before)
self.assertIn(['/usr/sbin/sshd', '-t'], self.calls)
self.assertTrue(any('MainPID' in args for args in self.calls))
def test_bootstrap_prerequisites_reject_socket_and_live_invocation_drift(self): def test_bootstrap_prerequisites_reject_socket_and_live_invocation_drift(self):
self.arguments = self.running_arguments = ['/usr/sbin/sshd', '-D'] self.arguments = self.running_arguments = ['/usr/sbin/sshd', '-D']
self.cmdline() self.cmdline()
+3 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Run the core and standalone provisioning test suites (stdlib unittest — no pytest). # Run CLI, bootstrap, and host provisioning checks with the project modules on the import path.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
export PYTHONPATH="$PWD/scripts${PYTHONPATH:+:$PYTHONPATH}"
python3 -m unittest discover -s tests -p 'test_*.py' -v python3 -m unittest discover -s tests -p 'test_*.py' -v
PYTHONPATH=ci/scripts/init-node${PYTHONPATH:+:$PYTHONPATH} python3 -m unittest discover -s ci/scripts/init-node/tests -p 'test_*.py' -v python3 -m unittest discover -s tests/provisioning -p 'test_*.py' -v
+1 -1
View File
@@ -60,7 +60,7 @@ class HostSetup(unittest.TestCase):
def test_installed_watchdog_survives_checkout_and_dispatches_cli_arguments(self): def test_installed_watchdog_survives_checkout_and_dispatches_cli_arguments(self):
hardening = action("config/hardening") hardening = action("config/hardening")
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
installer = overlay.load_module(str(root / "ci/scripts/init-node/provisioning/manual_bundle.py"), installer = overlay.load_module(str(root / "scripts/provisioning/manual_bundle.py"),
"test_manual_bundle") "test_manual_bundle")
with tempfile.TemporaryDirectory() as temp, patch.object(sys, "path", list(sys.path)): with tempfile.TemporaryDirectory() as temp, patch.object(sys, "path", list(sys.path)):
destination = installer.install(Path(temp).resolve()) destination = installer.install(Path(temp).resolve())
+2 -2
View File
@@ -26,7 +26,7 @@ class ToolingTests(unittest.TestCase):
sources = [] sources = []
(root / 'scripts').mkdir() (root / 'scripts').mkdir()
(root / 'scripts/bootstrap-node').write_text('print(1)\n') (root / 'scripts/bootstrap-node').write_text('print(1)\n')
for name in ('engine', 'actions', 'tests', 'ci/scripts', 'scripts'): for name in ('actions', 'tests', 'scripts'):
source = root / name / 'nested' / 'case.py' source = root / name / 'nested' / 'case.py'
source.parent.mkdir(parents=True) source.parent.mkdir(parents=True)
source.write_text('value = 1\n') source.write_text('value = 1\n')
@@ -49,7 +49,7 @@ class ToolingTests(unittest.TestCase):
def test_suite_runner_discovers_both_suites_and_propagates_failures(self): def test_suite_runner_discovers_both_suites_and_propagates_failures(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
suites = (root / 'tests', root / 'ci/scripts/init-node/tests') suites = (root / 'tests', root / 'tests/provisioning')
for suite in suites: for suite in suites:
suite.mkdir(parents=True) suite.mkdir(parents=True)
shutil.copyfile(ROOT / 'tests/run.sh', suites[0] / 'run.sh') shutil.copyfile(ROOT / 'tests/run.sh', suites[0] / 'run.sh')