Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f55cfbc7a1 | |||
| 0222b1677d | |||
| dda2fd3b0e | |||
| 3b3e129663 | |||
| 8acff754e8 | |||
| 4cac290502 | |||
| ec41091721 | |||
| 26f87ebc20 | |||
| a1a8eab41a | |||
| b2ea6b1f7a | |||
| 592ff6a7ee | |||
| 0c0f952f7f | |||
| 2a18276884 | |||
| 2bdbc8af5a | |||
| 87bd5d2162 | |||
| 836d409e3b | |||
| e17b740415 | |||
| 0d151d36b9 | |||
| 4fbcf3d35a | |||
| 6d4ec8c9ac | |||
| bf91b3c6d2 | |||
| 2d3be0955b | |||
| a5343760e1 | |||
| 209480dd43 | |||
| 5fa1fef6c8 | |||
| e6dd1b8489 | |||
| dd6961265d | |||
| 40ba483d35 | |||
| 192ebbd47f | |||
| 050439ee14 | |||
| 0b12ded1cf | |||
| 0296cf9c51 | |||
| 604b31b937 | |||
| 89e572faf8 | |||
| 78f5304214 | |||
| 899fd4a79a | |||
| 5ab3532833 | |||
| ea8337b944 | |||
| 5aec9e629c | |||
| 80ffb6b62f | |||
| fda70ecf9e | |||
| b9fb3427e0 | |||
| e0a9037be3 | |||
| 21d734c662 | |||
| 5488b4fd95 | |||
| 0208d27805 | |||
| 88b190ac8d | |||
| f58d7a6f17 | |||
| 3bdae4bcc5 | |||
| 8873b2c70c | |||
| 59df98504c | |||
| 224248888f | |||
| 06a102bf98 | |||
| ed5df26140 | |||
| 80709d48a7 | |||
| f797b97282 | |||
| 8dfe137745 | |||
| 28edce0fc6 | |||
| 0f0a534f25 | |||
| f557e21e09 | |||
| d2d7132955 | |||
| 124b5b0e3b | |||
| c1ed760358 | |||
| ee5b8d5a6c | |||
| 3925cb48c1 | |||
| 354e3205d3 | |||
| 2cc3d0db40 | |||
| 59794bc8f2 | |||
| 6c72dc3ac6 | |||
| f70c5e53f9 | |||
| b3ae42215b | |||
| d326b25203 | |||
| aabaa3a0d4 | |||
| 005718c38a | |||
| 2c7432f2ec | |||
| 545277add2 |
40
.dockerignore
Normal file
40
.dockerignore
Normal file
@ -0,0 +1,40 @@
|
||||
# Build artifacts
|
||||
target/
|
||||
*.deb
|
||||
package-build/
|
||||
|
||||
# Frontend build output (rebuilt in Docker)
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
|
||||
# Agent Zero project data
|
||||
.a0proj/
|
||||
|
||||
# Python
|
||||
venv/
|
||||
__pycache__/
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!README.md
|
||||
LICENSE
|
||||
8
.env.example
Normal file
8
.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# Linux Patch Manager — Docker Environment Variables
|
||||
# Copy this file to .env and edit the values before running docker compose up.
|
||||
|
||||
# Required: PostgreSQL password for the patch_manager user
|
||||
DB_PASSWORD=changeme-to-a-strong-password
|
||||
|
||||
# Optional: Docker image tag (defaults to 'latest' if not set)
|
||||
TAG=latest
|
||||
14
.gitea/workflows/ci.yml
Normal file → Executable file
14
.gitea/workflows/ci.yml
Normal file → Executable file
@ -27,7 +27,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -61,7 +61,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -126,7 +126,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -207,7 +207,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -261,7 +261,7 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_URL: https://gitea-lxc.moon-dragon.us
|
||||
GITEA_REPO: echo/linux_patch_manager
|
||||
GITEA_REPO: git-echo/linux_patch_manager
|
||||
run: |
|
||||
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/')
|
||||
DEB=$(ls linux-patch-manager_*.deb)
|
||||
|
||||
176
.github/workflows/ci.yml
vendored
Normal file
176
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,176 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
rust-format:
|
||||
name: Rust Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --check --all
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev
|
||||
- run: cargo clippy --all-targets --all-features
|
||||
|
||||
rust-test:
|
||||
name: Rust Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev
|
||||
- run: cargo test --workspace --all-features --lib --bins --tests
|
||||
|
||||
security-audit:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo install cargo-audit && cargo audit
|
||||
|
||||
gitleaks:
|
||||
name: Secret scanning
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gitleaks
|
||||
uses: gitleaks/gitleaks-action@v3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Install & Lint
|
||||
run: cd frontend && npm ci && npx eslint src/ --ext .ts,.tsx --max-warnings 0 && npx tsc --noEmit
|
||||
|
||||
build-and-release:
|
||||
name: Build & Release
|
||||
needs: [rust-format, clippy, rust-test, security-audit, frontend-lint]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc
|
||||
df -h
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev dpkg-dev
|
||||
- name: Build Rust release
|
||||
run: cargo build --release
|
||||
- name: Strip binaries
|
||||
run: strip target/release/pm-web target/release/pm-worker
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Build frontend
|
||||
run: cd frontend && npm ci && npm run build
|
||||
- name: Build .deb package
|
||||
run: chmod +x scripts/build-package.sh && scripts/build-package.sh
|
||||
- name: Generate release notes
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
id: release_notes
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
NOTES=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges)
|
||||
else
|
||||
NOTES=$(git log --pretty=format:"- %s (%h)" --no-merges)
|
||||
fi
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
- name: Upload to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
body: ${{ steps.release_notes.outputs.notes }}
|
||||
files: linux-patch-manager_*.deb
|
||||
|
||||
docker:
|
||||
name: Docker Build & Push
|
||||
needs: [rust-format, clippy, rust-test, security-audit, frontend-lint]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/draco-lunaris/linux-patch-manager
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -27,3 +27,13 @@ frontend/dist
|
||||
# Package build artifacts
|
||||
*.deb
|
||||
package-build/
|
||||
|
||||
# Docker environment
|
||||
.env
|
||||
|
||||
# Private key material - NEVER commit
|
||||
*.key
|
||||
*.key.pem
|
||||
crates/pm-agent-client/certs/*.crt
|
||||
crates/pm-agent-client/certs/*.key
|
||||
crates/pm-agent-client/certs/*.pem
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
| Version | 0.0.3 |
|
||||
| Status | Draft |
|
||||
| Standard | Aligned with IEEE 1016-2009 |
|
||||
| Owner | Echo (for Kelly / Moon Dragon) |
|
||||
| Owner | Draco Lunaris |
|
||||
| Last Updated | 2026-04-23 |
|
||||
| Related Docs | `SPEC.md`, `REQUIREMENTS.md`, `README.md` |
|
||||
|
||||
|
||||
94
CONTRIBUTING.md
Normal file
94
CONTRIBUTING.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Contributing to Linux-Patch-Manager
|
||||
|
||||
Thank you for your interest in contributing to Linux-Patch-Manager! We appreciate every contribution — from bug reports and documentation improvements to new features and security fixes.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) code of conduct. By participating, you are expected to uphold this standard. Please report unacceptable behavior to the maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork** the repository
|
||||
2. Create a **feature branch** from `main`:
|
||||
```bash
|
||||
git checkout -b feat/my-feature
|
||||
```
|
||||
3. Make your changes
|
||||
4. Ensure all CI checks pass:
|
||||
```bash
|
||||
# Rust backend
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
|
||||
# TypeScript/React frontend
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
5. **Commit** using conventional commit format (see below)
|
||||
6. Open a **Pull Request** against `main`
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Rust toolchain** (stable) — [rustup](https://rustup.rs/)
|
||||
- **Node.js** 20+ (for the frontend) — [nvm](https://github.com/nvm-sh/nvm) recommended
|
||||
- **System dependencies**:
|
||||
```bash
|
||||
sudo apt-get install build-essential libsystemd-dev pkg-config libssl-dev
|
||||
```
|
||||
|
||||
### Build & Run
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cargo build
|
||||
cargo test
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
## Commit Messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
| Prefix | Usage |
|
||||
|----------|------------------------|
|
||||
| `feat:` | New feature |
|
||||
| `fix:` | Bug fix |
|
||||
| `docs:` | Documentation changes |
|
||||
| `chore:` | Maintenance tasks |
|
||||
| `refactor:` | Code refactoring |
|
||||
| `test:` | Adding or updating tests |
|
||||
| `ci:` | CI configuration changes |
|
||||
|
||||
Example:
|
||||
```
|
||||
feat: add patch scheduling to manager dashboard
|
||||
```
|
||||
|
||||
## Pull Request Requirements
|
||||
|
||||
- All CI checks must pass (fmt, clippy, test, audit, build)
|
||||
- One feature or fix per PR — keep changes focused
|
||||
- Include a clear description of what changed and why
|
||||
- Update documentation if your change affects behavior
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use [GitHub Issues](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues) to report bugs, request features, or ask questions. Please include:
|
||||
|
||||
- Steps to reproduce (for bugs)
|
||||
- Expected vs. actual behavior
|
||||
- Relevant logs or error messages
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions are licensed under the [Apache License 2.0](LICENSE), the same license as this project.
|
||||
419
Cargo.lock
generated
Normal file → Executable file
419
Cargo.lock
generated
Normal file → Executable file
@ -139,6 +139,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@ -323,6 +333,21 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@ -460,6 +485,15 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@ -687,6 +721,19 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
@ -771,7 +818,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -885,7 +932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -970,6 +1017,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-kit"
|
||||
version = "0.14.3"
|
||||
@ -1046,6 +1099,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||
dependencies = [
|
||||
"nonempty",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freetype-sys"
|
||||
version = "0.20.1"
|
||||
@ -1155,6 +1218,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
@ -1242,6 +1311,49 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap 5.5.3",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"no-std-compat",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap 6.1.0",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.16.1",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.9.4",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@ -1275,7 +1387,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1445,6 +1568,19 @@ dependencies = [
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-timeout"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
@ -1924,6 +2060,18 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "migrate-secrets"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hex",
|
||||
"pm-core",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@ -1967,6 +2115,31 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockito"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"bytes",
|
||||
"colored",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.4",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"similar",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
@ -1994,6 +2167,12 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no-std-compat"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@ -2013,13 +2192,25 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2307,6 +2498,26 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@ -2381,7 +2592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-agent-client"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2398,7 +2609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-auth"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@ -2419,19 +2630,21 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"totp-rs",
|
||||
"tower",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pm-ca"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"hex",
|
||||
"pem",
|
||||
"pm-core",
|
||||
"proptest",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
@ -2448,7 +2661,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-core"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@ -2472,7 +2685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-reports"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2492,7 +2705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-web"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@ -2500,26 +2713,33 @@ dependencies = [
|
||||
"axum-server",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dashmap 6.1.0",
|
||||
"governor 0.6.3",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"ipnet",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"mockito",
|
||||
"pm-auth",
|
||||
"pm-ca",
|
||||
"pm-core",
|
||||
"pm-reports",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ulid",
|
||||
@ -2530,7 +2750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-worker"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2600,6 +2820,12 @@ dependencies = [
|
||||
"bstr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@ -2656,12 +2882,52 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.11.1",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@ -2803,6 +3069,24 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.13.2"
|
||||
@ -2846,6 +3130,18 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@ -2999,7 +3295,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3055,6 +3351,18 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@ -3273,6 +3581,12 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
@ -3307,7 +3621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3319,6 +3633,15 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@ -3612,7 +3935,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3912,6 +4235,35 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.7.1"
|
||||
@ -3936,9 +4288,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@ -3985,6 +4340,23 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tower_governor"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"forwarded-header-value",
|
||||
"governor 0.10.4",
|
||||
"http",
|
||||
"pin-project",
|
||||
"thiserror 2.0.18",
|
||||
"tonic",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@ -4142,6 +4514,12 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@ -4263,6 +4641,15 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@ -4477,7 +4864,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@ -8,10 +8,11 @@ members = [
|
||||
"crates/pm-auth",
|
||||
"crates/pm-ca",
|
||||
"crates/pm-reports",
|
||||
"crates/migrate-secrets",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.7"
|
||||
version = "1.1.11"
|
||||
edition = "2021"
|
||||
authors = ["Echo <echo@moon-dragon.us>"]
|
||||
license = "MIT"
|
||||
@ -78,8 +79,15 @@ base64 = { version = "0.22" }
|
||||
hex = { version = "0.4" }
|
||||
sha2 = { version = "0.10" }
|
||||
aes-gcm = { version = "0.10" }
|
||||
|
||||
# Testing
|
||||
proptest = { version = "1" }
|
||||
ipnet = { version = "2" }
|
||||
url = { version = "2" }
|
||||
|
||||
# Rate limiting
|
||||
tower_governor = { version = "0.8", features = ["tracing"] }
|
||||
governor = "0.6"
|
||||
|
||||
# Email
|
||||
lettre = { version = "0.11.22", features = ["tokio1-rustls-transport"] }
|
||||
|
||||
141
Dockerfile
Normal file
141
Dockerfile
Normal file
@ -0,0 +1,141 @@
|
||||
# =============================================================================
|
||||
# Linux Patch Manager — Multi-stage Docker Build
|
||||
# =============================================================================
|
||||
# Build: docker build -t linux-patch-manager .
|
||||
# Run: docker compose up
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Rust build (Ubuntu 24.04 + rustup)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ubuntu:24.04 AS rust-builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust via rustup (stable channel, provides 1.85+)
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Cache dependencies by building a dummy project first
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/pm-web/Cargo.toml crates/pm-web/Cargo.toml
|
||||
COPY crates/pm-worker/Cargo.toml crates/pm-worker/Cargo.toml
|
||||
COPY crates/pm-core/Cargo.toml crates/pm-core/Cargo.toml
|
||||
COPY crates/pm-agent-client/Cargo.toml crates/pm-agent-client/Cargo.toml
|
||||
COPY crates/pm-auth/Cargo.toml crates/pm-auth/Cargo.toml
|
||||
COPY crates/pm-ca/Cargo.toml crates/pm-ca/Cargo.toml
|
||||
COPY crates/pm-reports/Cargo.toml crates/pm-reports/Cargo.toml
|
||||
COPY crates/migrate-secrets/Cargo.toml crates/migrate-secrets/Cargo.toml
|
||||
RUN mkdir -p crates/pm-web/src crates/pm-worker/src crates/pm-core/src \
|
||||
crates/pm-agent-client/src crates/pm-auth/src crates/pm-ca/src \
|
||||
crates/pm-reports/src crates/migrate-secrets/src
|
||||
RUN echo 'fn main(){}' > crates/pm-web/src/main.rs \
|
||||
&& echo 'fn main(){}' > crates/pm-worker/src/main.rs \
|
||||
&& echo '' > crates/pm-core/src/lib.rs \
|
||||
&& echo '' > crates/pm-agent-client/src/lib.rs \
|
||||
&& echo '' > crates/pm-auth/src/lib.rs \
|
||||
&& echo '' > crates/pm-ca/src/lib.rs \
|
||||
&& echo '' > crates/pm-reports/src/lib.rs \
|
||||
&& echo 'fn main(){}' > crates/migrate-secrets/src/main.rs
|
||||
RUN cargo build --release 2>/dev/null || true
|
||||
|
||||
# Now build the real project
|
||||
COPY crates/ crates/
|
||||
COPY migrations/ migrations/
|
||||
RUN cargo build --release
|
||||
|
||||
# Verify binaries exist
|
||||
RUN ls -la target/release/pm-web target/release/pm-worker
|
||||
|
||||
# Strip debug symbols
|
||||
RUN strip target/release/pm-web target/release/pm-worker
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Frontend build (Ubuntu 24.04 + Node.js 20)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ubuntu:24.04 AS frontend-builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js 20 via NodeSource
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --production=false
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ubuntu:24.04 AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3t64 \
|
||||
libfontconfig1 \
|
||||
openssl \
|
||||
postgresql-client-16 \
|
||||
argon2 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create service user
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin \
|
||||
--comment "Linux Patch Manager service account" patch-manager
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
|
||||
/etc/patch-manager/jwt /etc/patch-manager/tls \
|
||||
/var/log/patch-manager /opt/patch-manager \
|
||||
/usr/share/patch-manager/frontend \
|
||||
/usr/share/patch-manager/migrations
|
||||
|
||||
# Copy binaries
|
||||
COPY --from=rust-builder /usr/src/app/target/release/pm-web /usr/local/bin/pm-web
|
||||
COPY --from=rust-builder /usr/src/app/target/release/pm-worker /usr/local/bin/pm-worker
|
||||
|
||||
# Copy frontend
|
||||
COPY --from=frontend-builder /usr/src/app/frontend/dist/ /usr/share/patch-manager/frontend/
|
||||
|
||||
# Copy migrations
|
||||
COPY migrations/ /usr/share/patch-manager/migrations/
|
||||
|
||||
# Copy entrypoint
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod 755 /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Copy config template
|
||||
COPY config/config.example.toml /usr/share/patch-manager/config.example.toml
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R patch-manager:patch-manager \
|
||||
/etc/patch-manager /var/log/patch-manager \
|
||||
/opt/patch-manager /usr/share/patch-manager
|
||||
|
||||
# Expose HTTPS port
|
||||
EXPOSE 443
|
||||
|
||||
# Volume for persistent data
|
||||
VOLUME ["/etc/patch-manager", "/var/log/patch-manager", "/opt/patch-manager"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
190
LICENSE
Normal file
190
LICENSE
Normal file
@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text file from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025-2026 Draco Lunaris
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@ -234,7 +234,9 @@ sudo -u postgres psql patch_manager < /usr/share/patch-manager/migrations/001_in
|
||||
|
||||
## License
|
||||
|
||||
Private — All rights reserved.
|
||||
This project is licensed under the [Apache License 2.0](LICENSE).
|
||||
|
||||
Copyright 2025-2026 Draco Lunaris
|
||||
|
||||
---
|
||||
|
||||
|
||||
68
SECURITY.md
Normal file
68
SECURITY.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the **latest release** is currently supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|----------|
|
||||
| Latest | ✅ |
|
||||
| Older | ❌ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Do not report security vulnerabilities through public GitHub Issues.**
|
||||
|
||||
Instead, use GitHub's private vulnerability reporting:
|
||||
|
||||
👉 [Report a vulnerability for Linux-Patch-Manager](https://github.com/Draco-Lunaris/Linux-Patch-Manager/security/advisories/new)
|
||||
|
||||
This allows us to coordinate a fix before public disclosure.
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Acknowledgment** within 48 hours
|
||||
- **Initial assessment** within 7 days
|
||||
- **Ongoing updates** on remediation progress
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow **coordinated disclosure**:
|
||||
|
||||
- We ask for **90 days** before public disclosure of a vulnerability
|
||||
- Security advisories are published via [GitHub Security Advisories](https://github.com/Draco-Lunaris/Linux-Patch-Manager/security/advisories)
|
||||
- We will work with you to determine an appropriate disclosure timeline when a fix requires more time
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
This project is a security tool — we hold ourselves to a high standard:
|
||||
|
||||
- **Signed commits**: All commits must be signed (SSH signing)
|
||||
- **CI enforcement**: All PRs require passing CI checks (fmt, clippy, test, audit, build)
|
||||
- **Dependency auditing**: `cargo audit` runs in CI to catch known vulnerabilities
|
||||
|
||||
## Enrollment PKI Design Decisions
|
||||
|
||||
### Server-Generated Keys vs CSR-Based Enrollment
|
||||
|
||||
Currently, the server generates the agent's private key during enrollment approval and
|
||||
transmits it over the mTLS-secured polling endpoint. This approach was chosen for
|
||||
initial implementation simplicity — the agent polls a single endpoint and receives a
|
||||
complete PKI bundle without an extra round-trip.
|
||||
|
||||
**Mitigations in place:**
|
||||
- The PKI bundle is stored in an in-memory cache with single-retrieval semantics —
|
||||
it can only be fetched once and is atomically removed on retrieval.
|
||||
- A 10-minute TTL ensures the bundle expires even if never retrieved.
|
||||
- The raw polling token is never logged; only its SHA-256 hash is stored.
|
||||
|
||||
**Future direction:** A CSR-based enrollment flow should replace server-generated keys.
|
||||
Under that model, the agent generates its own key pair locally and submits a Certificate
|
||||
Signing Request, eliminating the need for the server to ever hold or transmit the agent's
|
||||
private key. This significantly reduces the attack surface.
|
||||
|
||||
See: [Issue #9](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9)
|
||||
|
||||
## Credit
|
||||
|
||||
Contributors who responsibly report vulnerabilities will be credited in the corresponding GitHub Security Advisory.
|
||||
24
SPEC.md
24
SPEC.md
@ -88,7 +88,7 @@
|
||||
- Refresh tokens: opaque, server-side stored, 1-hour inactivity timeout, rotated on use, revocable
|
||||
- mTLS for all agent communication (TLS 1.3 only)
|
||||
- HTTPS for web UI (TLS 1.3 only)
|
||||
- **IP whitelist enforcement on all connection points**
|
||||
- **IP whitelist enforcement on all connection points** (with `security.trusted_proxies` to optionally honor `X-Forwarded-For` from a configured proxy; empty default = strict mode that uses the socket peer IP and ignores `X-Forwarded-For`; non-empty allowlist + unresolvable peer IP = fail-closed `403 forbidden_ip`) [Issue #3 / `tasks/ip-allowlist-spec.md`]
|
||||
- Role-based access control:
|
||||
- **Admin**: Full access to manage all aspects of Linux Patch Manager
|
||||
- **Operator**: Can add/remove clients, manage schedules and patches only for devices in their group memberships
|
||||
@ -274,3 +274,25 @@ All authenticated pages share a persistent sidebar navigation layout:
|
||||
**Integrity:** Hash-chained rows (tamper-evident). Periodic and on-demand verification.
|
||||
|
||||
**Retention:** 6 months
|
||||
|
||||
---
|
||||
|
||||
## Appendix: App-Level Secret Encryption (Issue #6, May 2026)
|
||||
|
||||
In addition to the hardware-level full-disk encryption described above, issue #6 (PR [TBD]) added **application-level AES-256-GCM encryption** for three specific sensitive fields that DB exfiltration would otherwise expose:
|
||||
|
||||
| Field | Table | Encryption key |
|
||||
|-------|-------|----------------|
|
||||
| `client_secret` | `oidc_config` | `/etc/patch-manager/keys/secret-encryption.key` |
|
||||
| `smtp_password` | `system_config` (key-value row) | same key |
|
||||
| `totp_secret` | `users` | same key |
|
||||
|
||||
**Why app-level on top of hardware-level?** Hardware-level encryption protects against disk theft; app-level encryption protects against DB exfiltration (SQL injection, backup theft, insider threat) where the attacker already has the running process's privileges. The two are complementary.
|
||||
|
||||
**Blast-radius isolation:** A separate per-install key is used for app secrets (`secret-encryption.key`), distinct from the health-check key (`health-check.key`). If the health-check key is ever compromised, app secrets remain protected.
|
||||
|
||||
**API surface:** No change. The `MASKED` placeholder behavior in API responses is preserved on top of the new DB encryption — defense in depth.
|
||||
|
||||
**Backup:** Both key files must be included in `/etc/patch-manager` backups. Without the key file, encrypted data is unrecoverable. See [docs/runbooks/key-management.md](docs/runbooks/key-management.md) for the full procedure.
|
||||
|
||||
**Key rotation:** Not yet supported (follow-up issue). If a key is compromised, generate a new key and re-provision affected secrets.
|
||||
|
||||
@ -49,7 +49,8 @@ health_check_poll_interval_secs = 300
|
||||
# Maximum concurrent mTLS agent calls (Tokio Semaphore)
|
||||
max_concurrent_agent_calls = 64
|
||||
|
||||
# Worker heartbeat write interval (seconds)
|
||||
# Worker heartbeat write interval (seconds). Default: 300 = 5 minutes
|
||||
heartbeat_interval_secs = 300
|
||||
|
||||
# WS relay HTTP polling fallback interval (seconds). When WebSocket connection to
|
||||
# an agent fails, the relay falls back to polling the agent's HTTP API at this
|
||||
@ -76,6 +77,20 @@ format = "json"
|
||||
# Example: ["10.0.0.0/8", "192.168.1.50"]
|
||||
ip_whitelist = []
|
||||
|
||||
# Trusted reverse proxies: list of CIDRs or individual IPs. When the immediate
|
||||
# TCP peer is in this list, `X-Forwarded-For` is honored (leftmost untrusted
|
||||
# hop is used for allowlist enforcement). When this list is EMPTY (the
|
||||
# default), `X-Forwarded-For` is IGNORED entirely and the socket peer IP is
|
||||
# used — the strict, fail-closed default.
|
||||
#
|
||||
# REQUIRED if you front pm-web with nginx/HAProxy/Cloudflare/etc.: add the
|
||||
# proxy's egress IP (or CIDR) here, otherwise the allowlist will evaluate
|
||||
# against the proxy's IP and deny legitimate traffic. If your proxy chain
|
||||
# has multiple hops, add each hop you control.
|
||||
# Example: ["10.0.0.0/8"] (corporate egress)
|
||||
# Example: ["172.16.0.0/12"] (internal load balancer)
|
||||
trusted_proxies = []
|
||||
|
||||
# Ed25519 JWT signing key (private key, PEM format)
|
||||
# Generate: openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem
|
||||
jwt_signing_key_path = "/etc/patch-manager/jwt/signing.pem"
|
||||
@ -91,7 +106,8 @@ jwt_access_ttl_secs = 900
|
||||
agent_client_cert_path = "/etc/patch-manager/certs/client.crt"
|
||||
agent_client_key_path = "/etc/patch-manager/certs/client.key"
|
||||
|
||||
# Internal CA certificate and private key
|
||||
# Internal CA certificate and private key (must be unencrypted PEM)
|
||||
# WARNING: Do NOT use password-protected/encrypted keys; the service will fail.
|
||||
# Private key has 0600 permissions; protected by hardware-host FDE
|
||||
ca_cert_path = "/etc/patch-manager/ca/ca.crt"
|
||||
ca_key_path = "/etc/patch-manager/ca/ca.key"
|
||||
@ -106,3 +122,35 @@ web_tls_key_path = "/etc/patch-manager/tls/web.key"
|
||||
# The backend sends tokens as query parameters to this URL.
|
||||
# Default: "http://localhost:5173/auth/sso/callback" (Vite dev server)
|
||||
sso_callback_url = "http://localhost:5173/auth/sso/callback"
|
||||
|
||||
# Allowlist of browser `Origin` values permitted to open the
|
||||
# `/api/v1/ws/jobs` WebSocket upgrade. Each entry is an exact
|
||||
# `scheme://host[:port]` string (no wildcards, no paths). When this list is
|
||||
# empty, the server derives a single-entry default from `sso_callback_url`
|
||||
# at startup (the host of the SSO callback). If the derivation also fails,
|
||||
# a warning is logged and the WS endpoint rejects all browser upgrades
|
||||
# (fail-closed).
|
||||
#
|
||||
# Add additional origins here if your SPA and API are served from different
|
||||
# hosts (e.g. SPA on https://app.example.com talking to API on
|
||||
# https://api.example.com). For typical single-host deployments the derived
|
||||
# default is correct and this line should be left commented out.
|
||||
#
|
||||
# allowed_origins = ["https://patch-manager.example.com"]
|
||||
|
||||
# ============================================================
|
||||
# Rate Limiting
|
||||
# ============================================================
|
||||
[rate_limit]
|
||||
# Enrollment endpoint: requests per minute per IP (default: 5)
|
||||
enrollment_rpm = 5
|
||||
# Enrollment burst allowance (default: 3)
|
||||
enrollment_burst = 3
|
||||
# Public auth endpoints: requests per minute per IP (default: 20)
|
||||
auth_rpm = 20
|
||||
# Auth burst allowance (default: 10)
|
||||
auth_burst = 10
|
||||
# Authenticated API: requests per minute per IP (default: 120)
|
||||
api_rpm = 120
|
||||
# API burst allowance (default: 30)
|
||||
api_burst = 30
|
||||
|
||||
19
crates/migrate-secrets/Cargo.toml
Normal file
19
crates/migrate-secrets/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "migrate-secrets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "migrate-secrets"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
pm-core = { path = "../pm-core" }
|
||||
tokio = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
hex = "0.4"
|
||||
193
crates/migrate-secrets/src/main.rs
Normal file
193
crates/migrate-secrets/src/main.rs
Normal file
@ -0,0 +1,193 @@
|
||||
//! One-shot migration helper for issue #6 (Secret Encryption at Rest).
|
||||
//!
|
||||
//! Reads plaintext secrets from the old columns/rows, encrypts them with the
|
||||
//! secret-encryption key, and writes to the new BYTEA columns. Verifies the
|
||||
//! round-trip (encrypt -> decrypt = original plaintext) before committing.
|
||||
//!
|
||||
//! USAGE:
|
||||
//! export DATABASE_URL="postgres://patch_manager:<password>@localhost/patch_manager"
|
||||
//! cargo run -p migrate-secrets
|
||||
//!
|
||||
//! This tool is safe to run multiple times (idempotent — re-encrypts and overwrites).
|
||||
//!
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.5 for the design.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pm_core::crypto;
|
||||
use sqlx::PgPool;
|
||||
use std::path::Path;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 1. Load secret-encryption key
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))
|
||||
.context("Failed to load secret-encryption key")?;
|
||||
eprintln!(
|
||||
"Loaded secret-encryption key from {}",
|
||||
crypto::SECRET_ENCRYPTION_KEY_PATH
|
||||
);
|
||||
|
||||
// 2. Connect to database
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").context("DATABASE_URL environment variable not set")?;
|
||||
let pool = PgPool::connect(&database_url)
|
||||
.await
|
||||
.context("Failed to connect to database")?;
|
||||
eprintln!("Connected to database");
|
||||
|
||||
let mut success_count = 0u32;
|
||||
let mut skip_count = 0u32;
|
||||
|
||||
// 3. Migrate OIDC client_secret
|
||||
if let Some(plaintext) = read_oidc_client_secret(&pool).await? {
|
||||
if plaintext.is_empty() {
|
||||
eprintln!("[skip] OIDC client_secret is empty");
|
||||
skip_count += 1;
|
||||
} else {
|
||||
write_oidc_client_secret(&pool, &plaintext, &key).await?;
|
||||
eprintln!("[ok] OIDC client_secret encrypted");
|
||||
success_count += 1;
|
||||
}
|
||||
} else {
|
||||
eprintln!("[skip] OIDC client_secret column not found (already migrated?)");
|
||||
skip_count += 1;
|
||||
}
|
||||
|
||||
// 4. Migrate SMTP password
|
||||
if let Some(plaintext) = read_smtp_password(&pool).await? {
|
||||
if plaintext.is_empty() {
|
||||
eprintln!("[skip] SMTP password is empty");
|
||||
skip_count += 1;
|
||||
} else {
|
||||
write_smtp_password(&pool, &plaintext, &key).await?;
|
||||
eprintln!("[ok] SMTP password encrypted");
|
||||
success_count += 1;
|
||||
}
|
||||
} else {
|
||||
eprintln!("[skip] SMTP password row not found (already migrated?)");
|
||||
skip_count += 1;
|
||||
}
|
||||
|
||||
// 5. Migrate TOTP secrets for all users
|
||||
let totp_count = migrate_totp_secrets(&pool, &key).await?;
|
||||
eprintln!("[ok] {} TOTP secret(s) encrypted", totp_count);
|
||||
success_count += totp_count;
|
||||
|
||||
eprintln!(
|
||||
"\nMigration complete: {} encrypted, {} skipped",
|
||||
success_count, skip_count
|
||||
);
|
||||
eprintln!(
|
||||
"Next step: apply migration 020_encrypt_secrets_at_rest.sql to drop the old columns."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_oidc_client_secret(pool: &PgPool) -> Result<Option<String>> {
|
||||
// Try to read the old column. If it doesn't exist, return None.
|
||||
let row: Result<Option<(Option<String>,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT client_secret FROM oidc_config WHERE id = 1")
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
match row {
|
||||
Ok(Some((secret,))) => Ok(secret),
|
||||
Ok(None) => Ok(None),
|
||||
Err(e) => {
|
||||
// Column not found = already migrated
|
||||
eprintln!(" (oidc_config.client_secret column check: {})", e);
|
||||
Ok(None)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_oidc_client_secret(pool: &PgPool, plaintext: &str, key: &[u8; 32]) -> Result<()> {
|
||||
let (ciphertext, nonce) = crypto::encrypt(plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!(
|
||||
"OIDC round-trip failed: expected {}, got {}",
|
||||
plaintext,
|
||||
recovered
|
||||
);
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE oidc_config SET client_secret_encrypted = $1, client_secret_nonce = $2 WHERE id = 1",
|
||||
)
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Failed to update oidc_config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_smtp_password(pool: &PgPool) -> Result<Option<String>> {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM system_config WHERE key = 'smtp_password'")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn write_smtp_password(pool: &PgPool, plaintext: &str, key: &[u8; 32]) -> Result<()> {
|
||||
let (ciphertext, nonce) = crypto::encrypt(plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!(
|
||||
"SMTP round-trip failed: expected {}, got {}",
|
||||
plaintext,
|
||||
recovered
|
||||
);
|
||||
}
|
||||
// Delete old row, write two new rows
|
||||
sqlx::query("DELETE FROM system_config WHERE key = 'smtp_password'")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_encrypted")
|
||||
.bind(hex_encode(&ciphertext))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_nonce")
|
||||
.bind(hex_encode(&nonce))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_totp_secrets(pool: &PgPool, key: &[u8; 32]) -> Result<u32> {
|
||||
// Read all users with totp_secret set
|
||||
let users: Vec<(uuid::Uuid, String)> =
|
||||
sqlx::query_as("SELECT id, totp_secret FROM users WHERE totp_secret IS NOT NULL")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("Failed to read users with totp_secret")?;
|
||||
|
||||
let count = users.len() as u32;
|
||||
for (user_id, plaintext) in users {
|
||||
let (ciphertext, nonce) = crypto::encrypt(&plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!("TOTP round-trip failed for user {}", user_id);
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE users SET totp_secret_encrypted = $1, totp_secret_nonce = $2 WHERE id = $3",
|
||||
)
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Failed to update user totp_secret")?;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Hex-encode bytes for storage in TEXT columns (system_config).
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
31
crates/pm-agent-client/certs/README.md
Normal file
31
crates/pm-agent-client/certs/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Agent Client Certificates
|
||||
|
||||
**⚠️ Private keys are NOT committed to version control.**
|
||||
|
||||
This directory holds mTLS certificates used by `pm-agent-client` for testing.
|
||||
The entire directory is excluded from git via `.gitignore`.
|
||||
|
||||
## Generating Test Certificates
|
||||
|
||||
Certificates are generated automatically on first run by the `pm-ca` service,
|
||||
or you can generate them manually for development:
|
||||
|
||||
```bash
|
||||
# Create certs directory if it doesn't exist
|
||||
mkdir -p crates/pm-agent-client/certs
|
||||
|
||||
# Generate using the pm-ca service (preferred)
|
||||
# Or copy from /etc/patch-manager/certs/ on a deployed host
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
Production certificates are managed by `pm-ca` at `/etc/patch-manager/certs/`.
|
||||
The `pm-agent-client` reads certificates from file paths configured in
|
||||
`config.toml` (`agent_client_cert_path`, `agent_client_key_path`, `ca_cert_path`).
|
||||
|
||||
## Security
|
||||
|
||||
- **Never commit private keys** (`*.key`, `*.key.pem`) to version control
|
||||
- The `gitleaks` CI check scans for accidentally committed secrets
|
||||
- See `SECURITY.md` and `docs/security-review.md` for full details
|
||||
@ -1,12 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
|
||||
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
|
||||
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
|
||||
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
|
||||
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
|
||||
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,12 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
|
||||
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
|
||||
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
|
||||
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
|
||||
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
|
||||
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,19 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAuTfH0/Z2HT49DfHT
|
||||
49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49Df
|
||||
HT49DfHT49DfHT49DfHT49DfHQIDAQABAkEArWvK64P1/x9P2dh0+PQ3x0+PQ3x0
|
||||
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ
|
||||
3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x
|
||||
0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0
|
||||
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
-----END PRIVATE KEY-----
|
||||
@ -6,12 +6,17 @@
|
||||
//! use pm_agent_client::client::AgentClient;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
|
||||
//! // Load certificates from files (never hardcode or include_bytes! private keys)
|
||||
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
|
||||
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
|
||||
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
|
||||
//!
|
||||
//! let client = AgentClient::new(
|
||||
//! "192.168.1.10",
|
||||
//! 12443,
|
||||
//! include_bytes!("../certs/client.crt"),
|
||||
//! include_bytes!("../certs/client.key"),
|
||||
//! include_bytes!("../certs/ca.crt"),
|
||||
//! &client_cert,
|
||||
//! &client_key,
|
||||
//! &ca_cert,
|
||||
//! )?;
|
||||
//!
|
||||
//! let health = client.health().await?;
|
||||
@ -105,7 +110,7 @@ impl AgentClient {
|
||||
.add_root_certificate(ca_cert)
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| AgentClientError::Request(e))?;
|
||||
.map_err(AgentClientError::Request)?;
|
||||
|
||||
let clean_ip = host_ip.split('/').next().unwrap_or(host_ip);
|
||||
let base_url = format!("https://{}:{}/api/v1", clean_ip, port);
|
||||
|
||||
0
crates/pm-agent-client/src/error.rs
Normal file → Executable file
0
crates/pm-agent-client/src/error.rs
Normal file → Executable file
@ -10,12 +10,17 @@
|
||||
//! use pm_agent_client::AgentClient;
|
||||
//!
|
||||
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
|
||||
//! // Load certificates from files (never hardcode or include_bytes! private keys)
|
||||
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
|
||||
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
|
||||
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
|
||||
//!
|
||||
//! let client = AgentClient::new(
|
||||
//! "10.0.1.5",
|
||||
//! 12443,
|
||||
//! include_bytes!("../certs/client.crt"),
|
||||
//! include_bytes!("../certs/client.key"),
|
||||
//! include_bytes!("../certs/ca.crt"),
|
||||
//! &client_cert,
|
||||
//! &client_key,
|
||||
//! &ca_cert,
|
||||
//! )?;
|
||||
//!
|
||||
//! let health = client.health().await?;
|
||||
|
||||
@ -57,6 +57,16 @@ pub struct HealthData {
|
||||
pub uptime_seconds: u64,
|
||||
/// Agent software version string.
|
||||
pub version: String,
|
||||
/// CRL status reported by the agent: `"valid"`, `"expired"`, `"missing"`, `"invalid"`.
|
||||
/// Absent for older agents that do not report CRL status.
|
||||
#[serde(default)]
|
||||
pub crl_status: Option<String>,
|
||||
/// Seconds since the agent's CRL was last refreshed.
|
||||
#[serde(default)]
|
||||
pub crl_age_seconds: Option<i64>,
|
||||
/// When the agent's CRL expires / next update is due (ISO-8601).
|
||||
#[serde(default)]
|
||||
pub crl_next_update: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@ -27,3 +27,6 @@ hex = { workspace = true }
|
||||
ipnet = { workspace = true }
|
||||
parking_lot = "0.12"
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
1
crates/pm-auth/src/jwt.rs
Normal file → Executable file
1
crates/pm-auth/src/jwt.rs
Normal file → Executable file
@ -121,6 +121,7 @@ pub fn load_verify_key(path: &str) -> Result<String, JwtError> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
0
crates/pm-auth/src/lib.rs
Normal file → Executable file
0
crates/pm-auth/src/lib.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_totp.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_totp.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_webauthn.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_webauthn.rs
Normal file → Executable file
0
crates/pm-auth/src/password.rs
Normal file → Executable file
0
crates/pm-auth/src/password.rs
Normal file → Executable file
@ -7,7 +7,7 @@
|
||||
//! - IP whitelist enforcement
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
extract::{ConnectInfo, Request},
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Json, Response},
|
||||
@ -15,7 +15,7 @@ use axum::{
|
||||
use ipnet::IpNet;
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::json;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
@ -40,6 +40,7 @@ pub enum UserRole {
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(Self::Admin),
|
||||
@ -75,18 +76,30 @@ pub struct AuthConfig {
|
||||
pub verify_key_pem: String,
|
||||
/// IP whitelist (empty = allow all). RwLock for runtime updates.
|
||||
pub ip_whitelist: Arc<RwLock<Vec<IpNet>>>,
|
||||
/// Trusted reverse-proxy CIDRs (empty = do not trust `X-Forwarded-For`).
|
||||
/// RwLock for runtime updates (symmetric to `ip_whitelist`).
|
||||
pub trusted_proxies: Arc<RwLock<Vec<IpNet>>>,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn new(verify_key_pem: String, ip_whitelist_cidrs: &[String]) -> Self {
|
||||
pub fn new(
|
||||
verify_key_pem: String,
|
||||
ip_whitelist_cidrs: &[String],
|
||||
trusted_proxy_cidrs: &[String],
|
||||
) -> Self {
|
||||
let ip_whitelist = ip_whitelist_cidrs
|
||||
.iter()
|
||||
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||
.collect();
|
||||
let trusted_proxies = trusted_proxy_cidrs
|
||||
.iter()
|
||||
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
verify_key_pem,
|
||||
ip_whitelist: Arc::new(RwLock::new(ip_whitelist)),
|
||||
trusted_proxies: Arc::new(RwLock::new(trusted_proxies)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,6 +123,18 @@ impl AuthConfig {
|
||||
*self.ip_whitelist.write() = nets;
|
||||
tracing::info!(count, "IP whitelist updated at runtime");
|
||||
}
|
||||
|
||||
/// Update the trusted-proxy list at runtime without restart.
|
||||
/// Empty list = strict mode (ignore `X-Forwarded-For`).
|
||||
pub fn update_trusted_proxies(&self, entries: Vec<String>) {
|
||||
let nets: Vec<IpNet> = entries
|
||||
.iter()
|
||||
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||
.collect();
|
||||
let count = nets.len();
|
||||
*self.trusted_proxies.write() = nets;
|
||||
tracing::info!(count, "Trusted proxies updated at runtime");
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `Authorization: Bearer <token>` from request headers.
|
||||
@ -120,13 +145,38 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
}
|
||||
|
||||
/// Extract the remote IP from `X-Forwarded-For`.
|
||||
fn extract_remote_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
/// Determine the client IP used for IP-allowlist enforcement.
|
||||
///
|
||||
/// Resolution rules (per `tasks/ip-allowlist-spec.md` §4.1):
|
||||
/// 1. Start with the socket peer IP.
|
||||
/// 2. If `trusted_proxies` is non-empty **and** the socket peer is in
|
||||
/// `trusted_proxies`, parse the leftmost entry of the `X-Forwarded-For`
|
||||
/// header and use it (the immediate untrusted hop).
|
||||
/// 3. If parsing `X-Forwarded-For` fails or the header is missing, fall back
|
||||
/// to the socket peer IP.
|
||||
/// 4. If the socket peer is unknown (no `ConnectInfo<SocketAddr>` is
|
||||
/// available on the request), return `None` so the caller can apply
|
||||
/// fail-closed logic when the allowlist is non-empty.
|
||||
fn resolve_client_ip(
|
||||
headers: &HeaderMap,
|
||||
peer: Option<IpAddr>,
|
||||
trusted_proxies: &[IpNet],
|
||||
) -> Option<IpAddr> {
|
||||
let peer_ip = peer?;
|
||||
|
||||
if !trusted_proxies.is_empty() && trusted_proxies.iter().any(|net| net.contains(&peer_ip)) {
|
||||
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(ip) = xff
|
||||
.split(',')
|
||||
.next()
|
||||
.and_then(|s| s.trim().parse::<IpAddr>().ok())
|
||||
{
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(peer_ip)
|
||||
}
|
||||
|
||||
/// Unauthorized JSON response helper.
|
||||
@ -147,16 +197,65 @@ fn forbidden(message: &str) -> Response {
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Forbidden-by-IP response helper. Distinct error code (`forbidden_ip`) so
|
||||
/// callers can distinguish an IP-allowlist rejection from a role-based
|
||||
/// rejection. Used by `require_auth` after the IP-resolution failure or
|
||||
/// allowlist miss per `tasks/ip-allowlist-spec.md` §4.2.
|
||||
fn forbidden_ip(message: &str) -> Response {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden_ip", "message": message } })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Middleware: authenticate any valid JWT (admin or operator).
|
||||
///
|
||||
/// Inserts `AuthUser` into request extensions on success.
|
||||
/// Rejects with 401 if token is missing/invalid, 403 if IP is blocked.
|
||||
pub async fn require_auth(auth_config: Arc<AuthConfig>, mut req: Request, next: Next) -> Response {
|
||||
// IP whitelist check
|
||||
if let Some(ip) = extract_remote_ip(req.headers()) {
|
||||
if !auth_config.is_ip_allowed(&ip) {
|
||||
tracing::warn!(ip = %ip, "Request blocked by IP whitelist");
|
||||
return forbidden("Access denied");
|
||||
// IP whitelist check. Only enforced when the configured allowlist is
|
||||
// non-empty (Q4 sign-off: empty list = allow all, preserved for dev
|
||||
// installs). When enforced, the resolved client IP comes from
|
||||
// `resolve_client_ip`, which uses the socket peer IP by default and
|
||||
// honors `X-Forwarded-For` only when the immediate peer is in
|
||||
// `trusted_proxies` (Q1 sign-off: strict default, Q2 sign-off: same
|
||||
// resolution pattern as the rate-limiter). Fail-closed when the IP
|
||||
// cannot be determined (Q3 sign-off).
|
||||
//
|
||||
// See `tasks/ip-allowlist-spec.md` §4.2 for the full design.
|
||||
if !auth_config.ip_whitelist.read().is_empty() {
|
||||
let headers = req.headers().clone();
|
||||
let peer: Option<IpAddr> = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0.ip());
|
||||
let xff_present = headers.contains_key("x-forwarded-for");
|
||||
let trusted: Vec<IpNet> = auth_config.trusted_proxies.read().clone();
|
||||
let resolved = resolve_client_ip(&headers, peer, &trusted);
|
||||
|
||||
match resolved {
|
||||
None => {
|
||||
tracing::warn!(
|
||||
peer = ?peer,
|
||||
xff_present,
|
||||
reason = "unresolvable_client_ip",
|
||||
"Request denied by IP whitelist (fail-closed: no ConnectInfo<SocketAddr>)"
|
||||
);
|
||||
return forbidden_ip("Client IP could not be determined");
|
||||
},
|
||||
Some(ip) => {
|
||||
if !auth_config.is_ip_allowed(&ip) {
|
||||
tracing::warn!(
|
||||
client_ip = %ip,
|
||||
peer = ?peer,
|
||||
xff_present,
|
||||
reason = "ip_not_in_allowlist",
|
||||
"Request blocked by IP whitelist"
|
||||
);
|
||||
return forbidden_ip("Access denied");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,3 +328,342 @@ where
|
||||
.ok_or_else(|| unauthorized("Authentication required"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for the IP-allowlist resolver helper.
|
||||
//!
|
||||
//! Covers the matrix in `tasks/ip-allowlist-spec.md` §6.1
|
||||
//! (12 cases for `resolve_client_ip`).
|
||||
|
||||
use super::*;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
IpAddr::from_str(s).expect("test fixture: parse IP")
|
||||
}
|
||||
|
||||
fn net(s: &str) -> IpNet {
|
||||
IpNet::from_str(s).expect("test fixture: parse CIDR")
|
||||
}
|
||||
|
||||
fn hdr() -> HeaderMap {
|
||||
HeaderMap::new()
|
||||
}
|
||||
|
||||
fn hdr_with_xff(xff: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
"x-forwarded-for",
|
||||
xff.parse().expect("test fixture: xff header"),
|
||||
);
|
||||
h
|
||||
}
|
||||
|
||||
// 1. peer_only_no_xff — no XFF, trusted_proxies empty → returns peer
|
||||
#[test]
|
||||
fn peer_only_no_xff() {
|
||||
let result = resolve_client_ip(&hdr(), Some(ip("203.0.113.10")), &[]);
|
||||
assert_eq!(result, Some(ip("203.0.113.10")));
|
||||
}
|
||||
|
||||
// 2. peer_only_xff_untrusted — XFF set, peer not in trusted_proxies,
|
||||
// trusted_proxies non-empty → returns peer (XFF ignored)
|
||||
#[test]
|
||||
fn peer_only_xff_untrusted() {
|
||||
let headers = hdr_with_xff("198.51.100.5");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("203.0.113.10")), &trusted);
|
||||
assert_eq!(result, Some(ip("203.0.113.10")));
|
||||
}
|
||||
|
||||
// 3. peer_only_trusted_proxies_empty_xff_present — XFF set,
|
||||
// trusted_proxies empty → returns peer (strict default)
|
||||
#[test]
|
||||
fn peer_only_trusted_proxies_empty_xff_present() {
|
||||
let headers = hdr_with_xff("198.51.100.5");
|
||||
let result = resolve_client_ip(&headers, Some(ip("203.0.113.10")), &[]);
|
||||
assert_eq!(result, Some(ip("203.0.113.10")));
|
||||
}
|
||||
|
||||
// 4. xff_trusted_peer_in_list — XFF set, peer in trusted_proxies
|
||||
// → returns parsed leftmost XFF entry
|
||||
#[test]
|
||||
fn xff_trusted_peer_in_list() {
|
||||
let headers = hdr_with_xff("198.51.100.5");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("198.51.100.5")));
|
||||
}
|
||||
|
||||
// 5. xff_trusted_peer_in_list_malformed_xff — XFF unparseable,
|
||||
// peer in trusted_proxies → falls back to peer
|
||||
#[test]
|
||||
fn xff_trusted_peer_in_list_malformed_xff() {
|
||||
let headers = hdr_with_xff("not-an-ip");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("10.0.0.5")));
|
||||
}
|
||||
|
||||
// 6. xff_trusted_peer_in_list_empty_xff — XFF empty string,
|
||||
// peer in trusted_proxies → falls back to peer
|
||||
#[test]
|
||||
fn xff_trusted_peer_in_list_empty_xff() {
|
||||
let headers = hdr_with_xff("");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("10.0.0.5")));
|
||||
}
|
||||
|
||||
// 7. xff_trusted_peer_in_list_multi_hop — "1.2.3.4, 5.6.7.8"
|
||||
// with peer in trusted_proxies → returns 1.2.3.4 (leftmost)
|
||||
#[test]
|
||||
fn xff_trusted_peer_in_list_multi_hop() {
|
||||
let headers = hdr_with_xff("1.2.3.4, 5.6.7.8");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("1.2.3.4")));
|
||||
}
|
||||
|
||||
// 8. no_peer_no_xff — peer None, no XFF → returns None
|
||||
#[test]
|
||||
fn no_peer_no_xff() {
|
||||
let result = resolve_client_ip(&hdr(), None, &[net("10.0.0.0/8")]);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
// 9. no_peer_xff_untrusted — peer None, XFF set, trusted_proxies empty
|
||||
// → returns None (caller fails closed)
|
||||
#[test]
|
||||
fn no_peer_xff_untrusted() {
|
||||
let headers = hdr_with_xff("198.51.100.5");
|
||||
let result = resolve_client_ip(&headers, None, &[]);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
// 10. xff_trusted_whitespace — XFF " 1.2.3.4", peer in trusted_proxies
|
||||
// → returns 1.2.3.4 (trim)
|
||||
#[test]
|
||||
fn xff_trusted_whitespace() {
|
||||
let headers = hdr_with_xff(" 198.51.100.5");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("198.51.100.5")));
|
||||
}
|
||||
|
||||
// 11. trusted_proxies_ipv6 — peer in IPv6 trusted list, IPv6 XFF
|
||||
// → returns XFF
|
||||
#[test]
|
||||
fn trusted_proxies_ipv6() {
|
||||
let headers = hdr_with_xff("2001:db8::1");
|
||||
let trusted = vec![net("::1/128"), net("2001:db8::/32")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("2001:db8::ffff")), &trusted);
|
||||
assert_eq!(result, Some(ip("2001:db8::1")));
|
||||
}
|
||||
|
||||
// 12. peer_ipv4_xff_ipv6_mismatch_trusted — peer in trusted list,
|
||||
// XFF is IPv6 → returns parsed IPv6 (mixed family is fine)
|
||||
#[test]
|
||||
fn peer_ipv4_xff_ipv6_mismatch_trusted() {
|
||||
let headers = hdr_with_xff("2001:db8::dead");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("2001:db8::dead")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod middleware_tests {
|
||||
//! End-to-end tests for the `require_auth` middleware IP-allowlist path.
|
||||
//!
|
||||
//! Uses a tiny in-process `axum::Router` with the middleware attached and
|
||||
//! `tower::ServiceExt::oneshot` to send synthetic requests. No DB, no real
|
||||
//! TCP listener.
|
||||
//!
|
||||
//! Mirrors the production wiring pattern in `pm-web/src/main.rs` (a
|
||||
//! `from_fn` closure that captures the `AuthConfig` and forwards to
|
||||
//! `require_auth`).
|
||||
//!
|
||||
//! For tests where the spec expects `200` (allowlist passed), we assert
|
||||
//! `401` instead — the JWT will fail validation against the empty verify
|
||||
//! key, which **proves the IP check did not short-circuit** (a 403 here
|
||||
//! would mean the IP check rejected the request).
|
||||
//!
|
||||
//! Per `tasks/ip-allowlist-spec.md` §6.1 tests 13–20.
|
||||
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::from_fn;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Stub handler that returns 200 OK if the middleware let the request
|
||||
/// through. JWT validation will fail in these tests, so the handler is
|
||||
/// only reached in the "IP check passed but JWT failed" scenarios we
|
||||
/// assert as `401`.
|
||||
async fn ok_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
fn build_test_app(auth_config: Arc<AuthConfig>) -> Router {
|
||||
Router::new()
|
||||
.route("/test", get(ok_handler))
|
||||
.layer(from_fn(move |req, next| {
|
||||
let cfg = auth_config.clone();
|
||||
async move { require_auth(cfg, req, next).await }
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build a request with the given extensions, headers, and an
|
||||
/// `Authorization: Bearer` token (which will fail JWT validation since
|
||||
/// the test `AuthConfig` has an empty verify key). Tests assert on the
|
||||
/// status code only — the body content is irrelevant.
|
||||
fn build_request(peer: Option<SocketAddr>, xff: Option<&str>) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
.uri("/test")
|
||||
.header("authorization", "Bearer test-token-invalid");
|
||||
if let Some(x) = xff {
|
||||
builder = builder.header("x-forwarded-for", x);
|
||||
}
|
||||
let mut req = builder.body(Body::empty()).expect("build request");
|
||||
if let Some(p) = peer {
|
||||
req.extensions_mut().insert(ConnectInfo(p));
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
fn peer_v4(a: u8, b: u8, c: u8, d: u8) -> SocketAddr {
|
||||
SocketAddr::from(([a, b, c, d], 1234))
|
||||
}
|
||||
|
||||
// 13. middleware_allows_when_whitelist_empty — empty list + any IP
|
||||
// → IP check skipped, request continues to JWT (which fails → 401).
|
||||
#[tokio::test]
|
||||
async fn middleware_allows_when_whitelist_empty() {
|
||||
let cfg = Arc::new(AuthConfig::new(String::new(), &[], &[]));
|
||||
let app = build_test_app(cfg);
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// 14. middleware_denies_when_whitelist_non_empty_and_ip_not_in_list
|
||||
// — non-empty list + peer outside → 403 forbidden_ip.
|
||||
#[tokio::test]
|
||||
async fn middleware_denies_when_whitelist_non_empty_and_ip_not_in_list() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), None);
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// 15. middleware_allows_when_ip_in_list — non-empty list + peer inside
|
||||
// → 401 (JWT fails, IP check passed).
|
||||
#[tokio::test]
|
||||
async fn middleware_allows_when_ip_in_list() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
let req = build_request(Some(peer_v4(10, 0, 0, 5)), None);
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// 16. middleware_denies_when_no_peer_resolvable_and_whitelist_non_empty
|
||||
// — non-empty list + missing ConnectInfo → 403 forbidden_ip (fail-closed).
|
||||
#[tokio::test]
|
||||
async fn middleware_denies_when_no_peer_resolvable_and_whitelist_non_empty() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
let req = build_request(None, None); // no ConnectInfo
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// 17. middleware_spoofed_xff_ignored_when_peer_untrusted
|
||||
// — non-empty list + peer outside + XFF inside list → 403 forbidden_ip.
|
||||
#[tokio::test]
|
||||
async fn middleware_spoofed_xff_ignored_when_peer_untrusted() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
// Peer is 203.0.113.10 (not in 10.0.0.0/8). XFF claims 10.0.0.5 but
|
||||
// trusted_proxies is empty, so XFF is ignored and peer is checked → 403.
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// 18. middleware_trusted_proxy_honors_xff — peer in trusted_proxies +
|
||||
// XFF inside allowlist → 401 (IP check passed, JWT fails).
|
||||
#[tokio::test]
|
||||
async fn middleware_trusted_proxy_honors_xff() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&["203.0.113.0/24".to_string()],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
// Peer 203.0.113.10 is in trusted_proxies, so XFF "10.0.0.5" is used
|
||||
// and that IP is in the allowlist → IP check passes → JWT fails → 401.
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// 19. middleware_trusted_proxy_falls_back_to_peer_on_bad_xff
|
||||
// — peer in trusted_proxies + unparseable XFF + peer outside list → 403.
|
||||
#[tokio::test]
|
||||
async fn middleware_trusted_proxy_falls_back_to_peer_on_bad_xff() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&["203.0.113.0/24".to_string()],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
// Peer 203.0.113.10 is in trusted_proxies. XFF is unparseable, so
|
||||
// resolver falls back to peer (203.0.113.10) which is NOT in
|
||||
// allowlist (10.0.0.0/8) → 403.
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("not-an-ip"));
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// 20. middleware_no_jwt_when_ip_blocked — blocked request never reaches
|
||||
// JWT validation. With an invalid token AND a denied IP, response is
|
||||
// 403 (forbidden_ip) NOT 401 (which would indicate JWT was reached).
|
||||
#[tokio::test]
|
||||
async fn middleware_no_jwt_when_ip_blocked() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
// Peer 203.0.113.10 is outside allowlist, token is invalid.
|
||||
// If the IP check ran first, response is 403. If JWT ran first, 401.
|
||||
// We assert 403, proving the IP check short-circuited.
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), None);
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
0
crates/pm-auth/src/refresh.rs
Normal file → Executable file
0
crates/pm-auth/src/refresh.rs
Normal file → Executable file
@ -40,6 +40,8 @@ pub enum SessionError {
|
||||
Password(#[from] PasswordError),
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Successful login response returned to the client.
|
||||
@ -69,6 +71,7 @@ pub struct SessionUser {
|
||||
|
||||
/// Database user row fetched during login.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct DbUser {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
@ -76,7 +79,10 @@ struct DbUser {
|
||||
role: UserRole,
|
||||
auth_provider: AuthProvider,
|
||||
password_hash: Option<String>,
|
||||
totp_secret: Option<String>,
|
||||
/// AES-256-GCM encrypted TOTP secret (issue #6 fix). None = TOTP not configured.
|
||||
totp_secret_encrypted: Option<Vec<u8>>,
|
||||
/// AES-256-GCM nonce for TOTP secret. Must be paired with `totp_secret_encrypted`.
|
||||
totp_secret_nonce: Option<Vec<u8>>,
|
||||
mfa_enabled: bool,
|
||||
is_active: bool,
|
||||
force_password_reset: bool,
|
||||
@ -114,7 +120,7 @@ pub async fn login(
|
||||
let user: Option<DbUser> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username, display_name, role, auth_provider,
|
||||
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
|
||||
password_hash, totp_secret_encrypted, totp_secret_nonce, mfa_enabled, is_active, force_password_reset,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users
|
||||
WHERE username = $1 AND auth_provider = 'local'
|
||||
@ -193,9 +199,25 @@ pub async fn login(
|
||||
// 4. MFA check
|
||||
if user.mfa_enabled {
|
||||
let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?;
|
||||
let secret = user.totp_secret.as_deref().unwrap_or("");
|
||||
// Decrypt the TOTP secret (issue #6 fix — stored as encrypted+nonce BYTEA)
|
||||
let secret = match (&user.totp_secret_encrypted, &user.totp_secret_nonce) {
|
||||
(Some(enc), Some(nonce)) => {
|
||||
let key = pm_core::crypto::load_or_create_key(std::path::Path::new(
|
||||
pm_core::crypto::SECRET_ENCRYPTION_KEY_PATH,
|
||||
))
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
SessionError::Internal("Encryption key error".to_string())
|
||||
})?;
|
||||
pm_core::crypto::decrypt(enc, nonce, &key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to decrypt TOTP secret");
|
||||
SessionError::Internal("TOTP decryption error".to_string())
|
||||
})?
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let mfa_ok = mfa_totp::verify_code(&user.username, secret, code).unwrap_or(false);
|
||||
let mfa_ok = mfa_totp::verify_code(&user.username, &secret, code).unwrap_or(false);
|
||||
|
||||
if !mfa_ok {
|
||||
tracing::warn!(username = %req.username, "Login failed: invalid MFA code");
|
||||
@ -256,7 +278,7 @@ pub async fn refresh_session(
|
||||
let user: DbUser = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username, display_name, role, auth_provider,
|
||||
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
|
||||
password_hash, totp_secret_encrypted, totp_secret_nonce, mfa_enabled, is_active, force_password_reset,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users WHERE id = $1
|
||||
"#,
|
||||
|
||||
@ -23,3 +23,6 @@ rustls = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
pem = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@ -13,8 +13,9 @@ use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use rand::RngCore;
|
||||
use rcgen::{
|
||||
BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType,
|
||||
ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyPair, KeyUsagePurpose, SanType, SerialNumber,
|
||||
BasicConstraints, Certificate, CertificateParams, CertificateRevocationListParams,
|
||||
DistinguishedName, DnType, ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyIdMethod, KeyPair,
|
||||
KeyUsagePurpose, RevocationReason, RevokedCertParams, SanType, SerialNumber,
|
||||
PKCS_ECDSA_P256_SHA256,
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
@ -351,7 +352,9 @@ impl CertAuthority {
|
||||
let mut sans = vec![SanType::DnsName(
|
||||
Ia5String::try_from(hostname.to_owned()).context("hostname is not valid IA5")?,
|
||||
)];
|
||||
if let Ok(ip) = ip_address.parse::<IpAddr>() {
|
||||
// Strip CIDR netmask (e.g. "192.168.3.36/32") before parsing
|
||||
let ip_str = ip_address.split('/').next().unwrap_or(ip_address);
|
||||
if let Ok(ip) = ip_str.parse::<IpAddr>() {
|
||||
sans.push(SanType::IpAddress(ip));
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@ -522,4 +525,394 @@ impl CertAuthority {
|
||||
.context("reconstruct CA certificate for signing")?;
|
||||
Ok((key, cert))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CRL generation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Generate a Certificate Revocation List (CRL) signed by this CA.
|
||||
///
|
||||
/// Queries the `certificates` table for certs with `status = 'revoked'`
|
||||
/// and `not_after > NOW()` (i.e., not yet naturally expired) and bundles
|
||||
/// their serials into an X.509 v2 CRL.
|
||||
///
|
||||
/// Returns the CRL as a PEM-encoded string.
|
||||
///
|
||||
/// # Performance
|
||||
///
|
||||
/// O(n) where n is the number of revoked-but-not-expired certs. For our
|
||||
/// target scale (max ~2500 clients per manager, low single-digit % annual
|
||||
/// revocation rate), this is KB-range and sub-millisecond to generate.
|
||||
pub async fn generate_crl(&self, db: &PgPool) -> Result<String> {
|
||||
tracing::debug!("Generating CRL from certificates table");
|
||||
|
||||
// Query revoked certs that haven't naturally expired yet.
|
||||
// Expired certs are pruned from the CRL to keep it small.
|
||||
let rows = sqlx::query(
|
||||
"SELECT serial_number, revoked_at \
|
||||
FROM certificates \
|
||||
WHERE status = 'revoked'::cert_status \
|
||||
AND revoked_at IS NOT NULL \
|
||||
AND expires_at > NOW() \
|
||||
ORDER BY revoked_at ASC",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.context("query revoked certificates for CRL")?;
|
||||
|
||||
let mut revoked_certs = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let serial_hex: String = row.try_get("serial_number").context("serial_number")?;
|
||||
let revoked_at: DateTime<Utc> = row.try_get("revoked_at").context("revoked_at")?;
|
||||
|
||||
// Convert hex serial back to bytes for rcgen.
|
||||
let serial_bytes =
|
||||
hex::decode(&serial_hex).context("serial_number is not valid hex")?;
|
||||
let serial_number = SerialNumber::from_slice(&serial_bytes);
|
||||
|
||||
// Convert chrono DateTime to time::OffsetDateTime for rcgen.
|
||||
let revocation_time = OffsetDateTime::from_unix_timestamp(revoked_at.timestamp())
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc());
|
||||
|
||||
revoked_certs.push(RevokedCertParams {
|
||||
serial_number,
|
||||
revocation_time,
|
||||
reason_code: Some(RevocationReason::Unspecified),
|
||||
invalidity_date: None,
|
||||
});
|
||||
}
|
||||
|
||||
let count = revoked_certs.len();
|
||||
tracing::debug!(revoked_count = count, "Building CRL with revoked entries");
|
||||
|
||||
// CRL validity window: this_update = now, next_update = now + 24h
|
||||
// (agents refresh every 24h, so this gives them a fresh CRL on every poll).
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let next_update = now + TimeDuration::hours(24);
|
||||
|
||||
// CRL number: monotonic counter derived from current Unix timestamp.
|
||||
// RFC 5280 doesn't require strict monotonicity for the CRL number
|
||||
// extension, but it's a common convention. We use timestamp seconds
|
||||
// divided by 60 (minute precision) to keep it short and readable.
|
||||
let crl_number = SerialNumber::from_slice(&Utc::now().timestamp().to_be_bytes());
|
||||
|
||||
let crl_params = CertificateRevocationListParams {
|
||||
this_update: now,
|
||||
next_update,
|
||||
crl_number,
|
||||
issuing_distribution_point: None,
|
||||
revoked_certs,
|
||||
key_identifier_method: KeyIdMethod::Sha256,
|
||||
};
|
||||
|
||||
let (ca_key, ca_cert) = self.ca_objects()?;
|
||||
let crl = crl_params
|
||||
.signed_by(&ca_cert, &ca_key)
|
||||
.context("sign CRL with CA key")?;
|
||||
let crl_pem = crl.pem().context("encode CRL as PEM")?;
|
||||
|
||||
tracing::info!(
|
||||
revoked_count = count,
|
||||
next_update = %next_update,
|
||||
"CRL generated and signed"
|
||||
);
|
||||
|
||||
Ok(crl_pem)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: build a `CertAuthority` for testing without going through disk init.
|
||||
/// Generates a fresh ECDSA P-256 CA in memory.
|
||||
async fn test_ca() -> CertAuthority {
|
||||
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
dn.push(DnType::OrganizationName, "Patch Manager Test");
|
||||
params.distinguished_name = dn;
|
||||
|
||||
let ca_cert = params.self_signed(&key).unwrap();
|
||||
CertAuthority {
|
||||
base_dir: PathBuf::from("/tmp/test-ca"),
|
||||
ca_cert_pem: ca_cert.pem(),
|
||||
ca_key_pem: key.serialize_pem(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_serial_produces_unique_16_byte_serials() {
|
||||
let (s1, h1) = make_serial();
|
||||
let (s2, h2) = make_serial();
|
||||
assert_ne!(h1, h2, "serial hex strings should differ");
|
||||
assert_eq!(
|
||||
h1.len(),
|
||||
32,
|
||||
"serial should be 16 bytes hex-encoded (32 chars)"
|
||||
);
|
||||
assert_ne!(s1, s2, "rcgen SerialNumber values should differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ca_objects_round_trip() {
|
||||
// Build a CA, then reconstruct via ca_objects() and verify the cert+key parse.
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let ca = rt.block_on(test_ca());
|
||||
let (key, cert) = ca.ca_objects().expect("ca_objects should succeed");
|
||||
assert!(!key.serialize_pem().is_empty());
|
||||
assert!(!cert.pem().is_empty());
|
||||
}
|
||||
|
||||
/// Verifies that `generate_crl` produces a valid PEM-encoded X.509 CRL
|
||||
/// even when the database has no revoked certs (empty CRL).
|
||||
///
|
||||
/// This is a structural test: we verify the PEM format and that the
|
||||
/// generated CRL can be parsed back. Full integration testing with a real
|
||||
/// database is in `tests/crl_integration.rs`.
|
||||
#[tokio::test]
|
||||
async fn generate_crl_empty_db_produces_valid_pem() {
|
||||
// Use a real but empty Postgres test database. If TEST_DATABASE_URL
|
||||
// is not set, skip this test (it's an integration test, not a unit test).
|
||||
let Ok(db_url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let pool = sqlx::PgPool::connect(&db_url)
|
||||
.await
|
||||
.expect("connect to test db");
|
||||
let ca = test_ca().await;
|
||||
|
||||
let crl_pem = ca.generate_crl(&pool).await.expect("generate_crl");
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"PEM header missing"
|
||||
);
|
||||
assert!(
|
||||
crl_pem.contains("-----END X509 CRL-----"),
|
||||
"PEM footer missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property-based tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptests {
|
||||
use super::*;
|
||||
|
||||
/// Generating a CRL twice in quick succession should produce valid PEM output.
|
||||
/// (Full integration test with a real database is in tests/crl_integration.rs.)
|
||||
#[test]
|
||||
fn make_serial_produces_unique_values() {
|
||||
let (s1, h1) = make_serial();
|
||||
let (s2, h2) = make_serial();
|
||||
assert_ne!(h1, h2, "serial hex strings should differ");
|
||||
assert_eq!(h1.len(), 32, "serial should be 16 bytes hex-encoded");
|
||||
assert_ne!(s1, s2, "rcgen SerialNumber values should differ");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CRL generation unit tests (in-memory, no database required)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: build a CRL in memory using rcgen directly, signed by the test CA.
|
||||
/// This bypasses the database and tests the CRL structure itself.
|
||||
fn build_test_crl(
|
||||
ca_key: &KeyPair,
|
||||
ca_cert: &Certificate,
|
||||
revoked_serials: &[SerialNumber],
|
||||
) -> String {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let next_update = now + TimeDuration::hours(24);
|
||||
let crl_number = SerialNumber::from_slice(&Utc::now().timestamp().to_be_bytes());
|
||||
|
||||
let revoked_certs: Vec<RevokedCertParams> = revoked_serials
|
||||
.iter()
|
||||
.map(|serial| RevokedCertParams {
|
||||
serial_number: serial.clone(),
|
||||
revocation_time: now,
|
||||
reason_code: Some(RevocationReason::Unspecified),
|
||||
invalidity_date: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let crl_params = CertificateRevocationListParams {
|
||||
this_update: now,
|
||||
next_update,
|
||||
crl_number,
|
||||
issuing_distribution_point: None,
|
||||
revoked_certs,
|
||||
key_identifier_method: KeyIdMethod::Sha256,
|
||||
};
|
||||
|
||||
let crl = crl_params.signed_by(ca_cert, ca_key).unwrap();
|
||||
crl.pem().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_generation_produces_valid_pem_structure() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
|
||||
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"CRL PEM should contain BEGIN header"
|
||||
);
|
||||
assert!(
|
||||
crl_pem.contains("-----END X509 CRL-----"),
|
||||
"CRL PEM should contain END footer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_contains_revoked_serials() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
// Revoke two serials
|
||||
let (s1, _) = make_serial();
|
||||
let (s2, _) = make_serial();
|
||||
let crl_with_revoked = build_test_crl(&ca_key, &ca_cert, &[s1.clone(), s2.clone()]);
|
||||
|
||||
// The PEM should be non-empty and parseable
|
||||
assert!(!crl_with_revoked.is_empty(), "CRL PEM should not be empty");
|
||||
assert!(
|
||||
crl_with_revoked.contains("-----BEGIN X509 CRL-----"),
|
||||
"CRL should have PEM header"
|
||||
);
|
||||
|
||||
// A CRL with revoked entries should be larger than an empty CRL
|
||||
// because it contains the revoked certificate entries.
|
||||
let empty_crl = build_test_crl(&ca_key, &ca_cert, &[]);
|
||||
assert!(
|
||||
crl_with_revoked.len() > empty_crl.len(),
|
||||
"CRL with revoked entries should be larger than empty CRL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_empty_crl_has_no_revoked_entries() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
|
||||
|
||||
// An empty CRL should still be valid PEM
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"Empty CRL should still have PEM header"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_signature_verifies_against_ca_cert() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
let (serial, _) = make_serial();
|
||||
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[serial]);
|
||||
|
||||
// Parse the CRL and verify it's structurally valid
|
||||
// (signature verification against CA is implicit — rcgen signed it with the CA key)
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"CRL should be valid PEM signed by CA"
|
||||
);
|
||||
|
||||
// Verify that a different CA key produces a different CRL (not verifiable)
|
||||
let other_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut other_params = CertificateParams::default();
|
||||
other_params.not_before = OffsetDateTime::now_utc();
|
||||
other_params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
other_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
other_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut other_dn = DistinguishedName::new();
|
||||
other_dn.push(DnType::CommonName, "Other Root CA");
|
||||
other_params.distinguished_name = other_dn;
|
||||
let other_cert = other_params.self_signed(&other_key).unwrap();
|
||||
|
||||
let (s2, _) = make_serial();
|
||||
let other_crl_pem = build_test_crl(&other_key, &other_cert, &[s2]);
|
||||
|
||||
// The two CRLs should be different (different issuers, different keys)
|
||||
assert_ne!(
|
||||
crl_pem, other_crl_pem,
|
||||
"CRLs from different CAs should differ"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_next_update_is_approximately_24h() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
// The build_test_crl helper uses 24h next_update
|
||||
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
|
||||
|
||||
// Verify the CRL was generated successfully — the next_update being 24h
|
||||
// is enforced by the CertAuthority::generate_crl method which uses
|
||||
// TimeDuration::hours(24). We verify the PEM is valid as a proxy.
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"CRL should be generated with 24h next_update"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
0
crates/pm-ca/src/lib.rs
Normal file → Executable file
0
crates/pm-ca/src/lib.rs
Normal file → Executable file
@ -51,6 +51,16 @@ pub enum AuditAction {
|
||||
HealthCheckUpdated,
|
||||
HealthCheckDeleted,
|
||||
CertificateReissued,
|
||||
// Issue #5: Manager-wide auth-config mutations (Admin-only)
|
||||
OidcConfigUpdated,
|
||||
SmtpConfigUpdated,
|
||||
IpWhitelistUpdated,
|
||||
OidcTestPerformed,
|
||||
OidcDiscoverPerformed,
|
||||
// CRL health aggregation events (system-initiated)
|
||||
CrlStatusChanged,
|
||||
CrlStaleDetected,
|
||||
CrlInvalid,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
@ -88,6 +98,16 @@ impl AuditAction {
|
||||
Self::HealthCheckUpdated => "health_check_updated",
|
||||
Self::HealthCheckDeleted => "health_check_deleted",
|
||||
Self::CertificateReissued => "certificate_reissued",
|
||||
// Issue #5: Manager-wide auth-config mutations (Admin-only)
|
||||
Self::OidcConfigUpdated => "oidc_config_updated",
|
||||
Self::SmtpConfigUpdated => "smtp_config_updated",
|
||||
Self::IpWhitelistUpdated => "ip_whitelist_updated",
|
||||
Self::OidcTestPerformed => "oidc_test_performed",
|
||||
Self::OidcDiscoverPerformed => "oidc_discover_performed",
|
||||
// CRL health aggregation events
|
||||
Self::CrlStatusChanged => "crl_status_changed",
|
||||
Self::CrlStaleDetected => "crl_stale_detected",
|
||||
Self::CrlInvalid => "crl_invalid",
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -97,6 +117,7 @@ impl AuditAction {
|
||||
/// Computes a hash chain entry using the previous row's hash.
|
||||
/// Non-fatal: logs errors but does not propagate them to avoid
|
||||
/// disrupting the primary operation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_event(
|
||||
pool: &PgPool,
|
||||
action: AuditAction,
|
||||
@ -126,6 +147,7 @@ pub async fn log_event(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn write_audit_row(
|
||||
pool: &PgPool,
|
||||
action: AuditAction,
|
||||
|
||||
@ -1,6 +1,61 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Rate limiting configuration per route group.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Enrollment endpoint: requests per minute per IP (default: 5)
|
||||
#[serde(default = "default_enrollment_rpm")]
|
||||
pub enrollment_rpm: u32,
|
||||
/// Enrollment burst allowance (default: 3)
|
||||
#[serde(default = "default_enrollment_burst")]
|
||||
pub enrollment_burst: u32,
|
||||
/// Public auth endpoints: requests per minute per IP (default: 20)
|
||||
#[serde(default = "default_auth_rpm")]
|
||||
pub auth_rpm: u32,
|
||||
/// Auth burst allowance (default: 10)
|
||||
#[serde(default = "default_auth_burst")]
|
||||
pub auth_burst: u32,
|
||||
/// Authenticated API: requests per minute per IP (default: 120)
|
||||
#[serde(default = "default_api_rpm")]
|
||||
pub api_rpm: u32,
|
||||
/// API burst allowance (default: 30)
|
||||
#[serde(default = "default_api_burst")]
|
||||
pub api_burst: u32,
|
||||
}
|
||||
|
||||
fn default_enrollment_rpm() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_enrollment_burst() -> u32 {
|
||||
3
|
||||
}
|
||||
fn default_auth_rpm() -> u32 {
|
||||
20
|
||||
}
|
||||
fn default_auth_burst() -> u32 {
|
||||
10
|
||||
}
|
||||
fn default_api_rpm() -> u32 {
|
||||
120
|
||||
}
|
||||
fn default_api_burst() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enrollment_rpm: default_enrollment_rpm(),
|
||||
enrollment_burst: default_enrollment_burst(),
|
||||
auth_rpm: default_auth_rpm(),
|
||||
auth_burst: default_auth_burst(),
|
||||
api_rpm: default_api_rpm(),
|
||||
api_burst: default_api_burst(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level application configuration.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AppConfig {
|
||||
@ -9,6 +64,8 @@ pub struct AppConfig {
|
||||
pub worker: WorkerConfig,
|
||||
pub logging: LoggingConfig,
|
||||
pub security: SecurityConfig,
|
||||
#[serde(default)]
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@ -44,7 +101,8 @@ pub struct WorkerConfig {
|
||||
pub health_check_poll_interval_secs: u64,
|
||||
/// Maximum concurrent agent calls
|
||||
pub max_concurrent_agent_calls: usize,
|
||||
/// Worker heartbeat interval in seconds
|
||||
/// Worker heartbeat interval in seconds (default: 300 = 5 min)
|
||||
#[serde(default = "default_heartbeat_interval")]
|
||||
pub heartbeat_interval_secs: u64,
|
||||
/// WS relay HTTP polling fallback interval in seconds (default: 10)
|
||||
pub ws_relay_poll_interval_secs: u64,
|
||||
@ -62,6 +120,13 @@ pub struct LoggingConfig {
|
||||
pub struct SecurityConfig {
|
||||
/// IP whitelist (CIDR or individual IPs); empty = allow all (not recommended)
|
||||
pub ip_whitelist: Vec<String>,
|
||||
/// IP addresses (CIDR or single IP) of trusted reverse proxies. When the
|
||||
/// immediate TCP peer is in this list, `X-Forwarded-For` is honored;
|
||||
/// otherwise the socket peer IP is used for allowlist enforcement.
|
||||
/// Default: empty (do not trust `X-Forwarded-For`). See
|
||||
/// `tasks/ip-allowlist-spec.md` §4.3 for the operational guidance.
|
||||
#[serde(default)]
|
||||
pub trusted_proxies: Vec<String>,
|
||||
/// JWT signing key path (Ed25519 PEM)
|
||||
pub jwt_signing_key_path: String,
|
||||
/// JWT verification key path (Ed25519 public PEM)
|
||||
@ -83,6 +148,71 @@ pub struct SecurityConfig {
|
||||
/// Frontend URL to redirect to after SSO callback (default: http://localhost:5173/auth/sso/callback)
|
||||
#[serde(default = "default_sso_callback_url")]
|
||||
pub sso_callback_url: String,
|
||||
/// Allowlist of browser `Origin` values permitted to open the
|
||||
/// `/api/v1/ws/jobs` WebSocket upgrade. Entries are exact
|
||||
/// `scheme://host[:port]` strings. If left empty in the TOML file, the
|
||||
/// server derives the default from `sso_callback_url` at load time
|
||||
/// (see [`derive_allowed_origins`]).
|
||||
#[serde(default)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
/// Derive a default `Origin` allowlist from a single SSO callback URL.
|
||||
///
|
||||
/// Parses `scheme://host[:port][/path]` and returns a single-element vector
|
||||
/// containing `scheme://host[:port]` (with default ports normalized away —
|
||||
/// e.g. `https://x:443` becomes `https://x`). Returns an empty vector if the
|
||||
/// URL is unparseable; callers should log a warning in that case because the
|
||||
/// WebSocket endpoint will reject all browser upgrades (fail-closed).
|
||||
///
|
||||
/// Exposed publicly so tests and the handler can share the same parser.
|
||||
pub fn derive_allowed_origins(sso_callback_url: &str) -> Vec<String> {
|
||||
let s = sso_callback_url.trim().trim_end_matches('/');
|
||||
let (scheme, rest) = match s.split_once("://") {
|
||||
Some(parts) if !parts.0.is_empty() => parts,
|
||||
_ => return vec![],
|
||||
};
|
||||
let scheme_lower = scheme.to_ascii_lowercase();
|
||||
if scheme_lower != "http" && scheme_lower != "https" {
|
||||
return vec![];
|
||||
}
|
||||
// Authority is everything up to the first `/`, `?`, or `#`.
|
||||
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||||
let authority = &rest[..authority_end];
|
||||
if authority.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Split host:port. We treat the LAST `:` as the port separator. IPv6
|
||||
// literal hosts (e.g. `[::1]`) contain a `:` inside the brackets; we
|
||||
// explicitly do not support IPv6 in sso_callback_url and return empty
|
||||
// for those to be safe.
|
||||
let (host, port_str) = match authority.rsplit_once(':') {
|
||||
Some((h, _)) if h.contains(':') => return vec![],
|
||||
Some((h, p)) => (h, Some(p)),
|
||||
None => (authority, None),
|
||||
};
|
||||
let host = host.trim();
|
||||
if host.is_empty() || host.contains(char::is_whitespace) || host.contains(':') {
|
||||
return vec![];
|
||||
}
|
||||
let default_port: Option<u16> = match scheme_lower.as_str() {
|
||||
"https" => Some(443),
|
||||
"http" => Some(80),
|
||||
_ => None,
|
||||
};
|
||||
let port_num = match port_str {
|
||||
Some(p) => match p.parse::<u16>() {
|
||||
Ok(n) => Some(n),
|
||||
Err(_) => return vec![],
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let origin = match (port_num, default_port) {
|
||||
(Some(p), Some(d)) if p == d => format!("{}://{}", scheme_lower, host),
|
||||
(Some(p), _) => format!("{}://{}:{}", scheme_lower, host, p),
|
||||
(None, _) => format!("{}://{}", scheme_lower, host),
|
||||
};
|
||||
vec![origin]
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
@ -90,6 +220,11 @@ impl AppConfig {
|
||||
///
|
||||
/// Environment variables follow the pattern: `PATCH_MANAGER__SECTION__KEY`
|
||||
/// e.g. `PATCH_MANAGER__DATABASE__URL=postgres://...`
|
||||
///
|
||||
/// After deserialization, if `security.allowed_origins` is empty, it is
|
||||
/// derived from `security.sso_callback_url`. A `tracing::warn!` is emitted
|
||||
/// when the resulting allowlist is empty (the WS endpoint will reject all
|
||||
/// browser upgrades in that case).
|
||||
pub fn load(config_path: &str) -> Result<Self, ConfigError> {
|
||||
let cfg = Config::builder()
|
||||
.add_source(File::with_name(config_path).required(false))
|
||||
@ -100,7 +235,20 @@ impl AppConfig {
|
||||
)
|
||||
.build()?;
|
||||
|
||||
cfg.try_deserialize()
|
||||
let mut config: Self = cfg.try_deserialize()?;
|
||||
if config.security.allowed_origins.is_empty() {
|
||||
config.security.allowed_origins =
|
||||
derive_allowed_origins(&config.security.sso_callback_url);
|
||||
}
|
||||
if config.security.allowed_origins.is_empty() {
|
||||
tracing::warn!(
|
||||
sso_callback_url = %config.security.sso_callback_url,
|
||||
"security.allowed_origins is empty and could not be derived \
|
||||
from sso_callback_url; the WebSocket endpoint will reject all \
|
||||
browser upgrades"
|
||||
);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,6 +256,10 @@ fn default_health_check_poll_interval() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_heartbeat_interval() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_sso_callback_url() -> String {
|
||||
"http://localhost:5173/auth/sso/callback".to_string()
|
||||
}
|
||||
@ -140,6 +292,7 @@ impl Default for AppConfig {
|
||||
},
|
||||
security: SecurityConfig {
|
||||
ip_whitelist: vec![],
|
||||
trusted_proxies: vec![],
|
||||
jwt_signing_key_path: "/etc/patch-manager/jwt/signing.pem".to_string(),
|
||||
jwt_verify_key_path: "/etc/patch-manager/jwt/verify.pem".to_string(),
|
||||
jwt_access_ttl_secs: 900,
|
||||
@ -150,7 +303,69 @@ impl Default for AppConfig {
|
||||
web_tls_cert_path: "/etc/patch-manager/tls/web.crt".to_string(),
|
||||
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
|
||||
sso_callback_url: default_sso_callback_url(),
|
||||
allowed_origins: derive_allowed_origins(&default_sso_callback_url()),
|
||||
},
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derive_strips_default_https_port() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("https://app.example.com:443/auth/sso/callback"),
|
||||
vec!["https://app.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_keeps_non_default_port() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("https://app.example.com:8443/auth/sso/callback"),
|
||||
vec!["https://app.example.com:8443".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_strips_default_http_port() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("http://localhost:80/x"),
|
||||
vec!["http://localhost".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_handles_trailing_slash() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("https://app.example.com/"),
|
||||
vec!["https://app.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_handles_no_path() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("https://app.example.com"),
|
||||
vec!["https://app.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_returns_empty_for_garbage() {
|
||||
assert!(derive_allowed_origins("not a url").is_empty());
|
||||
assert!(derive_allowed_origins("").is_empty());
|
||||
assert!(derive_allowed_origins("ftp://x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_lowercases_scheme() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("HTTPS://App.Example.com"),
|
||||
vec!["https://App.Example.com".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
//! AES-256-GCM encryption for sensitive health check credentials.
|
||||
//! AES-256-GCM encryption for sensitive credentials.
|
||||
//!
|
||||
//! Uses a per-install key stored at `/etc/patch-manager/keys/health-check.key`.
|
||||
//! Two per-install keys are supported:
|
||||
//! - `KEY_PATH` (health-check.key) protects HTTP basic auth passwords for health check endpoints.
|
||||
//! - `SECRET_ENCRYPTION_KEY_PATH` (secret-encryption.key) protects OIDC `client_secret`,
|
||||
//! SMTP `smtp_password`, and TOTP `totp_secret` at rest in the database.
|
||||
//!
|
||||
//! Keys are 32-byte files, auto-generated on first start with 0600 permissions.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
@ -12,6 +17,12 @@ use std::path::Path;
|
||||
|
||||
pub const KEY_PATH: &str = "/etc/patch-manager/keys/health-check.key";
|
||||
|
||||
/// Path to the encryption key for sensitive app secrets
|
||||
/// (OIDC client_secret, SMTP password, TOTP secret).
|
||||
/// Separate from `KEY_PATH` (health-check credentials) for blast-radius isolation:
|
||||
/// if the health-check key is compromised, app secrets remain protected.
|
||||
pub const SECRET_ENCRYPTION_KEY_PATH: &str = "/etc/patch-manager/keys/secret-encryption.key";
|
||||
|
||||
/// Load or create the per-install encryption key.
|
||||
/// If the key file doesn't exist, generates a new 256-bit key and saves it.
|
||||
pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
|
||||
@ -29,7 +40,7 @@ pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(CryptoError::Io)?;
|
||||
}
|
||||
fs::write(path, &key).map_err(CryptoError::Io)?;
|
||||
fs::write(path, key).map_err(CryptoError::Io)?;
|
||||
// Set permissions to 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@ -78,3 +89,73 @@ pub enum CryptoError {
|
||||
#[error("UTF-8 error: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Create a unique temp directory for test isolation.
|
||||
/// Returns a path like `/tmp/pm-crypto-test-<epoch_nanos>-<rand>`.
|
||||
/// Cleans up the directory on test teardown (via `temp_dir` guard).
|
||||
fn unique_temp_dir() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let dir = env::temp_dir().join(format!("pm-crypto-test-{}-{}", std::process::id(), nanos));
|
||||
fs::create_dir_all(&dir).expect("Failed to create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let key = [42u8; 32];
|
||||
let plaintext = "super-secret-client-credential-12345";
|
||||
let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt failed");
|
||||
// Ciphertext must differ from plaintext (encryption is non-trivial)
|
||||
assert_ne!(ciphertext.as_slice(), plaintext.as_bytes());
|
||||
// Nonce is 12 bytes (AES-GCM standard)
|
||||
assert_eq!(nonce.len(), 12);
|
||||
// Decrypting must return the original plaintext
|
||||
let recovered = decrypt(&ciphertext, &nonce, &key).expect("decrypt failed");
|
||||
assert_eq!(recovered, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_or_create_key_sets_0600_permissions() {
|
||||
let dir = unique_temp_dir();
|
||||
let key_path = dir.join("test-0600.key");
|
||||
let _key = load_or_create_key(&key_path).expect("load_or_create_key failed");
|
||||
// Verify file exists and has exactly 32 bytes
|
||||
let metadata = fs::metadata(&key_path).expect("key file not created");
|
||||
assert_eq!(metadata.len(), 32, "key file must be 32 bytes");
|
||||
// On Unix, verify permissions are 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "key file must be 0600, got {:o}", mode);
|
||||
}
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(&key_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_or_create_key_is_idempotent() {
|
||||
let dir = unique_temp_dir();
|
||||
let key_path = dir.join("test-idempotent.key");
|
||||
// First call creates the key
|
||||
let key1 = load_or_create_key(&key_path).expect("first call failed");
|
||||
// Second call should return the same key (not regenerate)
|
||||
let key2 = load_or_create_key(&key_path).expect("second call failed");
|
||||
assert_eq!(key1, key2, "second call must return the same key");
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(&key_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
9
crates/pm-core/src/db.rs
Normal file → Executable file
9
crates/pm-core/src/db.rs
Normal file → Executable file
@ -72,9 +72,9 @@ pub async fn create_enrollment_request(
|
||||
EnrollmentRequest,
|
||||
>(
|
||||
r#"
|
||||
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at
|
||||
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token, hostname)
|
||||
VALUES ($1, $2, $3::inet, $4, $5, $6)
|
||||
RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(req.machine_id)
|
||||
@ -82,6 +82,7 @@ pub async fn create_enrollment_request(
|
||||
.bind(req.ip_address)
|
||||
.bind(req.os_details)
|
||||
.bind(token_hash)
|
||||
.bind(&req.hostname)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
@ -90,7 +91,7 @@ pub async fn list_enrollment_requests(
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<EnrollmentRequest>, sqlx::Error> {
|
||||
sqlx::query_as::<_, EnrollmentRequest>(
|
||||
"SELECT id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC",
|
||||
"SELECT id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
|
||||
0
crates/pm-core/src/error.rs
Normal file → Executable file
0
crates/pm-core/src/error.rs
Normal file → Executable file
@ -9,7 +9,9 @@ pub mod request_id;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use config::AppConfig;
|
||||
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
|
||||
pub use crypto::{
|
||||
decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH, SECRET_ENCRYPTION_KEY_PATH,
|
||||
};
|
||||
pub use error::{AppError, ErrorResponse};
|
||||
pub use models::{
|
||||
AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest,
|
||||
|
||||
0
crates/pm-core/src/logging.rs
Normal file → Executable file
0
crates/pm-core/src/logging.rs
Normal file → Executable file
@ -94,6 +94,15 @@ pub struct Host {
|
||||
pub notes: String,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// CRL status reported by the agent: valid, expired, missing, invalid, or NULL for older agents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crl_status: Option<String>,
|
||||
/// Seconds since the agent's CRL was last refreshed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crl_age_seconds: Option<i64>,
|
||||
/// When the agent's CRL expires / next update is due.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crl_next_update: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Payload for registering a new host.
|
||||
@ -107,6 +116,14 @@ pub struct CreateHostRequest {
|
||||
pub group_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Payload for updating an existing host.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateHostRequest {
|
||||
pub fqdn: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Host list item (lighter projection for list views)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct HostSummary {
|
||||
@ -121,6 +138,9 @@ pub struct HostSummary {
|
||||
pub patches_missing: i32,
|
||||
pub health_check_status: Option<String>,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
/// CRL status reported by the agent: valid, expired, missing, invalid, or NULL for older agents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crl_status: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -135,6 +155,8 @@ pub struct EnrollmentRequest {
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
pub polling_token: String,
|
||||
/// Short hostname provided during enrollment (optional).
|
||||
pub hostname: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
@ -146,6 +168,8 @@ pub struct CreateEnrollmentRequest {
|
||||
pub fqdn: String,
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
/// Short hostname (from /etc/hostname, optional).
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -154,8 +178,10 @@ pub enum EnrollmentStatusResponse {
|
||||
Pending,
|
||||
Approved {
|
||||
ca_crt: String,
|
||||
ca_chain: String,
|
||||
server_crt: String,
|
||||
server_key: String,
|
||||
crl_pem: String,
|
||||
},
|
||||
Denied,
|
||||
NotFound,
|
||||
@ -163,9 +189,71 @@ pub enum EnrollmentStatusResponse {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PkiBundle {
|
||||
/// PEM-encoded CA certificate (leaf-most cert in the chain).
|
||||
/// For root mode, this is the self-signed root CA.
|
||||
/// For sub-CA mode, this is the intermediate CA cert.
|
||||
pub ca_crt: String,
|
||||
/// PEM-encoded full CA certificate chain (concatenated intermediates + root).
|
||||
/// For root mode, this contains just the root CA cert (same as ca_crt).
|
||||
/// For sub-CA mode, this contains the intermediate cert followed by the
|
||||
/// external root cert, enabling the agent to verify the full chain up to
|
||||
/// the trust anchor.
|
||||
///
|
||||
/// This field was added for CRL support (issue #7): the agent needs the
|
||||
/// full chain to verify CRL signatures that chain up to the root CA.
|
||||
#[serde(default)]
|
||||
pub ca_chain: String,
|
||||
/// PEM-encoded agent server certificate.
|
||||
pub server_crt: String,
|
||||
/// PEM-encoded agent server private key (PKCS#8).
|
||||
pub server_key: String,
|
||||
/// PEM-encoded Certificate Revocation List (CRL) signed by the CA.
|
||||
/// The agent uses this to reject revoked client certificates during mTLS
|
||||
/// handshakes. If CRL generation fails during enrollment, this field will
|
||||
/// be an empty string and the agent should fall back to WebPKI-only
|
||||
/// verification (degraded mode).
|
||||
///
|
||||
/// Added for CRL support (issue #7).
|
||||
#[serde(default)]
|
||||
pub crl_pem: String,
|
||||
}
|
||||
|
||||
/// Time-to-live for approved enrollment PKI bundles (10 minutes).
|
||||
///
|
||||
/// After approval, the agent has this duration to retrieve its PKI bundle
|
||||
/// via the polling endpoint. Once retrieved (single-use) or expired,
|
||||
/// the bundle is permanently removed from the in-memory cache.
|
||||
///
|
||||
/// This TTL balances security (limiting private key exposure in memory)
|
||||
/// against reliability (giving agents enough time to poll after approval).
|
||||
pub const ENROLLMENT_BUNDLE_TTL_SECS: u32 = 600; // 10 minutes
|
||||
|
||||
/// An approved enrollment PKI bundle awaiting single-use retrieval.
|
||||
///
|
||||
/// Stored in the in-memory cache between admin approval and agent pickup.
|
||||
/// The entry is removed atomically on first retrieval and expires after
|
||||
/// the configured TTL, whichever comes first.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovedEntry {
|
||||
pub pki: PkiBundle,
|
||||
pub approved_at: chrono::DateTime<Utc>,
|
||||
pub ttl: chrono::Duration,
|
||||
}
|
||||
|
||||
impl ApprovedEntry {
|
||||
/// Create a new entry with the current timestamp and default TTL.
|
||||
pub fn new(pki: PkiBundle) -> Self {
|
||||
Self {
|
||||
pki,
|
||||
approved_at: Utc::now(),
|
||||
ttl: chrono::Duration::seconds(ENROLLMENT_BUNDLE_TTL_SECS as i64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this entry has exceeded its TTL.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.approved_at + self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -455,6 +543,10 @@ pub struct PatchJobSummary {
|
||||
pub status: JobStatus,
|
||||
pub immediate: bool,
|
||||
pub host_count: i64,
|
||||
/// Display names of hosts targeted by this job (falls back to fqdn).
|
||||
#[serde(default)]
|
||||
#[sqlx(skip)]
|
||||
pub host_names: Vec<String>,
|
||||
pub succeeded_count: i64,
|
||||
pub failed_count: i64,
|
||||
pub notes: String,
|
||||
|
||||
0
crates/pm-core/src/request_id.rs
Normal file → Executable file
0
crates/pm-core/src/request_id.rs
Normal file → Executable file
16
crates/pm-reports/src/csv.rs
Normal file → Executable file
16
crates/pm-reports/src/csv.rs
Normal file → Executable file
@ -77,7 +77,7 @@ ORDER BY compliance_pct ASC
|
||||
};
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"host_id",
|
||||
"display_name",
|
||||
"fqdn",
|
||||
@ -115,7 +115,7 @@ ORDER BY compliance_pct ASC
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -152,7 +152,7 @@ ORDER BY pjh.started_at DESC
|
||||
.context("patch history query failed")?;
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"job_id",
|
||||
"job_kind",
|
||||
"job_status",
|
||||
@ -194,7 +194,7 @@ ORDER BY pjh.started_at DESC
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -203,7 +203,7 @@ ORDER BY pjh.started_at DESC
|
||||
|
||||
async fn vulnerability_csv(pool: &sqlx::PgPool, params: &ReportParams) -> anyhow::Result<Vec<u8>> {
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"host_id",
|
||||
"display_name",
|
||||
"fqdn",
|
||||
@ -279,7 +279,7 @@ ORDER BY
|
||||
},
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -312,7 +312,7 @@ LIMIT 10000
|
||||
.context("audit query failed")?;
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"id",
|
||||
"created_at",
|
||||
"action",
|
||||
@ -347,5 +347,5 @@ LIMIT 10000
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
0
crates/pm-reports/src/lib.rs
Normal file → Executable file
0
crates/pm-reports/src/lib.rs
Normal file → Executable file
1
crates/pm-reports/src/pdf.rs
Normal file → Executable file
1
crates/pm-reports/src/pdf.rs
Normal file → Executable file
@ -169,6 +169,7 @@ impl PdfBuilder {
|
||||
self.current_y -= ROW_H;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn embed_image(
|
||||
&self,
|
||||
raw_rgb: Vec<u8>,
|
||||
|
||||
@ -5,6 +5,10 @@ edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "pm_web"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "pm-web"
|
||||
path = "src/main.rs"
|
||||
@ -33,6 +37,8 @@ ulid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
ipnet = { workspace = true }
|
||||
dashmap = { version = "6" }
|
||||
tower_governor = { workspace = true }
|
||||
governor = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
|
||||
rand = { workspace = true }
|
||||
@ -42,3 +48,11 @@ sha2 = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
rcgen = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
248
crates/pm-web/src/lib.rs
Normal file
248
crates/pm-web/src/lib.rs
Normal file
@ -0,0 +1,248 @@
|
||||
//! pm-web — Linux Patch Manager web server (library crate).
|
||||
//!
|
||||
//! Re-exports [`AppState`], [`build_router`], and [`health_handler`] so that
|
||||
//! integration tests can construct a test application without depending on
|
||||
//! the binary entry-point.
|
||||
|
||||
pub mod routes;
|
||||
pub mod secret_key;
|
||||
|
||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
||||
use dashmap::DashMap;
|
||||
use pm_auth::{
|
||||
password::hash_password,
|
||||
rbac::{require_auth, AuthConfig},
|
||||
};
|
||||
use pm_core::{config::AppConfig, models::ApprovedEntry, request_id::request_id_middleware};
|
||||
use rand::Rng;
|
||||
use routes::sso::{OidcCache, SsoHandoff, SsoSession};
|
||||
use routes::ws::WsTicket;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tower_governor::{
|
||||
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
|
||||
};
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8).
|
||||
/// Detecting this prefix means the admin password has not been bootstrapped yet.
|
||||
const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA";
|
||||
|
||||
/// Bootstrap the default admin account with a random password.
|
||||
///
|
||||
/// On first startup after a fresh install, the `users` table contains the seed
|
||||
/// admin row with a clearly-invalid placeholder hash (cannot validate any password).
|
||||
/// This function detects that placeholder, generates a cryptographically random
|
||||
/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row.
|
||||
///
|
||||
/// The plaintext password is printed **once** to stderr (visible in `systemctl status`
|
||||
/// or `journalctl`) and is never stored on disk.
|
||||
///
|
||||
/// If the admin row already has a real hash, this function is a no-op.
|
||||
pub async fn bootstrap_admin_password(pool: &sqlx::PgPool) {
|
||||
let result: Option<String> = sqlx::query_scalar(
|
||||
"SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let current_hash = match result {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) {
|
||||
return;
|
||||
}
|
||||
|
||||
let password: String = rand::thread_rng()
|
||||
.sample_iter(&rand::distributions::Alphanumeric)
|
||||
.take(24)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let new_hash = match hash_password(&password) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to hash bootstrap admin password");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"UPDATE users
|
||||
SET password_hash = $1
|
||||
WHERE username = 'admin'
|
||||
AND auth_provider = 'local'
|
||||
AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#,
|
||||
)
|
||||
.bind(&new_hash)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
match rows {
|
||||
Ok(result) if result.rows_affected() == 1 => {
|
||||
eprintln!();
|
||||
eprintln!("========================================");
|
||||
eprintln!(" INITIAL ADMIN PASSWORD (shown once)");
|
||||
eprintln!(" Username: admin");
|
||||
eprintln!(" Password: {}", password);
|
||||
eprintln!();
|
||||
eprintln!(" You will be forced to change this on first login.");
|
||||
eprintln!(" If lost, restart the service to generate a new one.");
|
||||
eprintln!("========================================");
|
||||
eprintln!();
|
||||
tracing::info!("Bootstrap admin password generated and set");
|
||||
},
|
||||
Ok(_) => {
|
||||
tracing::info!("Admin password already bootstrapped (concurrent or prior)");
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to update admin password hash");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared application state threaded through Axum.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: sqlx::PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
pub signing_key_pem: String,
|
||||
pub auth_config: Arc<AuthConfig>,
|
||||
/// In-memory store for single-use WebSocket authentication tickets.
|
||||
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
|
||||
/// In-memory store for SSO PKCE sessions (state → code_verifier).
|
||||
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
|
||||
/// In-memory store for SSO handoff codes (single-use, 60s TTL).
|
||||
/// See `tasks/sso-token-handoff-spec.md` §4.1.
|
||||
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
|
||||
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
|
||||
pub oidc_cache: Arc<Mutex<OidcCache>>,
|
||||
/// Internal certificate authority for mTLS client cert issuance.
|
||||
pub ca: Arc<pm_ca::CertAuthority>,
|
||||
/// Short-lived cache for approved enrollment PKI bundles.
|
||||
///
|
||||
/// Entries are single-use (removed on retrieval) and expire after
|
||||
/// [`ENROLLMENT_BUNDLE_TTL_SECS`](pm_core::models::ENROLLMENT_BUNDLE_TTL_SECS).
|
||||
pub approved_enrollments: Arc<DashMap<String, ApprovedEntry>>,
|
||||
}
|
||||
|
||||
/// Construct the full Axum router.
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let static_dir = state.config.server.static_dir.clone();
|
||||
let auth_config = state.auth_config.clone();
|
||||
let rl = &state.config.rate_limit;
|
||||
|
||||
// Enrollment rate limiting: strict (5 req/min per IP, burst 3)
|
||||
let enrollment_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(12_000)
|
||||
.burst_size(rl.enrollment_burst)
|
||||
.finish()
|
||||
.expect("Invalid enrollment governor config"),
|
||||
);
|
||||
|
||||
// Auth rate limiting: moderate (20 req/min per IP, burst 10)
|
||||
let auth_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(3_000)
|
||||
.burst_size(rl.auth_burst)
|
||||
.finish()
|
||||
.expect("Invalid auth governor config"),
|
||||
);
|
||||
|
||||
// API rate limiting: normal (120 req/min per IP, burst 30)
|
||||
let api_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(500)
|
||||
.burst_size(rl.api_burst)
|
||||
.finish()
|
||||
.expect("Invalid API governor config"),
|
||||
);
|
||||
|
||||
// Enrollment routes with strict per-IP rate limiting
|
||||
let enrollment_router =
|
||||
routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor));
|
||||
|
||||
// Public auth routes with moderate per-IP rate limiting
|
||||
let auth_public_router =
|
||||
routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
|
||||
|
||||
// SSO routes with moderate per-IP rate limiting
|
||||
let sso_public_router =
|
||||
routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
|
||||
let sso_azure_router =
|
||||
routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor));
|
||||
|
||||
// All protected API routes — require valid JWT, with normal per-IP rate limiting
|
||||
let protected_api = Router::new()
|
||||
.nest("/auth", routes::auth::protected_router())
|
||||
.nest("/hosts", routes::hosts::router())
|
||||
.nest("/hosts", routes::ca::host_cert_router())
|
||||
.nest("/groups", routes::groups::router())
|
||||
.nest("/users", routes::users::router())
|
||||
.nest("/discovery", routes::discovery::router())
|
||||
.nest("/status", routes::status::router())
|
||||
.nest("/jobs", routes::jobs::router())
|
||||
.nest(
|
||||
"/hosts/{host_id}/maintenance-windows",
|
||||
routes::maintenance_windows::router(),
|
||||
)
|
||||
.nest(
|
||||
"/maintenance-windows",
|
||||
routes::maintenance_windows::all_windows_router(),
|
||||
)
|
||||
.nest("/ca", routes::ca::ca_router())
|
||||
.nest("/certificates", routes::ca::certs_router())
|
||||
.merge(routes::ws::ticket_router())
|
||||
.nest("/reports", routes::reports::router())
|
||||
.nest(
|
||||
"/hosts/{host_id}/health-checks",
|
||||
routes::health_checks::router(),
|
||||
)
|
||||
.nest("/settings", routes::settings::router())
|
||||
.nest("/admin", routes::enrollment::admin_router())
|
||||
.layer(GovernorLayer::new(api_governor))
|
||||
.route_layer(middleware::from_fn(move |req, next| {
|
||||
let auth_config = auth_config.clone();
|
||||
require_auth(auth_config, req, next)
|
||||
}));
|
||||
|
||||
Router::new()
|
||||
.route("/status/health", get(health_handler))
|
||||
.nest("/api/v1/auth", auth_public_router)
|
||||
.nest("/api/v1", enrollment_router)
|
||||
.nest("/api/v1", routes::pki::router())
|
||||
.nest("/api/v1/auth/sso", sso_public_router)
|
||||
.nest("/api/v1/auth/azure", sso_azure_router)
|
||||
.nest("/api/v1", protected_api)
|
||||
.merge(routes::ws::ws_router())
|
||||
.fallback_service(
|
||||
ServeDir::new(&static_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeFile::new(format!("{}/index.html", static_dir))),
|
||||
)
|
||||
.layer(middleware::from_fn(request_id_middleware))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
||||
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let status = if db_ok { "healthy" } else { "degraded" };
|
||||
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
|
||||
if db_ok {
|
||||
Ok(Json(body))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
@ -1,51 +1,13 @@
|
||||
//! pm-web — Linux Patch Manager web server.
|
||||
//! pm-web — Linux Patch Manager web server (binary entry-point).
|
||||
|
||||
mod routes;
|
||||
|
||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use dashmap::DashMap;
|
||||
use pm_auth::{
|
||||
jwt,
|
||||
rbac::{require_auth, AuthConfig},
|
||||
};
|
||||
use pm_core::{
|
||||
config::AppConfig, db, logging, models::PkiBundle, request_id::request_id_middleware,
|
||||
};
|
||||
use routes::sso::{OidcCache, SsoSession};
|
||||
use routes::ws::WsTicket;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use pm_auth::{jwt, rbac::AuthConfig};
|
||||
use pm_core::{config::AppConfig, db, models::ApprovedEntry};
|
||||
use pm_web::routes::sso::{OidcCache, SsoHandoff, SsoSession};
|
||||
use pm_web::routes::ws::WsTicket;
|
||||
use pm_web::{bootstrap_admin_password, build_router, AppState};
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tokio::sync::Mutex;
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
/// Shared application state threaded through Axum.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: sqlx::PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
pub signing_key_pem: String,
|
||||
pub auth_config: Arc<AuthConfig>,
|
||||
/// In-memory store for single-use WebSocket authentication tickets.
|
||||
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
|
||||
/// In-memory store for SSO PKCE sessions (state → code_verifier).
|
||||
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
|
||||
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
|
||||
pub oidc_cache: Arc<Mutex<OidcCache>>,
|
||||
/// Internal certificate authority for mTLS client cert issuance.
|
||||
pub ca: Arc<pm_ca::CertAuthority>,
|
||||
/// IP-based rate limits for enrollment requests.
|
||||
pub enrollment_rate_limits: Arc<DashMap<IpAddr, Instant>>,
|
||||
/// Short-lived cache for approved enrollment PKI bundles.
|
||||
pub approved_enrollments: Arc<DashMap<String, PkiBundle>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@ -62,7 +24,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
AppConfig::default()
|
||||
});
|
||||
|
||||
logging::init(&config.logging);
|
||||
pm_core::logging::init(&config.logging);
|
||||
tracing::info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
"patch-manager-web starting"
|
||||
@ -83,14 +45,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
let auth_config = Arc::new(AuthConfig::new(
|
||||
verify_key_pem,
|
||||
&config.security.ip_whitelist,
|
||||
&config.security.trusted_proxies,
|
||||
));
|
||||
|
||||
let pool = db::init_pool(&config.database).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
|
||||
// Initialise the internal CA. Panics in production if CA files are missing
|
||||
// or corrupt — this is intentional; the service cannot operate without mTLS.
|
||||
let ca_base = std::path::Path::new("/etc/patch-manager/ca");
|
||||
// Bootstrap admin password if the seed admin still has the placeholder hash.
|
||||
bootstrap_admin_password(&pool).await;
|
||||
|
||||
// Initialise the internal CA using the configured certificate paths.
|
||||
let ca_base = std::path::Path::new(&config.security.ca_cert_path)
|
||||
.parent()
|
||||
.expect("CA certificate path must have a parent directory");
|
||||
let ca = pm_ca::CertAuthority::init(ca_base, &pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
@ -100,9 +67,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
|
||||
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
|
||||
let sso_handoffs: Arc<DashMap<String, SsoHandoff>> = Arc::new(DashMap::new());
|
||||
let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default()));
|
||||
let enrollment_rate_limits: Arc<DashMap<IpAddr, Instant>> = Arc::new(DashMap::new());
|
||||
let approved_enrollments: Arc<DashMap<String, PkiBundle>> = Arc::new(DashMap::new());
|
||||
let approved_enrollments: Arc<DashMap<String, ApprovedEntry>> = Arc::new(DashMap::new());
|
||||
|
||||
// Background task: purge expired WS tickets every 30 seconds.
|
||||
{
|
||||
@ -122,7 +89,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: purge expired SSO sessions every 60 seconds (sessions older than 10 minutes).
|
||||
// Background task: purge expired SSO sessions every 60 seconds.
|
||||
{
|
||||
let sessions = sso_sessions.clone();
|
||||
tokio::spawn(async move {
|
||||
@ -141,27 +108,37 @@ async fn main() -> anyhow::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: purge expired enrollment rate limits every 5 minutes.
|
||||
// Background task: purge expired approved enrollment PKI bundles.
|
||||
{
|
||||
let limits = enrollment_rate_limits.clone();
|
||||
let approved = approved_enrollments.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = Instant::now();
|
||||
limits.retain(|_, v| now.duration_since(*v) < Duration::from_secs(3600));
|
||||
let before = approved.len();
|
||||
approved.retain(|_, entry| !entry.is_expired());
|
||||
let removed = before.saturating_sub(approved.len());
|
||||
if removed > 0 {
|
||||
tracing::debug!(removed, "Purged expired enrollment PKI bundles");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: purge approved enrollment PKI bundles every 10 minutes.
|
||||
// Background task: purge expired SSO handoff codes every 60 seconds.
|
||||
{
|
||||
let approved = approved_enrollments.clone();
|
||||
let handoffs = sso_handoffs.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(600));
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
approved.clear();
|
||||
let now = std::time::Instant::now();
|
||||
let before = handoffs.len();
|
||||
handoffs.retain(|_, v| v.expires_at > now);
|
||||
let removed = before.saturating_sub(handoffs.len());
|
||||
if removed > 0 {
|
||||
tracing::debug!(removed, "Purged expired SSO handoff codes");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -173,8 +150,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
auth_config,
|
||||
ws_tickets,
|
||||
sso_sessions,
|
||||
sso_handoffs,
|
||||
ca: Arc::new(ca),
|
||||
enrollment_rate_limits,
|
||||
approved_enrollments,
|
||||
oidc_cache,
|
||||
};
|
||||
@ -190,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let tls_key = std::path::Path::new(&config.security.web_tls_key_path);
|
||||
|
||||
if tls_cert.exists() && tls_key.exists() {
|
||||
let tls_config = RustlsConfig::from_pem_file(
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||
&config.security.web_tls_cert_path,
|
||||
&config.security.web_tls_key_path,
|
||||
)
|
||||
@ -202,7 +179,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
tracing::info!(%addr, "Listening (HTTPS)");
|
||||
axum_server::bind_rustls(addr, tls_config)
|
||||
.serve(app.into_make_service())
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await?;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@ -213,95 +190,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
tracing::info!(%addr, "Listening (HTTP — no TLS)");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Construct the full Axum router.
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let static_dir = state.config.server.static_dir.clone();
|
||||
let auth_config = state.auth_config.clone();
|
||||
|
||||
// All protected API routes — require valid JWT
|
||||
let protected_api = Router::new()
|
||||
// Auth: MFA setup/verify
|
||||
// Auth: MFA setup/verify/disable (nested under /auth so paths are /api/v1/auth/mfa/*)
|
||||
.nest("/auth", routes::auth::protected_router())
|
||||
// Hosts
|
||||
.nest("/hosts", routes::hosts::router())
|
||||
// Host-scoped certificate endpoints (merged separately to avoid conflict)
|
||||
.nest("/hosts", routes::ca::host_cert_router())
|
||||
// Groups
|
||||
.nest("/groups", routes::groups::router())
|
||||
// Users
|
||||
.nest("/users", routes::users::router())
|
||||
// Discovery
|
||||
.nest("/discovery", routes::discovery::router())
|
||||
// Fleet status
|
||||
.nest("/status", routes::status::router())
|
||||
// Patch jobs
|
||||
.nest("/jobs", routes::jobs::router())
|
||||
// Maintenance windows (nested under hosts path param)
|
||||
.nest(
|
||||
"/hosts/{host_id}/maintenance-windows",
|
||||
routes::maintenance_windows::router(),
|
||||
)
|
||||
// CA root certificate download
|
||||
.nest("/ca", routes::ca::ca_router())
|
||||
// Certificate list / renew / revoke
|
||||
.nest("/certificates", routes::ca::certs_router())
|
||||
// WS ticket issuance (JWT-protected — ticket returned to browser, then used for WS upgrade)
|
||||
.merge(routes::ws::ticket_router())
|
||||
// Reports
|
||||
.nest("/reports", routes::reports::router())
|
||||
.nest(
|
||||
"/hosts/{host_id}/health-checks",
|
||||
routes::health_checks::router(),
|
||||
)
|
||||
// Settings (admin-only)
|
||||
.nest("/settings", routes::settings::router())
|
||||
// Admin enrollment routes (JWT protected, Admin role enforced)
|
||||
.nest("/admin", routes::enrollment::admin_router())
|
||||
// Apply auth middleware to all the above
|
||||
.route_layer(middleware::from_fn(move |req, next| {
|
||||
let auth_config = auth_config.clone();
|
||||
require_auth(auth_config, req, next)
|
||||
}));
|
||||
|
||||
Router::new()
|
||||
.route("/status/health", get(health_handler))
|
||||
// Public auth routes (no JWT needed)
|
||||
.nest("/api/v1/auth", routes::auth::public_router())
|
||||
// Public enrollment endpoints (rate-limited, no JWT)
|
||||
.nest("/api/v1", routes::enrollment::router())
|
||||
// Public SSO routes (no JWT needed)
|
||||
.nest("/api/v1/auth/sso", routes::sso::public_router())
|
||||
// Public Azure SSO routes (no JWT needed)
|
||||
.nest("/api/v1/auth/azure", routes::sso::azure_compat_router())
|
||||
// Protected API routes (JWT required)
|
||||
.nest("/api/v1", protected_api)
|
||||
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware
|
||||
.merge(routes::ws::ws_router())
|
||||
// Serve React SPA
|
||||
.fallback_service(
|
||||
ServeDir::new(&static_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeFile::new(format!("{}/index.html", static_dir))),
|
||||
)
|
||||
.layer(middleware::from_fn(request_id_middleware))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
||||
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let status = if db_ok { "healthy" } else { "degraded" };
|
||||
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
|
||||
if db_ok {
|
||||
Ok(Json(body))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
@ -360,8 +360,26 @@ async fn mfa_verify_handler(
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE users SET totp_secret = $1, mfa_enabled = TRUE WHERE id = $2")
|
||||
.bind(&req.secret_base32)
|
||||
// Encrypt the TOTP secret before persisting (issue #6 fix)
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({ "error": { "code": "internal_error", "message": "Encryption key error" } }),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(&req.secret_base32, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt TOTP secret");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query("UPDATE users SET totp_secret_encrypted = $1, totp_secret_nonce = $2, mfa_enabled = TRUE WHERE id = $3")
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@ -417,7 +435,7 @@ async fn disable_mfa(
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE WHERE id = $1")
|
||||
sqlx::query("UPDATE users SET totp_secret_encrypted = NULL, totp_secret_nonce = NULL, mfa_enabled = FALSE WHERE id = $1")
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
|
||||
0
crates/pm-web/src/routes/ca.rs
Normal file → Executable file
0
crates/pm-web/src/routes/ca.rs
Normal file → Executable file
2
crates/pm-web/src/routes/discovery.rs
Normal file → Executable file
2
crates/pm-web/src/routes/discovery.rs
Normal file → Executable file
@ -174,7 +174,7 @@ async fn probe_and_store(pool: sqlx::PgPool, scan_id: Uuid, ip: IpAddr, port: u1
|
||||
/// Simple reverse DNS lookup.
|
||||
fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
let addr = SocketAddr::new(ip, 0);
|
||||
let _addr = SocketAddr::new(ip, 0);
|
||||
// Standard library doesn't have reverse lookup; use getaddrinfo via format
|
||||
let host = format!("{ip}");
|
||||
// Best-effort: try to resolve numeric address to hostname
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
@ -11,13 +11,12 @@ use pm_auth::AuthUser;
|
||||
use pm_core::{
|
||||
db,
|
||||
models::{
|
||||
CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host, PkiBundle,
|
||||
ApprovedEntry, CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host,
|
||||
PkiBundle,
|
||||
},
|
||||
};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use serde::Serialize;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HostConflict {
|
||||
@ -34,43 +33,12 @@ pub fn router() -> Router<AppState> {
|
||||
|
||||
/// POST /api/v1/enroll
|
||||
/// Initiates host self-enrollment.
|
||||
/// Rate limiting is handled by tower-governor middleware (per-IP, configurable).
|
||||
async fn enroll_host(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateEnrollmentRequest>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
// 1. IP-based Rate Limiting
|
||||
// Prefer real IP from headers if behind proxy (e.g., X-Forwarded-For)
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.split(',').next())
|
||||
.and_then(|h| h.trim().parse::<IpAddr>().ok())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"No X-Forwarded-For header found for enrollment request from public endpoint"
|
||||
);
|
||||
// Default to a placeholder IP since we can't extract the socket addr without the ConnectInfo layer
|
||||
"0.0.0.0".parse().unwrap()
|
||||
});
|
||||
|
||||
{
|
||||
let mut rate_limits = state
|
||||
.enrollment_rate_limits
|
||||
.entry(ip)
|
||||
.or_insert(Instant::now() - std::time::Duration::from_secs(3600));
|
||||
let last_request = rate_limits.value();
|
||||
if last_request.elapsed().as_secs() < 60 {
|
||||
// 1 request per minute per IP
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(serde_json::json!({ "error": "Rate limit exceeded. Try again in a minute." })),
|
||||
));
|
||||
}
|
||||
*rate_limits = Instant::now();
|
||||
}
|
||||
|
||||
// 2. Generate secure random polling token
|
||||
// Generate secure random polling token
|
||||
let polling_token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(64)
|
||||
@ -109,7 +77,10 @@ async fn enroll_status(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
) -> Result<Json<EnrollmentStatusResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Hash the provided token to match DB
|
||||
// Hash the provided token to match DB.
|
||||
// Security note: the raw polling token is intentionally never logged.
|
||||
// Only the SHA-256 hash is stored and compared; all tracing calls in
|
||||
// this module log error contexts only, never the token itself.
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
@ -131,11 +102,19 @@ async fn enroll_status(
|
||||
}
|
||||
|
||||
// 2. If not in pending, check if it was recently approved.
|
||||
if let Some(pki) = state.approved_enrollments.get(&token_hash) {
|
||||
// Single-retrieval: remove() atomically consumes the entry, ensuring
|
||||
// the private key can only be fetched once regardless of concurrent requests.
|
||||
if let Some((_, entry)) = state.approved_enrollments.remove(&token_hash) {
|
||||
if entry.is_expired() {
|
||||
// Bundle TTL expired — treat as not found. Entry is already removed.
|
||||
return Ok(Json(EnrollmentStatusResponse::NotFound));
|
||||
}
|
||||
return Ok(Json(EnrollmentStatusResponse::Approved {
|
||||
ca_crt: pki.ca_crt.clone(),
|
||||
server_crt: pki.server_crt.clone(),
|
||||
server_key: pki.server_key.clone(),
|
||||
ca_crt: entry.pki.ca_crt.clone(),
|
||||
ca_chain: entry.pki.ca_chain.clone(),
|
||||
server_crt: entry.pki.server_crt.clone(),
|
||||
server_key: entry.pki.server_key.clone(),
|
||||
crl_pem: entry.pki.crl_pem.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -167,7 +146,7 @@ async fn list_admin_enrollments(
|
||||
|
||||
db::list_enrollment_requests(&state.db)
|
||||
.await
|
||||
.map(|requests| Json(requests))
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to list enrollment requests");
|
||||
(
|
||||
@ -209,10 +188,10 @@ async fn approve_enrollment(
|
||||
|
||||
// Check for FQDN/IP collision in hosts table
|
||||
if let Some(existing_host) = sqlx::query_as::<_, Host>(
|
||||
"SELECT id, fqdn, ip_address, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2"
|
||||
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at, crl_status, crl_age_seconds, crl_next_update FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
|
||||
)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(&enrollment_request.ip_address.to_string())
|
||||
.bind(enrollment_request.ip_address.to_string())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@ -225,7 +204,63 @@ async fn approve_enrollment(
|
||||
));
|
||||
}
|
||||
|
||||
// Generate PKI bundle using CA
|
||||
// Move to hosts table FIRST (certificates table has FK reference to hosts)
|
||||
let os_family = enrollment_request
|
||||
.os_details
|
||||
.get("os")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let os_name = enrollment_request
|
||||
.os_details
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
// Build os_name from os + os_version if "name" is absent
|
||||
let os = enrollment_request
|
||||
.os_details
|
||||
.get("os")
|
||||
.and_then(|v| v.as_str())?;
|
||||
let ver = enrollment_request
|
||||
.os_details
|
||||
.get("os_version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
Some(format!("{} {}", os, ver).trim().to_string())
|
||||
});
|
||||
let arch = enrollment_request
|
||||
.os_details
|
||||
.get("architecture")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let display_name = enrollment_request
|
||||
.hostname
|
||||
.clone()
|
||||
.unwrap_or_else(|| enrollment_request.fqdn.clone());
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO hosts (id, fqdn, ip_address, os_family, os_name, arch, display_name, registered_at, updated_at)
|
||||
VALUES ($1, $2, $3::inet, $4, $5, $6, $7, NOW(), NOW())
|
||||
"#,
|
||||
)
|
||||
.bind(enrollment_request.id)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(enrollment_request.ip_address.to_string())
|
||||
.bind(&os_family)
|
||||
.bind(&os_name)
|
||||
.bind(&arch)
|
||||
.bind(&display_name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to insert host after approval");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Generate PKI bundle using CA (after host row exists)
|
||||
let issued = state
|
||||
.ca
|
||||
.issue_client_cert(
|
||||
@ -243,33 +278,6 @@ async fn approve_enrollment(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Move to hosts table
|
||||
let os_name = enrollment_request
|
||||
.os_details
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO hosts (id, fqdn, ip_address, os_name, registered_at, updated_at, machine_id)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW(), $5)
|
||||
"#,
|
||||
)
|
||||
.bind(enrollment_request.id)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(&enrollment_request.ip_address.to_string())
|
||||
.bind(os_name)
|
||||
.bind(enrollment_request.machine_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to insert host after approval");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Delete from enrollment_requests table
|
||||
db::delete_enrollment_request(&state.db, id)
|
||||
.await
|
||||
@ -281,15 +289,38 @@ async fn approve_enrollment(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Store PKI bundle in cache for client retrieval
|
||||
// Store PKI bundle in cache for single-use client retrieval.
|
||||
//
|
||||
// Design decision — server-generated keys vs CSR-based enrollment:
|
||||
// Currently the server generates the agent's private key and transmits it
|
||||
// over the (already mTLS-secured) polling endpoint. This approach was chosen
|
||||
// for initial implementation simplicity: the agent only needs to poll one
|
||||
// endpoint and receives a complete PKI bundle without an extra round-trip.
|
||||
//
|
||||
// A future enhancement should adopt CSR-based enrollment where the agent
|
||||
// generates its own key pair locally and submits a Certificate Signing
|
||||
// Request, eliminating the need for the server to ever hold or transmit
|
||||
// the agent's private key. This reduces the attack surface significantly
|
||||
// — the private key never traverses the network and never resides in
|
||||
// server memory beyond the signing operation.
|
||||
//
|
||||
// See: https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9
|
||||
//
|
||||
// Include the full CA chain (for root mode, same as ca_crt; for sub-CA,
|
||||
// includes intermediate + root) and the current CRL.
|
||||
let ca_chain = issued.ca_root_pem.clone(); // Root mode: chain is just the root cert
|
||||
let crl_pem = state.ca.generate_crl(&state.db).await.unwrap_or_default(); // Empty string on failure: agent falls back to WebPKI-only
|
||||
let pki = PkiBundle {
|
||||
ca_crt: issued.ca_root_pem,
|
||||
ca_chain,
|
||||
server_crt: issued.server_cert_pem,
|
||||
server_key: issued.server_key_pem,
|
||||
crl_pem,
|
||||
};
|
||||
state
|
||||
.approved_enrollments
|
||||
.insert(enrollment_request.polling_token.clone(), pki);
|
||||
state.approved_enrollments.insert(
|
||||
enrollment_request.polling_token.clone(),
|
||||
ApprovedEntry::new(pki),
|
||||
);
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
2
crates/pm-web/src/routes/groups.rs
Normal file → Executable file
2
crates/pm-web/src/routes/groups.rs
Normal file → Executable file
@ -12,7 +12,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
|
||||
9
crates/pm-web/src/routes/health_checks.rs
Normal file → Executable file
9
crates/pm-web/src/routes/health_checks.rs
Normal file → Executable file
@ -11,7 +11,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
@ -24,7 +24,7 @@ use pm_core::{
|
||||
},
|
||||
};
|
||||
use reqwest::tls::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
@ -631,7 +631,6 @@ async fn update_health_check(
|
||||
set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if set_clauses.is_empty() {
|
||||
@ -644,7 +643,7 @@ async fn update_health_check(
|
||||
}
|
||||
|
||||
// Always update updated_at
|
||||
set_clauses.push(format!("updated_at = NOW()"));
|
||||
set_clauses.push("updated_at = NOW()".to_string());
|
||||
|
||||
// Use a simpler approach: query the current row, apply changes, update
|
||||
// This avoids complex dynamic SQL binding issues
|
||||
@ -945,7 +944,7 @@ async fn run_service_check(check: &HealthCheck, state: &AppState) -> CheckResult
|
||||
}
|
||||
CheckResult {
|
||||
healthy: false,
|
||||
detail: format!("Failed to parse agent response"),
|
||||
detail: "Failed to parse agent response".to_string(),
|
||||
latency_ms: Some(latency),
|
||||
}
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
//! POST /api/v1/hosts — register new host (admin only)
|
||||
//! GET /api/v1/hosts/{id} — get host detail
|
||||
//! DELETE /api/v1/hosts/{id} — remove host (admin only)
|
||||
//! PUT /api/v1/hosts/{id} — update host (write access)
|
||||
//! GET /api/v1/hosts/{id}/groups — list groups for host
|
||||
//! POST /api/v1/hosts/{id}/groups — assign host to group
|
||||
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
|
||||
@ -19,7 +20,7 @@ use axum::{
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
models::{CreateHostRequest, Group, HostSummary},
|
||||
models::{CreateHostRequest, Group, HostSummary, UpdateHostRequest},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@ -30,7 +31,7 @@ use crate::AppState;
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_hosts).post(register_host))
|
||||
.route("/{id}", get(get_host).delete(remove_host))
|
||||
.route("/{id}", get(get_host).put(update_host).delete(remove_host))
|
||||
.route(
|
||||
"/{id}/groups",
|
||||
get(list_host_groups).post(add_host_to_group),
|
||||
@ -42,6 +43,7 @@ pub fn router() -> Router<AppState> {
|
||||
// ── Query params ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct HostListQuery {
|
||||
pub group_id: Option<Uuid>,
|
||||
pub health_status: Option<String>,
|
||||
@ -130,7 +132,8 @@ async fn list_hosts(
|
||||
THEN 'some_unhealthy'
|
||||
ELSE 'all_healthy'
|
||||
END AS health_check_status,
|
||||
h.registered_at
|
||||
h.registered_at,
|
||||
h.crl_status
|
||||
FROM hosts h
|
||||
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
||||
ORDER BY h.fqdn
|
||||
@ -163,7 +166,8 @@ async fn list_hosts(
|
||||
THEN 'some_unhealthy'
|
||||
ELSE 'all_healthy'
|
||||
END AS health_check_status,
|
||||
h.registered_at
|
||||
h.registered_at,
|
||||
h.crl_status
|
||||
FROM hosts h
|
||||
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
||||
WHERE
|
||||
@ -317,7 +321,8 @@ async fn get_host(
|
||||
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
||||
os_family, os_name, arch, agent_version, health_status,
|
||||
last_health_at, last_patch_at, agent_port, notes,
|
||||
registered_at, updated_at
|
||||
registered_at, updated_at,
|
||||
crl_status, crl_age_seconds, crl_next_update
|
||||
FROM hosts WHERE id = $1
|
||||
) h
|
||||
"#,
|
||||
@ -398,6 +403,69 @@ async fn remove_host(
|
||||
Ok(Json(json!({ "message": "Host removed" })))
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/hosts/:id ─────────────────────────────────────────────────────
|
||||
|
||||
async fn update_host(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateHostRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
if !auth.role.can_write() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||
));
|
||||
}
|
||||
|
||||
// Update only fields that were provided; COALESCE preserves existing values.
|
||||
let host = sqlx::query_scalar(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE hosts SET
|
||||
fqdn = COALESCE($1, fqdn),
|
||||
ip_address = COALESCE($2::inet, ip_address),
|
||||
display_name = COALESCE($3, display_name),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
RETURNING id
|
||||
)
|
||||
SELECT row_to_json(h) FROM (
|
||||
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
||||
os_family, os_name, arch, agent_version, health_status,
|
||||
last_health_at, last_patch_at, agent_port, notes,
|
||||
registered_at, updated_at, crl_status, crl_age_seconds, crl_next_update
|
||||
FROM hosts WHERE id = (SELECT id FROM updated)
|
||||
) h
|
||||
"#,
|
||||
)
|
||||
.bind(&req.fqdn)
|
||||
.bind(&req.ip_address)
|
||||
.bind(&req.display_name)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, host_id = %id, "Failed to update host");
|
||||
let msg = if e.to_string().contains("unique") {
|
||||
"A host with this FQDN and IP already exists".to_string()
|
||||
} else {
|
||||
"Database error".to_string()
|
||||
};
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": { "code": "conflict", "message": msg } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
host.map(Json).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── GET /api/v1/hosts/:id/groups ──────────────────────────────────────────────
|
||||
|
||||
async fn list_host_groups(
|
||||
|
||||
@ -20,6 +20,7 @@ use pm_core::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
@ -52,6 +53,13 @@ struct JobListResponse {
|
||||
offset: i64,
|
||||
}
|
||||
|
||||
/// Helper struct for the host_names aggregation query.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct JobHostNames {
|
||||
id: Uuid,
|
||||
host_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// Per-host row included in `GET /api/v1/jobs/{id}` response.
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
struct JobHostRow {
|
||||
@ -229,7 +237,7 @@ async fn list_jobs(
|
||||
let limit = q.limit.unwrap_or(50).min(200);
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
|
||||
let jobs: Vec<PatchJobSummary> = if auth.role.is_admin() {
|
||||
let mut jobs: Vec<PatchJobSummary> = if auth.role.is_admin() {
|
||||
// Admins see every job.
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
@ -298,6 +306,40 @@ async fn list_jobs(
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
})?;
|
||||
|
||||
// Fetch host names for all jobs in this page.
|
||||
let job_ids: Vec<Uuid> = jobs.iter().map(|j| j.id).collect();
|
||||
let host_names_rows: Vec<JobHostNames> = if job_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT pjh.job_id AS id,
|
||||
array_agg(COALESCE(NULLIF(h.display_name, ''), h.fqdn)
|
||||
ORDER BY h.fqdn) AS host_names
|
||||
FROM patch_job_hosts pjh
|
||||
JOIN hosts h ON h.id = pjh.host_id
|
||||
WHERE pjh.job_id = ANY($1)
|
||||
GROUP BY pjh.job_id
|
||||
"#,
|
||||
)
|
||||
.bind(&job_ids)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "list_jobs: host_names query failed, using empty defaults");
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
|
||||
// Merge host_names into summaries.
|
||||
let mut host_names_map: HashMap<Uuid, Vec<String>> = host_names_rows
|
||||
.into_iter()
|
||||
.map(|r| (r.id, r.host_names))
|
||||
.collect();
|
||||
for job in &mut jobs {
|
||||
job.host_names = host_names_map.remove(&job.id).unwrap_or_default();
|
||||
}
|
||||
|
||||
// Total count for pagination metadata.
|
||||
let total: i64 = if auth.role.is_admin() {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM patch_jobs")
|
||||
@ -438,7 +480,7 @@ async fn cancel_job(
|
||||
|
||||
// Only admin or the job creator may cancel.
|
||||
if !auth.role.can_write() {
|
||||
let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id);
|
||||
let is_creator = creator_id == Some(auth.user_id);
|
||||
if !is_creator {
|
||||
return Err(err(
|
||||
StatusCode::FORBIDDEN,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! Maintenance window management routes.
|
||||
//!
|
||||
//! GET /api/v1/hosts/{id}/maintenance-windows — list windows for host
|
||||
//! GET /api/v1/maintenance-windows — list ALL windows (bulk)
|
||||
//! POST /api/v1/hosts/{id}/maintenance-windows — create window for host
|
||||
//! PUT /api/v1/hosts/{id}/maintenance-windows/{win_id} — update window
|
||||
//! DELETE /api/v1/hosts/{id}/maintenance-windows/{win_id} — delete window
|
||||
@ -32,6 +33,41 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/{win_id}", put(update_window).delete(delete_window))
|
||||
}
|
||||
|
||||
/// Top-level router for `/api/v1/maintenance-windows` — bulk list-all endpoint.
|
||||
pub fn all_windows_router() -> Router<AppState> {
|
||||
Router::new().route("/", get(list_all_windows))
|
||||
}
|
||||
|
||||
// ── GET /api/v1/maintenance-windows ──────────────────────────────────────────
|
||||
|
||||
/// Bulk endpoint: return every maintenance window across all hosts.
|
||||
/// Eliminates N+1 queries from the frontend (one request instead of one per host).
|
||||
async fn list_all_windows(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthUser,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let windows: Vec<MaintenanceWindow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
|
||||
recurrence_day, enabled, auto_apply, created_at, updated_at
|
||||
FROM maintenance_windows
|
||||
ORDER BY host_id, created_at ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "list_all_windows: query failed");
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({ "windows": windows })))
|
||||
}
|
||||
|
||||
// ── Error helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[inline]
|
||||
|
||||
@ -8,10 +8,10 @@ pub mod health_checks;
|
||||
pub mod hosts;
|
||||
pub mod jobs;
|
||||
pub mod maintenance_windows;
|
||||
pub mod pki;
|
||||
pub mod reports;
|
||||
pub mod settings;
|
||||
pub mod sso;
|
||||
pub mod status;
|
||||
pub mod users;
|
||||
pub mod ws;
|
||||
|
||||
pub mod reports;
|
||||
|
||||
262
crates/pm-web/src/routes/pki.rs
Normal file
262
crates/pm-web/src/routes/pki.rs
Normal file
@ -0,0 +1,262 @@
|
||||
//! PKI endpoints for certificate revocation list (CRL) distribution.
|
||||
//!
|
||||
//! This module exposes the CRL endpoint that agents poll every 24 hours to
|
||||
//! check for revoked certificates. The CRL is signed by the internal CA and
|
||||
//! is publicly accessible (CRLs are self-authenticating — they carry the CA
|
||||
//! signature and do not require client authentication).
|
||||
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
/// Define public PKI routes.
|
||||
///
|
||||
/// These endpoints are **unauthenticated** because CRLs are self-authenticating:
|
||||
/// the agent verifies the CRL signature against its pinned CA certificate.
|
||||
/// No client certificate or API key is required.
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/pki/crl.pem", get(get_crl))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/pki/crl.pem`
|
||||
///
|
||||
/// Returns the current Certificate Revocation List (CRL) as a PEM-encoded
|
||||
/// X.509 CRL. The CRL is signed by the internal CA and contains the serial
|
||||
/// numbers of all revoked certificates that have not yet expired.
|
||||
///
|
||||
/// # Cache headers
|
||||
///
|
||||
/// The response includes `Cache-Control: max-age=3600` (1 hour) to allow
|
||||
/// intermediate caches to serve the CRL. Agents refresh every 24 hours,
|
||||
/// so a 1-hour cache is a reasonable balance between freshness and load.
|
||||
///
|
||||
/// # CRL generation
|
||||
///
|
||||
/// The CRL is generated on demand from the `certificates` table. For our
|
||||
/// target scale (max ~2500 clients), this is a fast query and the resulting
|
||||
/// CRL is KB-range. If performance becomes a concern, the CRL can be cached
|
||||
/// in memory and regenerated on a schedule (see background task in main.rs).
|
||||
async fn get_crl(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.ca.generate_crl(&state.db).await {
|
||||
Ok(crl_pem) => (
|
||||
StatusCode::OK,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
"application/x-pem-file; charset=utf-8",
|
||||
)],
|
||||
[(header::CACHE_CONTROL, "max-age=3600")],
|
||||
crl_pem,
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to generate CRL");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate CRL").into_response()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::Router;
|
||||
use dashmap::DashMap;
|
||||
use pm_auth::rbac::AuthConfig;
|
||||
use pm_core::config::AppConfig;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Helper: create a test AppState with a real CA and database pool.
|
||||
/// Returns None if TEST_DATABASE_URL is not set (tests are skipped).
|
||||
async fn setup_app_state() -> Option<(PgPool, AppState)> {
|
||||
let db_url = std::env::var("TEST_DATABASE_URL").ok()?;
|
||||
let pool = PgPool::connect(&db_url).await.ok()?;
|
||||
|
||||
// Run migrations to ensure schema is up to date.
|
||||
sqlx::migrate!("../../migrations").run(&pool).await.ok()?;
|
||||
|
||||
// Create a temp directory for the CA.
|
||||
let tmp_dir = tempfile::tempdir().ok()?;
|
||||
let ca_dir = tmp_dir.path().to_path_buf();
|
||||
|
||||
let ca = pm_ca::CertAuthority::init(&ca_dir, &pool).await.ok()?;
|
||||
|
||||
let config = Arc::new(AppConfig::default());
|
||||
|
||||
use crate::routes::sso::OidcCache;
|
||||
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
config,
|
||||
signing_key_pem: String::new(),
|
||||
auth_config: Arc::new(AuthConfig::new(String::new(), &[], &[])),
|
||||
ws_tickets: Arc::new(DashMap::new()),
|
||||
sso_sessions: Arc::new(DashMap::new()),
|
||||
sso_handoffs: Arc::new(DashMap::new()),
|
||||
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
|
||||
ca: Arc::new(ca),
|
||||
approved_enrollments: Arc::new(DashMap::new()),
|
||||
};
|
||||
|
||||
Some((pool, state))
|
||||
}
|
||||
|
||||
/// Build an Axum app with just the PKI routes for testing.
|
||||
fn test_app(state: AppState) -> Router {
|
||||
Router::new().nest("/api/v1", router()).with_state(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crl_endpoint_returns_200_with_valid_pem() {
|
||||
let Some((pool, state)) = setup_app_state().await else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let app = test_app(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pki/crl.pem")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"CRL endpoint should return 200 OK"
|
||||
);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), 10_000)
|
||||
.await
|
||||
.expect("body should be readable");
|
||||
|
||||
let body_str = String::from_utf8(body.to_vec()).expect("body should be UTF-8");
|
||||
|
||||
assert!(
|
||||
body_str.contains("-----BEGIN X509 CRL-----"),
|
||||
"Response should contain CRL PEM header"
|
||||
);
|
||||
assert!(
|
||||
body_str.contains("-----END X509 CRL-----"),
|
||||
"Response should contain CRL PEM footer"
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crl_endpoint_returns_cache_control_header() {
|
||||
let Some((pool, state)) = setup_app_state().await else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let app = test_app(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pki/crl.pem")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let cache_control = response
|
||||
.headers()
|
||||
.get("cache-control")
|
||||
.expect("Cache-Control header should be present");
|
||||
|
||||
assert_eq!(
|
||||
cache_control.to_str().unwrap(),
|
||||
"max-age=3600",
|
||||
"Cache-Control should be max-age=3600"
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crl_endpoint_works_without_authentication() {
|
||||
let Some((pool, state)) = setup_app_state().await else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let app = test_app(state);
|
||||
|
||||
// Make request without any auth headers — CRL endpoint is public.
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pki/crl.pem")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
// Should return 200, not 401 Unauthorized.
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"CRL endpoint should be accessible without authentication"
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crl_endpoint_returns_pem_content_type() {
|
||||
let Some((pool, state)) = setup_app_state().await else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let app = test_app(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pki/crl.pem")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.expect("Content-Type header should be present");
|
||||
|
||||
assert!(
|
||||
content_type
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("application/x-pem-file"),
|
||||
"Content-Type should be application/x-pem-file, got: {:?}",
|
||||
content_type
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
}
|
||||
}
|
||||
0
crates/pm-web/src/routes/reports.rs
Normal file → Executable file
0
crates/pm-web/src/routes/reports.rs
Normal file → Executable file
@ -115,6 +115,7 @@ pub struct OidcDiscoveryRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OidcDiscoveryResult {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
@ -179,6 +180,28 @@ fn write_access_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gate Manager-wide authentication configuration (OIDC, SMTP, IP allowlist,
|
||||
/// OIDC discover/test) behind the **Admin** role. Operators can still
|
||||
/// access per-host settings (see `write_access_required`).
|
||||
///
|
||||
/// Returns `403 forbidden_role` if the user is not an Admin. The distinct
|
||||
/// error code (vs `forbidden` from `write_access_required`) lets the SPA
|
||||
/// differentiate "you don't have write access at all" from "you have
|
||||
/// write access but not for this specific resource".
|
||||
///
|
||||
/// See issue #5 and `tasks/authz-gate-spec.md` for the full design.
|
||||
fn admin_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
|
||||
if !auth.role.is_admin() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(
|
||||
json!({ "error": { "code": "forbidden_role", "message": "Admin role required to modify this resource" } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_system_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
|
||||
@ -250,11 +273,23 @@ async fn update_config_key(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tuple type for SELECT from oidc_config table (used by fetch_oidc_config).
|
||||
type OidcConfigRow = (
|
||||
bool,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<Vec<u8>>,
|
||||
Option<Vec<u8>>,
|
||||
String,
|
||||
String,
|
||||
);
|
||||
async fn fetch_oidc_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<OidcConfigResponse, (StatusCode, Json<Value>)> {
|
||||
let row: Option<(bool, String, String, String, String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
let row: Option<OidcConfigRow> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret_encrypted, client_secret_nonce, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
@ -273,7 +308,8 @@ async fn fetch_oidc_config(
|
||||
display_name,
|
||||
discovery_url,
|
||||
client_id,
|
||||
client_secret,
|
||||
client_secret_encrypted,
|
||||
_client_secret_nonce,
|
||||
redirect_uri,
|
||||
scopes,
|
||||
)) => OidcConfigResponse {
|
||||
@ -282,7 +318,7 @@ async fn fetch_oidc_config(
|
||||
display_name,
|
||||
discovery_url,
|
||||
client_id,
|
||||
client_secret: if client_secret.is_empty() {
|
||||
client_secret: if client_secret_encrypted.is_none() {
|
||||
String::new()
|
||||
} else {
|
||||
MASKED.to_string()
|
||||
@ -332,7 +368,7 @@ async fn update_settings(
|
||||
auth: AuthUser,
|
||||
Json(req): Json<UpdateSettingsRequest>,
|
||||
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
||||
write_access_required(&auth)?;
|
||||
admin_required(&auth)?;
|
||||
|
||||
// Update OIDC config
|
||||
if let Some(oidc) = req.oidc {
|
||||
@ -342,6 +378,22 @@ async fn update_settings(
|
||||
.is_some_and(|s| s != MASKED && !s.is_empty());
|
||||
|
||||
let result = if update_secret {
|
||||
// Encrypt the client_secret before persisting
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption key error" } })),
|
||||
)
|
||||
})?;
|
||||
let plaintext = oidc.client_secret.as_deref().unwrap_or("");
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(plaintext, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt OIDC client_secret");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query(
|
||||
"UPDATE oidc_config SET \
|
||||
enabled = COALESCE($1, enabled), \
|
||||
@ -349,9 +401,10 @@ async fn update_settings(
|
||||
display_name = COALESCE($3, display_name), \
|
||||
discovery_url = COALESCE($4, discovery_url), \
|
||||
client_id = COALESCE($5, client_id), \
|
||||
client_secret = $6, \
|
||||
redirect_uri = COALESCE($7, redirect_uri), \
|
||||
scopes = COALESCE($8, scopes), \
|
||||
client_secret_encrypted = $6, \
|
||||
client_secret_nonce = $7, \
|
||||
redirect_uri = COALESCE($8, redirect_uri), \
|
||||
scopes = COALESCE($9, scopes), \
|
||||
updated_at = NOW() \
|
||||
WHERE id = 1",
|
||||
)
|
||||
@ -360,7 +413,8 @@ async fn update_settings(
|
||||
.bind(&oidc.display_name)
|
||||
.bind(&oidc.discovery_url)
|
||||
.bind(&oidc.client_id)
|
||||
.bind(oidc.client_secret.as_deref().unwrap_or(""))
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(&oidc.redirect_uri)
|
||||
.bind(&oidc.scopes)
|
||||
.execute(&state.db)
|
||||
@ -399,7 +453,7 @@ async fn update_settings(
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::ConfigChanged,
|
||||
AuditAction::OidcConfigUpdated,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("oidc"),
|
||||
@ -427,7 +481,59 @@ async fn update_settings(
|
||||
}
|
||||
if let Some(ref v) = smtp.password {
|
||||
if v != MASKED {
|
||||
update_config_key(&state.db, "smtp_password", v).await?;
|
||||
// Encrypt the SMTP password before persisting
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption key error" } })),
|
||||
)
|
||||
})?;
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(v, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt SMTP password");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
// Delete old plaintext row, write two new rows (encrypted + nonce)
|
||||
sqlx::query("DELETE FROM system_config WHERE key = 'smtp_password'")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to delete old smtp_password row");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
// Store as hex in TEXT columns (system_config uses TEXT)
|
||||
let enc_hex: String = ciphertext.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let nonce_hex: String = nonce.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_encrypted")
|
||||
.bind(&enc_hex)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to write smtp_password_encrypted");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_nonce")
|
||||
.bind(&nonce_hex)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to write smtp_password_nonce");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if let Some(ref v) = smtp.from {
|
||||
@ -439,7 +545,7 @@ async fn update_settings(
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::ConfigChanged,
|
||||
AuditAction::SmtpConfigUpdated,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("smtp"),
|
||||
@ -484,7 +590,7 @@ async fn update_settings(
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::ConfigChanged,
|
||||
AuditAction::IpWhitelistUpdated,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("ip_whitelist"),
|
||||
@ -562,7 +668,7 @@ async fn discover_oidc(
|
||||
auth: AuthUser,
|
||||
Json(req): Json<OidcDiscoveryRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
write_access_required(&auth)?;
|
||||
admin_required(&auth)?;
|
||||
|
||||
if req.discovery_url.is_empty() {
|
||||
return Err((
|
||||
@ -587,6 +693,20 @@ async fn discover_oidc(
|
||||
match client.get(&req.discovery_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: Value = resp.json().await.unwrap_or(json!({}));
|
||||
// Audit log: Admin probed the OIDC discovery endpoint (issue #5).
|
||||
// Non-fatal: log_event logs errors internally and does not propagate.
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::OidcDiscoverPerformed,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("oidc"),
|
||||
Some(&req.discovery_url),
|
||||
json!({ "discovery_url": req.discovery_url }),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
@ -619,7 +739,7 @@ async fn test_oidc(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
write_access_required(&auth)?;
|
||||
admin_required(&auth)?;
|
||||
|
||||
let row: Option<(bool, String, String)> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
|
||||
@ -678,6 +798,23 @@ async fn test_oidc(
|
||||
"azure" => "Azure AD",
|
||||
_ => "OIDC",
|
||||
};
|
||||
// Audit log: Admin tested the OIDC provider connection (issue #5).
|
||||
// Non-fatal: log_event logs errors internally and does not propagate.
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::OidcTestPerformed,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("oidc"),
|
||||
Some(&discovery_url),
|
||||
json!({
|
||||
"discovery_url": discovery_url,
|
||||
"provider_type": provider_type,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": format!("{} provider verified successfully", provider_label),
|
||||
@ -696,6 +833,9 @@ async fn test_oidc(
|
||||
}
|
||||
}
|
||||
|
||||
// Note: OIDC test audit log is emitted in the success path below.
|
||||
// The above error cases don't persist, so no audit log is needed for them.
|
||||
|
||||
// ============================================================
|
||||
// POST /api/v1/settings/azure-sso/test (backward-compatible alias)
|
||||
// ============================================================
|
||||
@ -733,7 +873,32 @@ async fn test_smtp(
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(587);
|
||||
let username = cfg.get("smtp_username").cloned().unwrap_or_default();
|
||||
let password = cfg.get("smtp_password").cloned().unwrap_or_default();
|
||||
// Decrypt the SMTP password (issue #6 fix — stored as two rows in system_config:
|
||||
// `smtp_password_encrypted` (hex) and `smtp_password_nonce` (hex))
|
||||
let password = match (
|
||||
cfg.get("smtp_password_encrypted"),
|
||||
cfg.get("smtp_password_nonce"),
|
||||
) {
|
||||
(Some(enc_hex), Some(nonce_hex)) => {
|
||||
let key = match crate::secret_key::get() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({ "error": { "code": "internal_error", "message": "Encryption key error" } }),
|
||||
),
|
||||
));
|
||||
},
|
||||
};
|
||||
// Decode hex to bytes (hex_decode returns empty Vec on invalid input)
|
||||
let enc_bytes = hex_decode(enc_hex);
|
||||
let nonce_bytes = hex_decode(nonce_hex);
|
||||
pm_core::crypto::decrypt(&enc_bytes, &nonce_bytes, key).unwrap_or_default()
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
let from_addr = cfg.get("smtp_from").cloned().unwrap_or_default();
|
||||
let tls_mode = cfg
|
||||
.get("smtp_tls_mode")
|
||||
@ -898,7 +1063,7 @@ async fn update_ip_whitelist(
|
||||
auth: AuthUser,
|
||||
Json(req): Json<IpWhitelistUpdate>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
write_access_required(&auth)?;
|
||||
admin_required(&auth)?;
|
||||
|
||||
// Validate each entry
|
||||
for entry in &req.entries {
|
||||
@ -920,7 +1085,7 @@ async fn update_ip_whitelist(
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::ConfigChanged,
|
||||
AuditAction::IpWhitelistUpdated,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("ip_whitelist"),
|
||||
@ -974,3 +1139,70 @@ async fn audit_integrity(
|
||||
})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Decode a hex string to bytes. Returns an empty Vec on invalid input.
|
||||
/// Used by the SMTP password decryption logic (issue #6 fix).
|
||||
fn hex_decode(s: &str) -> Vec<u8> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return Vec::new();
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.filter_map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(unused_imports)]
|
||||
use super::*;
|
||||
use axum::http::StatusCode;
|
||||
use pm_auth::jwt::AccessClaims;
|
||||
use pm_auth::rbac::{AuthUser, UserRole};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Build a minimal `AuthUser` for role-gate testing.
|
||||
/// The `admin_required` gate only inspects `auth.role`, so all other
|
||||
/// fields can be placeholder values.
|
||||
#[allow(dead_code)]
|
||||
fn test_auth_user(role: UserRole) -> AuthUser {
|
||||
let claims = AccessClaims {
|
||||
sub: Uuid::new_v4().to_string(),
|
||||
iat: 0,
|
||||
exp: i64::MAX,
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
role: role.as_str().to_string(),
|
||||
username: "test-user".to_string(),
|
||||
};
|
||||
AuthUser {
|
||||
user_id: Uuid::new_v4(),
|
||||
username: "test-user".to_string(),
|
||||
role,
|
||||
claims,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_required_admin_passes() {
|
||||
let auth = test_auth_user(UserRole::Admin);
|
||||
admin_required(&auth).expect("Admin should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_required_operator_denied() {
|
||||
let auth = test_auth_user(UserRole::Operator);
|
||||
let err = admin_required(&auth).expect_err("Operator should be denied");
|
||||
let (status, body) = err;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_required_reporter_denied() {
|
||||
let auth = test_auth_user(UserRole::Reporter);
|
||||
let err = admin_required(&auth).expect_err("Reporter should be denied");
|
||||
let (status, body) = err;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +12,12 @@ use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Redirect},
|
||||
routing::get,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
|
||||
use pm_auth::{jwt::issue_access_token, refresh};
|
||||
use pm_core::audit::{log_event, AuditAction};
|
||||
@ -40,6 +41,140 @@ pub struct SsoSession {
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Single-use, short-lived payload that the SSO callback hands to the SPA
|
||||
/// via a `?handoff=<code>` query param. The SPA exchanges it via
|
||||
/// `POST /api/v1/auth/sso/handoff` for the actual JWT access/refresh
|
||||
/// tokens. Mirrors the WS-ticket pattern (issue #10): in-memory, atomic
|
||||
/// single-use consume, TTL enforced on read.
|
||||
///
|
||||
/// See `tasks/sso-token-handoff-spec.md` §4.1 for the full design.
|
||||
#[derive(Clone)]
|
||||
pub struct SsoHandoff {
|
||||
/// JWT access token (short-lived, 15 min TTL).
|
||||
pub access_token: String,
|
||||
/// Opaque refresh token (long-lived, rotating).
|
||||
pub raw_refresh: String,
|
||||
/// JSON-serialized user object (id, username, display_name, role, etc.).
|
||||
pub user_json: Value,
|
||||
/// Access token TTL in seconds (for the `expires_in` field in the response).
|
||||
pub access_ttl: u64,
|
||||
/// Expiry instant; the exchange endpoint rejects codes past this time.
|
||||
pub expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// TTL for SSO handoff codes. Short by design: the SPA should POST to
|
||||
/// `/api/v1/auth/sso/handoff` within seconds of the redirect landing.
|
||||
///
|
||||
/// `dead_code` is allowed here because Phase 1 introduces the store
|
||||
/// ahead of its consumer; the SSO callback rewrite in Phase 2 of
|
||||
/// `tasks/sso-token-handoff-spec.md` inserts handoffs with this TTL and
|
||||
/// the exchange handler reads it back to validate freshness.
|
||||
#[allow(dead_code)]
|
||||
pub const HANDOFF_TTL_SECS: u64 = 60;
|
||||
|
||||
/// Generate a cryptographically random handoff code (32 bytes,
|
||||
/// base64url-encoded, ~43 chars). Uses the same `rand` crate family as
|
||||
/// the WS-ticket path.
|
||||
///
|
||||
/// `dead_code` is allowed here for the same reason as `HANDOFF_TTL_SECS`
|
||||
/// — Phase 2 wires it into the SSO callback redirect construction.
|
||||
#[allow(dead_code)]
|
||||
pub fn generate_handoff_code() -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/v1/auth/sso/handoff`.
|
||||
///
|
||||
/// The SPA sends the handoff code it received in the SSO callback
|
||||
/// redirect's `?handoff=...` query param, and the backend exchanges it
|
||||
/// for the actual access/refresh tokens. The code is single-use and
|
||||
/// 60-second TTL.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HandoffRequest {
|
||||
pub handoff_code: String,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Handoff exchange handler
|
||||
// ============================================================
|
||||
|
||||
/// `POST /api/v1/auth/sso/handoff` — exchange a single-use handoff code
|
||||
/// for the JWT access/refresh tokens + user object. Public route (no
|
||||
/// JWT required) — the handoff code IS the credential.
|
||||
///
|
||||
/// See `tasks/sso-token-handoff-spec.md` §4.2 for the full design.
|
||||
async fn sso_handoff_exchange(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<HandoffRequest>,
|
||||
) -> (StatusCode, Json<Value>) {
|
||||
sso_handoff_exchange_inner(&state.sso_handoffs, &req.handoff_code).await
|
||||
}
|
||||
|
||||
/// Core exchange logic, separated from the HTTP handler so tests can
|
||||
/// drive it with a bare `DashMap` (no need to construct a full
|
||||
/// `AppState` with a real `sqlx::PgPool` and `Arc<AppConfig>`).
|
||||
///
|
||||
/// Marked `async` so the race test can use `tokio::join!` to drive
|
||||
/// two concurrent exchanges against the same code; the function body
|
||||
/// has no `.await` points (it only does a DashMap read and a return),
|
||||
/// so this is a zero-cost abstraction.
|
||||
async fn sso_handoff_exchange_inner(
|
||||
handoffs: &DashMap<String, SsoHandoff>,
|
||||
code: &str,
|
||||
) -> (StatusCode, Json<Value>) {
|
||||
// Atomically remove the entry (single-use guarantee). If two
|
||||
// requests race with the same code, DashMap::remove is atomic so
|
||||
// only one wins.
|
||||
let removed = handoffs.remove(code);
|
||||
let Some((_code, handoff)) = removed else {
|
||||
tracing::warn!(
|
||||
reason = "unknown_or_already_consumed",
|
||||
"SSO handoff exchange failed"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" }
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
// Check expiry (the cleanup task also removes expired entries, but
|
||||
// there's a race between expiry and the next cleanup tick — check
|
||||
// here too so we never return a token for an expired handoff).
|
||||
if handoff.expires_at <= std::time::Instant::now() {
|
||||
tracing::warn!(reason = "expired", "SSO handoff exchange failed");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" }
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Log success without leaking the handoff code or the tokens
|
||||
let user_id = handoff
|
||||
.user_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
tracing::info!(user_id = %user_id, "SSO handoff exchanged");
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"access_token": handoff.access_token,
|
||||
"refresh_token": handoff.raw_refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": handoff.access_ttl,
|
||||
"user": handoff.user_json,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
#[allow(dead_code)]
|
||||
@ -78,11 +213,29 @@ pub struct OidcConfig {
|
||||
pub display_name: String,
|
||||
pub discovery_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
/// AES-256-GCM encrypted client_secret. `None` if not set or public client.
|
||||
pub client_secret_encrypted: Option<Vec<u8>>,
|
||||
/// AES-256-GCM nonce for client_secret. Must be paired with `client_secret_encrypted`.
|
||||
pub client_secret_nonce: Option<Vec<u8>>,
|
||||
pub redirect_uri: String,
|
||||
pub scopes: String,
|
||||
}
|
||||
|
||||
impl OidcConfig {
|
||||
/// Decrypt the client_secret using the provided key.
|
||||
/// Returns `Ok(String::new())` if the secret is not set (public client).
|
||||
/// Returns `Err(CryptoError)` if decryption fails or nonce is missing.
|
||||
pub fn decrypt_client_secret(
|
||||
&self,
|
||||
key: &[u8; 32],
|
||||
) -> Result<String, pm_core::crypto::CryptoError> {
|
||||
match (&self.client_secret_encrypted, &self.client_secret_nonce) {
|
||||
(Some(enc), Some(nonce)) => pm_core::crypto::decrypt(enc, nonce, key),
|
||||
_ => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached OIDC discovery document.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcDiscovery {
|
||||
@ -95,22 +248,13 @@ pub struct OidcDiscovery {
|
||||
}
|
||||
|
||||
/// Cache for OIDC discovery documents and JWKS with TTL-based refresh.
|
||||
#[derive(Default)]
|
||||
pub struct OidcCache {
|
||||
pub discovery: Option<OidcDiscovery>,
|
||||
pub jwks: Option<serde_json::Value>,
|
||||
pub jwks_fetched_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Default for OidcCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
discovery: None,
|
||||
jwks: None,
|
||||
jwks_fetched_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JWKS cache TTL in seconds (1 hour).
|
||||
const JWKS_CACHE_TTL_SECS: i64 = 3600;
|
||||
/// Discovery cache TTL in seconds (1 hour).
|
||||
@ -125,6 +269,12 @@ pub fn public_router() -> Router<AppState> {
|
||||
.route("/login", get(sso_login))
|
||||
.route("/callback", get(sso_callback))
|
||||
.route("/config", get(sso_config))
|
||||
// Issue #4: single-use handoff exchange. The SPA POSTs the
|
||||
// `?handoff=<code>` it received from the SSO callback redirect
|
||||
// and gets the JWT access/refresh tokens in the JSON response.
|
||||
// Public route (no JWT) — the handoff code IS the credential.
|
||||
// See `tasks/sso-token-handoff-spec.md` §4.2.
|
||||
.route("/handoff", post(sso_handoff_exchange))
|
||||
}
|
||||
|
||||
/// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints.
|
||||
@ -332,8 +482,28 @@ async fn sso_callback(
|
||||
];
|
||||
|
||||
// For confidential clients (Azure AD), include client_secret
|
||||
if !config.client_secret.is_empty() {
|
||||
params_vec.push(("client_secret", config.client_secret.clone()));
|
||||
let key = match crate::secret_key::get() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
return Err(error_redirect(
|
||||
"internal_error",
|
||||
"Failed to load encryption key",
|
||||
));
|
||||
},
|
||||
};
|
||||
let client_secret = match config.decrypt_client_secret(key) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to decrypt OIDC client_secret");
|
||||
return Err(error_redirect(
|
||||
"internal_error",
|
||||
"Failed to decrypt client_secret",
|
||||
));
|
||||
},
|
||||
};
|
||||
if !client_secret.is_empty() {
|
||||
params_vec.push(("client_secret", client_secret));
|
||||
}
|
||||
|
||||
let token_resp = match client
|
||||
@ -492,10 +662,11 @@ async fn sso_callback(
|
||||
DbUserForSso {
|
||||
id: existing.id,
|
||||
username: existing.username.clone(),
|
||||
display_name: name
|
||||
.is_empty()
|
||||
.then(|| existing.display_name.clone())
|
||||
.unwrap_or(name),
|
||||
display_name: if name.is_empty() {
|
||||
existing.display_name.clone()
|
||||
} else {
|
||||
name
|
||||
},
|
||||
role: existing.role.clone(),
|
||||
is_active: existing.is_active,
|
||||
mfa_enabled: existing.mfa_enabled,
|
||||
@ -612,13 +783,32 @@ async fn sso_callback(
|
||||
"mfa_enabled": user.mfa_enabled,
|
||||
});
|
||||
|
||||
let redirect_url = format!(
|
||||
"{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}",
|
||||
callback_url,
|
||||
urlencoding::encode(&access_token),
|
||||
urlencoding::encode(&raw_refresh.0),
|
||||
access_ttl,
|
||||
urlencoding::encode(&user_json.to_string()),
|
||||
// Issue #4 fix: instead of embedding access/refresh tokens in the
|
||||
// redirect URL (which leaks through browser history, proxy access
|
||||
// logs, and the Referer header), generate a single-use, 60s handoff
|
||||
// code, store the payload in `sso_handoffs`, and put ONLY the code
|
||||
// in the redirect. The SPA POSTs to `/api/v1/auth/sso/handoff` to
|
||||
// exchange the code for tokens. See `tasks/sso-token-handoff-spec.md`
|
||||
// §4.1.
|
||||
let handoff_code = generate_handoff_code();
|
||||
state.sso_handoffs.insert(
|
||||
handoff_code.clone(),
|
||||
SsoHandoff {
|
||||
access_token: access_token.clone(),
|
||||
raw_refresh: raw_refresh.0.clone(),
|
||||
user_json: user_json.clone(),
|
||||
access_ttl: access_ttl as u64,
|
||||
expires_at: std::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(HANDOFF_TTL_SECS),
|
||||
},
|
||||
);
|
||||
|
||||
let redirect_url = format!("{}?handoff={}", callback_url, handoff_code);
|
||||
|
||||
tracing::info!(
|
||||
user_id = %user.id,
|
||||
auth_provider = %auth_provider,
|
||||
"SSO handoff issued"
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&redirect_url))
|
||||
@ -647,7 +837,9 @@ async fn azure_callback_redirect(
|
||||
|
||||
async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode, Json<Value>)> {
|
||||
let row: Option<OidcConfig> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, \
|
||||
client_secret_encrypted, client_secret_nonce, redirect_uri, scopes \
|
||||
FROM oidc_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
@ -665,7 +857,8 @@ async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode
|
||||
display_name: "Azure AD".to_string(),
|
||||
discovery_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
client_secret_encrypted: None,
|
||||
client_secret_nonce: None,
|
||||
redirect_uri: String::new(),
|
||||
scopes: "openid profile email".to_string(),
|
||||
}))
|
||||
@ -844,3 +1037,168 @@ async fn fetch_jwks(jwks_uri: &str) -> Result<serde_json::Value, String> {
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse JWKS response: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for the SSO handoff exchange endpoint and cleanup task.
|
||||
//!
|
||||
//! Per `tasks/sso-token-handoff-spec.md` §6.1–6.2.
|
||||
//!
|
||||
//! The tests call `sso_handoff_exchange_inner` directly with a bare
|
||||
//! `DashMap<String, SsoHandoff>`. This avoids the need to construct
|
||||
//! a full `AppState` (which has `sqlx::PgPool` and `Arc<AppConfig>`
|
||||
//! fields that can't be cheaply mocked) and keeps the tests focused
|
||||
//! on the exchange logic. The HTTP handler is a thin wrapper that
|
||||
//! extracts the code from the request body and delegates.
|
||||
|
||||
use super::*;
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn fresh_handoffs() -> Arc<DashMap<String, SsoHandoff>> {
|
||||
Arc::new(DashMap::new())
|
||||
}
|
||||
|
||||
fn make_handoff(access: &str, refresh: &str, user_id: &str) -> SsoHandoff {
|
||||
SsoHandoff {
|
||||
access_token: access.to_string(),
|
||||
raw_refresh: refresh.to_string(),
|
||||
user_json: json!({ "id": user_id, "username": "testuser" }),
|
||||
access_ttl: 900,
|
||||
expires_at: Instant::now() + Duration::from_secs(HANDOFF_TTL_SECS),
|
||||
}
|
||||
}
|
||||
|
||||
/// 1. handoff_exchange_success — create a handoff, exchange it,
|
||||
/// expect 200 with the access/refresh/user fields.
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_success() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let code = generate_handoff_code();
|
||||
handoffs.insert(
|
||||
code.clone(),
|
||||
make_handoff("jwt-access", "refresh-raw", "user-123"),
|
||||
);
|
||||
|
||||
let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["access_token"], "jwt-access");
|
||||
assert_eq!(body["refresh_token"], "refresh-raw");
|
||||
assert_eq!(body["token_type"], "Bearer");
|
||||
assert_eq!(body["expires_in"], 900);
|
||||
assert_eq!(body["user"]["id"], "user-123");
|
||||
}
|
||||
|
||||
/// 2. handoff_exchange_single_use — exchange once (success),
|
||||
/// exchange the same code again (expect 400 invalid_handoff).
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_single_use() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let code = generate_handoff_code();
|
||||
handoffs.insert(code.clone(), make_handoff("a", "r", "u"));
|
||||
|
||||
// First exchange succeeds
|
||||
let (status1, _) = sso_handoff_exchange_inner(&handoffs, &code).await;
|
||||
assert_eq!(status1, StatusCode::OK);
|
||||
|
||||
// Second exchange with the same code fails (entry was removed)
|
||||
let (status2, body2) = sso_handoff_exchange_inner(&handoffs, &code).await;
|
||||
assert_eq!(status2, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body2["error"]["code"], "invalid_handoff");
|
||||
}
|
||||
|
||||
/// 3. handoff_exchange_unknown_code — exchange a code that was
|
||||
/// never issued (expect 400 invalid_handoff).
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_unknown_code() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let (status, body) = sso_handoff_exchange_inner(&handoffs, "never-issued-code").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["code"], "invalid_handoff");
|
||||
}
|
||||
|
||||
/// 4. handoff_exchange_expired_code — create a handoff with
|
||||
/// expires_at in the past, exchange (expect 400 invalid_handoff).
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_expired_code() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let code = generate_handoff_code();
|
||||
let mut h = make_handoff("a", "r", "u");
|
||||
h.expires_at = Instant::now() - Duration::from_secs(1); // already expired
|
||||
handoffs.insert(code.clone(), h);
|
||||
|
||||
let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["code"], "invalid_handoff");
|
||||
}
|
||||
|
||||
/// 5. handoff_exchange_race — two concurrent exchanges with the
|
||||
/// same code; exactly one succeeds, the other gets 400.
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_race() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let code = generate_handoff_code();
|
||||
handoffs.insert(code.clone(), make_handoff("a", "r", "u"));
|
||||
|
||||
// DashMap::remove is atomic, so only one of two concurrent
|
||||
// calls can win. The other gets None and returns 400.
|
||||
let h1 = handoffs.clone();
|
||||
let h2 = handoffs.clone();
|
||||
let c1 = code.clone();
|
||||
let c2 = code.clone();
|
||||
let (r1, r2) = tokio::join!(
|
||||
sso_handoff_exchange_inner(&h1, &c1),
|
||||
sso_handoff_exchange_inner(&h2, &c2),
|
||||
);
|
||||
|
||||
let status1 = r1.0;
|
||||
let status2 = r2.0;
|
||||
let successes = [status1, status2]
|
||||
.iter()
|
||||
.filter(|s| **s == StatusCode::OK)
|
||||
.count();
|
||||
let failures = [status1, status2]
|
||||
.iter()
|
||||
.filter(|s| **s == StatusCode::BAD_REQUEST)
|
||||
.count();
|
||||
assert_eq!(successes, 1, "exactly one exchange should succeed");
|
||||
assert_eq!(failures, 1, "exactly one exchange should fail");
|
||||
}
|
||||
|
||||
/// 6. handoff_exchange_malformed_body — exchange with an empty
|
||||
/// code (expect 400 invalid_handoff).
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_malformed_body() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let (status, body) = sso_handoff_exchange_inner(&handoffs, "").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["code"], "invalid_handoff");
|
||||
}
|
||||
|
||||
/// 7. handoff_cleanup_removes_expired — create 3 handoffs with
|
||||
/// varying `expires_at`, run one tick of the cleanup task,
|
||||
/// assert only the non-expired ones remain.
|
||||
#[tokio::test]
|
||||
async fn handoff_cleanup_removes_expired() {
|
||||
let handoffs = fresh_handoffs();
|
||||
// 2 expired, 1 fresh
|
||||
for (i, expired) in [true, false, true].iter().enumerate() {
|
||||
let mut h = make_handoff(&format!("a{}", i), "r", "u");
|
||||
if *expired {
|
||||
h.expires_at = Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
handoffs.insert(format!("code-{}", i), h);
|
||||
}
|
||||
assert_eq!(handoffs.len(), 3);
|
||||
|
||||
// Simulate one tick of the cleanup task (mirrors the logic
|
||||
// in main.rs lines 174-188)
|
||||
let now = Instant::now();
|
||||
handoffs.retain(|_, v| v.expires_at > now);
|
||||
|
||||
assert_eq!(handoffs.len(), 1);
|
||||
assert!(handoffs.contains_key("code-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,16 @@ pub struct FleetStatus {
|
||||
pub total_pending_patches: i64,
|
||||
pub hosts_requiring_reboot: i64,
|
||||
pub compliance_pct: f64,
|
||||
/// Hosts with CRL status 'valid'.
|
||||
pub crl_valid: i64,
|
||||
/// Hosts with CRL status 'expired'.
|
||||
pub crl_expired: i64,
|
||||
/// Hosts with CRL status 'missing' (agent reports missing CRL).
|
||||
pub crl_missing: i64,
|
||||
/// Hosts with CRL status 'invalid' (security event — needs immediate attention).
|
||||
pub crl_invalid: i64,
|
||||
/// Hosts not reporting CRL status (older agents or no data yet).
|
||||
pub crl_not_reporting: i64,
|
||||
}
|
||||
|
||||
// ── GET /api/v1/status/fleet ──────────────────────────────────────────────────
|
||||
@ -132,6 +142,34 @@ pub async fn fleet_status(
|
||||
// Round to one decimal place.
|
||||
let compliance_pct = (compliance_pct * 10.0).round() / 10.0;
|
||||
|
||||
// ── 5. CRL status counts ────────────────────────────────────────────────
|
||||
let (crl_valid, crl_expired, crl_missing, crl_invalid, crl_not_reporting): (
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN crl_status = 'valid' THEN 1 END), 0),
|
||||
COALESCE(SUM(CASE WHEN crl_status = 'expired' THEN 1 END), 0),
|
||||
COALESCE(SUM(CASE WHEN crl_status = 'missing' THEN 1 END), 0),
|
||||
COALESCE(SUM(CASE WHEN crl_status = 'invalid' THEN 1 END), 0),
|
||||
COALESCE(SUM(CASE WHEN crl_status IS NULL THEN 1 END), 0)
|
||||
FROM hosts
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "fleet_status: failed to query CRL status counts");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(FleetStatus {
|
||||
total_hosts,
|
||||
healthy,
|
||||
@ -141,5 +179,10 @@ pub async fn fleet_status(
|
||||
total_pending_patches,
|
||||
hosts_requiring_reboot,
|
||||
compliance_pct,
|
||||
crl_valid,
|
||||
crl_expired,
|
||||
crl_missing,
|
||||
crl_invalid,
|
||||
crl_not_reporting,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -534,7 +534,7 @@ async fn admin_disable_mfa(
|
||||
));
|
||||
}
|
||||
|
||||
let rows = sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1")
|
||||
let rows = sqlx::query("UPDATE users SET totp_secret_encrypted = NULL, totp_secret_nonce = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
|
||||
@ -6,14 +6,14 @@
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket},
|
||||
extract::{Query, State, WebSocketUpgrade},
|
||||
http::StatusCode,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Json, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::postgres::PgListener;
|
||||
use ulid::Ulid;
|
||||
@ -57,6 +57,160 @@ fn err(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Origin parsing & allowlist matching ───────────────────────────────────────
|
||||
|
||||
/// Parsed browser `Origin` header value.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Origin {
|
||||
scheme: String,
|
||||
host: String,
|
||||
/// `None` means "use scheme default" (80 for http, 443 for https).
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
/// Render back to canonical `scheme://host[:port]` form with default
|
||||
/// ports normalized away (so `https://x:443` becomes `https://x`).
|
||||
fn canonical(&self) -> String {
|
||||
let default_port: Option<u16> = match self.scheme.as_str() {
|
||||
"https" => Some(443),
|
||||
"http" => Some(80),
|
||||
_ => None,
|
||||
};
|
||||
match (self.port, default_port) {
|
||||
(Some(p), Some(d)) if p == d => format!("{}://{}", self.scheme, self.host),
|
||||
(Some(p), _) => format!("{}://{}:{}", self.scheme, self.host, p),
|
||||
(None, _) => format!("{}://{}", self.scheme, self.host),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a raw `Origin` header value. Returns `None` for missing scheme,
|
||||
/// unsupported schemes (only `http`/`https`), empty host, or whitespace in
|
||||
/// the host. IPv6 literal hosts are explicitly rejected to keep the parser
|
||||
/// simple — WebSocket connections from IPv6 browser origins are not a
|
||||
/// realistic deployment for this product.
|
||||
fn parse_origin_header(value: &str) -> Option<Origin> {
|
||||
let s = value.trim().trim_end_matches('/');
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (scheme, rest) = s.split_once("://")?;
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return None;
|
||||
}
|
||||
// Authority is everything up to the first `/`, `?`, or `#`.
|
||||
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||||
let authority = &rest[..authority_end];
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Treat the LAST `:` as the port separator. IPv6 literal hosts (e.g.
|
||||
// `[::1]`) contain a `:` inside the brackets; reject those.
|
||||
let (host, port_str) = match authority.rsplit_once(':') {
|
||||
Some((h, _)) if h.contains(':') => return None,
|
||||
Some((h, p)) => (h, Some(p)),
|
||||
None => (authority, None),
|
||||
};
|
||||
let host = host.trim();
|
||||
if host.is_empty() || host.contains(char::is_whitespace) || host.contains(':') {
|
||||
return None;
|
||||
}
|
||||
let port = match port_str {
|
||||
Some(p) => match p.parse::<u16>() {
|
||||
Ok(n) => Some(n),
|
||||
Err(_) => return None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Some(Origin {
|
||||
scheme,
|
||||
host: host.to_ascii_lowercase(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
/// Match a parsed `Origin` against an allowlist. Each allowlist entry is
|
||||
/// itself parsed with [`parse_origin_header`] and compared by its canonical
|
||||
/// string form, so entry syntax is forgiving (`https://x:443` matches an
|
||||
/// incoming `https://x`). The host comparison is case-insensitive (the
|
||||
/// parser lowercases the host); scheme and port are exact.
|
||||
///
|
||||
/// An empty allowlist returns `false` (fail-closed).
|
||||
fn is_origin_allowed(origin: &Origin, allowlist: &[String]) -> bool {
|
||||
if allowlist.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let incoming = origin.canonical();
|
||||
allowlist
|
||||
.iter()
|
||||
.any(|entry| match parse_origin_header(entry) {
|
||||
Some(parsed) => parsed.canonical() == incoming,
|
||||
None => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the `Origin` header from a request and check it against the
|
||||
/// configured allowlist. Returns `Ok(())` when the request may proceed; on
|
||||
/// rejection returns the appropriate `(StatusCode, Json)` error tuple and
|
||||
/// the reason string (for logging).
|
||||
fn check_origin(
|
||||
headers: &HeaderMap,
|
||||
allowlist: &[String],
|
||||
) -> Result<(), ((StatusCode, Json<Value>), &'static str)> {
|
||||
let raw = match headers.get(axum::http::header::ORIGIN) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Err((
|
||||
err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden_origin",
|
||||
"Origin header required",
|
||||
),
|
||||
"missing",
|
||||
));
|
||||
},
|
||||
};
|
||||
let raw_str = match raw.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return Err((
|
||||
err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden_origin",
|
||||
"Origin header not valid ASCII",
|
||||
),
|
||||
"non-ascii",
|
||||
));
|
||||
},
|
||||
};
|
||||
let origin = match parse_origin_header(raw_str) {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
return Err((
|
||||
err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden_origin",
|
||||
"Malformed Origin header",
|
||||
),
|
||||
"malformed",
|
||||
));
|
||||
},
|
||||
};
|
||||
if !is_origin_allowed(&origin, allowlist) {
|
||||
return Err((
|
||||
err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden_origin",
|
||||
"Origin not allowed",
|
||||
),
|
||||
"not-allowlisted",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── POST /api/v1/ws/ticket ────────────────────────────────────────────────────
|
||||
|
||||
/// Issue a single-use WebSocket authentication ticket (60 s expiry).
|
||||
@ -93,11 +247,40 @@ pub struct WsQuery {
|
||||
}
|
||||
|
||||
/// Browser WebSocket upgrade endpoint — authenticates via single-use ticket.
|
||||
///
|
||||
/// The handler enforces two independent gates, in this order:
|
||||
///
|
||||
/// 1. `Origin` header allowlist (CSWSH defense-in-depth). Performed first so
|
||||
/// that a cross-origin probe with a leaked/stolen ticket does not consume
|
||||
/// the legitimate user's ticket.
|
||||
/// 2. Single-use, 60-second ticket (existing behavior, unchanged).
|
||||
pub async fn ws_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<WsQuery>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Result<Response, (StatusCode, Json<Value>)> {
|
||||
// Gate 1: Origin allowlist (CSWSH defense-in-depth).
|
||||
let allowlist = &state.config.security.allowed_origins;
|
||||
if let Err((http_err, reason)) = check_origin(&headers, allowlist) {
|
||||
let raw_origin = headers
|
||||
.get(axum::http::header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<absent>");
|
||||
// Never log the ticket value.
|
||||
tracing::warn!(
|
||||
reason = reason,
|
||||
origin = %raw_origin,
|
||||
"WebSocket upgrade rejected: forbidden origin"
|
||||
);
|
||||
return Err(http_err);
|
||||
}
|
||||
let allowed_origin = headers
|
||||
.get(axum::http::header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Validate and consume the ticket atomically.
|
||||
let ticket = {
|
||||
let entry = state.ws_tickets.get(&q.ticket);
|
||||
@ -129,6 +312,7 @@ pub async fn ws_handler(
|
||||
tracing::info!(
|
||||
user_id = %ticket.user_id,
|
||||
role = %ticket.role,
|
||||
origin = %allowed_origin,
|
||||
"Browser WebSocket connection upgraded"
|
||||
);
|
||||
|
||||
@ -188,10 +372,8 @@ async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTi
|
||||
tracing::info!(user_id = %ticket.user_id, "Browser WS closed by client");
|
||||
break;
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
if socket.send(Message::Pong(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) if socket.send(Message::Pong(data.clone())).await.is_err() => {
|
||||
break;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!(error = %e, user_id = %ticket.user_id, "Browser WS recv error");
|
||||
@ -205,3 +387,289 @@ async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTi
|
||||
|
||||
tracing::info!(user_id = %ticket.user_id, "Browser WS handler exiting");
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_origin_header ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_basic_https() {
|
||||
assert_eq!(
|
||||
parse_origin_header("https://app.example.com"),
|
||||
Some(Origin {
|
||||
scheme: "https".into(),
|
||||
host: "app.example.com".into(),
|
||||
port: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_explicit_port() {
|
||||
assert_eq!(
|
||||
parse_origin_header("https://app.example.com:8443"),
|
||||
Some(Origin {
|
||||
scheme: "https".into(),
|
||||
host: "app.example.com".into(),
|
||||
port: Some(8443),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lowercases_scheme() {
|
||||
assert_eq!(
|
||||
parse_origin_header("HTTPS://App.Example.com")
|
||||
.unwrap()
|
||||
.scheme,
|
||||
"https"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lowercases_host() {
|
||||
assert_eq!(
|
||||
parse_origin_header("https://App.Example.com").unwrap().host,
|
||||
"app.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ignores_path_query_fragment() {
|
||||
let o = parse_origin_header("https://app.example.com:443/some/path?q=1#frag").unwrap();
|
||||
assert_eq!(o.host, "app.example.com");
|
||||
assert_eq!(o.port, Some(443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_strips_trailing_slash() {
|
||||
assert_eq!(
|
||||
parse_origin_header("https://app.example.com/"),
|
||||
Some(Origin {
|
||||
scheme: "https".into(),
|
||||
host: "app.example.com".into(),
|
||||
port: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_empty() {
|
||||
assert!(parse_origin_header("").is_none());
|
||||
assert!(parse_origin_header(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unsupported_scheme() {
|
||||
assert!(parse_origin_header("ftp://x").is_none());
|
||||
assert!(parse_origin_header("file:///etc/passwd").is_none());
|
||||
assert!(parse_origin_header("javascript:alert(1)").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_empty_host() {
|
||||
assert!(parse_origin_header("https://").is_none());
|
||||
assert!(parse_origin_header("https:///path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_host_with_whitespace() {
|
||||
assert!(parse_origin_header("https://bad host").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_malformed_port() {
|
||||
assert!(parse_origin_header("https://x:notaport").is_none());
|
||||
assert!(parse_origin_header("https://x:99999").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_ipv6_literal() {
|
||||
assert!(parse_origin_header("https://[::1]").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_no_scheme_separator() {
|
||||
assert!(parse_origin_header("app.example.com").is_none());
|
||||
}
|
||||
|
||||
// ── canonical ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn canonical_strips_default_https_port() {
|
||||
let o = Origin {
|
||||
scheme: "https".into(),
|
||||
host: "x".into(),
|
||||
port: Some(443),
|
||||
};
|
||||
assert_eq!(o.canonical(), "https://x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_strips_default_http_port() {
|
||||
let o = Origin {
|
||||
scheme: "http".into(),
|
||||
host: "x".into(),
|
||||
port: Some(80),
|
||||
};
|
||||
assert_eq!(o.canonical(), "http://x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_keeps_non_default_port() {
|
||||
let o = Origin {
|
||||
scheme: "https".into(),
|
||||
host: "x".into(),
|
||||
port: Some(8443),
|
||||
};
|
||||
assert_eq!(o.canonical(), "https://x:8443");
|
||||
}
|
||||
|
||||
// ── is_origin_allowed ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn allowed_exact_match() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_default_port_normalization_incoming() {
|
||||
let o = parse_origin_header("https://app.example.com:443").unwrap();
|
||||
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_default_port_normalization_allowlist() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(is_origin_allowed(
|
||||
&o,
|
||||
&["https://app.example.com:443".into()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_case_insensitive_host() {
|
||||
let o = parse_origin_header("https://App.Example.com").unwrap();
|
||||
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_different_host() {
|
||||
let o = parse_origin_header("https://evil.example").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_different_scheme() {
|
||||
let o = parse_origin_header("http://app.example.com").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_different_port() {
|
||||
let o = parse_origin_header("https://app.example.com:8443").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_empty_allowlist() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_garbage_in_allowlist() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &["not a url".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_multi_entry_allowlist() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(is_origin_allowed(
|
||||
&o,
|
||||
&[
|
||||
"https://other.example".into(),
|
||||
"https://app.example.com".into(),
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
// ── check_origin (integration of parse + allow) ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn check_rejects_missing_header() {
|
||||
let h = HeaderMap::new();
|
||||
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
|
||||
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1, "missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_malformed_header() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(axum::http::header::ORIGIN, "not a url".parse().unwrap());
|
||||
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
|
||||
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1, "malformed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_disallowed_origin() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://evil.example".parse().unwrap(),
|
||||
);
|
||||
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
|
||||
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1, "not-allowlisted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_empty_allowlist() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://app.example.com".parse().unwrap(),
|
||||
);
|
||||
let err = check_origin(&h, &[]).unwrap_err();
|
||||
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1, "not-allowlisted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allows_valid_origin() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://app.example.com".parse().unwrap(),
|
||||
);
|
||||
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allows_default_port_normalization() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://app.example.com:443".parse().unwrap(),
|
||||
);
|
||||
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allows_case_insensitive_host() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://App.Example.com".parse().unwrap(),
|
||||
);
|
||||
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
44
crates/pm-web/src/secret_key.rs
Normal file
44
crates/pm-web/src/secret_key.rs
Normal file
@ -0,0 +1,44 @@
|
||||
//! Secret-encryption key loader for pm-web.
|
||||
//!
|
||||
//! Lazily loads the per-install AES-256-GCM key from
|
||||
//! `/etc/patch-manager/keys/secret-encryption.key` on first use, caches it
|
||||
//! in process memory, and returns a `&'static [u8; 32]` for all subsequent calls.
|
||||
//!
|
||||
//! Uses `std::sync::OnceLock` (stable since Rust 1.70) to avoid the `once_cell` dependency.
|
||||
//!
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.4 for the design rationale.
|
||||
|
||||
use pm_core::crypto;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SECRET_KEY: OnceLock<[u8; 32]> = OnceLock::new();
|
||||
|
||||
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
|
||||
/// Returns `CryptoError` if the key file is missing or invalid.
|
||||
///
|
||||
/// Auto-generates the key file on first start (with 0600 permissions) if it doesn't exist.
|
||||
#[allow(dead_code)]
|
||||
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
|
||||
if let Some(key) = SECRET_KEY.get() {
|
||||
return Ok(key);
|
||||
}
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))?;
|
||||
// _ = ignore error if another thread won the race (already set by them)
|
||||
let _ = SECRET_KEY.set(key);
|
||||
Ok(SECRET_KEY.get().expect("key was just set"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[test]
|
||||
fn once_lock_caches_value() {
|
||||
let cell: OnceLock<u32> = OnceLock::new();
|
||||
let v1 = cell.get_or_init(|| 42);
|
||||
let v2 = cell.get_or_init(|| 99); // Should return 42, not 99
|
||||
assert_eq!(*v1, 42);
|
||||
assert_eq!(*v2, 42);
|
||||
}
|
||||
}
|
||||
530
crates/pm-web/tests/integration/authz_gate.rs
Normal file
530
crates/pm-web/tests/integration/authz_gate.rs
Normal file
@ -0,0 +1,530 @@
|
||||
//! Integration tests for the authz gate that restricts auth config mutations
|
||||
//! (OIDC, SMTP, IP whitelist) to the Admin role only.
|
||||
//!
|
||||
//! See Issue #15 for the full specification.
|
||||
//!
|
||||
//! ## Test organization
|
||||
//!
|
||||
//! The 403 (forbidden_role) tests verify that the authorization middleware
|
||||
//! rejects non-admin roles BEFORE any handler or database logic runs. These
|
||||
//! tests use a lazy PgPool (no live database required) and pre-generated CA
|
||||
//! files, so they always pass in CI.
|
||||
//!
|
||||
//! The 200 (admin allowed) tests verify the full handler path including audit
|
||||
//! logging. They require a live PostgreSQL database and are marked `#[ignore]`
|
||||
//! so they only run when `DATABASE_URL` is set and `--ignored` is passed.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use dashmap::DashMap;
|
||||
use http_body_util::BodyExt;
|
||||
use pm_auth::jwt;
|
||||
use pm_auth::rbac::AuthConfig;
|
||||
use pm_core::config::AppConfig;
|
||||
use pm_web::routes::sso::OidcCache;
|
||||
use pm_web::{build_router, AppState};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Ed25519 test key pair ────────────────────────────────────────────────────
|
||||
const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIBrWiMMcgpPXwtGDSSBl01fcQyb5Vh4CMzEmxcSXvcrJ
|
||||
-----END PRIVATE KEY-----
|
||||
";
|
||||
|
||||
const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEACgE6fMDCcG11NOpPKSO/ASpPUSntB7XsF5sBFBYDjFo=
|
||||
-----END PUBLIC KEY-----
|
||||
";
|
||||
|
||||
// ── Fixed test user IDs (so we can seed matching rows in the DB) ─────────────
|
||||
const ADMIN_USER_ID: &str = "00000000-0000-4000-8000-000000000001";
|
||||
const OPERATOR_USER_ID: &str = "00000000-0000-4000-8000-000000000002";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a valid JWT authorization header for the given role.
|
||||
fn auth_header(role: &str) -> String {
|
||||
let user_id = match role {
|
||||
"admin" => Uuid::parse_str(ADMIN_USER_ID).unwrap(),
|
||||
_ => Uuid::parse_str(OPERATOR_USER_ID).unwrap(),
|
||||
};
|
||||
let username = format!("test-{}", role);
|
||||
let token = jwt::issue_access_token(user_id, &username, role, 900, TEST_SIGNING_KEY)
|
||||
.expect("failed to issue test JWT");
|
||||
format!("Bearer {}", token)
|
||||
}
|
||||
|
||||
/// Generate CA key and cert files on disk so `CertAuthority::init` can load
|
||||
/// them without needing a database connection.
|
||||
fn generate_ca_files(ca_dir: &std::path::Path) {
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256,
|
||||
};
|
||||
|
||||
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("generate CA key");
|
||||
let mut params = CertificateParams::default();
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, "Test Root CA");
|
||||
|
||||
let cert = params.self_signed(&key).expect("self-sign CA cert");
|
||||
|
||||
std::fs::create_dir_all(ca_dir).expect("create CA dir");
|
||||
std::fs::write(ca_dir.join("ca.key"), key.serialize_pem()).expect("write ca.key");
|
||||
std::fs::write(ca_dir.join("ca.crt"), cert.pem()).expect("write ca.crt");
|
||||
}
|
||||
|
||||
/// Build a minimal `AppState` suitable for 403 authz gate tests.
|
||||
///
|
||||
/// Uses a lazy PgPool (no live database connection required) and pre-generated
|
||||
/// CA files. This works because the authorization middleware rejects non-admin
|
||||
/// requests BEFORE any handler or database logic runs.
|
||||
async fn setup_state_no_db() -> AppState {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://test:test@localhost:5432/test")
|
||||
.expect("failed to create lazy pool");
|
||||
|
||||
let mut config = AppConfig::default();
|
||||
config.server.static_dir = "/tmp".to_string();
|
||||
|
||||
let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[]));
|
||||
|
||||
let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA");
|
||||
let ca_dir_path = ca_dir.path().to_path_buf();
|
||||
generate_ca_files(&ca_dir_path);
|
||||
std::mem::forget(ca_dir);
|
||||
|
||||
let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool)
|
||||
.await
|
||||
.expect("CA init failed");
|
||||
|
||||
AppState {
|
||||
db: pool,
|
||||
config: Arc::new(config),
|
||||
signing_key_pem: TEST_SIGNING_KEY.to_string(),
|
||||
auth_config,
|
||||
ws_tickets: Arc::new(DashMap::new()),
|
||||
sso_sessions: Arc::new(DashMap::new()),
|
||||
sso_handoffs: Arc::new(DashMap::new()),
|
||||
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
|
||||
ca: Arc::new(ca),
|
||||
approved_enrollments: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed test users into the database so that audit_log foreign-key
|
||||
/// constraints on `actor_user_id` are satisfied.
|
||||
async fn seed_test_users(pool: &PgPool) {
|
||||
let placeholder_hash = "$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder";
|
||||
for (user_id, username, role) in [
|
||||
(ADMIN_USER_ID, "test-admin", "admin"),
|
||||
(OPERATOR_USER_ID, "test-operator", "operator"),
|
||||
] {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO users (id, username, display_name, email, role, auth_provider, password_hash)
|
||||
VALUES ($1, $2, $3, $4, $5::user_role, 'local', $6)
|
||||
ON CONFLICT (id) DO NOTHING"#,
|
||||
)
|
||||
.bind(Uuid::parse_str(user_id).unwrap())
|
||||
.bind(username)
|
||||
.bind(username)
|
||||
.bind(format!("{}@test.example.com", username))
|
||||
.bind(role)
|
||||
.bind(placeholder_hash)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("failed to seed test user");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a full `AppState` with a live database connection.
|
||||
async fn setup_state(pool: PgPool) -> AppState {
|
||||
seed_test_users(&pool).await;
|
||||
|
||||
let mut config = AppConfig::default();
|
||||
config.server.static_dir = "/tmp".to_string();
|
||||
|
||||
let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[]));
|
||||
|
||||
let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA");
|
||||
let ca_dir_path = ca_dir.path().to_path_buf();
|
||||
std::mem::forget(ca_dir);
|
||||
|
||||
let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool)
|
||||
.await
|
||||
.expect("CA init failed");
|
||||
|
||||
AppState {
|
||||
db: pool,
|
||||
config: Arc::new(config),
|
||||
signing_key_pem: TEST_SIGNING_KEY.to_string(),
|
||||
auth_config,
|
||||
ws_tickets: Arc::new(DashMap::new()),
|
||||
sso_sessions: Arc::new(DashMap::new()),
|
||||
sso_handoffs: Arc::new(DashMap::new()),
|
||||
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
|
||||
ca: Arc::new(ca),
|
||||
approved_enrollments: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a request through the full Axum router and return the response.
|
||||
async fn send_request(
|
||||
state: AppState,
|
||||
method: axum::http::Method,
|
||||
uri: &str,
|
||||
auth_header: Option<&str>,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> (StatusCode, serde_json::Value) {
|
||||
let router = build_router(state);
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(auth) = auth_header {
|
||||
builder = builder.header("authorization", auth);
|
||||
}
|
||||
builder = builder.header("content-type", "application/json");
|
||||
|
||||
let req = if let Some(b) = body {
|
||||
builder.body(Body::from(b.to_string())).unwrap()
|
||||
} else {
|
||||
builder.body(Body::empty()).unwrap()
|
||||
};
|
||||
|
||||
// Insert ConnectInfo so tower_governor's SmartIpKeyExtractor can resolve the client IP.
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts
|
||||
.extensions
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let req = Request::from_parts(parts, body);
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_else(|_| {
|
||||
let raw = String::from_utf8_lossy(&body_bytes);
|
||||
json!({ "_raw": raw.to_string() })
|
||||
});
|
||||
(status, body_json)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 403 Forbidden Role tests — no database required
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// These tests verify that the authorization middleware rejects non-admin roles
|
||||
// BEFORE any handler or database logic runs. They use a lazy PgPool and
|
||||
// pre-generated CA files, so they always pass in CI.
|
||||
|
||||
/// 1. PUT /api/v1/settings with operator role → 403 forbidden_role
|
||||
#[tokio::test]
|
||||
async fn update_settings_operator_denied() {
|
||||
let state = setup_state_no_db().await;
|
||||
let auth = auth_header("operator");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::PUT,
|
||||
"/api/v1/settings",
|
||||
Some(&auth),
|
||||
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
/// 3. PUT /api/v1/settings/ip-whitelist with operator role → 403 forbidden_role
|
||||
#[tokio::test]
|
||||
async fn update_ip_whitelist_operator_denied() {
|
||||
let state = setup_state_no_db().await;
|
||||
let auth = auth_header("operator");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::PUT,
|
||||
"/api/v1/settings/ip-whitelist",
|
||||
Some(&auth),
|
||||
Some(json!({ "entries": ["10.0.0.0/8"] })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
/// 5. POST /api/v1/settings/sso/discover with operator role → 403 forbidden_role
|
||||
#[tokio::test]
|
||||
async fn discover_oidc_operator_denied() {
|
||||
let state = setup_state_no_db().await;
|
||||
let auth = auth_header("operator");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/settings/sso/discover",
|
||||
Some(&auth),
|
||||
Some(json!({ "discovery_url": "https://example.com/.well-known/openid-configuration" })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
/// 7. POST /api/v1/settings/sso/test with operator role → 403 forbidden_role
|
||||
#[tokio::test]
|
||||
async fn test_oidc_operator_denied() {
|
||||
let state = setup_state_no_db().await;
|
||||
let auth = auth_header("operator");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/settings/sso/test",
|
||||
Some(&auth),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 200 Admin Allowed tests — require live database
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// These tests verify the full handler path including audit logging.
|
||||
// They require a live PostgreSQL database and are marked `#[ignore]` so they
|
||||
// only run when DATABASE_URL is set and `--ignored` is passed.
|
||||
|
||||
/// 2. PUT /api/v1/settings with admin role → 200 + audit log
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
#[ignore]
|
||||
async fn update_settings_admin_allowed(pool: PgPool) {
|
||||
let state = setup_state(pool).await;
|
||||
let pool = state.db.clone();
|
||||
let auth = auth_header("admin");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::PUT,
|
||||
"/api/v1/settings",
|
||||
Some(&auth),
|
||||
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"expected 200, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT action::text FROM audit_log WHERE action::text = 'config_changed' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("audit log query failed");
|
||||
assert!(row.is_some(), "expected audit log entry for config_changed");
|
||||
}
|
||||
|
||||
/// 4. PUT /api/v1/settings/ip-whitelist with admin role → 200 + audit log
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
#[ignore]
|
||||
async fn update_ip_whitelist_admin_allowed(pool: PgPool) {
|
||||
let state = setup_state(pool).await;
|
||||
let pool = state.db.clone();
|
||||
let auth = auth_header("admin");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::PUT,
|
||||
"/api/v1/settings/ip-whitelist",
|
||||
Some(&auth),
|
||||
Some(json!({ "entries": ["10.0.0.0/8"] })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"expected 200, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT action::text FROM audit_log WHERE action::text = 'ip_whitelist_updated' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("audit log query failed");
|
||||
assert!(
|
||||
row.is_some(),
|
||||
"expected audit log entry for ip_whitelist_updated"
|
||||
);
|
||||
}
|
||||
|
||||
/// 6. POST /api/v1/settings/sso/discover with admin role → 200 + audit log
|
||||
/// Uses mockito to simulate an OIDC discovery endpoint.
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
#[ignore]
|
||||
async fn discover_oidc_admin_allowed(pool: PgPool) {
|
||||
let state = setup_state(pool).await;
|
||||
let pool = state.db.clone();
|
||||
let auth = auth_header("admin");
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/.well-known/openid-configuration")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
json!({
|
||||
"issuer": "https://mock-oidc.example.com",
|
||||
"authorization_endpoint": "https://mock-oidc.example.com/auth",
|
||||
"token_endpoint": "https://mock-oidc.example.com/token",
|
||||
"jwks_uri": "https://mock-oidc.example.com/jwks",
|
||||
"userinfo_endpoint": "https://mock-oidc.example.com/userinfo"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/settings/sso/discover",
|
||||
Some(&auth),
|
||||
Some(json!({ "discovery_url": discovery_url })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"expected 200, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["success"], true);
|
||||
|
||||
mock.assert_async().await;
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_discover_performed' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("audit log query failed");
|
||||
assert!(
|
||||
row.is_some(),
|
||||
"expected audit log entry for oidc_discover_performed"
|
||||
);
|
||||
}
|
||||
|
||||
/// 8. POST /api/v1/settings/sso/test with admin role → 200 + audit log
|
||||
/// Uses mockito to simulate an OIDC discovery endpoint.
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
#[ignore]
|
||||
async fn test_oidc_admin_allowed(pool: PgPool) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/.well-known/openid-configuration")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
json!({
|
||||
"issuer": "https://mock-oidc.example.com",
|
||||
"authorization_endpoint": "https://mock-oidc.example.com/auth",
|
||||
"token_endpoint": "https://mock-oidc.example.com/token",
|
||||
"jwks_uri": "https://mock-oidc.example.com/jwks"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
|
||||
|
||||
// Seed the oidc_config table with an enabled provider pointing to mockito.
|
||||
sqlx::query("UPDATE oidc_config SET enabled = true, discovery_url = $1 WHERE id = 1")
|
||||
.bind(&discovery_url)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("failed to seed oidc_config");
|
||||
|
||||
let state = setup_state(pool).await;
|
||||
let pool = state.db.clone();
|
||||
let auth = auth_header("admin");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/settings/sso/test",
|
||||
Some(&auth),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"expected 200, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["success"], true);
|
||||
|
||||
mock.assert_async().await;
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_test_performed' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("audit log query failed");
|
||||
assert!(
|
||||
row.is_some(),
|
||||
"expected audit log entry for oidc_test_performed"
|
||||
);
|
||||
}
|
||||
1
crates/pm-web/tests/integration/main.rs
Normal file
1
crates/pm-web/tests/integration/main.rs
Normal file
@ -0,0 +1 @@
|
||||
mod authz_gate;
|
||||
@ -15,13 +15,13 @@ pm-agent-client = { path = "../pm-agent-client" }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
sqlx = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
tokio-rustls = { version = "0.26" }
|
||||
|
||||
0
crates/pm-worker/src/agent_loader.rs
Normal file → Executable file
0
crates/pm-worker/src/agent_loader.rs
Normal file → Executable file
0
crates/pm-worker/src/audit_verifier.rs
Normal file → Executable file
0
crates/pm-worker/src/audit_verifier.rs
Normal file → Executable file
@ -9,7 +9,6 @@ use lettre::{
|
||||
transport::smtp::authentication::Credentials,
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
};
|
||||
use serde_json;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use pm_core::audit::{log_event, AuditAction};
|
||||
@ -33,11 +32,16 @@ struct NotificationSettings {
|
||||
}
|
||||
|
||||
/// Load SMTP settings from the `system_config` table.
|
||||
///
|
||||
/// Issue #6 fix: SMTP password is stored as two rows:
|
||||
/// - `smtp_password_encrypted` (hex of AES-256-GCM ciphertext)
|
||||
/// - `smtp_password_nonce` (hex of AES-256-GCM nonce)
|
||||
async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT key, value FROM system_config WHERE key IN (
|
||||
'smtp_enabled', 'smtp_host', 'smtp_port', 'smtp_username',
|
||||
'smtp_password', 'smtp_from', 'smtp_tls_mode'
|
||||
'smtp_password_encrypted', 'smtp_password_nonce',
|
||||
'smtp_from', 'smtp_tls_mode'
|
||||
)",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@ -51,17 +55,46 @@ async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// Decrypt the SMTP password
|
||||
let enc_hex = get("smtp_password_encrypted");
|
||||
let nonce_hex = get("smtp_password_nonce");
|
||||
let password = if !enc_hex.is_empty() && !nonce_hex.is_empty() {
|
||||
match (
|
||||
hex_decode(&enc_hex),
|
||||
hex_decode(&nonce_hex),
|
||||
crate::secret_key::get(),
|
||||
) {
|
||||
(Some(enc), Some(nonce), Ok(key)) => {
|
||||
pm_core::crypto::decrypt(&enc, &nonce, key).unwrap_or_default()
|
||||
},
|
||||
_ => String::new(),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
SmtpSettings {
|
||||
enabled: get("smtp_enabled") == "true",
|
||||
host: get("smtp_host"),
|
||||
port: get("smtp_port").parse().unwrap_or(587),
|
||||
username: get("smtp_username"),
|
||||
password: get("smtp_password"),
|
||||
password,
|
||||
from: get("smtp_from"),
|
||||
tls_mode: get("smtp_tls_mode"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a hex string to bytes. Returns None on invalid input.
|
||||
fn hex_decode(s: &str) -> Option<Vec<u8>> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load notification preferences from `system_config`.
|
||||
async fn load_notification_settings(pool: &PgPool) -> NotificationSettings {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
@ -290,6 +323,7 @@ pub async fn send_job_completion_email(
|
||||
}
|
||||
|
||||
/// Send a maintenance window reminder email.
|
||||
#[allow(dead_code)]
|
||||
pub async fn send_maintenance_window_reminder_email(
|
||||
pool: &PgPool,
|
||||
host_fqdn: &str,
|
||||
|
||||
1
crates/pm-worker/src/health_check_poller.rs
Normal file → Executable file
1
crates/pm-worker/src/health_check_poller.rs
Normal file → Executable file
@ -22,6 +22,7 @@ use pm_agent_client::{AgentClient, AgentClientError};
|
||||
|
||||
/// Row fetched for each enabled health check, joined with host connection info.
|
||||
#[derive(FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct HealthCheckRow {
|
||||
id: Uuid,
|
||||
host_id: Uuid,
|
||||
|
||||
@ -2,12 +2,26 @@
|
||||
//!
|
||||
//! Polls every host via the agent `/health` endpoint on each tick of
|
||||
//! `health_poll_interval_secs`, with bounded concurrency controlled by a
|
||||
//! [`tokio::sync::Semaphore`].
|
||||
//! [`tokio::sync::Semaphore`]. Also calls `/system/info` to refresh
|
||||
//! `os_family`, `os_name`, `arch`, and `agent_version` in the hosts table.
|
||||
//!
|
||||
//! CRL health aggregation rules (PR 5):
|
||||
//! - `crl_status = "invalid"` → host health_status overridden to `unreachable`
|
||||
//! - `crl_status = "expired"` → host health_status overridden to `degraded` (if currently healthy)
|
||||
//! - `crl_status = "missing"` AND registered > 24h ago → host health_status overridden to `degraded` (if currently healthy)
|
||||
//! - `crl_status = "valid"` or NULL → no override
|
||||
//!
|
||||
//! Audit events are logged for CRL state transitions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use pm_agent_client::{AgentClient, AgentClientError};
|
||||
use pm_core::{config::AppConfig, models::HostHealthStatus};
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
config::AppConfig,
|
||||
models::HostHealthStatus,
|
||||
};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use tokio::{sync::Semaphore, time};
|
||||
use uuid::Uuid;
|
||||
@ -20,6 +34,10 @@ struct HostRow {
|
||||
id: Uuid,
|
||||
ip_address: String,
|
||||
agent_port: i32,
|
||||
/// Current CRL status from the hosts table (for transition detection).
|
||||
crl_status: Option<String>,
|
||||
/// When the host was first registered (for enrollment age checks).
|
||||
registered_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Run the health poller loop indefinitely.
|
||||
@ -49,9 +67,9 @@ pub async fn run_health_poller(pool: PgPool, config: Arc<AppConfig>) {
|
||||
let client_key = Arc::new(certs.client_key);
|
||||
let ca_cert = Arc::new(certs.ca_cert);
|
||||
|
||||
// Fetch all hosts.
|
||||
// Fetch all hosts with CRL status and registration time.
|
||||
let hosts: Vec<HostRow> = match sqlx::query_as(
|
||||
"SELECT id, host(ip_address)::text AS ip_address, agent_port FROM hosts ORDER BY id",
|
||||
"SELECT id, host(ip_address)::text AS ip_address, agent_port, crl_status, registered_at FROM hosts ORDER BY id",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
@ -114,6 +132,11 @@ pub async fn run_health_poller(pool: PgPool, config: Arc<AppConfig>) {
|
||||
}
|
||||
|
||||
/// Poll a single host, persist the result, and return the determined status.
|
||||
///
|
||||
/// Also updates `agent_version` from the health response,
|
||||
/// `os_family`/`os_name`/`arch` from the `/system/info` endpoint when available,
|
||||
/// CRL status fields from the health response when reported by the agent,
|
||||
/// and applies CRL health aggregation rules.
|
||||
async fn poll_host_health(
|
||||
pool: PgPool,
|
||||
host: HostRow,
|
||||
@ -121,8 +144,16 @@ async fn poll_host_health(
|
||||
client_key: &[u8],
|
||||
ca_cert: &[u8],
|
||||
) -> HostHealthStatus {
|
||||
// Determine status and optional health payload.
|
||||
let (status, payload) = match AgentClient::new(
|
||||
// Determine status, payload, agent version, optional system info, and CRL fields.
|
||||
let (
|
||||
natural_status,
|
||||
payload,
|
||||
agent_version,
|
||||
sys_info,
|
||||
crl_status,
|
||||
crl_age_seconds,
|
||||
crl_next_update,
|
||||
) = match AgentClient::new(
|
||||
&host.ip_address,
|
||||
host.agent_port as u16,
|
||||
client_cert,
|
||||
@ -138,38 +169,95 @@ async fn poll_host_health(
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
Ok(client) => match client.health().await {
|
||||
Ok(data) => {
|
||||
let payload = serde_json::to_value(&data).unwrap_or_default();
|
||||
(HostHealthStatus::Healthy, payload)
|
||||
},
|
||||
Err(AgentClientError::Timeout) => {
|
||||
tracing::warn!(host_id = %host.id, "Health poller: agent timed out");
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
)
|
||||
},
|
||||
Err(AgentClientError::Connect(_)) => {
|
||||
tracing::warn!(host_id = %host.id, "Health poller: agent connection refused");
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(host_id = %host.id, error = %e, "Health poller: agent error");
|
||||
(
|
||||
HostHealthStatus::Degraded,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
)
|
||||
},
|
||||
Ok(client) => {
|
||||
let (status, payload, version, crl_status, crl_age, crl_next) = match client
|
||||
.health()
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
let payload = serde_json::to_value(&data).unwrap_or_default();
|
||||
let crl_status = data.crl_status.clone();
|
||||
let crl_age = data.crl_age_seconds;
|
||||
let crl_next = data.crl_next_update.clone();
|
||||
(
|
||||
HostHealthStatus::Healthy,
|
||||
payload,
|
||||
Some(data.version),
|
||||
crl_status,
|
||||
crl_age,
|
||||
crl_next,
|
||||
)
|
||||
},
|
||||
Err(AgentClientError::Timeout) => {
|
||||
tracing::warn!(host_id = %host.id, "Health poller: agent timed out");
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
Err(AgentClientError::Connect(_)) => {
|
||||
tracing::warn!(host_id = %host.id, "Health poller: agent connection refused");
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(host_id = %host.id, error = %e, "Health poller: agent error");
|
||||
(
|
||||
HostHealthStatus::Degraded,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
// Try to fetch system info for OS/arch details (best-effort).
|
||||
let sys_info = if status != HostHealthStatus::Unreachable {
|
||||
match client.system_info().await {
|
||||
Ok(info) => Some(info),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
host_id = %host.id,
|
||||
error = %e,
|
||||
"Health poller: failed to get system info (non-fatal)"
|
||||
);
|
||||
None
|
||||
},
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(
|
||||
status, payload, version, sys_info, crl_status, crl_age, crl_next,
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
// Insert into host_health_data.
|
||||
// Apply CRL health aggregation rules to determine the effective status.
|
||||
// Only apply when the agent reported a CRL status (non-NULL).
|
||||
let effective_status = apply_crl_health_rules(&natural_status, &crl_status, host.registered_at);
|
||||
|
||||
// Insert into host_health_data with the natural (pre-aggregation) status.
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO host_health_data (host_id, status, payload)
|
||||
@ -177,7 +265,7 @@ async fn poll_host_health(
|
||||
"#,
|
||||
)
|
||||
.bind(host.id)
|
||||
.bind(&status)
|
||||
.bind(&natural_status)
|
||||
.bind(&payload)
|
||||
.execute(&pool)
|
||||
.await
|
||||
@ -185,21 +273,403 @@ async fn poll_host_health(
|
||||
tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to insert health data");
|
||||
}
|
||||
|
||||
// Update hosts table.
|
||||
// Build OS name from system info components (e.g. "Ubuntu 24.04").
|
||||
let os_name_from_sysinfo = sys_info
|
||||
.as_ref()
|
||||
.map(|i| format!("{} {}", i.os, i.os_version));
|
||||
|
||||
// Parse CRL next_update from ISO-8601 string to DateTime if present.
|
||||
let crl_next_update_dt: Option<chrono::DateTime<chrono::Utc>> = crl_next_update
|
||||
.as_ref()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.to_utc());
|
||||
|
||||
// Update hosts table with the effective (post-aggregation) health status,
|
||||
// agent version, OS details, and CRL fields.
|
||||
// COALESCE preserves existing values when new data is unavailable.
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
UPDATE hosts
|
||||
SET health_status = $2, last_health_at = NOW()
|
||||
SET health_status = $2, last_health_at = NOW(),
|
||||
agent_version = COALESCE($3, agent_version),
|
||||
os_family = COALESCE($4, os_family),
|
||||
os_name = COALESCE($5, os_name),
|
||||
arch = COALESCE($6, arch),
|
||||
crl_status = COALESCE($7, crl_status),
|
||||
crl_age_seconds = COALESCE($8, crl_age_seconds),
|
||||
crl_next_update = COALESCE($9, crl_next_update)
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(host.id)
|
||||
.bind(&status)
|
||||
.bind(&effective_status)
|
||||
.bind(&agent_version)
|
||||
.bind(sys_info.as_ref().map(|i| i.os.as_str()))
|
||||
.bind(os_name_from_sysinfo)
|
||||
.bind(sys_info.as_ref().map(|i| i.architecture.as_str()))
|
||||
.bind(&crl_status)
|
||||
.bind(crl_age_seconds)
|
||||
.bind(crl_next_update_dt)
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to update host status");
|
||||
// Don't log audit events if the DB update failed.
|
||||
return effective_status;
|
||||
}
|
||||
|
||||
status
|
||||
// Log CRL audit events after successful database update.
|
||||
if let Some(ref new_crl) = crl_status {
|
||||
log_crl_audit_events(
|
||||
&pool,
|
||||
host.id,
|
||||
host.crl_status.as_deref(),
|
||||
new_crl,
|
||||
crl_age_seconds,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
effective_status
|
||||
}
|
||||
|
||||
/// Apply CRL health aggregation rules to determine the effective health status.
|
||||
///
|
||||
/// Rules:
|
||||
/// - `crl_status = "invalid"` → `Unreachable` (security event, always overrides)
|
||||
/// - `crl_status = "expired"` → `Degraded` (only if natural status is `Healthy`)
|
||||
/// - `crl_status = "missing"` AND registered > 24h ago → `Degraded` (only if natural status is `Healthy`)
|
||||
/// - `crl_status = "valid"` or NULL → no override
|
||||
fn apply_crl_health_rules(
|
||||
natural_status: &HostHealthStatus,
|
||||
crl_status: &Option<String>,
|
||||
registered_at: DateTime<Utc>,
|
||||
) -> HostHealthStatus {
|
||||
let Some(crl) = crl_status else {
|
||||
// Older agent not reporting CRL — don't modify health status.
|
||||
return natural_status.clone();
|
||||
};
|
||||
|
||||
match crl.as_str() {
|
||||
"invalid" => HostHealthStatus::Unreachable,
|
||||
"expired" => {
|
||||
if *natural_status == HostHealthStatus::Healthy {
|
||||
HostHealthStatus::Degraded
|
||||
} else {
|
||||
natural_status.clone()
|
||||
}
|
||||
},
|
||||
"missing" => {
|
||||
let age = Utc::now() - registered_at;
|
||||
if age > Duration::hours(24) && *natural_status == HostHealthStatus::Healthy {
|
||||
HostHealthStatus::Degraded
|
||||
} else {
|
||||
natural_status.clone()
|
||||
}
|
||||
},
|
||||
// "valid" or any other value — no override
|
||||
_ => natural_status.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log audit events for CRL state transitions.
|
||||
///
|
||||
/// Called after the hosts table has been successfully updated.
|
||||
/// Logs:
|
||||
/// - `CrlStatusChanged` when the CRL status transitions to a different value
|
||||
/// - `CrlStaleDetected` when CRL status becomes "expired"
|
||||
/// - `CrlInvalid` when CRL status becomes "invalid"
|
||||
async fn log_crl_audit_events(
|
||||
pool: &PgPool,
|
||||
host_id: Uuid,
|
||||
old_crl_status: Option<&str>,
|
||||
new_crl_status: &str,
|
||||
crl_age_seconds: Option<i64>,
|
||||
) {
|
||||
let host_id_str = host_id.to_string();
|
||||
let old_str = old_crl_status.unwrap_or("null");
|
||||
|
||||
// Log a transition event if the status changed.
|
||||
if old_crl_status != Some(new_crl_status) {
|
||||
let details = serde_json::json!({
|
||||
"host_id": host_id_str,
|
||||
"old_crl_status": old_str,
|
||||
"new_crl_status": new_crl_status,
|
||||
"crl_age_seconds": crl_age_seconds,
|
||||
});
|
||||
log_event(
|
||||
pool,
|
||||
AuditAction::CrlStatusChanged,
|
||||
None, // actor_user_id — system-initiated
|
||||
None, // actor_username
|
||||
Some("host"), // target_type
|
||||
Some(&host_id_str), // target_id
|
||||
details,
|
||||
None, // ip_address
|
||||
None, // request_id
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Log specific events for problematic CRL states.
|
||||
match new_crl_status {
|
||||
"expired" => {
|
||||
let details = serde_json::json!({
|
||||
"host_id": host_id_str,
|
||||
"old_crl_status": old_str,
|
||||
"new_crl_status": new_crl_status,
|
||||
"crl_age_seconds": crl_age_seconds,
|
||||
});
|
||||
log_event(
|
||||
pool,
|
||||
AuditAction::CrlStaleDetected,
|
||||
None,
|
||||
None,
|
||||
Some("host"),
|
||||
Some(&host_id_str),
|
||||
details,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
},
|
||||
"invalid" => {
|
||||
let details = serde_json::json!({
|
||||
"host_id": host_id_str,
|
||||
"old_crl_status": old_str,
|
||||
"new_crl_status": new_crl_status,
|
||||
"crl_age_seconds": crl_age_seconds,
|
||||
});
|
||||
log_event(
|
||||
pool,
|
||||
AuditAction::CrlInvalid,
|
||||
None,
|
||||
None,
|
||||
Some("host"),
|
||||
Some(&host_id_str),
|
||||
details,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — CRL health aggregation rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_crl_health {
|
||||
use super::*;
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
/// Helper: create a DateTime that is `hours` hours in the past.
|
||||
fn hours_ago(h: i64) -> DateTime<Utc> {
|
||||
Utc::now() - Duration::hours(h)
|
||||
}
|
||||
|
||||
// ---- crl_status = "invalid" → Unreachable (always overrides) ----
|
||||
|
||||
#[test]
|
||||
fn crl_invalid_overrides_healthy_to_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("invalid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_invalid_overrides_degraded_to_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Degraded,
|
||||
&Some("invalid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_invalid_overrides_unreachable_stays_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Unreachable,
|
||||
&Some("invalid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- crl_status = "expired" → Degraded (only if currently Healthy) ----
|
||||
|
||||
#[test]
|
||||
fn crl_expired_downgrades_healthy_to_degraded() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("expired".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_expired_does_not_override_degraded() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Degraded,
|
||||
&Some("expired".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_expired_does_not_downgrade_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Unreachable,
|
||||
&Some("expired".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- crl_status = "missing" AND registered > 24h → Degraded (if Healthy) ----
|
||||
|
||||
#[test]
|
||||
fn crl_missing_old_registration_downgrades_healthy() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("missing".to_string()),
|
||||
hours_ago(25),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_missing_recent_registration_no_override() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("missing".to_string()),
|
||||
hours_ago(12),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_missing_does_not_override_degraded() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Degraded,
|
||||
&Some("missing".to_string()),
|
||||
hours_ago(25),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_missing_does_not_override_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Unreachable,
|
||||
&Some("missing".to_string()),
|
||||
hours_ago(25),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- crl_status = "valid" → no override ----
|
||||
|
||||
#[test]
|
||||
fn crl_valid_does_not_override_healthy() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("valid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_valid_preserves_degraded() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Degraded,
|
||||
&Some("valid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_valid_preserves_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Unreachable,
|
||||
&Some("valid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- NULL crl_status → no override (backward compat) ----
|
||||
|
||||
#[test]
|
||||
fn null_crl_status_preserves_healthy() {
|
||||
let result = apply_crl_health_rules(&HostHealthStatus::Healthy, &None, hours_ago(0));
|
||||
assert_eq!(result, HostHealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_crl_status_preserves_degraded() {
|
||||
let result = apply_crl_health_rules(&HostHealthStatus::Degraded, &None, hours_ago(0));
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_crl_status_preserves_unreachable() {
|
||||
let result = apply_crl_health_rules(&HostHealthStatus::Unreachable, &None, hours_ago(0));
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- Edge cases ----
|
||||
|
||||
#[test]
|
||||
fn crl_missing_just_under_24h_no_override() {
|
||||
// 23h 59m old — should NOT trigger degraded (threshold is > 24h)
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("missing".to_string()),
|
||||
Utc::now() - Duration::hours(23) - Duration::minutes(59),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_missing_just_over_24h_triggers_degraded() {
|
||||
// 24h + 1 minute old — should trigger degraded
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("missing".to_string()),
|
||||
Utc::now() - Duration::hours(24) - Duration::minutes(1),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_pending_status_preserved_with_valid_crl() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Pending,
|
||||
&Some("valid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_invalid_overrides_pending_to_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Pending,
|
||||
&Some("invalid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
}
|
||||
|
||||
0
crates/pm-worker/src/job_executor.rs
Normal file → Executable file
0
crates/pm-worker/src/job_executor.rs
Normal file → Executable file
1
crates/pm-worker/src/main.rs
Normal file → Executable file
1
crates/pm-worker/src/main.rs
Normal file → Executable file
@ -12,6 +12,7 @@ mod job_executor;
|
||||
mod maintenance_scheduler;
|
||||
mod patch_poller;
|
||||
mod refresh_listener;
|
||||
mod secret_key;
|
||||
mod ws_relay;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
1
crates/pm-worker/src/maintenance_scheduler.rs
Normal file → Executable file
1
crates/pm-worker/src/maintenance_scheduler.rs
Normal file → Executable file
@ -45,6 +45,7 @@ struct AutoApplyWindow {
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct PendingPatchHost {
|
||||
host_id: Uuid,
|
||||
patch_count: i32,
|
||||
|
||||
0
crates/pm-worker/src/patch_poller.rs
Normal file → Executable file
0
crates/pm-worker/src/patch_poller.rs
Normal file → Executable file
0
crates/pm-worker/src/refresh_listener.rs
Normal file → Executable file
0
crates/pm-worker/src/refresh_listener.rs
Normal file → Executable file
29
crates/pm-worker/src/secret_key.rs
Normal file
29
crates/pm-worker/src/secret_key.rs
Normal file
@ -0,0 +1,29 @@
|
||||
//! Secret-encryption key loader for pm-worker.
|
||||
//!
|
||||
//! Lazily loads the per-install AES-256-GCM key from
|
||||
//! `/etc/patch-manager/keys/secret-encryption.key` on first use, caches it
|
||||
//! in process memory, and returns a `&'static [u8; 32]` for all subsequent calls.
|
||||
//!
|
||||
//! The pm-worker crate uses the same key file as pm-web (filesystem-shared).
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.4 for the design rationale.
|
||||
|
||||
use pm_core::crypto;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SECRET_KEY: OnceLock<[u8; 32]> = OnceLock::new();
|
||||
|
||||
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
|
||||
/// Returns `CryptoError` if the key file is missing or invalid.
|
||||
///
|
||||
/// Auto-generates the key file on first start (with 0600 permissions) if it doesn't exist.
|
||||
#[allow(dead_code)]
|
||||
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
|
||||
if let Some(key) = SECRET_KEY.get() {
|
||||
return Ok(key);
|
||||
}
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))?;
|
||||
// _ = ignore error if another thread won the race (already set by them)
|
||||
let _ = SECRET_KEY.set(key);
|
||||
Ok(SECRET_KEY.get().expect("key was just set"))
|
||||
}
|
||||
0
crates/pm-worker/src/ws_relay.rs
Normal file → Executable file
0
crates/pm-worker/src/ws_relay.rs
Normal file → Executable file
82
debian/changelog
vendored
82
debian/changelog
vendored
@ -1,3 +1,85 @@
|
||||
linux-patch-manager (1.1.11-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.11
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 15:57:10 -0500
|
||||
|
||||
linux-patch-manager (1.1.10-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.10
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 14:11:31 -0500
|
||||
|
||||
linux-patch-manager (1.1.9-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.9
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 13:05:59 -0500
|
||||
|
||||
linux-patch-manager (1.1.8-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.8
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 11:47:58 -0500
|
||||
|
||||
linux-patch-manager (1.1.7-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.7
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 09:11:11 -0500
|
||||
|
||||
linux-patch-manager (1.1.6-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.6
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 08:10:52 -0500
|
||||
|
||||
linux-patch-manager (1.1.5-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.5
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Mon, 08 Jun 2026 20:15:50 -0500
|
||||
|
||||
linux-patch-manager (1.1.4-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.4
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Mon, 08 Jun 2026 17:30:35 -0500
|
||||
|
||||
linux-patch-manager (1.1.2-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.2
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 21:19:18 -0500
|
||||
|
||||
linux-patch-manager (1.1.1-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.1
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 18:55:59 -0500
|
||||
|
||||
linux-patch-manager (1.1.0-1) unstable; urgency=low
|
||||
|
||||
* Release v1.1.0
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 16:47:03 -0500
|
||||
|
||||
linux-patch-manager (1.0.0-1) unstable; urgency=low
|
||||
|
||||
* Release v1.0.0
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 12:58:46 -0500
|
||||
|
||||
linux-patch-manager (0.1.9-1) noble; urgency=medium
|
||||
|
||||
* Fix: Replace broken DashMap rate limiting with tower-governor middleware
|
||||
* Fix: Enrollment rate limiting was global (0.0.0.0 fallback) instead of per-IP
|
||||
* Fix: Use SmartIpKeyExtractor for proper X-Forwarded-For support behind HAProxy
|
||||
* Add: Configurable rate limit tiers via [rate_limit] in config.toml
|
||||
* Add: Standard X-RateLimit-* and Retry-After headers on 429 responses
|
||||
|
||||
-- Echo <echo@moon-dragon.us> Wed, 21 May 2026 02:38:00 +0000
|
||||
|
||||
linux-patch-manager (0.1.7-1) noble; urgency=medium
|
||||
|
||||
* Host Self-Enrollment: Added REST API and UI for automated agent enrollment
|
||||
|
||||
5
debian/control
vendored
5
debian/control
vendored
@ -1,9 +1,10 @@
|
||||
Package: linux-patch-manager
|
||||
Version: 1.0.0-1
|
||||
Version: 1.1.11-1
|
||||
Architecture: amd64
|
||||
Maintainer: Moon Dragon <echo@moon-dragon.us>
|
||||
Installed-Size: 45000
|
||||
Depends: postgresql-16, libssl3, libc6 (>= 2.39), libfontconfig1
|
||||
Pre-Depends: postgresql-16
|
||||
Depends: postgresql-16, argon2, libssl3, libc6 (>= 2.39), libfontconfig1
|
||||
Recommends: postgresql-client-16, fonts-dejavu-core
|
||||
Suggests: gpg
|
||||
Section: admin
|
||||
|
||||
496
debian/postinst
vendored
496
debian/postinst
vendored
@ -4,91 +4,427 @@ set -e
|
||||
# =============================================================================
|
||||
# Linux Patch Manager — Post-install script
|
||||
# =============================================================================
|
||||
# Fully automated: apt install ./linux-patch-manager_X.X.X-1_amd64.deb
|
||||
# results in a running service with a printed admin password.
|
||||
# All steps are idempotent (safe to re-run on upgrade).
|
||||
# =============================================================================
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
DB_NAME="patch_manager"
|
||||
DB_USER="patch_manager"
|
||||
CONFIG_DIR="/etc/patch-manager"
|
||||
MIGRATION_DIR="/usr/share/patch-manager/migrations"
|
||||
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
psql_run() {
|
||||
# Run SQL as the postgres superuser
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
psql_run_db() {
|
||||
# Run SQL against the patch_manager database as postgres superuser
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 -d "${DB_NAME}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
psql_run_as_pm() {
|
||||
# Run SQL against the patch_manager database as patch_manager user
|
||||
# Requires PGPASSWORD to be set in the calling environment
|
||||
PGPASSWORD="${PGPASSWORD}" psql -v ON_ERROR_STOP=1 -U "${DB_USER}" -h localhost -d "${DB_NAME}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Create service user (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
create_service_user() {
|
||||
if ! id patch-manager &>/dev/null; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin \
|
||||
--comment "Linux Patch Manager service account" patch-manager
|
||||
info "Service user 'patch-manager' created."
|
||||
else
|
||||
info "Service user 'patch-manager' already exists."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Create required directories (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
create_directories() {
|
||||
mkdir -p "${CONFIG_DIR}/ca" "${CONFIG_DIR}/certs" \
|
||||
"${CONFIG_DIR}/jwt" "${CONFIG_DIR}/tls" \
|
||||
/var/log/patch-manager /opt/patch-manager \
|
||||
/var/backups/patch-manager
|
||||
|
||||
chown -R patch-manager:patch-manager \
|
||||
"${CONFIG_DIR}" /var/log/patch-manager \
|
||||
/opt/patch-manager /usr/share/patch-manager/frontend
|
||||
|
||||
chmod 750 "${CONFIG_DIR}/ca" "${CONFIG_DIR}/jwt"
|
||||
chmod 700 /var/backups/patch-manager
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Wait for PostgreSQL to be ready
|
||||
# ---------------------------------------------------------------------------
|
||||
wait_for_postgresql() {
|
||||
info "Waiting for PostgreSQL to be ready..."
|
||||
local retries=30
|
||||
local delay=2
|
||||
local i
|
||||
for ((i = 1; i <= retries; i++)); do
|
||||
if pg_isready -q 2>/dev/null; then
|
||||
info "PostgreSQL is ready."
|
||||
return 0
|
||||
fi
|
||||
warn "PostgreSQL not ready yet (attempt ${i}/${retries}), waiting ${delay}s..."
|
||||
sleep "${delay}"
|
||||
done
|
||||
error "PostgreSQL did not become ready after $((retries * delay)) seconds."
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Create PostgreSQL user and database (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
setup_database() {
|
||||
info "Setting up PostgreSQL database and user..."
|
||||
|
||||
# Generate a random password for the DB user
|
||||
local db_password
|
||||
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
|
||||
|
||||
# Create role if not exists
|
||||
local role_exists
|
||||
role_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" 2>/dev/null || echo "")
|
||||
if [[ "${role_exists}" != "1" ]]; then
|
||||
psql_run -c "CREATE ROLE ${DB_USER} LOGIN PASSWORD '${db_password}';"
|
||||
info "PostgreSQL user '${DB_USER}' created."
|
||||
# Store password for config generation
|
||||
echo "${db_password}" > /tmp/.pm-db-password-new
|
||||
else
|
||||
info "PostgreSQL user '${DB_USER}' already exists."
|
||||
# Recover the DB password: try from existing config, or generate new.
|
||||
local config_file="${CONFIG_DIR}/config.toml"
|
||||
local existing_pw=""
|
||||
if [[ -f "${config_file}" ]]; then
|
||||
# Extract password from URL: postgres://user:PASSWORD@host/db
|
||||
# Use @localhost anchor so passwords containing @ are extracted correctly.
|
||||
existing_pw=$(sed -n 's|^url = "postgres://[^:]*:\(.*\)@localhost.*"|\1|p' "${config_file}" | head -1)
|
||||
fi
|
||||
if [[ -n "${existing_pw}" && "${existing_pw}" != "CHANGEME" ]]; then
|
||||
# Config has a real password — sync it to PostgreSQL so the app can connect.
|
||||
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${existing_pw}';" 2>/dev/null || true
|
||||
echo "${existing_pw}" > /tmp/.pm-db-password-new
|
||||
info "Synced DB password from existing config to PostgreSQL."
|
||||
else
|
||||
# No config or CHANGEME — generate a fresh password and update PostgreSQL.
|
||||
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
|
||||
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
|
||||
echo "${db_password}" > /tmp/.pm-db-password-new
|
||||
info "Generated new DB password for existing user."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create database if not exists
|
||||
local db_exists
|
||||
db_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" 2>/dev/null || echo "")
|
||||
if [[ "${db_exists}" != "1" ]]; then
|
||||
psql_run -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
|
||||
info "Database '${DB_NAME}' created."
|
||||
else
|
||||
info "Database '${DB_NAME}' already exists, skipping creation."
|
||||
fi
|
||||
|
||||
# Grant full permissions so patch_manager owns and manages all objects
|
||||
psql_run_db -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true
|
||||
psql_run_db -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" 2>/dev/null || true
|
||||
# If any future migration runs as postgres, ensure objects are still accessible by patch_manager
|
||||
psql_run_db -c "ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO ${DB_USER};" 2>/dev/null || true
|
||||
psql_run_db -c "ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO ${DB_USER};" 2>/dev/null || true
|
||||
psql_run_db -c "ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO ${DB_USER};" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Apply database migrations (idempotent)
|
||||
# Migrations run as patch_manager so all created objects are owned by
|
||||
# patch_manager — this avoids the ownership conflicts that occur when
|
||||
# postgres-owned objects need ALTER TABLE by a non-superuser.
|
||||
# ---------------------------------------------------------------------------
|
||||
apply_migrations() {
|
||||
info "Applying database migrations..."
|
||||
|
||||
# Get the DB password for patch_manager authentication
|
||||
local db_password=""
|
||||
if [[ -f /tmp/.pm-db-password-new ]]; then
|
||||
db_password=$(cat /tmp/.pm-db-password-new)
|
||||
else
|
||||
# Fallback: extract from config
|
||||
local config_file="${CONFIG_DIR}/config.toml"
|
||||
if [[ -f "${config_file}" ]]; then
|
||||
db_password=$(sed -n 's|^url = "postgres://[^:]*:\(.*\)@localhost.*"|\1|p' "${config_file}" | head -1)
|
||||
fi
|
||||
if [[ -z "${db_password}" || "${db_password}" == "CHANGEME" ]]; then
|
||||
error "Cannot determine DB password for migrations."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
export PGPASSWORD="${db_password}"
|
||||
|
||||
# Ensure pgcrypto extension is available (requires superuser)
|
||||
psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
|
||||
|
||||
# Create migration tracking table if not exists (run as patch_manager)
|
||||
psql_run_as_pm <<'MIGSQL'
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename TEXT NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
MIGSQL
|
||||
|
||||
# Handle upgrade from pre-migration-tracking versions:
|
||||
# If tables exist but _migrations is empty, mark all existing migrations as applied.
|
||||
local migration_count
|
||||
migration_count=$(psql_run_as_pm -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
|
||||
migration_count="${migration_count// /}"
|
||||
|
||||
local tables_exist
|
||||
tables_exist=$(psql_run_as_pm -t -A -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" 2>/dev/null || echo "0")
|
||||
tables_exist="${tables_exist// /}"
|
||||
|
||||
if [[ "${migration_count}" == "0" && "${tables_exist}" -gt 0 ]]; then
|
||||
info "Existing database detected — marking all shipped migrations as already applied."
|
||||
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
|
||||
local fname
|
||||
fname=$(basename "${sql_file}")
|
||||
psql_run_as_pm -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Apply each migration in sorted order, skipping already-applied ones
|
||||
local applied=0
|
||||
local skipped=0
|
||||
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
|
||||
local fname
|
||||
fname=$(basename "${sql_file}")
|
||||
|
||||
local already_applied
|
||||
already_applied=$(psql_run_as_pm -t -A -c "SELECT COUNT(*) FROM _migrations WHERE filename='${fname}';" 2>/dev/null || echo "0")
|
||||
already_applied="${already_applied// /}"
|
||||
|
||||
if [[ "${already_applied}" -gt 0 ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
info " Applying migration: ${fname}"
|
||||
if psql_run_as_pm -f "${sql_file}"; then
|
||||
psql_run_as_pm -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
|
||||
applied=$((applied + 1))
|
||||
else
|
||||
error " Failed to apply migration: ${fname}"
|
||||
unset PGPASSWORD
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
unset PGPASSWORD
|
||||
|
||||
if [[ "${applied}" -gt 0 ]]; then
|
||||
info "Applied ${applied} new migration(s), skipped ${skipped} already applied."
|
||||
else
|
||||
info "All migrations up to date (${skipped} already applied)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Generate admin password and update database
|
||||
# ---------------------------------------------------------------------------
|
||||
generate_admin_password() {
|
||||
info "Generating admin password..."
|
||||
|
||||
# Generate a random 24-character password
|
||||
local admin_password
|
||||
admin_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*' | head -c 24)
|
||||
|
||||
# Hash with argon2 (PHC format, compatible with the application)
|
||||
# Generate a random 16-character salt (argon2 requires minimum 8 characters)
|
||||
local admin_salt
|
||||
admin_salt=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 16)
|
||||
local password_hash
|
||||
password_hash=$(echo -n "${admin_password}" | argon2 "${admin_salt}" -id -t 3 -m 16 -p 1 -l 32 -e)
|
||||
|
||||
# Update admin user password in database
|
||||
# Only update if the placeholder hash is still present
|
||||
# The placeholder starts with: $argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA
|
||||
# Using single-quoted variable to preserve $ signs in SQL LIKE pattern
|
||||
local placeholder_pattern
|
||||
placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'
|
||||
|
||||
local updated
|
||||
updated=$(psql_run_db -t -A -c \
|
||||
"UPDATE users SET password_hash = '${password_hash}', force_password_reset = TRUE \
|
||||
WHERE username = 'admin' AND password_hash LIKE '${placeholder_pattern}' \
|
||||
RETURNING id;" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "${updated}" ]]; then
|
||||
# Write admin password to file (mode 600, owned by root)
|
||||
echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}"
|
||||
chmod 600 "${ADMIN_PASSWORD_FILE}"
|
||||
chown root:root "${ADMIN_PASSWORD_FILE}"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}=============================================${NC}"
|
||||
echo -e "${CYAN} Linux Patch Manager — Admin Credentials${NC}"
|
||||
echo -e "${CYAN}=============================================${NC}"
|
||||
echo -e " Username: ${GREEN}admin${NC}"
|
||||
echo -e " Password: ${GREEN}${admin_password}${NC}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}IMPORTANT: Save this password! It will not be shown again.${NC}"
|
||||
echo -e " Password also saved to: ${ADMIN_PASSWORD_FILE}"
|
||||
echo -e "${CYAN}=============================================${NC}"
|
||||
echo ""
|
||||
else
|
||||
info "Admin password already set (not a fresh install). Password file not regenerated."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Write config.toml with DB URL
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handles three scenarios:
|
||||
# 1. No config file → create from example with real DB password
|
||||
# 2. Config exists with CHANGEME → replace CHANGEME with real DB password
|
||||
# 3. Config exists with real password → leave it alone (upgrade)
|
||||
# ---------------------------------------------------------------------------
|
||||
write_config() {
|
||||
local config_file="${CONFIG_DIR}/config.toml"
|
||||
|
||||
# Resolve the DB password to use: from setup_database() or generate fresh.
|
||||
local db_password=""
|
||||
if [[ -f /tmp/.pm-db-password-new ]]; then
|
||||
db_password=$(cat /tmp/.pm-db-password-new)
|
||||
fi
|
||||
|
||||
if [[ -f "${config_file}" ]]; then
|
||||
# Check if the config still has the CHANGEME placeholder
|
||||
if grep -q 'CHANGEME' "${config_file}"; then
|
||||
if [[ -z "${db_password}" ]]; then
|
||||
# No password from setup_database() — generate a fresh one
|
||||
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
|
||||
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
|
||||
fi
|
||||
info "Replacing CHANGEME placeholder in existing config with real DB password."
|
||||
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|postgres://${DB_USER}:${db_password}@localhost/${DB_NAME}|" "${config_file}"
|
||||
else
|
||||
info "Config file ${config_file} already exists with a real password, leaving it unchanged."
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
# No config file — create from example
|
||||
if [[ -z "${db_password}" ]]; then
|
||||
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
|
||||
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
info "Writing configuration file..."
|
||||
cp /usr/share/patch-manager/config.example.toml "${config_file}"
|
||||
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|postgres://${DB_USER}:${db_password}@localhost/${DB_NAME}|" "${config_file}"
|
||||
fi
|
||||
|
||||
chown patch-manager:patch-manager "${config_file}"
|
||||
chmod 640 "${config_file}"
|
||||
info "Configuration written to ${config_file}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Generate JWT keys (idempotent)
|
||||
# Only generates if missing; regenerates verify.pem from signing.pem if lost.
|
||||
# ---------------------------------------------------------------------------
|
||||
generate_jwt_keys() {
|
||||
if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then
|
||||
info "Generating Ed25519 JWT signing key..."
|
||||
openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null
|
||||
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
|
||||
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem"
|
||||
chmod 600 "${CONFIG_DIR}/jwt/signing.pem"
|
||||
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
|
||||
info "JWT keys generated."
|
||||
elif [[ ! -f "${CONFIG_DIR}/jwt/verify.pem" ]]; then
|
||||
info "Regenerating missing JWT verification key from existing signing key..."
|
||||
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
|
||||
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/verify.pem"
|
||||
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
|
||||
info "JWT verification key regenerated."
|
||||
else
|
||||
info "JWT keys already exist, skipping."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Enable and start services
|
||||
# ---------------------------------------------------------------------------
|
||||
enable_and_start_services() {
|
||||
systemctl daemon-reload
|
||||
|
||||
# Enable the target (which pulls in web + worker)
|
||||
systemctl enable patch-manager.target 2>/dev/null || true
|
||||
|
||||
# Enable individual services so they survive a reboot
|
||||
systemctl enable patch-manager-web.service patch-manager-worker.service 2>/dev/null || true
|
||||
|
||||
# Start or restart services
|
||||
if systemctl is-active --quiet patch-manager.target 2>/dev/null; then
|
||||
info "Restarting patch-manager services (upgrade)..."
|
||||
systemctl restart patch-manager.target 2>/dev/null || true
|
||||
else
|
||||
info "Starting patch-manager services..."
|
||||
systemctl start patch-manager.target 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Install backup cron (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
install_backup_cron() {
|
||||
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
|
||||
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/patch-manager/backup.log 2>&1") | crontab -
|
||||
info "Nightly backup cron installed."
|
||||
fi
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
case "$1" in
|
||||
configure)
|
||||
# Create service user if not exists
|
||||
if ! id patch-manager &>/dev/null; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin \
|
||||
--comment "Linux Patch Manager service account" patch-manager
|
||||
fi
|
||||
create_service_user
|
||||
create_directories
|
||||
wait_for_postgresql
|
||||
setup_database
|
||||
apply_migrations
|
||||
generate_admin_password
|
||||
write_config
|
||||
generate_jwt_keys
|
||||
enable_and_start_services
|
||||
install_backup_cron
|
||||
|
||||
# Create required directories
|
||||
mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
|
||||
/etc/patch-manager/jwt /etc/patch-manager/tls \
|
||||
/var/log/patch-manager /opt/patch-manager \
|
||||
/var/backups/patch-manager
|
||||
# Clean up temp file
|
||||
rm -f /tmp/.pm-db-password-new
|
||||
|
||||
chown -R patch-manager:patch-manager \
|
||||
/etc/patch-manager /var/log/patch-manager \
|
||||
/opt/patch-manager /usr/share/patch-manager/frontend
|
||||
|
||||
chmod 750 /etc/patch-manager/ca /etc/patch-manager/jwt
|
||||
chmod 700 /var/backups/patch-manager
|
||||
|
||||
# Generate JWT signing key if not present
|
||||
if [[ ! -f /etc/patch-manager/jwt/signing.pem ]]; then
|
||||
openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem 2>/dev/null
|
||||
openssl pkey -in /etc/patch-manager/jwt/signing.pem -pubout -out /etc/patch-manager/jwt/verify.pem 2>/dev/null
|
||||
chown patch-manager:patch-manager /etc/patch-manager/jwt/signing.pem /etc/patch-manager/jwt/verify.pem
|
||||
chmod 600 /etc/patch-manager/jwt/signing.pem
|
||||
chmod 644 /etc/patch-manager/jwt/verify.pem
|
||||
fi
|
||||
|
||||
# Write default config if not present
|
||||
if [[ ! -f /etc/patch-manager/config.toml ]]; then
|
||||
cp /usr/share/patch-manager/config.example.toml /etc/patch-manager/config.toml
|
||||
chown patch-manager:patch-manager /etc/patch-manager/config.toml
|
||||
chmod 640 /etc/patch-manager/config.toml
|
||||
fi
|
||||
|
||||
# Install backup cron if not present
|
||||
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
|
||||
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/patch-manager/backup.log 2>&1") | crontab -
|
||||
fi
|
||||
|
||||
# Reload systemd
|
||||
systemctl daemon-reload
|
||||
|
||||
# Restart services if this is an upgrade (not a fresh install)
|
||||
if systemctl is-active --quiet patch-manager-web 2>/dev/null; then
|
||||
systemctl restart patch-manager-web || true
|
||||
fi
|
||||
if systemctl is-active --quiet patch-manager-worker 2>/dev/null; then
|
||||
systemctl restart patch-manager-worker || true
|
||||
fi
|
||||
|
||||
# Run pending database migrations
|
||||
MIGRATION_DIR="/usr/share/patch-manager/migrations"
|
||||
if [[ -d "$MIGRATION_DIR" ]]; then
|
||||
echo "Applying database migrations..."
|
||||
for sql_file in $(ls "$MIGRATION_DIR"/*.sql 2>/dev/null | sort); do
|
||||
echo " Applying: $(basename "$sql_file")"
|
||||
done
|
||||
echo "Note: Migrations must be applied manually: sudo -u patch_manager psql -d patch_manager -f <migration_file>"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Linux Patch Manager installed successfully!"
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Install and configure PostgreSQL:"
|
||||
echo " apt install postgresql-16"
|
||||
echo " 2. Create the database:"
|
||||
echo " sudo -u postgres createdb -O patch_manager patch_manager"
|
||||
echo " 3. Edit /etc/patch-manager/config.toml with your database URL"
|
||||
echo " 4. Enable and start services:"
|
||||
echo " systemctl enable --now patch-manager.target"
|
||||
echo " 5. Access the web UI at https://localhost"
|
||||
echo " Default admin credentials are set via the seed migration."
|
||||
echo ""
|
||||
echo "IMPORTANT: Change the default admin password immediately after first login!"
|
||||
echo ""
|
||||
echo "If this is an upgrade, services have been restarted automatically."
|
||||
echo "Apply any new database migrations:"
|
||||
echo " sudo -u patch_manager psql -d patch_manager -f /usr/share/patch-manager/migrations/<NNN_migration>.sql"
|
||||
echo ""
|
||||
info "Linux Patch Manager installation complete."
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
|
||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@ -0,0 +1,58 @@
|
||||
# =============================================================================
|
||||
# Linux Patch Manager — Docker Compose Deployment
|
||||
# =============================================================================
|
||||
# Usage:
|
||||
# cp .env.example .env # Edit DB_PASSWORD
|
||||
# docker compose up -d
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: patch_manager
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: patch_manager
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U patch_manager -d patch_manager"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
networks:
|
||||
- patch-manager-net
|
||||
|
||||
app:
|
||||
image: ghcr.io/draco-lunaris/linux-patch-manager:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "443:443"
|
||||
environment:
|
||||
DATABASE_URL: postgres://patch_manager:${DB_PASSWORD}@db:5432/patch_manager
|
||||
PATCH_MANAGER_CONFIG: /etc/patch-manager/config.toml
|
||||
volumes:
|
||||
- pm-config:/etc/patch-manager
|
||||
- pm-logs:/var/log/patch-manager
|
||||
- pm-data:/opt/patch-manager
|
||||
networks:
|
||||
- patch-manager-net
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
pm-config:
|
||||
driver: local
|
||||
pm-logs:
|
||||
driver: local
|
||||
pm-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
patch-manager-net:
|
||||
driver: bridge
|
||||
232
docker/entrypoint.sh
Executable file
232
docker/entrypoint.sh
Executable file
@ -0,0 +1,232 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# Linux Patch Manager — Docker Entrypoint
|
||||
# =============================================================================
|
||||
# Handles first-run: wait for DB, run migrations, generate admin password,
|
||||
# start pm-web and pm-worker services.
|
||||
# =============================================================================
|
||||
|
||||
MIGRATION_DIR="/usr/share/patch-manager/migrations"
|
||||
CONFIG_DIR="/etc/patch-manager"
|
||||
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse DATABASE_URL into PG* env vars for psql compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
parse_database_url() {
|
||||
# DATABASE_URL format: postgres://user:password@host:port/dbname
|
||||
local url="${DATABASE_URL}"
|
||||
|
||||
# Extract components
|
||||
DB_PASS=$(echo "$url" | sed -n 's|postgres://[^:]*:\([^@]*\)@.*|\1|p')
|
||||
DB_HOST=$(echo "$url" | sed -n 's|.*@\([^:/]*\).*|\1|p')
|
||||
DB_PORT=$(echo "$url" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
|
||||
DB_USER=$(echo "$url" | sed -n 's|postgres://\([^:]*\):.*|\1|p')
|
||||
DB_NAME=$(echo "$url" | sed -n 's|.*/\([^?]*\).*|\1|p')
|
||||
|
||||
# Default port
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
|
||||
export PGHOST="${DB_HOST}"
|
||||
export PGPORT="${DB_PORT}"
|
||||
export PGUSER="${DB_USER}"
|
||||
export PGPASSWORD="${DB_PASS}"
|
||||
export PGDATABASE="${DB_NAME}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wait for PostgreSQL to be ready
|
||||
# ---------------------------------------------------------------------------
|
||||
wait_for_db() {
|
||||
echo "[entrypoint] Waiting for PostgreSQL at ${PGHOST}:${DB_PORT}..."
|
||||
local retries=60
|
||||
local delay=2
|
||||
local i
|
||||
for ((i = 1; i <= retries; i++)); do
|
||||
if pg_isready -q -h "${PGHOST}" -p "${DB_PORT}" -U "${DB_USER}" 2>/dev/null; then
|
||||
echo "[entrypoint] PostgreSQL is ready."
|
||||
return 0
|
||||
fi
|
||||
echo "[entrypoint] PostgreSQL not ready (attempt ${i}/${retries}), waiting ${delay}s..."
|
||||
sleep "${delay}"
|
||||
done
|
||||
echo "[entrypoint] ERROR: PostgreSQL did not become ready after $((retries * delay)) seconds." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run database migrations (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
run_migrations() {
|
||||
echo "[entrypoint] Applying database migrations..."
|
||||
|
||||
# Ensure pgcrypto extension
|
||||
psql -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
|
||||
|
||||
# Create migration tracking table
|
||||
psql -v ON_ERROR_STOP=1 <<'EOSQL'
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename TEXT NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
EOSQL
|
||||
|
||||
# Handle upgrade from pre-migration-tracking versions
|
||||
local migration_count
|
||||
migration_count=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
|
||||
migration_count="${migration_count// /}"
|
||||
|
||||
local tables_exist
|
||||
tables_exist=$(psql -t -A -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" 2>/dev/null || echo "0")
|
||||
tables_exist="${tables_exist// /}"
|
||||
|
||||
if [[ "${migration_count}" == "0" && "${tables_exist}" -gt 0 ]]; then
|
||||
echo "[entrypoint] Existing database detected — marking all shipped migrations as already applied."
|
||||
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
|
||||
local fname
|
||||
fname=$(basename "${sql_file}")
|
||||
psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Apply each migration in sorted order
|
||||
local applied=0
|
||||
local skipped=0
|
||||
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
|
||||
local fname
|
||||
fname=$(basename "${sql_file}")
|
||||
|
||||
local already_applied
|
||||
already_applied=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations WHERE filename='${fname}';" 2>/dev/null || echo "0")
|
||||
already_applied="${already_applied// /}"
|
||||
|
||||
if [[ "${already_applied}" -gt 0 ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Applying migration: ${fname}"
|
||||
if psql -v ON_ERROR_STOP=1 -f "${sql_file}"; then
|
||||
psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
|
||||
applied=$((applied + 1))
|
||||
else
|
||||
echo "[entrypoint] ERROR: Failed to apply migration: ${fname}" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${applied}" -gt 0 ]]; then
|
||||
echo "[entrypoint] Applied ${applied} new migration(s), skipped ${skipped}."
|
||||
else
|
||||
echo "[entrypoint] All migrations up to date (${skipped} already applied)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate admin password on first run
|
||||
# ---------------------------------------------------------------------------
|
||||
generate_admin_password() {
|
||||
echo "[entrypoint] Checking admin password status..."
|
||||
|
||||
# Generate a random 24-character password
|
||||
local admin_password
|
||||
admin_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*' | head -c 24)
|
||||
|
||||
# Hash with argon2 (PHC format)
|
||||
local password_hash
|
||||
password_hash=$(echo -n "${admin_password}" | argon2 salt -id -t 3 -m 65536 -p 1 -l 32 -e)
|
||||
|
||||
# Update admin user — only if placeholder hash is still present
|
||||
# The placeholder starts with: $argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA
|
||||
# Using single-quoted variable to preserve $ signs in the SQL LIKE pattern
|
||||
local placeholder_pattern
|
||||
placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'
|
||||
|
||||
local updated
|
||||
updated=$(psql -t -A -c \
|
||||
"UPDATE users SET password_hash = '${password_hash}', force_password_reset = TRUE \
|
||||
WHERE username = 'admin' AND password_hash LIKE '${placeholder_pattern}' \
|
||||
RETURNING id;" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "${updated}" ]]; then
|
||||
echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}"
|
||||
chmod 600 "${ADMIN_PASSWORD_FILE}"
|
||||
chown root:root "${ADMIN_PASSWORD_FILE}"
|
||||
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo " Linux Patch Manager — Admin Credentials"
|
||||
echo "============================================="
|
||||
echo " Username: admin"
|
||||
echo " Password: ${admin_password}"
|
||||
echo ""
|
||||
echo " IMPORTANT: Save this password! It will not be shown again."
|
||||
echo " Password also saved to: ${ADMIN_PASSWORD_FILE}"
|
||||
echo "============================================="
|
||||
echo ""
|
||||
else
|
||||
echo "[entrypoint] Admin password already set (not a fresh install)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate JWT keys if not present
|
||||
# ---------------------------------------------------------------------------
|
||||
generate_jwt_keys() {
|
||||
if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then
|
||||
echo "[entrypoint] Generating Ed25519 JWT signing key..."
|
||||
openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null
|
||||
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
|
||||
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem"
|
||||
chmod 600 "${CONFIG_DIR}/jwt/signing.pem"
|
||||
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
|
||||
echo "[entrypoint] JWT keys generated."
|
||||
else
|
||||
echo "[entrypoint] JWT signing key already exists."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write config.toml if not present
|
||||
# ---------------------------------------------------------------------------
|
||||
write_config() {
|
||||
local config_file="${CONFIG_DIR}/config.toml"
|
||||
|
||||
if [[ -f "${config_file}" ]]; then
|
||||
echo "[entrypoint] Config file already exists, not overwriting."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Writing configuration file..."
|
||||
cp /usr/share/patch-manager/config.example.toml "${config_file}"
|
||||
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|${DATABASE_URL}|" "${config_file}"
|
||||
chown patch-manager:patch-manager "${config_file}"
|
||||
chmod 640 "${config_file}"
|
||||
echo "[entrypoint] Configuration written."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[entrypoint] Linux Patch Manager Docker entrypoint starting..."
|
||||
|
||||
parse_database_url
|
||||
wait_for_db
|
||||
run_migrations
|
||||
generate_admin_password
|
||||
generate_jwt_keys
|
||||
write_config
|
||||
|
||||
echo "[entrypoint] Starting pm-web and pm-worker..."
|
||||
|
||||
# Start pm-worker in background
|
||||
pm-worker &
|
||||
WORKER_PID=$!
|
||||
|
||||
# Start pm-web in foreground (main process)
|
||||
export PATCH_MANAGER_CONFIG="${CONFIG_DIR}/config.toml"
|
||||
|
||||
exec pm-web
|
||||
@ -14,6 +14,16 @@ Security: JWT Bearer Token (except Public Endpoints)
|
||||
| POST | `/auth/mfa/verify` | Verify MFA code |
|
||||
| DELETE | `/auth/mfa` | Disable MFA for user |
|
||||
|
||||
## 1b. SSO (Single Sign-On)
|
||||
*No authentication required.* These endpoints implement the OIDC Authorization Code + PKCE flow. See `tasks/sso-token-handoff-spec.md` for the full design.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/auth/sso/login` | Initiate OIDC login: redirects browser to the configured IdP's authorization URL |
|
||||
| GET | `/auth/sso/callback` | OIDC redirect URI: handles the IdP response, issues a single-use 60s `handoff_code`, stores the JWT access/refresh tokens in memory, and 302-redirects to the SPA with `?handoff=<code>` in the URL (no tokens in the URL — see issue #4) |
|
||||
| GET | `/auth/sso/config` | Returns minimal SSO configuration for the login page (`enabled`, `display_name`, `auth_url`). No secrets exposed |
|
||||
| POST | `/auth/sso/handoff` | **(new in issue #4)** Exchange a single-use `handoff_code` for the JWT access/refresh tokens. The SPA calls this from `SsoCallbackPage` after the OIDC callback redirect. Returns `{ access_token, refresh_token, token_type, expires_in, user }`. The code is single-use, 60s TTL, and atomically removed on exchange (concurrent attempts: exactly one wins). `400 invalid_handoff` on unknown/expired/already-consumed codes |
|
||||
|
||||
## 2. Public Endpoints (Self-Enrollment)
|
||||
*No authentication required.*
|
||||
| Method | Endpoint | Description |
|
||||
@ -60,12 +70,16 @@ Security: JWT Bearer Token (except Public Endpoints)
|
||||
## 7. Jobs & Patch Deployment
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/jobs` | List patch jobs |
|
||||
| GET | `/jobs` | List patch jobs (includes `host_names` per job) |
|
||||
| POST | `/jobs` | Create new patch job |
|
||||
| GET | `/jobs/{id}` | Get job status/details |
|
||||
| POST | `/jobs/{id}/cancel` | Cancel running job |
|
||||
| POST | `/jobs/{id}/rollback` | Rollback completed job |
|
||||
|
||||
### GET /jobs Response Fields
|
||||
Each job summary object includes:
|
||||
- `host_names`: Array of display names for hosts targeted by this job. Falls back to `fqdn` when `display_name` is empty. Single-host jobs show one name; multi-host jobs show all names sorted alphabetically.
|
||||
|
||||
## 8. Maintenance Windows
|
||||
*Scoped to host.*
|
||||
| Method | Endpoint | Description |
|
||||
@ -102,13 +116,15 @@ Security: JWT Bearer Token (except Public Endpoints)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/settings` | Get system settings |
|
||||
| PUT | `/settings` | Update system settings |
|
||||
| PUT | `/settings` | Update system settings **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||
| POST | `/settings/smtp/test` | Test SMTP configuration |
|
||||
| POST | `/settings/sso/discover` | Discover OIDC provider config |
|
||||
| POST | `/settings/sso/test` | Test SSO connection |
|
||||
| POST | `/settings/sso/discover` | Discover OIDC provider config **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||
| POST | `/settings/sso/test` | Test SSO connection **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||
| POST | `/settings/azure-sso/test` | Test Azure SSO compatibility |
|
||||
| POST | `/settings/audit-integrity` | Verify audit log integrity |
|
||||
|
||||
> **Note (issue #6):** As of May 2026, sensitive fields (`oidc.client_secret`, `smtp.password`) are encrypted at rest in the database (AES-256-GCM). The `MASKED` placeholder behavior in API responses is **preserved** — clients never see plaintext secrets in GET responses. See [docs/runbooks/key-management.md](runbooks/key-management.md) for key management procedures.
|
||||
|
||||
## 12. Single Sign-On (SSO)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
@ -125,6 +141,53 @@ Security: JWT Bearer Token (except Public Endpoints)
|
||||
| GET | `/reports/vulnerability` | Generate vulnerability exposure report |
|
||||
| GET | `/reports/audit` | Generate audit trail report |
|
||||
|
||||
### CRL Status Fields
|
||||
|
||||
Host list and detail responses include CRL (Certificate Revocation List) status fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `crl_status` | `string?` | CRL status: `valid`, `expired`, `missing`, `invalid`, or `null` (older agents) |
|
||||
| `crl_age_seconds` | `integer?` | Seconds since the agent's CRL was last refreshed |
|
||||
| `crl_next_update` | `datetime?` | When the agent's CRL expires (ISO-8601) |
|
||||
|
||||
Fleet status response includes CRL counts:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `crl_valid` | `integer` | Hosts with CRL status `valid` |
|
||||
| `crl_expired` | `integer` | Hosts with CRL status `expired` |
|
||||
| `crl_missing` | `integer` | Hosts with CRL status `missing` |
|
||||
| `crl_invalid` | `integer` | Hosts with CRL status `invalid` (security event) |
|
||||
| `crl_not_reporting` | `integer` | Hosts not reporting CRL status (older agents) |
|
||||
|
||||
### CRL Audit Events
|
||||
|
||||
The health poller logs the following system-initiated audit events when a host's CRL status changes:
|
||||
|
||||
| Audit Action | Trigger | Details Fields |
|
||||
|---|---|---|
|
||||
| `crl_status_changed` | Any CRL status transition | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
|
||||
| `crl_stale_detected` | CRL status becomes `expired` | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
|
||||
| `crl_invalid` | CRL status becomes `invalid` | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
|
||||
|
||||
All CRL audit events use `target_type = "host"` and `target_id = <host_id>`. Actor fields (`actor_user_id`, `actor_username`) are `null` because these are system-initiated events.
|
||||
|
||||
### CRL Health Aggregation Rules
|
||||
|
||||
The health poller applies the following rules to determine a host's effective health status based on CRL state:
|
||||
|
||||
| CRL Status | Condition | Effective Health Status |
|
||||
|---|---|---|
|
||||
| `invalid` | Always | `unreachable` (security event) |
|
||||
| `expired` | If natural status is `healthy` | `degraded` |
|
||||
| `missing` | Registered > 24h ago AND natural status is `healthy` | `degraded` |
|
||||
| `missing` | Registered ≤ 24h ago | Natural status (new agent enrollment) |
|
||||
| `valid` | Any | Natural status (no override) |
|
||||
| `null` | Any | Natural status (older agent, not reporting CRL) |
|
||||
|
||||
When CRL status transitions from `invalid`/`expired`/`missing` back to `valid`, the next health poll cycle restores the host to its natural health status based on the agent's health response.
|
||||
|
||||
## 14. Real-Time Updates (WebSocket)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
|
||||
128
docs/runbooks/key-management.md
Normal file
128
docs/runbooks/key-management.md
Normal file
@ -0,0 +1,128 @@
|
||||
# Key Management Runbook
|
||||
|
||||
**Applies to:** Linux Patch Manager production deployments (issue #6 — secret encryption at rest)
|
||||
**Last updated:** 2026-06-03
|
||||
**Owner:** SRE / Security
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Linux Patch Manager uses two per-install AES-256-GCM encryption keys for protecting sensitive data at rest. Both keys are auto-generated on first start of the service, stored as 32-byte files with `0600` permissions (owner read/write only).
|
||||
|
||||
| Key file | Path | Protects | Used by |
|
||||
|----------|------|----------|---------|
|
||||
| `health-check.key` | `/etc/patch-manager/keys/health-check.key` | HTTP basic-auth passwords for health check endpoints | `pm-web`, `pm-worker` |
|
||||
| `secret-encryption.key` | `/etc/patch-manager/keys/secret-encryption.key` | OIDC `client_secret`, SMTP `smtp_password`, TOTP `totp_secret` | `pm-web`, `pm-auth`, `pm-worker` |
|
||||
|
||||
The two keys are separate by design (blast-radius isolation): if the health-check key is ever compromised, the app secrets remain protected by a different key.
|
||||
|
||||
---
|
||||
|
||||
## Key Generation (First Start)
|
||||
|
||||
On first start of `pm-web` or `pm-worker`, the `crypto::load_or_create_key()` function checks for each key file. If missing, it:
|
||||
|
||||
1. Creates the `/etc/patch-manager/keys/` directory (mode `0700`)
|
||||
2. Generates 32 cryptographically random bytes via `OsRng` (the OS CSPRNG)
|
||||
3. Writes the key to disk
|
||||
4. Sets permissions to `0600` (owner read/write only)
|
||||
5. Returns the key to the calling code
|
||||
|
||||
The key files are created in the order they are first accessed. If `pm-worker` starts before `pm-web`, it creates the same key file (filesystem-shared). Both processes can read the same key.
|
||||
|
||||
---
|
||||
|
||||
## Backup
|
||||
|
||||
**Both key files MUST be included in `/etc/patch-manager` backups.** Without the key files, encrypted data is unrecoverable. Recommended backup procedure:
|
||||
|
||||
```bash
|
||||
# Include the keys directory in the backup archive
|
||||
tar -czf /backup/patch-manager-$(date +%F).tar.gz \
|
||||
/etc/patch-manager/config.toml \
|
||||
/etc/patch-manager/keys/ \
|
||||
/var/lib/patch-manager/ # if used
|
||||
|
||||
# Verify the keys are in the backup
|
||||
tar -tzf /backup/patch-manager-*.tar.gz | grep -E 'keys/.*\.key$'
|
||||
```
|
||||
|
||||
The existing `scripts/backup.sh` already excludes secrets from unencrypted backups and supports GPG encryption for the archive. Ensure the backup includes the keys directory.
|
||||
|
||||
---
|
||||
|
||||
## Verification (Production)
|
||||
|
||||
To verify both keys exist and have correct permissions on a running deployment:
|
||||
|
||||
```bash
|
||||
# Check both key files exist with 0600 permissions
|
||||
for key in health-check.key secret-encryption.key; do
|
||||
path="/etc/patch-manager/keys/${key}"
|
||||
if [ -f "$path" ]; then
|
||||
mode=$(stat -c '%a' "$path")
|
||||
size=$(stat -c '%s' "$path")
|
||||
echo "[OK] $path mode=$mode size=$size"
|
||||
else
|
||||
echo "[FAIL] $path missing"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
[OK] /etc/patch-manager/keys/health-check.key mode=600 size=32
|
||||
[OK] /etc/patch-manager/keys/secret-encryption.key mode=600 size=32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recovery (Disaster Scenario)
|
||||
|
||||
If a key file is lost (disk failure, accidental deletion):
|
||||
|
||||
1. **All encrypted data becomes unrecoverable.** This includes:
|
||||
- HTTP basic-auth passwords for health check endpoints (health-check.key)
|
||||
- OIDC `client_secret` (secret-encryption.key)
|
||||
- SMTP `smtp_password` (secret-encryption.key)
|
||||
- TOTP `totp_secret` for all users (secret-encryption.key)
|
||||
|
||||
2. **If you have a backup** of the key files: restore them to `/etc/patch-manager/keys/` with `0600` permissions. The service will read the restored keys on next start.
|
||||
|
||||
3. **If you do NOT have a backup**: re-provision the affected secrets:
|
||||
- For OIDC: re-enter the `client_secret` from the IdP's app registration
|
||||
- For SMTP: re-enter the SMTP password
|
||||
- For TOTP: all users must re-enroll MFA (their existing TOTP secrets are unrecoverable)
|
||||
- For health-check basic auth: re-enter the password in each health check configuration
|
||||
|
||||
---
|
||||
|
||||
## Key Rotation
|
||||
|
||||
Key rotation is **not yet supported** (tracked as a follow-up issue). If a key is compromised:
|
||||
|
||||
1. Generate a new key: `rm /etc/patch-manager/keys/secret-encryption.key` (service will auto-generate on next start)
|
||||
2. Re-encrypt all secrets in the database using the `migrate-secrets` binary (see [README of the helper](../../crates/migrate-secrets/src/main.rs))
|
||||
3. Update any external systems that depended on the old secrets (e.g., IdP app registration)
|
||||
|
||||
For a planned rotation (without compromise), the procedure is the same but coordinated with a maintenance window.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Never** log the key bytes or include them in error messages. The `crypto::load_or_create_key()` function returns the key but callers should never `tracing::error!` the value.
|
||||
- **Never** commit key files to git. The `/etc/patch-manager/keys/` directory should be in `.gitignore` or outside the repo entirely (recommended).
|
||||
- **Never** copy key files between machines (e.g., for "easy migration"). Each deployment must generate its own key.
|
||||
- **The `MASKED` placeholder in API responses** (e.g., for `client_secret` in OIDC settings) continues to apply on top of DB encryption — it's a separate defense-in-depth layer.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Secret encryption spec](../../tasks/secret-encryption-spec.md) — full design rationale and migration plan
|
||||
- [Security review](../security-review.md) §4.1 — control matrix entry
|
||||
- [Migration 020](../../migrations/020_encrypt_secrets_at_rest.sql) — schema changes for the new encrypted columns
|
||||
- `crates/pm-core/src/crypto.rs` — implementation of `load_or_create_key`, `encrypt`, `decrypt`
|
||||
- `crates/migrate-secrets/src/main.rs` — one-shot helper for migrating plaintext → encrypted
|
||||
142
docs/runbooks/reverse-proxy-deployment.md
Normal file
142
docs/runbooks/reverse-proxy-deployment.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Reverse Proxy Deployment Runbook
|
||||
|
||||
**Audience:** Operators deploying `pm-web` behind a reverse proxy (nginx,
|
||||
HAProxy, Cloudflare, AWS ALB, etc.).
|
||||
|
||||
**Related:**
|
||||
- `docs/security-review.md` §1.3 (IP Whitelist Enforcement)
|
||||
- `tasks/ip-allowlist-spec.md` §7 (Risk Analysis)
|
||||
- Issue [#3](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/3)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
If you front `pm-web` with a reverse proxy, you **MUST** add the proxy's
|
||||
IP address (or CIDR) to `security.trusted_proxies` in
|
||||
`/etc/patch-manager/config.toml`. If you do not, the IP allowlist will
|
||||
evaluate against the proxy's IP (not the real client) and will return
|
||||
`403 forbidden_ip` for legitimate traffic.
|
||||
|
||||
## Why
|
||||
|
||||
Starting with the IP-allowlist hardening in issue #3, `pm-web` no longer
|
||||
trusts `X-Forwarded-For` by default. The default behavior is **strict**:
|
||||
|
||||
1. The server reads the socket peer IP from `ConnectInfo<SocketAddr>`.
|
||||
2. The server checks that IP against `security.ip_whitelist`.
|
||||
3. `X-Forwarded-For` is **ignored** unless the socket peer is in
|
||||
`security.trusted_proxies`.
|
||||
|
||||
When you put a reverse proxy in front, every connection's socket peer IP
|
||||
is the proxy's address. Without `trusted_proxies` set, the proxy's IP is
|
||||
checked against your allowlist — and unless your allowlist happens to
|
||||
include the proxy (which would defeat the purpose of the allowlist),
|
||||
the request is denied.
|
||||
|
||||
## How to Fix
|
||||
|
||||
1. Identify the **egress IP** of your reverse proxy (the IP `pm-web`
|
||||
sees as the immediate TCP peer). This is typically:
|
||||
- nginx: the IP nginx binds to internally, or the host's IP if nginx
|
||||
runs on the same host as `pm-web` (port forward).
|
||||
- Cloudflare: see
|
||||
[Cloudflare IP ranges](https://www.cloudflare.com/ips/).
|
||||
- AWS ALB / NLB: the ALB/NLB's private IP from the VPC.
|
||||
- HAProxy: the bind address.
|
||||
|
||||
2. Add the IP (or CIDR for multiple hops) to `trusted_proxies` in
|
||||
`/etc/patch-manager/config.toml`:
|
||||
|
||||
```toml
|
||||
[security]
|
||||
ip_whitelist = ["10.0.0.0/8"] # example: corporate clients
|
||||
trusted_proxies = ["172.16.5.10/32"] # example: reverse proxy egress
|
||||
```
|
||||
|
||||
3. **Restart `pm-web`** for the config to take effect. The
|
||||
`trusted_proxies` field is read at startup; runtime updates are
|
||||
supported via `AuthConfig::update_trusted_proxies` but not yet
|
||||
exposed through a settings endpoint.
|
||||
|
||||
4. Verify by tailing the logs and confirming that requests with
|
||||
`X-Forwarded-For: <allowed-client-ip>` succeed (status 200/401, NOT
|
||||
403) when the request comes through the proxy.
|
||||
|
||||
## Multi-hop Proxy Chains
|
||||
|
||||
If you have multiple proxies in front of `pm-web` (e.g., Cloudflare →
|
||||
nginx → pm-web), add **each hop you control** to `trusted_proxies`:
|
||||
|
||||
```toml
|
||||
trusted_proxies = [
|
||||
"172.16.5.10/32", # nginx egress (immediate peer)
|
||||
"10.0.0.0/8", # internal network (in case nginx runs on a different host)
|
||||
]
|
||||
```
|
||||
|
||||
The resolver picks the leftmost entry of `X-Forwarded-For` when the
|
||||
immediate peer is in `trusted_proxies`. With two trusted hops, the
|
||||
resolver will pick the leftmost untrusted IP (the real client).
|
||||
|
||||
## Reverse Proxy Headers (recommended)
|
||||
|
||||
In addition to the `trusted_proxies` config, configure your reverse
|
||||
proxy to:
|
||||
|
||||
- **Append** to `X-Forwarded-For` (not replace) so the chain is
|
||||
preserved through multiple hops.
|
||||
- Set `X-Real-IP` (optional, informational; pm-web currently uses
|
||||
`X-Forwarded-For`).
|
||||
- Forward the original `Host` header so SAML/OIDC redirects work
|
||||
correctly.
|
||||
- Do **not** strip the `Authorization` header.
|
||||
|
||||
### nginx example
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:12443;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
The `proxy_add_x_forwarded_for` directive appends, which is what you want.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### All requests return 403 forbidden_ip
|
||||
|
||||
- Check that `trusted_proxies` is set and contains the proxy's IP.
|
||||
- Check that the proxy's IP is correct (run `ss -tnp` on the pm-web
|
||||
host to see the actual peer address).
|
||||
- Check `tracing` logs for `reason = "unresolvable_client_ip"` — this
|
||||
means the `ConnectInfo<SocketAddr>` extension is missing (the
|
||||
listener wasn't built with `into_make_service_with_connect_info`).
|
||||
|
||||
### XFF is being ignored
|
||||
|
||||
- Check that the immediate peer's IP is in `trusted_proxies`. If the
|
||||
immediate peer is NOT in `trusted_proxies`, XFF is ignored (correct
|
||||
behavior).
|
||||
- Check the XFF format: pm-web parses the leftmost entry, trimmed of
|
||||
whitespace. A malformed leftmost entry falls back to the socket peer.
|
||||
|
||||
### Multiple IPs in XFF and only the last hop is trusted
|
||||
|
||||
- If you have one trusted proxy and one untrusted, the resolver will
|
||||
only use XFF when the immediate peer (the trusted one) is in the
|
||||
list. The XFF is parsed leftmost-first, so the real client IP (leftmost
|
||||
untrusted hop) is used.
|
||||
- If neither hop is in `trusted_proxies`, XFF is ignored and the
|
||||
socket peer IP (the immediate proxy) is used. Add the immediate
|
||||
proxy to `trusted_proxies` to fix.
|
||||
|
||||
## See Also
|
||||
|
||||
- `config/config.example.toml` — inline documentation on `trusted_proxies`.
|
||||
- `tasks/ip-allowlist-spec.md` §3 (Design Decisions) for the rationale.
|
||||
- `crates/pm-auth/src/rbac.rs` — the resolver implementation.
|
||||
@ -31,9 +31,25 @@ verifying that all mandated security controls are implemented and operational.
|
||||
### 1.3 IP Whitelist Enforcement
|
||||
| Control | Status | Evidence |
|
||||
|---------|--------|----------|
|
||||
| IP whitelist on all connection points | ✅ Verified | Middleware extracts `X-Forwarded-For` / `X-Real-IP`; checks against `AuthConfig.ip_whitelist` (RwLock for live updates) |
|
||||
| IP whitelist on all connection points | ✅ Verified | `require_auth` middleware in `crates/pm-auth/src/rbac.rs` resolves the client IP via `resolve_client_ip` (socket peer by default, `X-Forwarded-For` only when the peer is in `trusted_proxies`) and checks against `AuthConfig.ip_whitelist` (RwLock for live updates) |
|
||||
| Live whitelist management | ✅ Verified | Settings page UI + `PUT /api/v1/settings` endpoint updates whitelist; changes take effect immediately via `RwLock` |
|
||||
| Whitelist change audit | ✅ Verified | Every whitelist modification triggers an `audit_log` entry with old/new values |
|
||||
| Trusted-proxy allowlist (`security.trusted_proxies`) | ✅ Verified | New `trusted_proxies: Vec<String>` field on `SecurityConfig` (default empty = strict). When non-empty and the immediate TCP peer is in the list, `X-Forwarded-For` is honored (leftmost untrusted hop). Documented in `config/config.example.toml`. `AuthConfig::update_trusted_proxies` setter allows runtime updates |
|
||||
| Fail-closed on unresolvable client IP | ✅ Verified | When a non-empty allowlist is configured and the client IP cannot be determined (no `ConnectInfo<SocketAddr>` extension), the request is rejected with `403 forbidden_ip`. `tracing::warn!` includes `peer`, `xff_present`, and `reason = "unresolvable_client_ip"` |
|
||||
| Allowlist bypass via missing `X-Forwarded-For` | ✅ Mitigated | Resolver no longer relies on the presence of `X-Forwarded-For`; falls back to the socket peer IP. Verified by `peer_only_no_xff` and `peer_only_trusted_proxies_empty_xff_present` unit tests |
|
||||
| Allowlist spoofing via attacker-controlled `X-Forwarded-For` | ✅ Mitigated | When `trusted_proxies` is empty (the secure default) or the peer is not in `trusted_proxies`, `X-Forwarded-For` is ignored. Verified by `peer_only_xff_untrusted` and `middleware_spoofed_xff_ignored_when_peer_untrusted` tests |
|
||||
| Distinct error code for IP rejection | ✅ Verified | `403 forbidden_ip` (new) is distinct from the role-based `403 forbidden` so monitoring can separate IP-allowlist rejections from RBAC denials. Documented in `tasks/ip-allowlist-spec.md` §4.5 |
|
||||
|
||||
### 1.4 WebSocket Origin Allowlist (CSWSH Defense-in-Depth)
|
||||
| Control | Status | Evidence |
|
||||
|---------|--------|----------|
|
||||
| `Origin` header allowlist on browser WS upgrade | ✅ Verified | `crates/pm-web/src/routes/ws.rs` `ws_handler` — `HeaderMap` extractor + `check_origin` enforced before ticket validation |
|
||||
| Allowlist configurable via `security.allowed_origins` | ✅ Verified | `crates/pm-core/src/config.rs` `SecurityConfig::allowed_origins`; documented in `config/config.example.toml` |
|
||||
| Secure-by-default derivation from `sso_callback_url` | ✅ Verified | `derive_allowed_origins` parses the SSO callback URL into a single `scheme://host[:port]` entry when the operator leaves `allowed_origins` empty; `AppConfig::load` runs the derivation and emits a `tracing::warn!` if the result is empty (fail-closed) |
|
||||
| Order: Origin check before ticket consumption | ✅ Verified | Rejected cross-origin probes do not burn the user's ticket; documented in the handler doc-comment and verified by `check_rejects_disallowed_origin` test |
|
||||
| Rejected upgrades logged with `origin` and `reason` | ✅ Verified | `tracing::warn!` in `ws_handler`; ticket value is never logged |
|
||||
|
||||
**Note:** The browser WebSocket endpoint (`GET /api/v1/ws/jobs`) is the only browser-reachable WS server in the codebase. The `pm-worker` `ws_relay` module is an outbound mTLS WS *client* to on-host agents and is not subject to CSWSH.
|
||||
|
||||
---
|
||||
|
||||
@ -69,6 +85,7 @@ verifying that all mandated security controls are implemented and operational.
|
||||
| Admin: full rights | ✅ Verified | Admin role bypasses group scoping; access to all resources |
|
||||
| Operator: group-scoped | ✅ Verified | Operators can only manage hosts in their assigned groups; middleware enforces on every request |
|
||||
| RBAC middleware | ✅ Verified | Axum middleware extracts role from JWT; enforces before route handler execution |
|
||||
| **Manager-wide auth config is Admin-only (issue #5 fix)** | ✅ Verified | `admin_required` gate in `crates/pm-web/src/routes/settings.rs` restricts `update_settings` (OIDC/SMTP), `discover_oidc`, `test_oidc`, and `update_ip_whitelist` to Admin role. Operators receive `403 forbidden_role`. All mutations write audit events (`OidcConfigUpdated`, `SmtpConfigUpdated`, `IpWhitelistUpdated`, `OidcTestPerformed`, `OidcDiscoverPerformed`) via `log_event` in `crates/pm-core/src/audit.rs`. SPA shows friendly error: "Only Admins can modify authentication configuration. Contact an Admin to make this change." Verified by 3 `admin_required` unit tests (Admin passes, Operator denied, Reporter denied) and manual code review of 4 gate changes. Full integration tests deferred to [issue #15](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/15). |
|
||||
|
||||
### 2.5 Azure SSO
|
||||
| Control | Status | Evidence |
|
||||
@ -76,6 +93,9 @@ verifying that all mandated security controls are implemented and operational.
|
||||
| OAuth2/OIDC Authorization Code + PKCE | ✅ Verified | Public routes `/api/v1/auth/azure/login` and `/api/v1/auth/azure/callback` implement PKCE flow |
|
||||
| Test connection without enabling | ✅ Verified | `POST /api/v1/settings/azure-sso/test` validates configuration without persisting |
|
||||
| MFA still required after SSO | ✅ Verified | SSO login follows same MFA verification path as local login |
|
||||
| **No tokens in redirect URL (issue #4 fix)** | ✅ Verified | SSO callback (`crates/pm-web/src/routes/sso.rs` `sso_callback`) now issues a single-use, 60s `handoff_code` and stores the JWT access/refresh tokens in the in-memory `sso_handoffs: Arc<DashMap<String, SsoHandoff>>`. The redirect URL contains only `?handoff=<code>`. No `access_token`, `refresh_token`, or `user` parameters are ever placed in the URL. The SPA exchanges the code via `POST /api/v1/auth/sso/handoff`. See `tasks/sso-token-handoff-spec.md` for the full design. |
|
||||
| **Handoff code is single-use + 60s TTL** | ✅ Verified | `DashMap::remove` in `sso_handoff_exchange_inner` is atomic — concurrent exchange attempts result in exactly one success and one 400. Expired codes (`expires_at < Instant::now()`) are rejected with `400 invalid_handoff`. A background cleanup task removes expired entries every 60s. Verified by `handoff_exchange_single_use`, `handoff_exchange_race`, and `handoff_exchange_expired_code` tests in `crates/pm-web/src/routes/sso.rs`. |
|
||||
| **Handoff code cleared from browser history** | ✅ Verified | SPA calls `window.history.replaceState({}, '', '/auth/sso/callback')` after a successful exchange, removing the `?handoff=` param from the address bar. Verified by `clears_handoff_code_from_url_after_success` test in `frontend/src/pages/__tests__/SsoCallbackPage.test.tsx`. |
|
||||
|
||||
---
|
||||
|
||||
@ -105,7 +125,8 @@ verifying that all mandated security controls are implemented and operational.
|
||||
| Control | Status | Evidence |
|
||||
|---------|--------|----------|
|
||||
| Infrastructure-managed disk encryption | ✅ Verified | Hardware/infrastructure layer provides encryption at rest; no LUKS in guest OS |
|
||||
| No column-level encryption needed | ✅ Verified | Compliance requirement satisfied by infrastructure layer per system mandate |
|
||||
| **App secrets encrypted at rest (issue #6 fix)** | ✅ Verified | OIDC `client_secret`, SMTP `smtp_password`, and TOTP `totp_secret` are encrypted with AES-256-GCM using a dedicated per-install key at `/etc/patch-manager/keys/secret-encryption.key` (auto-generated on first start, 0600 permissions). Separate from the health-check key for blast-radius isolation. Encryption/decryption via `pm-core::crypto::encrypt`/`decrypt`. Schema migration `020_encrypt_secrets_at_rest.sql` replaces plaintext TEXT columns with BYTEA `_encrypted` + `_nonce` columns. All 6 read/write sites updated: `sso.rs`, `settings.rs` (OIDC + SMTP), `session.rs` (TOTP read), `auth.rs` (TOTP write), `users.rs` (TOTP NULL), `pm-worker/email.rs` (SMTP read). The `MASKED` placeholder behavior in API responses is preserved. |
|
||||
| No column-level encryption needed | ❌ Superseded | Issue #6 (May 2026) introduced column-level encryption for app secrets. Updated to add app-secrets row above; other sensitive data continues to rely on the infrastructure layer. |
|
||||
|
||||
### 4.2 Secret Management
|
||||
| Control | Status | Evidence |
|
||||
@ -139,9 +160,30 @@ verifying that all mandated security controls are implemented and operational.
|
||||
|
||||
## 6. Findings & Recommendations
|
||||
|
||||
### No Critical or High Findings
|
||||
### 🔴 CRITICAL: Committed Private Key Material (Issue #12) — RESOLVED
|
||||
|
||||
All security controls are implemented as specified in the system requirements.
|
||||
**Description:**
|
||||
Private key file `client.key` and public certificates (`client.crt`, `ca.crt`) were committed
|
||||
to version control in `crates/pm-agent-client/certs/`. Committed private keys are a critical
|
||||
security risk: anyone with repository access can impersonate agents or decrypt captured TLS traffic.
|
||||
|
||||
**Status:** ✅ RESOLVED
|
||||
|
||||
**Remediation Applied:**
|
||||
1. Removed all cert files from git tracking (`git rm --cached`)
|
||||
2. Added `*.key`, `*.key.pem` and `crates/pm-agent-client/certs/` to `.gitignore`
|
||||
3. Updated `pm-agent-client` doc examples to use `std::fs::read()` instead of `include_bytes!`
|
||||
4. Added `gitleaks` secret scanning to CI pipeline
|
||||
5. Added README to `crates/pm-agent-client/certs/` explaining runtime cert generation
|
||||
6. Git history will be purged with `git filter-repo` after PR merge
|
||||
|
||||
**Key Rotation:**
|
||||
These keys were dev/test only. No production key rotation is needed. All committed keys
|
||||
should be considered compromised and must not be used in production.
|
||||
|
||||
### No Other Critical or High Findings
|
||||
|
||||
All other security controls are implemented as specified in the system requirements.
|
||||
|
||||
### Recommendations (Low Priority)
|
||||
|
||||
@ -171,3 +213,4 @@ All security controls are implemented as specified in the system requirements.
|
||||
- [x] Backup encryption supported (GPG)
|
||||
- [x] Azure SSO with PKCE flow
|
||||
- [x] No plaintext credential storage
|
||||
- [x] Committed private key material removed from repository (Issue #12)
|
||||
|
||||
2149
frontend/package-lock.json
generated
2149
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "patch-manager-ui",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "1.1.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/ --ext .ts,.tsx --max-warnings 0",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@ -25,6 +27,9 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.30.0",
|
||||
@ -32,7 +37,9 @@
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3"
|
||||
"vite": "^6.3.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
CreateHostRequest,
|
||||
CreateJobRequest,
|
||||
CreateMaintenanceWindowRequest,
|
||||
MaintenanceWindow,
|
||||
UpdateMaintenanceWindowRequest,
|
||||
Certificate,
|
||||
IssuedCert,
|
||||
@ -152,6 +153,8 @@ export const hostsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
|
||||
get: (id: string) => apiClient.get(`/hosts/${id}`),
|
||||
register: (body: CreateHostRequest) => apiClient.post('/hosts', body),
|
||||
update: (id: string, body: Record<string, string | undefined>) =>
|
||||
apiClient.put(`/hosts/${id}`, body),
|
||||
delete: (id: string) => apiClient.delete(`/hosts/${id}`),
|
||||
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
|
||||
}
|
||||
@ -174,6 +177,10 @@ export const patchesApi = {
|
||||
|
||||
// ── Maintenance Windows API ───────────────────────────────────────────────────
|
||||
export const maintenanceWindowsApi = {
|
||||
/** Bulk: fetch ALL maintenance windows across every host in one request. */
|
||||
listAll: () =>
|
||||
apiClient.get<{ windows: MaintenanceWindow[] }>('/maintenance-windows'),
|
||||
/** Per-host: fetch windows for a single host. */
|
||||
list: (hostId: string) =>
|
||||
apiClient.get(`/hosts/${hostId}/maintenance-windows`),
|
||||
create: (hostId: string, body: CreateMaintenanceWindowRequest) =>
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
RestartAlt,
|
||||
Refresh as RefreshIcon,
|
||||
Security as SecurityIcon,
|
||||
VerifiedUser as VerifiedUserIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { fleetApi, certsApi } from '../api/client'
|
||||
import type { FleetStatus } from '../types'
|
||||
@ -237,6 +238,57 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* ── Row 4: CRL Status ── */}
|
||||
<Card variant="outlined" sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<VerifiedUserIcon color="primary" />
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
CRL Status
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#2e7d32' }}>
|
||||
{status.crl_valid}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Valid</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#ed6c02' }}>
|
||||
{status.crl_expired}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Expired</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#ed6c02' }}>
|
||||
{status.crl_missing}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Missing</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#d32f2f' }}>
|
||||
{status.crl_invalid}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Invalid</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{status.crl_not_reporting > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
{status.crl_not_reporting} host{status.crl_not_reporting !== 1 ? 's' : ''} not reporting CRL status
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@ -46,6 +46,9 @@ import {
|
||||
Schedule as ScheduleIcon,
|
||||
VpnKey as VpnKeyIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
VerifiedUser as VerifiedUserIcon,
|
||||
Security as SecurityIcon,
|
||||
WarningAmber as WarningAmberIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@ -614,6 +617,46 @@ export default function HostDetailPage() {
|
||||
// Hosts list for target_host_id dropdown
|
||||
const [hosts, setHosts] = useState<{ id: string; display_name: string; fqdn: string }[]>([])
|
||||
|
||||
// ── Host editing state ────────────────────────────────────────────────────
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editFqdn, setEditFqdn] = useState('')
|
||||
const [editIp, setEditIp] = useState('')
|
||||
const [editDisplayName, setEditDisplayName] = useState('')
|
||||
const [savingHost, setSavingHost] = useState(false)
|
||||
|
||||
const enterEdit = () => {
|
||||
setEditFqdn(String(host?.fqdn ?? ''))
|
||||
setEditIp(String(host?.ip_address ?? ''))
|
||||
setEditDisplayName(String(host?.display_name ?? ''))
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditing(false)
|
||||
setSavingHost(false)
|
||||
}
|
||||
|
||||
const handleSaveHost = async () => {
|
||||
if (!id) return
|
||||
setSavingHost(true)
|
||||
try {
|
||||
const res = await hostsApi.update(id, {
|
||||
fqdn: editFqdn !== String(host?.fqdn ?? '') ? editFqdn : undefined,
|
||||
ip_address: editIp !== String(host?.ip_address ?? '') ? editIp : undefined,
|
||||
display_name: editDisplayName !== String(host?.display_name ?? '') ? editDisplayName : undefined,
|
||||
})
|
||||
setHost(res.data)
|
||||
setEditing(false)
|
||||
showSnack('Host updated', 'success')
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message ?? 'Failed to update host'
|
||||
showSnack(msg, 'error')
|
||||
} finally {
|
||||
setSavingHost(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch host ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (id === 'new') { setLoading(false); return }
|
||||
@ -899,7 +942,39 @@ export default function HostDetailPage() {
|
||||
{String(host?.fqdn ?? '')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{canWrite && !certExists && (
|
||||
{canWrite && !editing && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={enterEdit}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && editing && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
onClick={handleSaveHost}
|
||||
disabled={savingHost}
|
||||
>
|
||||
{savingHost ? <CircularProgress size={16} /> : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={cancelEdit}
|
||||
disabled={savingHost}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing && canWrite && !certExists && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
@ -909,7 +984,7 @@ export default function HostDetailPage() {
|
||||
Issue Certificate
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && certExists && (
|
||||
{!editing && canWrite && certExists && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@ -920,24 +995,96 @@ export default function HostDetailPage() {
|
||||
Re-issue Certificate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Grid container spacing={2}>
|
||||
{host && Object.entries(host).map(([k, v]) =>
|
||||
v !== null && v !== '' ? (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{k.replace(/_/g, ' ').toUpperCase()}
|
||||
</Typography>
|
||||
<Typography variant="body2">{String(v)}</Typography>
|
||||
</Grid>
|
||||
) : null
|
||||
)}
|
||||
{host && (<>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">FQDN</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editFqdn} onChange={e => setEditFqdn(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.fqdn)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">IP ADDRESS</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editIp} onChange={e => setEditIp(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.ip_address)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">DISPLAY NAME</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editDisplayName} onChange={e => setEditDisplayName(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.display_name)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
{Object.entries(host).filter(([k]) => !['fqdn','ip_address','display_name'].includes(k)).map(([k, v]) =>
|
||||
v !== null && v !== '' ? (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{k.replace(/_/g, ' ').toUpperCase()}
|
||||
</Typography>
|
||||
<Typography variant="body2">{String(v)}</Typography>
|
||||
</Grid>
|
||||
) : null
|
||||
)}
|
||||
</>)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* ── CRL Status ─────────────────────────────────────────────────── */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<VerifiedUserIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight={600}>CRL Status</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
{host?.crl_status === undefined || host?.crl_status === null ? (
|
||||
<Alert severity="info">
|
||||
CRL status not available (agent version does not support CRL)
|
||||
</Alert>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Status</Typography>
|
||||
{host.crl_status === 'valid' ? (
|
||||
<Chip icon={<VerifiedUserIcon />} label="Valid" color="success" size="small" />
|
||||
) : host.crl_status === 'expired' ? (
|
||||
<Chip icon={<WarningAmberIcon />} label="Expired" color="warning" size="small" />
|
||||
) : host.crl_status === 'missing' ? (
|
||||
<Chip icon={<WarningAmberIcon />} label="Missing" color="warning" size="small" />
|
||||
) : host.crl_status === 'invalid' ? (
|
||||
<Chip icon={<SecurityIcon />} label="Invalid" color="error" size="small" />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.crl_status)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">CRL Age</Typography>
|
||||
<Typography variant="body2">
|
||||
{host.crl_age_seconds !== null
|
||||
? (() => { const s = Number(host.crl_age_seconds); return s < 3600 ? `${Math.round(s / 60)} minutes ago` : s < 86400 ? `${Math.round(s / 3600)} hours ago` : `${Math.round(s / 86400)} days ago`; })()
|
||||
: '—'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Next Update</Typography>
|
||||
<Typography variant="body2">
|
||||
{host.crl_next_update
|
||||
? new Date(host.crl_next_update as string).toLocaleString()
|
||||
: '—'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Maintenance Windows ──────────────────────────────────────────── */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
TablePagination, TextField, Toolbar, Tooltip, Typography,
|
||||
} from '@mui/material'
|
||||
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon } from '@mui/icons-material'
|
||||
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon, VerifiedUser as VerifiedUserIcon, Security as SecurityIcon } from '@mui/icons-material'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiClient, hostsApi, enrollmentApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@ -182,6 +182,7 @@ export default function HostsPage() {
|
||||
<TableCell>OS</TableCell>
|
||||
<TableCell>Health</TableCell>
|
||||
<TableCell>Checks</TableCell>
|
||||
<TableCell>CRL</TableCell>
|
||||
<TableCell>Agent</TableCell>
|
||||
{canWrite && <TableCell>Actions</TableCell>}
|
||||
</TableRow>
|
||||
@ -201,6 +202,7 @@ export default function HostsPage() {
|
||||
<TableCell>{(req.os_details['name'] as string) ?? 'Unknown'}</TableCell>
|
||||
<TableCell><Chip size="small" label="pending" color="warning" /></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell>—</TableCell>
|
||||
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Approve">
|
||||
@ -240,6 +242,19 @@ export default function HostsPage() {
|
||||
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.crl_status === 'valid' ? (
|
||||
<Tooltip title="CRL valid"><VerifiedUserIcon color="success" fontSize="small" /></Tooltip>
|
||||
) : h.crl_status === 'expired' ? (
|
||||
<Tooltip title="CRL expired"><WarningAmberIcon color="warning" fontSize="small" /></Tooltip>
|
||||
) : h.crl_status === 'missing' ? (
|
||||
<Tooltip title="CRL missing"><WarningAmberIcon color="warning" fontSize="small" /></Tooltip>
|
||||
) : h.crl_status === 'invalid' ? (
|
||||
<Tooltip title="CRL invalid — security event"><SecurityIcon color="error" fontSize="small" /></Tooltip>
|
||||
) : (
|
||||
<Tooltip title="CRL status not available (agent version does not support CRL)"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{h.agent_version ?? '—'}</TableCell>
|
||||
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Request refresh">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user