Compare commits
61 Commits
v0.1.7
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 89326889e4 | |||
| 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: |
|
run: |
|
||||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
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
|
-o repo.tar.gz
|
||||||
tar xzf repo.tar.gz --strip-components=1
|
tar xzf repo.tar.gz --strip-components=1
|
||||||
rm repo.tar.gz
|
rm repo.tar.gz
|
||||||
@ -61,7 +61,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
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
|
-o repo.tar.gz
|
||||||
tar xzf repo.tar.gz --strip-components=1
|
tar xzf repo.tar.gz --strip-components=1
|
||||||
rm repo.tar.gz
|
rm repo.tar.gz
|
||||||
@ -94,7 +94,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
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
|
-o repo.tar.gz
|
||||||
tar xzf repo.tar.gz --strip-components=1
|
tar xzf repo.tar.gz --strip-components=1
|
||||||
rm repo.tar.gz
|
rm repo.tar.gz
|
||||||
@ -126,7 +126,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
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
|
-o repo.tar.gz
|
||||||
tar xzf repo.tar.gz --strip-components=1
|
tar xzf repo.tar.gz --strip-components=1
|
||||||
rm repo.tar.gz
|
rm repo.tar.gz
|
||||||
@ -171,7 +171,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
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
|
-o repo.tar.gz
|
||||||
tar xzf repo.tar.gz --strip-components=1
|
tar xzf repo.tar.gz --strip-components=1
|
||||||
rm repo.tar.gz
|
rm repo.tar.gz
|
||||||
@ -207,7 +207,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
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
|
-o repo.tar.gz
|
||||||
tar xzf repo.tar.gz --strip-components=1
|
tar xzf repo.tar.gz --strip-components=1
|
||||||
rm repo.tar.gz
|
rm repo.tar.gz
|
||||||
@ -261,7 +261,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||||
GITEA_URL: https://gitea-lxc.moon-dragon.us
|
GITEA_URL: https://gitea-lxc.moon-dragon.us
|
||||||
GITEA_REPO: echo/linux_patch_manager
|
GITEA_REPO: git-echo/linux_patch_manager
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/')
|
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/')
|
||||||
DEB=$(ls linux-patch-manager_*.deb)
|
DEB=$(ls linux-patch-manager_*.deb)
|
||||||
|
|||||||
174
.github/workflows/ci.yml
vendored
Normal file
174
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust-format:
|
||||||
|
name: Rust Format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- 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@v4
|
||||||
|
- 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@v4
|
||||||
|
- 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@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- run: cargo install cargo-audit && cargo audit
|
||||||
|
|
||||||
|
gitleaks:
|
||||||
|
name: Secret scanning
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Gitleaks
|
||||||
|
uses: gitleaks/gitleaks-action@v2
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
frontend-lint:
|
||||||
|
name: Frontend Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
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@v4
|
||||||
|
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@v4
|
||||||
|
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@v2
|
||||||
|
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
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
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@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
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
|
# Package build artifacts
|
||||||
*.deb
|
*.deb
|
||||||
package-build/
|
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 |
|
| Version | 0.0.3 |
|
||||||
| Status | Draft |
|
| Status | Draft |
|
||||||
| Standard | Aligned with IEEE 1016-2009 |
|
| Standard | Aligned with IEEE 1016-2009 |
|
||||||
| Owner | Echo (for Kelly / Moon Dragon) |
|
| Owner | Draco Lunaris |
|
||||||
| Last Updated | 2026-04-23 |
|
| Last Updated | 2026-04-23 |
|
||||||
| Related Docs | `SPEC.md`, `REQUIREMENTS.md`, `README.md` |
|
| 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",
|
"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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@ -323,6 +333,21 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@ -460,6 +485,15 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
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]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@ -687,6 +721,19 @@ dependencies = [
|
|||||||
"cipher",
|
"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]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "6.1.0"
|
version = "6.1.0"
|
||||||
@ -771,7 +818,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -885,7 +932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -970,6 +1017,12 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "font-kit"
|
name = "font-kit"
|
||||||
version = "0.14.3"
|
version = "0.14.3"
|
||||||
@ -1046,6 +1099,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "freetype-sys"
|
name = "freetype-sys"
|
||||||
version = "0.20.1"
|
version = "0.20.1"
|
||||||
@ -1155,6 +1218,12 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-timer"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@ -1242,6 +1311,49 @@ dependencies = [
|
|||||||
"weezl",
|
"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]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@ -1275,7 +1387,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"allocator-api2",
|
"allocator-api2",
|
||||||
"equivalent",
|
"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]]
|
[[package]]
|
||||||
@ -1445,6 +1568,19 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.7",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@ -1924,6 +2060,18 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "migrate-secrets"
|
||||||
|
version = "0.2.4"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"hex",
|
||||||
|
"pm-core",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@ -1967,6 +2115,31 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "moxcms"
|
name = "moxcms"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@ -1994,6 +2167,12 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "no-std-compat"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@ -2013,13 +2192,25 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2307,6 +2498,26 @@ dependencies = [
|
|||||||
"sha2",
|
"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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@ -2381,7 +2592,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-agent-client"
|
name = "pm-agent-client"
|
||||||
version = "0.1.7"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -2398,7 +2609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-auth"
|
name = "pm-auth"
|
||||||
version = "0.1.7"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@ -2419,19 +2630,21 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"totp-rs",
|
"totp-rs",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-ca"
|
name = "pm-ca"
|
||||||
version = "0.1.7"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"pem",
|
"pem",
|
||||||
"pm-core",
|
"pm-core",
|
||||||
|
"proptest",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
@ -2448,7 +2661,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-core"
|
name = "pm-core"
|
||||||
version = "0.1.7"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@ -2472,7 +2685,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-reports"
|
name = "pm-reports"
|
||||||
version = "0.1.7"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -2492,7 +2705,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-web"
|
name = "pm-web"
|
||||||
version = "0.1.7"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@ -2500,26 +2713,33 @@ dependencies = [
|
|||||||
"axum-server",
|
"axum-server",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap 6.1.0",
|
||||||
|
"governor 0.6.3",
|
||||||
"hex",
|
"hex",
|
||||||
|
"http-body-util",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
|
"mockito",
|
||||||
"pm-auth",
|
"pm-auth",
|
||||||
"pm-ca",
|
"pm-ca",
|
||||||
"pm-core",
|
"pm-core",
|
||||||
"pm-reports",
|
"pm-reports",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
"tower_governor",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ulid",
|
"ulid",
|
||||||
@ -2530,7 +2750,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-worker"
|
name = "pm-worker"
|
||||||
version = "0.1.7"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -2600,6 +2820,12 @@ dependencies = [
|
|||||||
"bstr",
|
"bstr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -2656,12 +2882,52 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "pxfm"
|
name = "pxfm"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@ -2803,6 +3069,24 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"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]]
|
[[package]]
|
||||||
name = "rcgen"
|
name = "rcgen"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@ -2846,6 +3130,18 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"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]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@ -2999,7 +3295,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3055,6 +3351,18 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
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]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@ -3273,6 +3581,12 @@ version = "0.3.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simple_asn1"
|
name = "simple_asn1"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@ -3307,7 +3621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3319,6 +3633,15 @@ dependencies = [
|
|||||||
"lock_api",
|
"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]]
|
[[package]]
|
||||||
name = "spki"
|
name = "spki"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@ -3612,7 +3935,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3912,6 +4235,35 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
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]]
|
[[package]]
|
||||||
name = "totp-rs"
|
name = "totp-rs"
|
||||||
version = "5.7.1"
|
version = "5.7.1"
|
||||||
@ -3936,9 +4288,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"indexmap",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@ -3985,6 +4340,23 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
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]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@ -4142,6 +4514,12 @@ dependencies = [
|
|||||||
"web-time",
|
"web-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unarray"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
@ -4263,6 +4641,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
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]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@ -4477,7 +4864,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@ -8,10 +8,11 @@ members = [
|
|||||||
"crates/pm-auth",
|
"crates/pm-auth",
|
||||||
"crates/pm-ca",
|
"crates/pm-ca",
|
||||||
"crates/pm-reports",
|
"crates/pm-reports",
|
||||||
|
"crates/migrate-secrets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.7"
|
version = "1.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Echo <echo@moon-dragon.us>"]
|
authors = ["Echo <echo@moon-dragon.us>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -78,8 +79,15 @@ base64 = { version = "0.22" }
|
|||||||
hex = { version = "0.4" }
|
hex = { version = "0.4" }
|
||||||
sha2 = { version = "0.10" }
|
sha2 = { version = "0.10" }
|
||||||
aes-gcm = { version = "0.10" }
|
aes-gcm = { version = "0.10" }
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
proptest = { version = "1" }
|
||||||
ipnet = { version = "2" }
|
ipnet = { version = "2" }
|
||||||
url = { version = "2" }
|
url = { version = "2" }
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
tower_governor = { version = "0.8", features = ["tracing"] }
|
||||||
|
governor = "0.6"
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
lettre = { version = "0.11.22", features = ["tokio1-rustls-transport"] }
|
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
|
## 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
|
- Refresh tokens: opaque, server-side stored, 1-hour inactivity timeout, rotated on use, revocable
|
||||||
- mTLS for all agent communication (TLS 1.3 only)
|
- mTLS for all agent communication (TLS 1.3 only)
|
||||||
- HTTPS for web UI (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:
|
- Role-based access control:
|
||||||
- **Admin**: Full access to manage all aspects of Linux Patch Manager
|
- **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
|
- **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.
|
**Integrity:** Hash-chained rows (tamper-evident). Periodic and on-demand verification.
|
||||||
|
|
||||||
**Retention:** 6 months
|
**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.
|
||||||
|
|||||||
@ -76,6 +76,20 @@ format = "json"
|
|||||||
# Example: ["10.0.0.0/8", "192.168.1.50"]
|
# Example: ["10.0.0.0/8", "192.168.1.50"]
|
||||||
ip_whitelist = []
|
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)
|
# Ed25519 JWT signing key (private key, PEM format)
|
||||||
# Generate: openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem
|
# Generate: openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem
|
||||||
jwt_signing_key_path = "/etc/patch-manager/jwt/signing.pem"
|
jwt_signing_key_path = "/etc/patch-manager/jwt/signing.pem"
|
||||||
@ -91,7 +105,8 @@ jwt_access_ttl_secs = 900
|
|||||||
agent_client_cert_path = "/etc/patch-manager/certs/client.crt"
|
agent_client_cert_path = "/etc/patch-manager/certs/client.crt"
|
||||||
agent_client_key_path = "/etc/patch-manager/certs/client.key"
|
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
|
# Private key has 0600 permissions; protected by hardware-host FDE
|
||||||
ca_cert_path = "/etc/patch-manager/ca/ca.crt"
|
ca_cert_path = "/etc/patch-manager/ca/ca.crt"
|
||||||
ca_key_path = "/etc/patch-manager/ca/ca.key"
|
ca_key_path = "/etc/patch-manager/ca/ca.key"
|
||||||
@ -106,3 +121,35 @@ web_tls_key_path = "/etc/patch-manager/tls/web.key"
|
|||||||
# The backend sends tokens as query parameters to this URL.
|
# The backend sends tokens as query parameters to this URL.
|
||||||
# Default: "http://localhost:5173/auth/sso/callback" (Vite dev server)
|
# Default: "http://localhost:5173/auth/sso/callback" (Vite dev server)
|
||||||
sso_callback_url = "http://localhost:5173/auth/sso/callback"
|
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;
|
//! use pm_agent_client::client::AgentClient;
|
||||||
//!
|
//!
|
||||||
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
|
//! # 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(
|
//! let client = AgentClient::new(
|
||||||
//! "192.168.1.10",
|
//! "192.168.1.10",
|
||||||
//! 12443,
|
//! 12443,
|
||||||
//! include_bytes!("../certs/client.crt"),
|
//! &client_cert,
|
||||||
//! include_bytes!("../certs/client.key"),
|
//! &client_key,
|
||||||
//! include_bytes!("../certs/ca.crt"),
|
//! &ca_cert,
|
||||||
//! )?;
|
//! )?;
|
||||||
//!
|
//!
|
||||||
//! let health = client.health().await?;
|
//! let health = client.health().await?;
|
||||||
@ -105,7 +110,7 @@ impl AgentClient {
|
|||||||
.add_root_certificate(ca_cert)
|
.add_root_certificate(ca_cert)
|
||||||
.timeout(REQUEST_TIMEOUT)
|
.timeout(REQUEST_TIMEOUT)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| AgentClientError::Request(e))?;
|
.map_err(AgentClientError::Request)?;
|
||||||
|
|
||||||
let clean_ip = host_ip.split('/').next().unwrap_or(host_ip);
|
let clean_ip = host_ip.split('/').next().unwrap_or(host_ip);
|
||||||
let base_url = format!("https://{}:{}/api/v1", clean_ip, port);
|
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;
|
//! use pm_agent_client::AgentClient;
|
||||||
//!
|
//!
|
||||||
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
|
//! # 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(
|
//! let client = AgentClient::new(
|
||||||
//! "10.0.1.5",
|
//! "10.0.1.5",
|
||||||
//! 12443,
|
//! 12443,
|
||||||
//! include_bytes!("../certs/client.crt"),
|
//! &client_cert,
|
||||||
//! include_bytes!("../certs/client.key"),
|
//! &client_key,
|
||||||
//! include_bytes!("../certs/ca.crt"),
|
//! &ca_cert,
|
||||||
//! )?;
|
//! )?;
|
||||||
//!
|
//!
|
||||||
//! let health = client.health().await?;
|
//! let health = client.health().await?;
|
||||||
|
|||||||
@ -57,6 +57,16 @@ pub struct HealthData {
|
|||||||
pub uptime_seconds: u64,
|
pub uptime_seconds: u64,
|
||||||
/// Agent software version string.
|
/// Agent software version string.
|
||||||
pub 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 }
|
ipnet = { workspace = true }
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
sha2 = { workspace = true }
|
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)]
|
#[cfg(test)]
|
||||||
|
#[allow(dead_code)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
//! - IP whitelist enforcement
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Request,
|
extract::{ConnectInfo, Request},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Json, Response},
|
response::{IntoResponse, Json, Response},
|
||||||
@ -15,7 +15,7 @@ use axum::{
|
|||||||
use ipnet::IpNet;
|
use ipnet::IpNet;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::net::IpAddr;
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -40,6 +40,7 @@ pub enum UserRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserRole {
|
impl UserRole {
|
||||||
|
#[allow(clippy::should_implement_trait)]
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
match s {
|
match s {
|
||||||
"admin" => Some(Self::Admin),
|
"admin" => Some(Self::Admin),
|
||||||
@ -75,18 +76,30 @@ pub struct AuthConfig {
|
|||||||
pub verify_key_pem: String,
|
pub verify_key_pem: String,
|
||||||
/// IP whitelist (empty = allow all). RwLock for runtime updates.
|
/// IP whitelist (empty = allow all). RwLock for runtime updates.
|
||||||
pub ip_whitelist: Arc<RwLock<Vec<IpNet>>>,
|
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 {
|
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
|
let ip_whitelist = ip_whitelist_cidrs
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
let trusted_proxies = trusted_proxy_cidrs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
verify_key_pem,
|
verify_key_pem,
|
||||||
ip_whitelist: Arc::new(RwLock::new(ip_whitelist)),
|
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;
|
*self.ip_whitelist.write() = nets;
|
||||||
tracing::info!(count, "IP whitelist updated at runtime");
|
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.
|
/// 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 "))
|
.and_then(|s| s.strip_prefix("Bearer "))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the remote IP from `X-Forwarded-For`.
|
/// Determine the client IP used for IP-allowlist enforcement.
|
||||||
fn extract_remote_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
///
|
||||||
headers
|
/// Resolution rules (per `tasks/ip-allowlist-spec.md` §4.1):
|
||||||
.get("x-forwarded-for")
|
/// 1. Start with the socket peer IP.
|
||||||
.and_then(|v| v.to_str().ok())
|
/// 2. If `trusted_proxies` is non-empty **and** the socket peer is in
|
||||||
.and_then(|s| s.split(',').next())
|
/// `trusted_proxies`, parse the leftmost entry of the `X-Forwarded-For`
|
||||||
.and_then(|s| s.trim().parse().ok())
|
/// 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.
|
/// Unauthorized JSON response helper.
|
||||||
@ -147,16 +197,65 @@ fn forbidden(message: &str) -> Response {
|
|||||||
.into_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).
|
/// Middleware: authenticate any valid JWT (admin or operator).
|
||||||
///
|
///
|
||||||
/// Inserts `AuthUser` into request extensions on success.
|
/// Inserts `AuthUser` into request extensions on success.
|
||||||
/// Rejects with 401 if token is missing/invalid, 403 if IP is blocked.
|
/// 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 {
|
pub async fn require_auth(auth_config: Arc<AuthConfig>, mut req: Request, next: Next) -> Response {
|
||||||
// IP whitelist check
|
// IP whitelist check. Only enforced when the configured allowlist is
|
||||||
if let Some(ip) = extract_remote_ip(req.headers()) {
|
// non-empty (Q4 sign-off: empty list = allow all, preserved for dev
|
||||||
if !auth_config.is_ip_allowed(&ip) {
|
// installs). When enforced, the resolved client IP comes from
|
||||||
tracing::warn!(ip = %ip, "Request blocked by IP whitelist");
|
// `resolve_client_ip`, which uses the socket peer IP by default and
|
||||||
return forbidden("Access denied");
|
// 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"))
|
.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),
|
Password(#[from] PasswordError),
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
Database(#[from] sqlx::Error),
|
Database(#[from] sqlx::Error),
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Successful login response returned to the client.
|
/// Successful login response returned to the client.
|
||||||
@ -69,6 +71,7 @@ pub struct SessionUser {
|
|||||||
|
|
||||||
/// Database user row fetched during login.
|
/// Database user row fetched during login.
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
struct DbUser {
|
struct DbUser {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
username: String,
|
username: String,
|
||||||
@ -76,7 +79,10 @@ struct DbUser {
|
|||||||
role: UserRole,
|
role: UserRole,
|
||||||
auth_provider: AuthProvider,
|
auth_provider: AuthProvider,
|
||||||
password_hash: Option<String>,
|
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,
|
mfa_enabled: bool,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
force_password_reset: bool,
|
force_password_reset: bool,
|
||||||
@ -114,7 +120,7 @@ pub async fn login(
|
|||||||
let user: Option<DbUser> = sqlx::query_as(
|
let user: Option<DbUser> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, username, display_name, role, auth_provider,
|
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
|
failed_login_attempts, locked_until
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = $1 AND auth_provider = 'local'
|
WHERE username = $1 AND auth_provider = 'local'
|
||||||
@ -193,9 +199,25 @@ pub async fn login(
|
|||||||
// 4. MFA check
|
// 4. MFA check
|
||||||
if user.mfa_enabled {
|
if user.mfa_enabled {
|
||||||
let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?;
|
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 {
|
if !mfa_ok {
|
||||||
tracing::warn!(username = %req.username, "Login failed: invalid MFA code");
|
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(
|
let user: DbUser = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, username, display_name, role, auth_provider,
|
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
|
failed_login_attempts, locked_until
|
||||||
FROM users WHERE id = $1
|
FROM users WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@ -23,3 +23,6 @@ rustls = { workspace = true }
|
|||||||
rcgen = { workspace = true }
|
rcgen = { workspace = true }
|
||||||
pem = { workspace = true }
|
pem = { workspace = true }
|
||||||
time = { 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 chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use rcgen::{
|
use rcgen::{
|
||||||
BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType,
|
BasicConstraints, Certificate, CertificateParams, CertificateRevocationListParams,
|
||||||
ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyPair, KeyUsagePurpose, SanType, SerialNumber,
|
DistinguishedName, DnType, ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyIdMethod, KeyPair,
|
||||||
|
KeyUsagePurpose, RevocationReason, RevokedCertParams, SanType, SerialNumber,
|
||||||
PKCS_ECDSA_P256_SHA256,
|
PKCS_ECDSA_P256_SHA256,
|
||||||
};
|
};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
@ -351,7 +352,9 @@ impl CertAuthority {
|
|||||||
let mut sans = vec![SanType::DnsName(
|
let mut sans = vec![SanType::DnsName(
|
||||||
Ia5String::try_from(hostname.to_owned()).context("hostname is not valid IA5")?,
|
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));
|
sans.push(SanType::IpAddress(ip));
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@ -522,4 +525,394 @@ impl CertAuthority {
|
|||||||
.context("reconstruct CA certificate for signing")?;
|
.context("reconstruct CA certificate for signing")?;
|
||||||
Ok((key, cert))
|
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,
|
HealthCheckUpdated,
|
||||||
HealthCheckDeleted,
|
HealthCheckDeleted,
|
||||||
CertificateReissued,
|
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 {
|
impl AuditAction {
|
||||||
@ -88,6 +98,16 @@ impl AuditAction {
|
|||||||
Self::HealthCheckUpdated => "health_check_updated",
|
Self::HealthCheckUpdated => "health_check_updated",
|
||||||
Self::HealthCheckDeleted => "health_check_deleted",
|
Self::HealthCheckDeleted => "health_check_deleted",
|
||||||
Self::CertificateReissued => "certificate_reissued",
|
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.
|
/// Computes a hash chain entry using the previous row's hash.
|
||||||
/// Non-fatal: logs errors but does not propagate them to avoid
|
/// Non-fatal: logs errors but does not propagate them to avoid
|
||||||
/// disrupting the primary operation.
|
/// disrupting the primary operation.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn log_event(
|
pub async fn log_event(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
action: AuditAction,
|
action: AuditAction,
|
||||||
@ -126,6 +147,7 @@ pub async fn log_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn write_audit_row(
|
async fn write_audit_row(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
action: AuditAction,
|
action: AuditAction,
|
||||||
|
|||||||
@ -1,6 +1,61 @@
|
|||||||
use config::{Config, ConfigError, Environment, File};
|
use config::{Config, ConfigError, Environment, File};
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// Top-level application configuration.
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@ -9,6 +64,8 @@ pub struct AppConfig {
|
|||||||
pub worker: WorkerConfig,
|
pub worker: WorkerConfig,
|
||||||
pub logging: LoggingConfig,
|
pub logging: LoggingConfig,
|
||||||
pub security: SecurityConfig,
|
pub security: SecurityConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rate_limit: RateLimitConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@ -62,6 +119,13 @@ pub struct LoggingConfig {
|
|||||||
pub struct SecurityConfig {
|
pub struct SecurityConfig {
|
||||||
/// IP whitelist (CIDR or individual IPs); empty = allow all (not recommended)
|
/// IP whitelist (CIDR or individual IPs); empty = allow all (not recommended)
|
||||||
pub ip_whitelist: Vec<String>,
|
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)
|
/// JWT signing key path (Ed25519 PEM)
|
||||||
pub jwt_signing_key_path: String,
|
pub jwt_signing_key_path: String,
|
||||||
/// JWT verification key path (Ed25519 public PEM)
|
/// JWT verification key path (Ed25519 public PEM)
|
||||||
@ -83,6 +147,71 @@ pub struct SecurityConfig {
|
|||||||
/// Frontend URL to redirect to after SSO callback (default: http://localhost:5173/auth/sso/callback)
|
/// Frontend URL to redirect to after SSO callback (default: http://localhost:5173/auth/sso/callback)
|
||||||
#[serde(default = "default_sso_callback_url")]
|
#[serde(default = "default_sso_callback_url")]
|
||||||
pub sso_callback_url: String,
|
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 {
|
impl AppConfig {
|
||||||
@ -90,6 +219,11 @@ impl AppConfig {
|
|||||||
///
|
///
|
||||||
/// Environment variables follow the pattern: `PATCH_MANAGER__SECTION__KEY`
|
/// Environment variables follow the pattern: `PATCH_MANAGER__SECTION__KEY`
|
||||||
/// e.g. `PATCH_MANAGER__DATABASE__URL=postgres://...`
|
/// 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> {
|
pub fn load(config_path: &str) -> Result<Self, ConfigError> {
|
||||||
let cfg = Config::builder()
|
let cfg = Config::builder()
|
||||||
.add_source(File::with_name(config_path).required(false))
|
.add_source(File::with_name(config_path).required(false))
|
||||||
@ -100,7 +234,20 @@ impl AppConfig {
|
|||||||
)
|
)
|
||||||
.build()?;
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,6 +287,7 @@ impl Default for AppConfig {
|
|||||||
},
|
},
|
||||||
security: SecurityConfig {
|
security: SecurityConfig {
|
||||||
ip_whitelist: vec![],
|
ip_whitelist: vec![],
|
||||||
|
trusted_proxies: vec![],
|
||||||
jwt_signing_key_path: "/etc/patch-manager/jwt/signing.pem".to_string(),
|
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_verify_key_path: "/etc/patch-manager/jwt/verify.pem".to_string(),
|
||||||
jwt_access_ttl_secs: 900,
|
jwt_access_ttl_secs: 900,
|
||||||
@ -150,7 +298,69 @@ impl Default for AppConfig {
|
|||||||
web_tls_cert_path: "/etc/patch-manager/tls/web.crt".to_string(),
|
web_tls_cert_path: "/etc/patch-manager/tls/web.crt".to_string(),
|
||||||
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
|
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
|
||||||
sso_callback_url: default_sso_callback_url(),
|
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::{
|
use aes_gcm::{
|
||||||
aead::{Aead, KeyInit, OsRng},
|
aead::{Aead, KeyInit, OsRng},
|
||||||
@ -12,6 +17,12 @@ use std::path::Path;
|
|||||||
|
|
||||||
pub const KEY_PATH: &str = "/etc/patch-manager/keys/health-check.key";
|
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.
|
/// Load or create the per-install encryption key.
|
||||||
/// If the key file doesn't exist, generates a new 256-bit key and saves it.
|
/// 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> {
|
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() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(CryptoError::Io)?;
|
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)
|
// Set permissions to 0600 (owner read/write only)
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@ -78,3 +89,73 @@ pub enum CryptoError {
|
|||||||
#[error("UTF-8 error: {0}")]
|
#[error("UTF-8 error: {0}")]
|
||||||
Utf8(#[from] std::string::FromUtf8Error),
|
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,
|
EnrollmentRequest,
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token)
|
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token, hostname)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3::inet, $4, $5, $6)
|
||||||
RETURNING id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at
|
RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(req.machine_id)
|
.bind(req.machine_id)
|
||||||
@ -82,6 +82,7 @@ pub async fn create_enrollment_request(
|
|||||||
.bind(req.ip_address)
|
.bind(req.ip_address)
|
||||||
.bind(req.os_details)
|
.bind(req.os_details)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
|
.bind(&req.hostname)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -90,7 +91,7 @@ pub async fn list_enrollment_requests(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
) -> Result<Vec<EnrollmentRequest>, sqlx::Error> {
|
) -> Result<Vec<EnrollmentRequest>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, EnrollmentRequest>(
|
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)
|
.fetch_all(pool)
|
||||||
.await
|
.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
|
// Re-export commonly used types
|
||||||
pub use config::AppConfig;
|
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 error::{AppError, ErrorResponse};
|
||||||
pub use models::{
|
pub use models::{
|
||||||
AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest,
|
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 notes: String,
|
||||||
pub registered_at: DateTime<Utc>,
|
pub registered_at: DateTime<Utc>,
|
||||||
pub updated_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.
|
/// Payload for registering a new host.
|
||||||
@ -107,6 +116,14 @@ pub struct CreateHostRequest {
|
|||||||
pub group_ids: Option<Vec<Uuid>>,
|
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)
|
/// Host list item (lighter projection for list views)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
pub struct HostSummary {
|
pub struct HostSummary {
|
||||||
@ -121,6 +138,9 @@ pub struct HostSummary {
|
|||||||
pub patches_missing: i32,
|
pub patches_missing: i32,
|
||||||
pub health_check_status: Option<String>,
|
pub health_check_status: Option<String>,
|
||||||
pub registered_at: DateTime<Utc>,
|
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 ip_address: String,
|
||||||
pub os_details: serde_json::Value,
|
pub os_details: serde_json::Value,
|
||||||
pub polling_token: String,
|
pub polling_token: String,
|
||||||
|
/// Short hostname provided during enrollment (optional).
|
||||||
|
pub hostname: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub expires_at: DateTime<Utc>,
|
pub expires_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@ -146,6 +168,8 @@ pub struct CreateEnrollmentRequest {
|
|||||||
pub fqdn: String,
|
pub fqdn: String,
|
||||||
pub ip_address: String,
|
pub ip_address: String,
|
||||||
pub os_details: serde_json::Value,
|
pub os_details: serde_json::Value,
|
||||||
|
/// Short hostname (from /etc/hostname, optional).
|
||||||
|
pub hostname: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -154,8 +178,10 @@ pub enum EnrollmentStatusResponse {
|
|||||||
Pending,
|
Pending,
|
||||||
Approved {
|
Approved {
|
||||||
ca_crt: String,
|
ca_crt: String,
|
||||||
|
ca_chain: String,
|
||||||
server_crt: String,
|
server_crt: String,
|
||||||
server_key: String,
|
server_key: String,
|
||||||
|
crl_pem: String,
|
||||||
},
|
},
|
||||||
Denied,
|
Denied,
|
||||||
NotFound,
|
NotFound,
|
||||||
@ -163,9 +189,71 @@ pub enum EnrollmentStatusResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PkiBundle {
|
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,
|
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,
|
pub server_crt: String,
|
||||||
|
/// PEM-encoded agent server private key (PKCS#8).
|
||||||
pub server_key: String,
|
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 status: JobStatus,
|
||||||
pub immediate: bool,
|
pub immediate: bool,
|
||||||
pub host_count: i64,
|
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 succeeded_count: i64,
|
||||||
pub failed_count: i64,
|
pub failed_count: i64,
|
||||||
pub notes: String,
|
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![]);
|
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||||
wtr.write_record(&[
|
wtr.write_record([
|
||||||
"host_id",
|
"host_id",
|
||||||
"display_name",
|
"display_name",
|
||||||
"fqdn",
|
"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")?;
|
.context("patch history query failed")?;
|
||||||
|
|
||||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||||
wtr.write_record(&[
|
wtr.write_record([
|
||||||
"job_id",
|
"job_id",
|
||||||
"job_kind",
|
"job_kind",
|
||||||
"job_status",
|
"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>> {
|
async fn vulnerability_csv(pool: &sqlx::PgPool, params: &ReportParams) -> anyhow::Result<Vec<u8>> {
|
||||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||||
wtr.write_record(&[
|
wtr.write_record([
|
||||||
"host_id",
|
"host_id",
|
||||||
"display_name",
|
"display_name",
|
||||||
"fqdn",
|
"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")?;
|
.context("audit query failed")?;
|
||||||
|
|
||||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||||
wtr.write_record(&[
|
wtr.write_record([
|
||||||
"id",
|
"id",
|
||||||
"created_at",
|
"created_at",
|
||||||
"action",
|
"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;
|
self.current_y -= ROW_H;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn embed_image(
|
fn embed_image(
|
||||||
&self,
|
&self,
|
||||||
raw_rgb: Vec<u8>,
|
raw_rgb: Vec<u8>,
|
||||||
|
|||||||
@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "pm_web"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pm-web"
|
name = "pm-web"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
@ -33,6 +37,8 @@ ulid = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
ipnet = { workspace = true }
|
ipnet = { workspace = true }
|
||||||
dashmap = { version = "6" }
|
dashmap = { version = "6" }
|
||||||
|
tower_governor = { workspace = true }
|
||||||
|
governor = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
|
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
@ -42,3 +48,11 @@ sha2 = { workspace = true }
|
|||||||
jsonwebtoken = { workspace = true }
|
jsonwebtoken = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
urlencoding = "2"
|
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 dashmap::DashMap;
|
||||||
use pm_auth::{
|
use pm_auth::{jwt, rbac::AuthConfig};
|
||||||
jwt,
|
use pm_core::{config::AppConfig, db, models::ApprovedEntry};
|
||||||
rbac::{require_auth, AuthConfig},
|
use pm_web::routes::sso::{OidcCache, SsoHandoff, SsoSession};
|
||||||
};
|
use pm_web::routes::ws::WsTicket;
|
||||||
use pm_core::{
|
use pm_web::{bootstrap_admin_password, build_router, AppState};
|
||||||
config::AppConfig, db, logging, models::PkiBundle, request_id::request_id_middleware,
|
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||||
};
|
|
||||||
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 tokio::sync::Mutex;
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@ -62,7 +24,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
AppConfig::default()
|
AppConfig::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
logging::init(&config.logging);
|
pm_core::logging::init(&config.logging);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
version = env!("CARGO_PKG_VERSION"),
|
version = env!("CARGO_PKG_VERSION"),
|
||||||
"patch-manager-web starting"
|
"patch-manager-web starting"
|
||||||
@ -83,14 +45,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let auth_config = Arc::new(AuthConfig::new(
|
let auth_config = Arc::new(AuthConfig::new(
|
||||||
verify_key_pem,
|
verify_key_pem,
|
||||||
&config.security.ip_whitelist,
|
&config.security.ip_whitelist,
|
||||||
|
&config.security.trusted_proxies,
|
||||||
));
|
));
|
||||||
|
|
||||||
let pool = db::init_pool(&config.database).await?;
|
let pool = db::init_pool(&config.database).await?;
|
||||||
db::run_migrations(&pool).await?;
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
// Initialise the internal CA. Panics in production if CA files are missing
|
// Bootstrap admin password if the seed admin still has the placeholder hash.
|
||||||
// or corrupt — this is intentional; the service cannot operate without mTLS.
|
bootstrap_admin_password(&pool).await;
|
||||||
let ca_base = std::path::Path::new("/etc/patch-manager/ca");
|
|
||||||
|
// 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)
|
let ca = pm_ca::CertAuthority::init(ca_base, &pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| {
|
.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 ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
|
||||||
let sso_sessions: Arc<DashMap<String, SsoSession>> = 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 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, ApprovedEntry>> = Arc::new(DashMap::new());
|
||||||
let approved_enrollments: Arc<DashMap<String, PkiBundle>> = Arc::new(DashMap::new());
|
|
||||||
|
|
||||||
// Background task: purge expired WS tickets every 30 seconds.
|
// 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();
|
let sessions = sso_sessions.clone();
|
||||||
tokio::spawn(async move {
|
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 {
|
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 {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
let now = Instant::now();
|
let before = approved.len();
|
||||||
limits.retain(|_, v| now.duration_since(*v) < Duration::from_secs(3600));
|
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 {
|
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 {
|
loop {
|
||||||
interval.tick().await;
|
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,
|
auth_config,
|
||||||
ws_tickets,
|
ws_tickets,
|
||||||
sso_sessions,
|
sso_sessions,
|
||||||
|
sso_handoffs,
|
||||||
ca: Arc::new(ca),
|
ca: Arc::new(ca),
|
||||||
enrollment_rate_limits,
|
|
||||||
approved_enrollments,
|
approved_enrollments,
|
||||||
oidc_cache,
|
oidc_cache,
|
||||||
};
|
};
|
||||||
@ -190,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let tls_key = std::path::Path::new(&config.security.web_tls_key_path);
|
let tls_key = std::path::Path::new(&config.security.web_tls_key_path);
|
||||||
|
|
||||||
if tls_cert.exists() && tls_key.exists() {
|
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_cert_path,
|
||||||
&config.security.web_tls_key_path,
|
&config.security.web_tls_key_path,
|
||||||
)
|
)
|
||||||
@ -202,7 +179,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
tracing::info!(%addr, "Listening (HTTPS)");
|
tracing::info!(%addr, "Listening (HTTPS)");
|
||||||
axum_server::bind_rustls(addr, tls_config)
|
axum_server::bind_rustls(addr, tls_config)
|
||||||
.serve(app.into_make_service())
|
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||||
.await?;
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@ -213,95 +190,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
tracing::info!(%addr, "Listening (HTTP — no TLS)");
|
tracing::info!(%addr, "Listening (HTTP — no TLS)");
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
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(())
|
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")
|
// Encrypt the TOTP secret before persisting (issue #6 fix)
|
||||||
.bind(&req.secret_base32)
|
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)
|
.bind(auth_user.user_id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.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)
|
.bind(auth_user.user_id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.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.
|
/// Simple reverse DNS lookup.
|
||||||
fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
|
fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
|
||||||
use std::net::{SocketAddr, ToSocketAddrs};
|
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
|
// Standard library doesn't have reverse lookup; use getaddrinfo via format
|
||||||
let host = format!("{ip}");
|
let host = format!("{ip}");
|
||||||
// Best-effort: try to resolve numeric address to hostname
|
// Best-effort: try to resolve numeric address to hostname
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{ConnectInfo, Path, State},
|
extract::{Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
@ -11,13 +11,12 @@ use pm_auth::AuthUser;
|
|||||||
use pm_core::{
|
use pm_core::{
|
||||||
db,
|
db,
|
||||||
models::{
|
models::{
|
||||||
CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host, PkiBundle,
|
ApprovedEntry, CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host,
|
||||||
|
PkiBundle,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use rand::{distributions::Alphanumeric, Rng};
|
use rand::{distributions::Alphanumeric, Rng};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::net::{IpAddr, SocketAddr};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct HostConflict {
|
pub struct HostConflict {
|
||||||
@ -34,43 +33,12 @@ pub fn router() -> Router<AppState> {
|
|||||||
|
|
||||||
/// POST /api/v1/enroll
|
/// POST /api/v1/enroll
|
||||||
/// Initiates host self-enrollment.
|
/// Initiates host self-enrollment.
|
||||||
|
/// Rate limiting is handled by tower-governor middleware (per-IP, configurable).
|
||||||
async fn enroll_host(
|
async fn enroll_host(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<CreateEnrollmentRequest>,
|
Json(payload): Json<CreateEnrollmentRequest>,
|
||||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||||
// 1. IP-based Rate Limiting
|
// Generate secure random polling token
|
||||||
// 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
|
|
||||||
let polling_token: String = rand::thread_rng()
|
let polling_token: String = rand::thread_rng()
|
||||||
.sample_iter(&Alphanumeric)
|
.sample_iter(&Alphanumeric)
|
||||||
.take(64)
|
.take(64)
|
||||||
@ -109,7 +77,10 @@ async fn enroll_status(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
) -> Result<Json<EnrollmentStatusResponse>, (StatusCode, Json<serde_json::Value>)> {
|
) -> 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};
|
use sha2::{Digest, Sha256};
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(token.as_bytes());
|
hasher.update(token.as_bytes());
|
||||||
@ -131,11 +102,19 @@ async fn enroll_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. If not in pending, check if it was recently approved.
|
// 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 {
|
return Ok(Json(EnrollmentStatusResponse::Approved {
|
||||||
ca_crt: pki.ca_crt.clone(),
|
ca_crt: entry.pki.ca_crt.clone(),
|
||||||
server_crt: pki.server_crt.clone(),
|
ca_chain: entry.pki.ca_chain.clone(),
|
||||||
server_key: pki.server_key.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)
|
db::list_enrollment_requests(&state.db)
|
||||||
.await
|
.await
|
||||||
.map(|requests| Json(requests))
|
.map(Json)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!(error = %e, "Failed to list enrollment requests");
|
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
|
// Check for FQDN/IP collision in hosts table
|
||||||
if let Some(existing_host) = sqlx::query_as::<_, Host>(
|
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.fqdn)
|
||||||
.bind(&enrollment_request.ip_address.to_string())
|
.bind(enrollment_request.ip_address.to_string())
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.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
|
let issued = state
|
||||||
.ca
|
.ca
|
||||||
.issue_client_cert(
|
.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
|
// Delete from enrollment_requests table
|
||||||
db::delete_enrollment_request(&state.db, id)
|
db::delete_enrollment_request(&state.db, id)
|
||||||
.await
|
.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 {
|
let pki = PkiBundle {
|
||||||
ca_crt: issued.ca_root_pem,
|
ca_crt: issued.ca_root_pem,
|
||||||
|
ca_chain,
|
||||||
server_crt: issued.server_cert_pem,
|
server_crt: issued.server_cert_pem,
|
||||||
server_key: issued.server_key_pem,
|
server_key: issued.server_key_pem,
|
||||||
|
crl_pem,
|
||||||
};
|
};
|
||||||
state
|
state.approved_enrollments.insert(
|
||||||
.approved_enrollments
|
enrollment_request.polling_token.clone(),
|
||||||
.insert(enrollment_request.polling_token.clone(), pki);
|
ApprovedEntry::new(pki),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
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},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{delete, get, post, put},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use pm_auth::rbac::AuthUser;
|
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},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{delete, get, post, put},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use pm_auth::rbac::AuthUser;
|
use pm_auth::rbac::AuthUser;
|
||||||
@ -24,7 +24,7 @@ use pm_core::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use reqwest::tls::Version;
|
use reqwest::tls::Version;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -631,7 +631,6 @@ async fn update_health_check(
|
|||||||
set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx));
|
set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx));
|
||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx));
|
set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx));
|
||||||
param_idx += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if set_clauses.is_empty() {
|
if set_clauses.is_empty() {
|
||||||
@ -644,7 +643,7 @@ async fn update_health_check(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always update updated_at
|
// 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
|
// Use a simpler approach: query the current row, apply changes, update
|
||||||
// This avoids complex dynamic SQL binding issues
|
// This avoids complex dynamic SQL binding issues
|
||||||
@ -945,7 +944,7 @@ async fn run_service_check(check: &HealthCheck, state: &AppState) -> CheckResult
|
|||||||
}
|
}
|
||||||
CheckResult {
|
CheckResult {
|
||||||
healthy: false,
|
healthy: false,
|
||||||
detail: format!("Failed to parse agent response"),
|
detail: "Failed to parse agent response".to_string(),
|
||||||
latency_ms: Some(latency),
|
latency_ms: Some(latency),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
//! POST /api/v1/hosts — register new host (admin only)
|
//! POST /api/v1/hosts — register new host (admin only)
|
||||||
//! GET /api/v1/hosts/{id} — get host detail
|
//! GET /api/v1/hosts/{id} — get host detail
|
||||||
//! DELETE /api/v1/hosts/{id} — remove host (admin only)
|
//! 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
|
//! GET /api/v1/hosts/{id}/groups — list groups for host
|
||||||
//! POST /api/v1/hosts/{id}/groups — assign host to group
|
//! POST /api/v1/hosts/{id}/groups — assign host to group
|
||||||
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from 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_auth::rbac::AuthUser;
|
||||||
use pm_core::{
|
use pm_core::{
|
||||||
audit::{log_event, AuditAction},
|
audit::{log_event, AuditAction},
|
||||||
models::{CreateHostRequest, Group, HostSummary},
|
models::{CreateHostRequest, Group, HostSummary, UpdateHostRequest},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@ -30,7 +31,7 @@ use crate::AppState;
|
|||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_hosts).post(register_host))
|
.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(
|
.route(
|
||||||
"/{id}/groups",
|
"/{id}/groups",
|
||||||
get(list_host_groups).post(add_host_to_group),
|
get(list_host_groups).post(add_host_to_group),
|
||||||
@ -42,6 +43,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
// ── Query params ─────────────────────────────────────────────────────────────
|
// ── Query params ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct HostListQuery {
|
pub struct HostListQuery {
|
||||||
pub group_id: Option<Uuid>,
|
pub group_id: Option<Uuid>,
|
||||||
pub health_status: Option<String>,
|
pub health_status: Option<String>,
|
||||||
@ -130,7 +132,8 @@ async fn list_hosts(
|
|||||||
THEN 'some_unhealthy'
|
THEN 'some_unhealthy'
|
||||||
ELSE 'all_healthy'
|
ELSE 'all_healthy'
|
||||||
END AS health_check_status,
|
END AS health_check_status,
|
||||||
h.registered_at
|
h.registered_at,
|
||||||
|
h.crl_status
|
||||||
FROM hosts h
|
FROM hosts h
|
||||||
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
||||||
ORDER BY h.fqdn
|
ORDER BY h.fqdn
|
||||||
@ -163,7 +166,8 @@ async fn list_hosts(
|
|||||||
THEN 'some_unhealthy'
|
THEN 'some_unhealthy'
|
||||||
ELSE 'all_healthy'
|
ELSE 'all_healthy'
|
||||||
END AS health_check_status,
|
END AS health_check_status,
|
||||||
h.registered_at
|
h.registered_at,
|
||||||
|
h.crl_status
|
||||||
FROM hosts h
|
FROM hosts h
|
||||||
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
||||||
WHERE
|
WHERE
|
||||||
@ -317,7 +321,8 @@ async fn get_host(
|
|||||||
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
||||||
os_family, os_name, arch, agent_version, health_status,
|
os_family, os_name, arch, agent_version, health_status,
|
||||||
last_health_at, last_patch_at, agent_port, notes,
|
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
|
FROM hosts WHERE id = $1
|
||||||
) h
|
) h
|
||||||
"#,
|
"#,
|
||||||
@ -398,6 +403,69 @@ async fn remove_host(
|
|||||||
Ok(Json(json!({ "message": "Host removed" })))
|
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 ──────────────────────────────────────────────
|
// ── GET /api/v1/hosts/:id/groups ──────────────────────────────────────────────
|
||||||
|
|
||||||
async fn list_host_groups(
|
async fn list_host_groups(
|
||||||
|
|||||||
@ -20,6 +20,7 @@ use pm_core::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@ -52,6 +53,13 @@ struct JobListResponse {
|
|||||||
offset: i64,
|
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.
|
/// Per-host row included in `GET /api/v1/jobs/{id}` response.
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
struct JobHostRow {
|
struct JobHostRow {
|
||||||
@ -229,7 +237,7 @@ async fn list_jobs(
|
|||||||
let limit = q.limit.unwrap_or(50).min(200);
|
let limit = q.limit.unwrap_or(50).min(200);
|
||||||
let offset = q.offset.unwrap_or(0);
|
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.
|
// Admins see every job.
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
@ -298,6 +306,40 @@ async fn list_jobs(
|
|||||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
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.
|
// Total count for pagination metadata.
|
||||||
let total: i64 = if auth.role.is_admin() {
|
let total: i64 = if auth.role.is_admin() {
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM patch_jobs")
|
sqlx::query_scalar("SELECT COUNT(*) FROM patch_jobs")
|
||||||
@ -438,7 +480,7 @@ async fn cancel_job(
|
|||||||
|
|
||||||
// Only admin or the job creator may cancel.
|
// Only admin or the job creator may cancel.
|
||||||
if !auth.role.can_write() {
|
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 {
|
if !is_creator {
|
||||||
return Err(err(
|
return Err(err(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
//! Maintenance window management routes.
|
//! Maintenance window management routes.
|
||||||
//!
|
//!
|
||||||
//! GET /api/v1/hosts/{id}/maintenance-windows — list windows for host
|
//! 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
|
//! POST /api/v1/hosts/{id}/maintenance-windows — create window for host
|
||||||
//! PUT /api/v1/hosts/{id}/maintenance-windows/{win_id} — update window
|
//! PUT /api/v1/hosts/{id}/maintenance-windows/{win_id} — update window
|
||||||
//! DELETE /api/v1/hosts/{id}/maintenance-windows/{win_id} — delete 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))
|
.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 ──────────────────────────────────────────────────────────────
|
// ── Error helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
|||||||
@ -8,10 +8,10 @@ pub mod health_checks;
|
|||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod maintenance_windows;
|
pub mod maintenance_windows;
|
||||||
|
pub mod pki;
|
||||||
|
pub mod reports;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod sso;
|
pub mod sso;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod ws;
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct OidcDiscoveryResult {
|
pub struct OidcDiscoveryResult {
|
||||||
pub issuer: String,
|
pub issuer: String,
|
||||||
pub authorization_endpoint: String,
|
pub authorization_endpoint: String,
|
||||||
@ -179,6 +180,28 @@ fn write_access_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>
|
|||||||
Ok(())
|
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(
|
async fn load_system_config(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
|
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
|
||||||
@ -250,11 +273,23 @@ async fn update_config_key(
|
|||||||
Ok(())
|
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(
|
async fn fetch_oidc_config(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> Result<OidcConfigResponse, (StatusCode, Json<Value>)> {
|
) -> Result<OidcConfigResponse, (StatusCode, Json<Value>)> {
|
||||||
let row: Option<(bool, String, String, String, String, String, String, String)> = sqlx::query_as(
|
let row: Option<OidcConfigRow> = 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)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
@ -273,7 +308,8 @@ async fn fetch_oidc_config(
|
|||||||
display_name,
|
display_name,
|
||||||
discovery_url,
|
discovery_url,
|
||||||
client_id,
|
client_id,
|
||||||
client_secret,
|
client_secret_encrypted,
|
||||||
|
_client_secret_nonce,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
scopes,
|
scopes,
|
||||||
)) => OidcConfigResponse {
|
)) => OidcConfigResponse {
|
||||||
@ -282,7 +318,7 @@ async fn fetch_oidc_config(
|
|||||||
display_name,
|
display_name,
|
||||||
discovery_url,
|
discovery_url,
|
||||||
client_id,
|
client_id,
|
||||||
client_secret: if client_secret.is_empty() {
|
client_secret: if client_secret_encrypted.is_none() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
MASKED.to_string()
|
MASKED.to_string()
|
||||||
@ -332,7 +368,7 @@ async fn update_settings(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<UpdateSettingsRequest>,
|
Json(req): Json<UpdateSettingsRequest>,
|
||||||
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
||||||
write_access_required(&auth)?;
|
admin_required(&auth)?;
|
||||||
|
|
||||||
// Update OIDC config
|
// Update OIDC config
|
||||||
if let Some(oidc) = req.oidc {
|
if let Some(oidc) = req.oidc {
|
||||||
@ -342,6 +378,22 @@ async fn update_settings(
|
|||||||
.is_some_and(|s| s != MASKED && !s.is_empty());
|
.is_some_and(|s| s != MASKED && !s.is_empty());
|
||||||
|
|
||||||
let result = if update_secret {
|
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(
|
sqlx::query(
|
||||||
"UPDATE oidc_config SET \
|
"UPDATE oidc_config SET \
|
||||||
enabled = COALESCE($1, enabled), \
|
enabled = COALESCE($1, enabled), \
|
||||||
@ -349,9 +401,10 @@ async fn update_settings(
|
|||||||
display_name = COALESCE($3, display_name), \
|
display_name = COALESCE($3, display_name), \
|
||||||
discovery_url = COALESCE($4, discovery_url), \
|
discovery_url = COALESCE($4, discovery_url), \
|
||||||
client_id = COALESCE($5, client_id), \
|
client_id = COALESCE($5, client_id), \
|
||||||
client_secret = $6, \
|
client_secret_encrypted = $6, \
|
||||||
redirect_uri = COALESCE($7, redirect_uri), \
|
client_secret_nonce = $7, \
|
||||||
scopes = COALESCE($8, scopes), \
|
redirect_uri = COALESCE($8, redirect_uri), \
|
||||||
|
scopes = COALESCE($9, scopes), \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = 1",
|
WHERE id = 1",
|
||||||
)
|
)
|
||||||
@ -360,7 +413,8 @@ async fn update_settings(
|
|||||||
.bind(&oidc.display_name)
|
.bind(&oidc.display_name)
|
||||||
.bind(&oidc.discovery_url)
|
.bind(&oidc.discovery_url)
|
||||||
.bind(&oidc.client_id)
|
.bind(&oidc.client_id)
|
||||||
.bind(oidc.client_secret.as_deref().unwrap_or(""))
|
.bind(&ciphertext)
|
||||||
|
.bind(&nonce)
|
||||||
.bind(&oidc.redirect_uri)
|
.bind(&oidc.redirect_uri)
|
||||||
.bind(&oidc.scopes)
|
.bind(&oidc.scopes)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
@ -399,7 +453,7 @@ async fn update_settings(
|
|||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
AuditAction::ConfigChanged,
|
AuditAction::OidcConfigUpdated,
|
||||||
Some(auth.user_id),
|
Some(auth.user_id),
|
||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("oidc"),
|
Some("oidc"),
|
||||||
@ -427,7 +481,59 @@ async fn update_settings(
|
|||||||
}
|
}
|
||||||
if let Some(ref v) = smtp.password {
|
if let Some(ref v) = smtp.password {
|
||||||
if v != MASKED {
|
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 {
|
if let Some(ref v) = smtp.from {
|
||||||
@ -439,7 +545,7 @@ async fn update_settings(
|
|||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
AuditAction::ConfigChanged,
|
AuditAction::SmtpConfigUpdated,
|
||||||
Some(auth.user_id),
|
Some(auth.user_id),
|
||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("smtp"),
|
Some("smtp"),
|
||||||
@ -484,7 +590,7 @@ async fn update_settings(
|
|||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
AuditAction::ConfigChanged,
|
AuditAction::IpWhitelistUpdated,
|
||||||
Some(auth.user_id),
|
Some(auth.user_id),
|
||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("ip_whitelist"),
|
Some("ip_whitelist"),
|
||||||
@ -562,7 +668,7 @@ async fn discover_oidc(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<OidcDiscoveryRequest>,
|
Json(req): Json<OidcDiscoveryRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
write_access_required(&auth)?;
|
admin_required(&auth)?;
|
||||||
|
|
||||||
if req.discovery_url.is_empty() {
|
if req.discovery_url.is_empty() {
|
||||||
return Err((
|
return Err((
|
||||||
@ -587,6 +693,20 @@ async fn discover_oidc(
|
|||||||
match client.get(&req.discovery_url).send().await {
|
match client.get(&req.discovery_url).send().await {
|
||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
let body: Value = resp.json().await.unwrap_or(json!({}));
|
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!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
|
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
@ -619,7 +739,7 @@ async fn test_oidc(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
write_access_required(&auth)?;
|
admin_required(&auth)?;
|
||||||
|
|
||||||
let row: Option<(bool, String, String)> = sqlx::query_as(
|
let row: Option<(bool, String, String)> = sqlx::query_as(
|
||||||
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
|
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
|
||||||
@ -678,6 +798,23 @@ async fn test_oidc(
|
|||||||
"azure" => "Azure AD",
|
"azure" => "Azure AD",
|
||||||
_ => "OIDC",
|
_ => "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!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": format!("{} provider verified successfully", provider_label),
|
"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)
|
// POST /api/v1/settings/azure-sso/test (backward-compatible alias)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -733,7 +873,32 @@ async fn test_smtp(
|
|||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(587);
|
.unwrap_or(587);
|
||||||
let username = cfg.get("smtp_username").cloned().unwrap_or_default();
|
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 from_addr = cfg.get("smtp_from").cloned().unwrap_or_default();
|
||||||
let tls_mode = cfg
|
let tls_mode = cfg
|
||||||
.get("smtp_tls_mode")
|
.get("smtp_tls_mode")
|
||||||
@ -898,7 +1063,7 @@ async fn update_ip_whitelist(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<IpWhitelistUpdate>,
|
Json(req): Json<IpWhitelistUpdate>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
write_access_required(&auth)?;
|
admin_required(&auth)?;
|
||||||
|
|
||||||
// Validate each entry
|
// Validate each entry
|
||||||
for entry in &req.entries {
|
for entry in &req.entries {
|
||||||
@ -920,7 +1085,7 @@ async fn update_ip_whitelist(
|
|||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
AuditAction::ConfigChanged,
|
AuditAction::IpWhitelistUpdated,
|
||||||
Some(auth.user_id),
|
Some(auth.user_id),
|
||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("ip_whitelist"),
|
Some("ip_whitelist"),
|
||||||
@ -974,3 +1139,70 @@ async fn audit_integrity(
|
|||||||
})).collect::<Vec<_>>(),
|
})).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,
|
extract::State,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Json, Redirect},
|
response::{IntoResponse, Json, Redirect},
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use dashmap::DashMap;
|
||||||
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
|
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
|
||||||
use pm_auth::{jwt::issue_access_token, refresh};
|
use pm_auth::{jwt::issue_access_token, refresh};
|
||||||
use pm_core::audit::{log_event, AuditAction};
|
use pm_core::audit::{log_event, AuditAction};
|
||||||
@ -40,6 +41,140 @@ pub struct SsoSession {
|
|||||||
pub created_at: chrono::DateTime<Utc>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TokenResponse {
|
struct TokenResponse {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@ -78,11 +213,29 @@ pub struct OidcConfig {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub discovery_url: String,
|
pub discovery_url: String,
|
||||||
pub client_id: 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 redirect_uri: String,
|
||||||
pub scopes: 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.
|
/// Cached OIDC discovery document.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OidcDiscovery {
|
pub struct OidcDiscovery {
|
||||||
@ -95,22 +248,13 @@ pub struct OidcDiscovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cache for OIDC discovery documents and JWKS with TTL-based refresh.
|
/// Cache for OIDC discovery documents and JWKS with TTL-based refresh.
|
||||||
|
#[derive(Default)]
|
||||||
pub struct OidcCache {
|
pub struct OidcCache {
|
||||||
pub discovery: Option<OidcDiscovery>,
|
pub discovery: Option<OidcDiscovery>,
|
||||||
pub jwks: Option<serde_json::Value>,
|
pub jwks: Option<serde_json::Value>,
|
||||||
pub jwks_fetched_at: Option<chrono::DateTime<Utc>>,
|
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).
|
/// JWKS cache TTL in seconds (1 hour).
|
||||||
const JWKS_CACHE_TTL_SECS: i64 = 3600;
|
const JWKS_CACHE_TTL_SECS: i64 = 3600;
|
||||||
/// Discovery cache TTL in seconds (1 hour).
|
/// Discovery cache TTL in seconds (1 hour).
|
||||||
@ -125,6 +269,12 @@ pub fn public_router() -> Router<AppState> {
|
|||||||
.route("/login", get(sso_login))
|
.route("/login", get(sso_login))
|
||||||
.route("/callback", get(sso_callback))
|
.route("/callback", get(sso_callback))
|
||||||
.route("/config", get(sso_config))
|
.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.
|
/// 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
|
// For confidential clients (Azure AD), include client_secret
|
||||||
if !config.client_secret.is_empty() {
|
let key = match crate::secret_key::get() {
|
||||||
params_vec.push(("client_secret", config.client_secret.clone()));
|
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
|
let token_resp = match client
|
||||||
@ -492,10 +662,11 @@ async fn sso_callback(
|
|||||||
DbUserForSso {
|
DbUserForSso {
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
username: existing.username.clone(),
|
username: existing.username.clone(),
|
||||||
display_name: name
|
display_name: if name.is_empty() {
|
||||||
.is_empty()
|
existing.display_name.clone()
|
||||||
.then(|| existing.display_name.clone())
|
} else {
|
||||||
.unwrap_or(name),
|
name
|
||||||
|
},
|
||||||
role: existing.role.clone(),
|
role: existing.role.clone(),
|
||||||
is_active: existing.is_active,
|
is_active: existing.is_active,
|
||||||
mfa_enabled: existing.mfa_enabled,
|
mfa_enabled: existing.mfa_enabled,
|
||||||
@ -612,13 +783,32 @@ async fn sso_callback(
|
|||||||
"mfa_enabled": user.mfa_enabled,
|
"mfa_enabled": user.mfa_enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
let redirect_url = format!(
|
// Issue #4 fix: instead of embedding access/refresh tokens in the
|
||||||
"{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}",
|
// redirect URL (which leaks through browser history, proxy access
|
||||||
callback_url,
|
// logs, and the Referer header), generate a single-use, 60s handoff
|
||||||
urlencoding::encode(&access_token),
|
// code, store the payload in `sso_handoffs`, and put ONLY the code
|
||||||
urlencoding::encode(&raw_refresh.0),
|
// in the redirect. The SPA POSTs to `/api/v1/auth/sso/handoff` to
|
||||||
access_ttl,
|
// exchange the code for tokens. See `tasks/sso-token-handoff-spec.md`
|
||||||
urlencoding::encode(&user_json.to_string()),
|
// §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))
|
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>)> {
|
async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode, Json<Value>)> {
|
||||||
let row: Option<OidcConfig> = sqlx::query_as(
|
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)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
@ -665,7 +857,8 @@ async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode
|
|||||||
display_name: "Azure AD".to_string(),
|
display_name: "Azure AD".to_string(),
|
||||||
discovery_url: String::new(),
|
discovery_url: String::new(),
|
||||||
client_id: String::new(),
|
client_id: String::new(),
|
||||||
client_secret: String::new(),
|
client_secret_encrypted: None,
|
||||||
|
client_secret_nonce: None,
|
||||||
redirect_uri: String::new(),
|
redirect_uri: String::new(),
|
||||||
scopes: "openid profile email".to_string(),
|
scopes: "openid profile email".to_string(),
|
||||||
}))
|
}))
|
||||||
@ -844,3 +1037,168 @@ async fn fetch_jwks(jwks_uri: &str) -> Result<serde_json::Value, String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to parse JWKS response: {}", e))
|
.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 total_pending_patches: i64,
|
||||||
pub hosts_requiring_reboot: i64,
|
pub hosts_requiring_reboot: i64,
|
||||||
pub compliance_pct: f64,
|
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 ──────────────────────────────────────────────────
|
// ── GET /api/v1/status/fleet ──────────────────────────────────────────────────
|
||||||
@ -132,6 +142,34 @@ pub async fn fleet_status(
|
|||||||
// Round to one decimal place.
|
// Round to one decimal place.
|
||||||
let compliance_pct = (compliance_pct * 10.0).round() / 10.0;
|
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 {
|
Ok(Json(FleetStatus {
|
||||||
total_hosts,
|
total_hosts,
|
||||||
healthy,
|
healthy,
|
||||||
@ -141,5 +179,10 @@ pub async fn fleet_status(
|
|||||||
total_pending_patches,
|
total_pending_patches,
|
||||||
hosts_requiring_reboot,
|
hosts_requiring_reboot,
|
||||||
compliance_pct,
|
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)
|
.bind(id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -6,14 +6,14 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::ws::{Message, WebSocket},
|
extract::ws::{Message, WebSocket},
|
||||||
extract::{Query, State, WebSocketUpgrade},
|
extract::{Query, State, WebSocketUpgrade},
|
||||||
http::StatusCode,
|
http::{HeaderMap, StatusCode},
|
||||||
response::{Json, Response},
|
response::{Json, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use pm_auth::rbac::AuthUser;
|
use pm_auth::rbac::AuthUser;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::postgres::PgListener;
|
use sqlx::postgres::PgListener;
|
||||||
use ulid::Ulid;
|
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 ────────────────────────────────────────────────────
|
// ── POST /api/v1/ws/ticket ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Issue a single-use WebSocket authentication ticket (60 s expiry).
|
/// 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.
|
/// 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(
|
pub async fn ws_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Query(q): Query<WsQuery>,
|
Query(q): Query<WsQuery>,
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
) -> Result<Response, (StatusCode, Json<Value>)> {
|
) -> 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.
|
// Validate and consume the ticket atomically.
|
||||||
let ticket = {
|
let ticket = {
|
||||||
let entry = state.ws_tickets.get(&q.ticket);
|
let entry = state.ws_tickets.get(&q.ticket);
|
||||||
@ -129,6 +312,7 @@ pub async fn ws_handler(
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
user_id = %ticket.user_id,
|
user_id = %ticket.user_id,
|
||||||
role = %ticket.role,
|
role = %ticket.role,
|
||||||
|
origin = %allowed_origin,
|
||||||
"Browser WebSocket connection upgraded"
|
"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");
|
tracing::info!(user_id = %ticket.user_id, "Browser WS closed by client");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Ping(data))) => {
|
Some(Ok(Message::Ping(data))) if socket.send(Message::Pong(data.clone())).await.is_err() => {
|
||||||
if socket.send(Message::Pong(data)).await.is_err() {
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
tracing::debug!(error = %e, user_id = %ticket.user_id, "Browser WS recv error");
|
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");
|
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"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
rustls = { workspace = true }
|
rustls = { workspace = true }
|
||||||
tokio-rustls = { version = "0.26" }
|
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,
|
transport::smtp::authentication::Credentials,
|
||||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||||
};
|
};
|
||||||
use serde_json;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use pm_core::audit::{log_event, AuditAction};
|
use pm_core::audit::{log_event, AuditAction};
|
||||||
@ -33,11 +32,16 @@ struct NotificationSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load SMTP settings from the `system_config` table.
|
/// 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 {
|
async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
|
||||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||||
"SELECT key, value FROM system_config WHERE key IN (
|
"SELECT key, value FROM system_config WHERE key IN (
|
||||||
'smtp_enabled', 'smtp_host', 'smtp_port', 'smtp_username',
|
'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)
|
.fetch_all(pool)
|
||||||
@ -51,17 +55,46 @@ async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
|
|||||||
.unwrap_or_default()
|
.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 {
|
SmtpSettings {
|
||||||
enabled: get("smtp_enabled") == "true",
|
enabled: get("smtp_enabled") == "true",
|
||||||
host: get("smtp_host"),
|
host: get("smtp_host"),
|
||||||
port: get("smtp_port").parse().unwrap_or(587),
|
port: get("smtp_port").parse().unwrap_or(587),
|
||||||
username: get("smtp_username"),
|
username: get("smtp_username"),
|
||||||
password: get("smtp_password"),
|
password,
|
||||||
from: get("smtp_from"),
|
from: get("smtp_from"),
|
||||||
tls_mode: get("smtp_tls_mode"),
|
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`.
|
/// Load notification preferences from `system_config`.
|
||||||
async fn load_notification_settings(pool: &PgPool) -> NotificationSettings {
|
async fn load_notification_settings(pool: &PgPool) -> NotificationSettings {
|
||||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
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.
|
/// Send a maintenance window reminder email.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn send_maintenance_window_reminder_email(
|
pub async fn send_maintenance_window_reminder_email(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
host_fqdn: &str,
|
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.
|
/// Row fetched for each enabled health check, joined with host connection info.
|
||||||
#[derive(FromRow)]
|
#[derive(FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
struct HealthCheckRow {
|
struct HealthCheckRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
host_id: Uuid,
|
host_id: Uuid,
|
||||||
|
|||||||
@ -2,12 +2,26 @@
|
|||||||
//!
|
//!
|
||||||
//! Polls every host via the agent `/health` endpoint on each tick of
|
//! Polls every host via the agent `/health` endpoint on each tick of
|
||||||
//! `health_poll_interval_secs`, with bounded concurrency controlled by a
|
//! `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 std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use pm_agent_client::{AgentClient, AgentClientError};
|
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 sqlx::{FromRow, PgPool};
|
||||||
use tokio::{sync::Semaphore, time};
|
use tokio::{sync::Semaphore, time};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -20,6 +34,10 @@ struct HostRow {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
ip_address: String,
|
ip_address: String,
|
||||||
agent_port: i32,
|
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.
|
/// 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 client_key = Arc::new(certs.client_key);
|
||||||
let ca_cert = Arc::new(certs.ca_cert);
|
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(
|
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)
|
.fetch_all(&pool)
|
||||||
.await
|
.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.
|
/// 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(
|
async fn poll_host_health(
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
host: HostRow,
|
host: HostRow,
|
||||||
@ -121,8 +144,16 @@ async fn poll_host_health(
|
|||||||
client_key: &[u8],
|
client_key: &[u8],
|
||||||
ca_cert: &[u8],
|
ca_cert: &[u8],
|
||||||
) -> HostHealthStatus {
|
) -> HostHealthStatus {
|
||||||
// Determine status and optional health payload.
|
// Determine status, payload, agent version, optional system info, and CRL fields.
|
||||||
let (status, payload) = match AgentClient::new(
|
let (
|
||||||
|
natural_status,
|
||||||
|
payload,
|
||||||
|
agent_version,
|
||||||
|
sys_info,
|
||||||
|
crl_status,
|
||||||
|
crl_age_seconds,
|
||||||
|
crl_next_update,
|
||||||
|
) = match AgentClient::new(
|
||||||
&host.ip_address,
|
&host.ip_address,
|
||||||
host.agent_port as u16,
|
host.agent_port as u16,
|
||||||
client_cert,
|
client_cert,
|
||||||
@ -138,38 +169,95 @@ async fn poll_host_health(
|
|||||||
(
|
(
|
||||||
HostHealthStatus::Unreachable,
|
HostHealthStatus::Unreachable,
|
||||||
serde_json::Value::Object(Default::default()),
|
serde_json::Value::Object(Default::default()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
Ok(client) => match client.health().await {
|
Ok(client) => {
|
||||||
Ok(data) => {
|
let (status, payload, version, crl_status, crl_age, crl_next) = match client
|
||||||
let payload = serde_json::to_value(&data).unwrap_or_default();
|
.health()
|
||||||
(HostHealthStatus::Healthy, payload)
|
.await
|
||||||
},
|
{
|
||||||
Err(AgentClientError::Timeout) => {
|
Ok(data) => {
|
||||||
tracing::warn!(host_id = %host.id, "Health poller: agent timed out");
|
let payload = serde_json::to_value(&data).unwrap_or_default();
|
||||||
(
|
let crl_status = data.crl_status.clone();
|
||||||
HostHealthStatus::Unreachable,
|
let crl_age = data.crl_age_seconds;
|
||||||
serde_json::Value::Object(Default::default()),
|
let crl_next = data.crl_next_update.clone();
|
||||||
)
|
(
|
||||||
},
|
HostHealthStatus::Healthy,
|
||||||
Err(AgentClientError::Connect(_)) => {
|
payload,
|
||||||
tracing::warn!(host_id = %host.id, "Health poller: agent connection refused");
|
Some(data.version),
|
||||||
(
|
crl_status,
|
||||||
HostHealthStatus::Unreachable,
|
crl_age,
|
||||||
serde_json::Value::Object(Default::default()),
|
crl_next,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(AgentClientError::Timeout) => {
|
||||||
tracing::warn!(host_id = %host.id, error = %e, "Health poller: agent error");
|
tracing::warn!(host_id = %host.id, "Health poller: agent timed out");
|
||||||
(
|
(
|
||||||
HostHealthStatus::Degraded,
|
HostHealthStatus::Unreachable,
|
||||||
serde_json::Value::Object(Default::default()),
|
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(
|
if let Err(e) = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO host_health_data (host_id, status, payload)
|
INSERT INTO host_health_data (host_id, status, payload)
|
||||||
@ -177,7 +265,7 @@ async fn poll_host_health(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(host.id)
|
.bind(host.id)
|
||||||
.bind(&status)
|
.bind(&natural_status)
|
||||||
.bind(&payload)
|
.bind(&payload)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.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");
|
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(
|
if let Err(e) = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE hosts
|
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
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(host.id)
|
.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)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to update host status");
|
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 maintenance_scheduler;
|
||||||
mod patch_poller;
|
mod patch_poller;
|
||||||
mod refresh_listener;
|
mod refresh_listener;
|
||||||
|
mod secret_key;
|
||||||
mod ws_relay;
|
mod ws_relay;
|
||||||
|
|
||||||
use chrono::Utc;
|
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)]
|
#[derive(Debug, FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
struct PendingPatchHost {
|
struct PendingPatchHost {
|
||||||
host_id: Uuid,
|
host_id: Uuid,
|
||||||
patch_count: i32,
|
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
40
debian/changelog
vendored
40
debian/changelog
vendored
@ -1,3 +1,43 @@
|
|||||||
|
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
|
linux-patch-manager (0.1.7-1) noble; urgency=medium
|
||||||
|
|
||||||
* Host Self-Enrollment: Added REST API and UI for automated agent enrollment
|
* 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
|
Package: linux-patch-manager
|
||||||
Version: 1.0.0-1
|
Version: 1.1.4-1
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
Maintainer: Moon Dragon <echo@moon-dragon.us>
|
Maintainer: Moon Dragon <echo@moon-dragon.us>
|
||||||
Installed-Size: 45000
|
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
|
Recommends: postgresql-client-16, fonts-dejavu-core
|
||||||
Suggests: gpg
|
Suggests: gpg
|
||||||
Section: admin
|
Section: admin
|
||||||
|
|||||||
417
debian/postinst
vendored
Normal file → Executable file
417
debian/postinst
vendored
Normal file → Executable file
@ -4,91 +4,348 @@ set -e
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Linux Patch Manager — Post-install script
|
# 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
|
||||||
|
sudo -u postgres psql -v ON_ERROR_STOP=1 -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, skipping creation."
|
||||||
|
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 permissions (idempotent)
|
||||||
|
psql_run_db -c "GRANT USAGE ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true
|
||||||
|
psql_run_db -c "GRANT CREATE 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Apply database migrations (idempotent)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
apply_migrations() {
|
||||||
|
info "Applying database migrations..."
|
||||||
|
|
||||||
|
# Ensure pgcrypto extension is available
|
||||||
|
psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create migration tracking table if not exists
|
||||||
|
psql_run_db <<'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_db -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
|
||||||
|
migration_count="${migration_count// /}"
|
||||||
|
|
||||||
|
local tables_exist
|
||||||
|
tables_exist=$(psql_run_db -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_db -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_db -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_db -f "${sql_file}"; then
|
||||||
|
psql_run_db -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
|
||||||
|
applied=$((applied + 1))
|
||||||
|
else
|
||||||
|
error " Failed to apply migration: ${fname}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
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)
|
||||||
|
local password_hash
|
||||||
|
password_hash=$(echo -n "${admin_password}" | argon2 salt -id -t 3 -m 65536 -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 (only if file doesn't exist)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
write_config() {
|
||||||
|
local config_file="${CONFIG_DIR}/config.toml"
|
||||||
|
|
||||||
|
if [[ -f "${config_file}" ]]; then
|
||||||
|
info "Config file ${config_file} already exists, not overwriting."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Writing configuration file..."
|
||||||
|
|
||||||
|
# Get the DB password — use the one we just generated if we created the user
|
||||||
|
local db_password=""
|
||||||
|
if [[ -f /tmp/.pm-db-password-new ]]; then
|
||||||
|
db_password=$(cat /tmp/.pm-db-password-new)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If we don't have a password (user already existed), generate a new one
|
||||||
|
# and update the PostgreSQL user so we can connect
|
||||||
|
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
|
||||||
|
|
||||||
|
# Copy example config and set the DB URL
|
||||||
|
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}"
|
||||||
|
chown patch-manager:patch-manager "${config_file}"
|
||||||
|
chmod 640 "${config_file}"
|
||||||
|
|
||||||
|
info "Configuration written to ${config_file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Generate JWT keys (idempotent)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
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."
|
||||||
|
else
|
||||||
|
info "JWT signing key already exists, 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
|
||||||
|
|
||||||
|
# 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
|
case "$1" in
|
||||||
configure)
|
configure)
|
||||||
# Create service user if not exists
|
create_service_user
|
||||||
if ! id patch-manager &>/dev/null; then
|
create_directories
|
||||||
useradd --system --no-create-home --shell /usr/sbin/nologin \
|
wait_for_postgresql
|
||||||
--comment "Linux Patch Manager service account" patch-manager
|
setup_database
|
||||||
fi
|
apply_migrations
|
||||||
|
generate_admin_password
|
||||||
|
write_config
|
||||||
|
generate_jwt_keys
|
||||||
|
enable_and_start_services
|
||||||
|
install_backup_cron
|
||||||
|
|
||||||
# Create required directories
|
# Clean up temp file
|
||||||
mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
|
rm -f /tmp/.pm-db-password-new
|
||||||
/etc/patch-manager/jwt /etc/patch-manager/tls \
|
|
||||||
/var/log/patch-manager /opt/patch-manager \
|
|
||||||
/var/backups/patch-manager
|
|
||||||
|
|
||||||
chown -R patch-manager:patch-manager \
|
info "Linux Patch Manager installation complete."
|
||||||
/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 ""
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
abort-upgrade|abort-remove|abort-deconfigure)
|
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 |
|
| POST | `/auth/mfa/verify` | Verify MFA code |
|
||||||
| DELETE | `/auth/mfa` | Disable MFA for user |
|
| 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)
|
## 2. Public Endpoints (Self-Enrollment)
|
||||||
*No authentication required.*
|
*No authentication required.*
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@ -60,12 +70,16 @@ Security: JWT Bearer Token (except Public Endpoints)
|
|||||||
## 7. Jobs & Patch Deployment
|
## 7. Jobs & Patch Deployment
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/jobs` | List patch jobs |
|
| GET | `/jobs` | List patch jobs (includes `host_names` per job) |
|
||||||
| POST | `/jobs` | Create new patch job |
|
| POST | `/jobs` | Create new patch job |
|
||||||
| GET | `/jobs/{id}` | Get job status/details |
|
| GET | `/jobs/{id}` | Get job status/details |
|
||||||
| POST | `/jobs/{id}/cancel` | Cancel running job |
|
| POST | `/jobs/{id}/cancel` | Cancel running job |
|
||||||
| POST | `/jobs/{id}/rollback` | Rollback completed 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
|
## 8. Maintenance Windows
|
||||||
*Scoped to host.*
|
*Scoped to host.*
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@ -102,13 +116,15 @@ Security: JWT Bearer Token (except Public Endpoints)
|
|||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/settings` | Get system settings |
|
| 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/smtp/test` | Test SMTP configuration |
|
||||||
| POST | `/settings/sso/discover` | Discover OIDC provider config |
|
| POST | `/settings/sso/discover` | Discover OIDC provider config **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||||
| POST | `/settings/sso/test` | Test SSO connection |
|
| 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/azure-sso/test` | Test Azure SSO compatibility |
|
||||||
| POST | `/settings/audit-integrity` | Verify audit log integrity |
|
| 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)
|
## 12. Single Sign-On (SSO)
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
@ -125,6 +141,53 @@ Security: JWT Bearer Token (except Public Endpoints)
|
|||||||
| GET | `/reports/vulnerability` | Generate vulnerability exposure report |
|
| GET | `/reports/vulnerability` | Generate vulnerability exposure report |
|
||||||
| GET | `/reports/audit` | Generate audit trail 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)
|
## 14. Real-Time Updates (WebSocket)
|
||||||
| Method | Endpoint | Description |
|
| 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
|
### 1.3 IP Whitelist Enforcement
|
||||||
| Control | Status | Evidence |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
### 2.5 Azure SSO
|
||||||
| Control | Status | Evidence |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Control | Status | Evidence |
|
||||||
|---------|--------|----------|
|
|---------|--------|----------|
|
||||||
| Infrastructure-managed disk encryption | ✅ Verified | Hardware/infrastructure layer provides encryption at rest; no LUKS in guest OS |
|
| 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
|
### 4.2 Secret Management
|
||||||
| Control | Status | Evidence |
|
| Control | Status | Evidence |
|
||||||
@ -139,9 +160,30 @@ verifying that all mandated security controls are implemented and operational.
|
|||||||
|
|
||||||
## 6. Findings & Recommendations
|
## 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)
|
### Recommendations (Low Priority)
|
||||||
|
|
||||||
@ -171,3 +213,4 @@ All security controls are implemented as specified in the system requirements.
|
|||||||
- [x] Backup encryption supported (GPG)
|
- [x] Backup encryption supported (GPG)
|
||||||
- [x] Azure SSO with PKCE flow
|
- [x] Azure SSO with PKCE flow
|
||||||
- [x] No plaintext credential storage
|
- [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",
|
"name": "patch-manager-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.7",
|
"version": "1.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint src/ --ext .ts,.tsx --max-warnings 0",
|
"lint": "eslint src/ --ext .ts,.tsx --max-warnings 0",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@ -25,6 +27,9 @@
|
|||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.30.0",
|
"@typescript-eslint/eslint-plugin": "^8.30.0",
|
||||||
@ -32,7 +37,9 @@
|
|||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.24.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.3.3"
|
"vite": "^6.3.3",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
CreateHostRequest,
|
CreateHostRequest,
|
||||||
CreateJobRequest,
|
CreateJobRequest,
|
||||||
CreateMaintenanceWindowRequest,
|
CreateMaintenanceWindowRequest,
|
||||||
|
MaintenanceWindow,
|
||||||
UpdateMaintenanceWindowRequest,
|
UpdateMaintenanceWindowRequest,
|
||||||
Certificate,
|
Certificate,
|
||||||
IssuedCert,
|
IssuedCert,
|
||||||
@ -152,6 +153,8 @@ export const hostsApi = {
|
|||||||
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
|
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
|
||||||
get: (id: string) => apiClient.get(`/hosts/${id}`),
|
get: (id: string) => apiClient.get(`/hosts/${id}`),
|
||||||
register: (body: CreateHostRequest) => apiClient.post('/hosts', body),
|
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}`),
|
delete: (id: string) => apiClient.delete(`/hosts/${id}`),
|
||||||
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
|
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
|
||||||
}
|
}
|
||||||
@ -174,6 +177,10 @@ export const patchesApi = {
|
|||||||
|
|
||||||
// ── Maintenance Windows API ───────────────────────────────────────────────────
|
// ── Maintenance Windows API ───────────────────────────────────────────────────
|
||||||
export const maintenanceWindowsApi = {
|
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) =>
|
list: (hostId: string) =>
|
||||||
apiClient.get(`/hosts/${hostId}/maintenance-windows`),
|
apiClient.get(`/hosts/${hostId}/maintenance-windows`),
|
||||||
create: (hostId: string, body: CreateMaintenanceWindowRequest) =>
|
create: (hostId: string, body: CreateMaintenanceWindowRequest) =>
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export default function AppLayout() {
|
|||||||
|
|
||||||
const drawer = (
|
const drawer = (
|
||||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Toolbar sx={{ justifyContent: 'center', py: 1.5 }}>
|
<Toolbar sx={{ justifyContent: 'center', py: 1.5, flexDirection: 'column' }}>
|
||||||
<Typography variant="h6" fontWeight={700} sx={{
|
<Typography variant="h6" fontWeight={700} sx={{
|
||||||
background: 'linear-gradient(135deg, #42A5F5 30%, #26C6DA 100%)',
|
background: 'linear-gradient(135deg, #42A5F5 30%, #26C6DA 100%)',
|
||||||
WebkitBackgroundClip: 'text',
|
WebkitBackgroundClip: 'text',
|
||||||
@ -94,6 +94,9 @@ export default function AppLayout() {
|
|||||||
}}>
|
}}>
|
||||||
🐉 Patch Manager
|
🐉 Patch Manager
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', opacity: 0.7, mt: -0.5 }}>
|
||||||
|
v{__APP_VERSION__}
|
||||||
|
</Typography>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
RestartAlt,
|
RestartAlt,
|
||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
Security as SecurityIcon,
|
Security as SecurityIcon,
|
||||||
|
VerifiedUser as VerifiedUserIcon,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { fleetApi, certsApi } from '../api/client'
|
import { fleetApi, certsApi } from '../api/client'
|
||||||
import type { FleetStatus } from '../types'
|
import type { FleetStatus } from '../types'
|
||||||
@ -237,6 +238,57 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -46,6 +46,9 @@ import {
|
|||||||
Schedule as ScheduleIcon,
|
Schedule as ScheduleIcon,
|
||||||
VpnKey as VpnKeyIcon,
|
VpnKey as VpnKeyIcon,
|
||||||
ContentCopy as CopyIcon,
|
ContentCopy as CopyIcon,
|
||||||
|
VerifiedUser as VerifiedUserIcon,
|
||||||
|
Security as SecurityIcon,
|
||||||
|
WarningAmber as WarningAmberIcon,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client'
|
import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
@ -614,6 +617,46 @@ export default function HostDetailPage() {
|
|||||||
// Hosts list for target_host_id dropdown
|
// Hosts list for target_host_id dropdown
|
||||||
const [hosts, setHosts] = useState<{ id: string; display_name: string; fqdn: string }[]>([])
|
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 ────────────────────────────────────────────────────────────
|
// ── Fetch host ────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id === 'new') { setLoading(false); return }
|
if (id === 'new') { setLoading(false); return }
|
||||||
@ -899,7 +942,39 @@ export default function HostDetailPage() {
|
|||||||
{String(host?.fqdn ?? '')}
|
{String(host?.fqdn ?? '')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<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
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
@ -909,7 +984,7 @@ export default function HostDetailPage() {
|
|||||||
Issue Certificate
|
Issue Certificate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canWrite && certExists && (
|
{!editing && canWrite && certExists && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
@ -920,24 +995,96 @@ export default function HostDetailPage() {
|
|||||||
Re-issue Certificate
|
Re-issue Certificate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{host && Object.entries(host).map(([k, v]) =>
|
{host && (<>
|
||||||
v !== null && v !== '' ? (
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
|
<Typography variant="caption" color="text.secondary" display="block">FQDN</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
{editing ? (
|
||||||
{k.replace(/_/g, ' ').toUpperCase()}
|
<TextField size="small" fullWidth value={editFqdn} onChange={e => setEditFqdn(e.target.value)} />
|
||||||
</Typography>
|
) : (
|
||||||
<Typography variant="body2">{String(v)}</Typography>
|
<Typography variant="body2">{String(host.fqdn)}</Typography>
|
||||||
</Grid>
|
)}
|
||||||
) : null
|
</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>
|
</Grid>
|
||||||
</Paper>
|
</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 ──────────────────────────────────────────── */}
|
{/* ── Maintenance Windows ──────────────────────────────────────────── */}
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user