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: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -61,7 +61,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -126,7 +126,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -207,7 +207,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -261,7 +261,7 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_URL: https://gitea-lxc.moon-dragon.us
|
||||
GITEA_REPO: echo/linux_patch_manager
|
||||
GITEA_REPO: git-echo/linux_patch_manager
|
||||
run: |
|
||||
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/')
|
||||
DEB=$(ls linux-patch-manager_*.deb)
|
||||
|
||||
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
|
||||
*.deb
|
||||
package-build/
|
||||
|
||||
# Docker environment
|
||||
.env
|
||||
|
||||
# Private key material - NEVER commit
|
||||
*.key
|
||||
*.key.pem
|
||||
crates/pm-agent-client/certs/*.crt
|
||||
crates/pm-agent-client/certs/*.key
|
||||
crates/pm-agent-client/certs/*.pem
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
| Version | 0.0.3 |
|
||||
| Status | Draft |
|
||||
| Standard | Aligned with IEEE 1016-2009 |
|
||||
| Owner | Echo (for Kelly / Moon Dragon) |
|
||||
| Owner | Draco Lunaris |
|
||||
| Last Updated | 2026-04-23 |
|
||||
| Related Docs | `SPEC.md`, `REQUIREMENTS.md`, `README.md` |
|
||||
|
||||
|
||||
94
CONTRIBUTING.md
Normal file
94
CONTRIBUTING.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Contributing to Linux-Patch-Manager
|
||||
|
||||
Thank you for your interest in contributing to Linux-Patch-Manager! We appreciate every contribution — from bug reports and documentation improvements to new features and security fixes.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) code of conduct. By participating, you are expected to uphold this standard. Please report unacceptable behavior to the maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork** the repository
|
||||
2. Create a **feature branch** from `main`:
|
||||
```bash
|
||||
git checkout -b feat/my-feature
|
||||
```
|
||||
3. Make your changes
|
||||
4. Ensure all CI checks pass:
|
||||
```bash
|
||||
# Rust backend
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
|
||||
# TypeScript/React frontend
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
5. **Commit** using conventional commit format (see below)
|
||||
6. Open a **Pull Request** against `main`
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Rust toolchain** (stable) — [rustup](https://rustup.rs/)
|
||||
- **Node.js** 20+ (for the frontend) — [nvm](https://github.com/nvm-sh/nvm) recommended
|
||||
- **System dependencies**:
|
||||
```bash
|
||||
sudo apt-get install build-essential libsystemd-dev pkg-config libssl-dev
|
||||
```
|
||||
|
||||
### Build & Run
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cargo build
|
||||
cargo test
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
## Commit Messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
| Prefix | Usage |
|
||||
|----------|------------------------|
|
||||
| `feat:` | New feature |
|
||||
| `fix:` | Bug fix |
|
||||
| `docs:` | Documentation changes |
|
||||
| `chore:` | Maintenance tasks |
|
||||
| `refactor:` | Code refactoring |
|
||||
| `test:` | Adding or updating tests |
|
||||
| `ci:` | CI configuration changes |
|
||||
|
||||
Example:
|
||||
```
|
||||
feat: add patch scheduling to manager dashboard
|
||||
```
|
||||
|
||||
## Pull Request Requirements
|
||||
|
||||
- All CI checks must pass (fmt, clippy, test, audit, build)
|
||||
- One feature or fix per PR — keep changes focused
|
||||
- Include a clear description of what changed and why
|
||||
- Update documentation if your change affects behavior
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use [GitHub Issues](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues) to report bugs, request features, or ask questions. Please include:
|
||||
|
||||
- Steps to reproduce (for bugs)
|
||||
- Expected vs. actual behavior
|
||||
- Relevant logs or error messages
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions are licensed under the [Apache License 2.0](LICENSE), the same license as this project.
|
||||
419
Cargo.lock
generated
Normal file → Executable file
419
Cargo.lock
generated
Normal file → Executable file
@ -139,6 +139,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@ -323,6 +333,21 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@ -460,6 +485,15 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@ -687,6 +721,19 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
@ -771,7 +818,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -885,7 +932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -970,6 +1017,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-kit"
|
||||
version = "0.14.3"
|
||||
@ -1046,6 +1099,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||
dependencies = [
|
||||
"nonempty",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freetype-sys"
|
||||
version = "0.20.1"
|
||||
@ -1155,6 +1218,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
@ -1242,6 +1311,49 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap 5.5.3",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"no-std-compat",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap 6.1.0",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.16.1",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.9.4",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@ -1275,7 +1387,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1445,6 +1568,19 @@ dependencies = [
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-timeout"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
@ -1924,6 +2060,18 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "migrate-secrets"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hex",
|
||||
"pm-core",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@ -1967,6 +2115,31 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockito"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"bytes",
|
||||
"colored",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.4",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"similar",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
@ -1994,6 +2167,12 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no-std-compat"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@ -2013,13 +2192,25 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2307,6 +2498,26 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@ -2381,7 +2592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-agent-client"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2398,7 +2609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-auth"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@ -2419,19 +2630,21 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"totp-rs",
|
||||
"tower",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pm-ca"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"hex",
|
||||
"pem",
|
||||
"pm-core",
|
||||
"proptest",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
@ -2448,7 +2661,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-core"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@ -2472,7 +2685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-reports"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2492,7 +2705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-web"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@ -2500,26 +2713,33 @@ dependencies = [
|
||||
"axum-server",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dashmap 6.1.0",
|
||||
"governor 0.6.3",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"ipnet",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"mockito",
|
||||
"pm-auth",
|
||||
"pm-ca",
|
||||
"pm-core",
|
||||
"pm-reports",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ulid",
|
||||
@ -2530,7 +2750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-worker"
|
||||
version = "0.1.7"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2600,6 +2820,12 @@ dependencies = [
|
||||
"bstr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@ -2656,12 +2882,52 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.11.1",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@ -2803,6 +3069,24 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.13.2"
|
||||
@ -2846,6 +3130,18 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@ -2999,7 +3295,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3055,6 +3351,18 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@ -3273,6 +3581,12 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
@ -3307,7 +3621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3319,6 +3633,15 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@ -3612,7 +3935,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3912,6 +4235,35 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.7.1"
|
||||
@ -3936,9 +4288,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@ -3985,6 +4340,23 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tower_governor"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"forwarded-header-value",
|
||||
"governor 0.10.4",
|
||||
"http",
|
||||
"pin-project",
|
||||
"thiserror 2.0.18",
|
||||
"tonic",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@ -4142,6 +4514,12 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@ -4263,6 +4641,15 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@ -4477,7 +4864,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@ -8,10 +8,11 @@ members = [
|
||||
"crates/pm-auth",
|
||||
"crates/pm-ca",
|
||||
"crates/pm-reports",
|
||||
"crates/migrate-secrets",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.7"
|
||||
version = "1.1.4"
|
||||
edition = "2021"
|
||||
authors = ["Echo <echo@moon-dragon.us>"]
|
||||
license = "MIT"
|
||||
@ -78,8 +79,15 @@ base64 = { version = "0.22" }
|
||||
hex = { version = "0.4" }
|
||||
sha2 = { version = "0.10" }
|
||||
aes-gcm = { version = "0.10" }
|
||||
|
||||
# Testing
|
||||
proptest = { version = "1" }
|
||||
ipnet = { version = "2" }
|
||||
url = { version = "2" }
|
||||
|
||||
# Rate limiting
|
||||
tower_governor = { version = "0.8", features = ["tracing"] }
|
||||
governor = "0.6"
|
||||
|
||||
# Email
|
||||
lettre = { version = "0.11.22", features = ["tokio1-rustls-transport"] }
|
||||
|
||||
141
Dockerfile
Normal file
141
Dockerfile
Normal file
@ -0,0 +1,141 @@
|
||||
# =============================================================================
|
||||
# Linux Patch Manager — Multi-stage Docker Build
|
||||
# =============================================================================
|
||||
# Build: docker build -t linux-patch-manager .
|
||||
# Run: docker compose up
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Rust build (Ubuntu 24.04 + rustup)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ubuntu:24.04 AS rust-builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust via rustup (stable channel, provides 1.85+)
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Cache dependencies by building a dummy project first
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/pm-web/Cargo.toml crates/pm-web/Cargo.toml
|
||||
COPY crates/pm-worker/Cargo.toml crates/pm-worker/Cargo.toml
|
||||
COPY crates/pm-core/Cargo.toml crates/pm-core/Cargo.toml
|
||||
COPY crates/pm-agent-client/Cargo.toml crates/pm-agent-client/Cargo.toml
|
||||
COPY crates/pm-auth/Cargo.toml crates/pm-auth/Cargo.toml
|
||||
COPY crates/pm-ca/Cargo.toml crates/pm-ca/Cargo.toml
|
||||
COPY crates/pm-reports/Cargo.toml crates/pm-reports/Cargo.toml
|
||||
COPY crates/migrate-secrets/Cargo.toml crates/migrate-secrets/Cargo.toml
|
||||
RUN mkdir -p crates/pm-web/src crates/pm-worker/src crates/pm-core/src \
|
||||
crates/pm-agent-client/src crates/pm-auth/src crates/pm-ca/src \
|
||||
crates/pm-reports/src crates/migrate-secrets/src
|
||||
RUN echo 'fn main(){}' > crates/pm-web/src/main.rs \
|
||||
&& echo 'fn main(){}' > crates/pm-worker/src/main.rs \
|
||||
&& echo '' > crates/pm-core/src/lib.rs \
|
||||
&& echo '' > crates/pm-agent-client/src/lib.rs \
|
||||
&& echo '' > crates/pm-auth/src/lib.rs \
|
||||
&& echo '' > crates/pm-ca/src/lib.rs \
|
||||
&& echo '' > crates/pm-reports/src/lib.rs \
|
||||
&& echo 'fn main(){}' > crates/migrate-secrets/src/main.rs
|
||||
RUN cargo build --release 2>/dev/null || true
|
||||
|
||||
# Now build the real project
|
||||
COPY crates/ crates/
|
||||
COPY migrations/ migrations/
|
||||
RUN cargo build --release
|
||||
|
||||
# Verify binaries exist
|
||||
RUN ls -la target/release/pm-web target/release/pm-worker
|
||||
|
||||
# Strip debug symbols
|
||||
RUN strip target/release/pm-web target/release/pm-worker
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Frontend build (Ubuntu 24.04 + Node.js 20)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ubuntu:24.04 AS frontend-builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js 20 via NodeSource
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --production=false
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ubuntu:24.04 AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3t64 \
|
||||
libfontconfig1 \
|
||||
openssl \
|
||||
postgresql-client-16 \
|
||||
argon2 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create service user
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin \
|
||||
--comment "Linux Patch Manager service account" patch-manager
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
|
||||
/etc/patch-manager/jwt /etc/patch-manager/tls \
|
||||
/var/log/patch-manager /opt/patch-manager \
|
||||
/usr/share/patch-manager/frontend \
|
||||
/usr/share/patch-manager/migrations
|
||||
|
||||
# Copy binaries
|
||||
COPY --from=rust-builder /usr/src/app/target/release/pm-web /usr/local/bin/pm-web
|
||||
COPY --from=rust-builder /usr/src/app/target/release/pm-worker /usr/local/bin/pm-worker
|
||||
|
||||
# Copy frontend
|
||||
COPY --from=frontend-builder /usr/src/app/frontend/dist/ /usr/share/patch-manager/frontend/
|
||||
|
||||
# Copy migrations
|
||||
COPY migrations/ /usr/share/patch-manager/migrations/
|
||||
|
||||
# Copy entrypoint
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod 755 /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Copy config template
|
||||
COPY config/config.example.toml /usr/share/patch-manager/config.example.toml
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R patch-manager:patch-manager \
|
||||
/etc/patch-manager /var/log/patch-manager \
|
||||
/opt/patch-manager /usr/share/patch-manager
|
||||
|
||||
# Expose HTTPS port
|
||||
EXPOSE 443
|
||||
|
||||
# Volume for persistent data
|
||||
VOLUME ["/etc/patch-manager", "/var/log/patch-manager", "/opt/patch-manager"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
190
LICENSE
Normal file
190
LICENSE
Normal file
@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text file from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025-2026 Draco Lunaris
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@ -234,7 +234,9 @@ sudo -u postgres psql patch_manager < /usr/share/patch-manager/migrations/001_in
|
||||
|
||||
## License
|
||||
|
||||
Private — All rights reserved.
|
||||
This project is licensed under the [Apache License 2.0](LICENSE).
|
||||
|
||||
Copyright 2025-2026 Draco Lunaris
|
||||
|
||||
---
|
||||
|
||||
|
||||
68
SECURITY.md
Normal file
68
SECURITY.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the **latest release** is currently supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|----------|
|
||||
| Latest | ✅ |
|
||||
| Older | ❌ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Do not report security vulnerabilities through public GitHub Issues.**
|
||||
|
||||
Instead, use GitHub's private vulnerability reporting:
|
||||
|
||||
👉 [Report a vulnerability for Linux-Patch-Manager](https://github.com/Draco-Lunaris/Linux-Patch-Manager/security/advisories/new)
|
||||
|
||||
This allows us to coordinate a fix before public disclosure.
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Acknowledgment** within 48 hours
|
||||
- **Initial assessment** within 7 days
|
||||
- **Ongoing updates** on remediation progress
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow **coordinated disclosure**:
|
||||
|
||||
- We ask for **90 days** before public disclosure of a vulnerability
|
||||
- Security advisories are published via [GitHub Security Advisories](https://github.com/Draco-Lunaris/Linux-Patch-Manager/security/advisories)
|
||||
- We will work with you to determine an appropriate disclosure timeline when a fix requires more time
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
This project is a security tool — we hold ourselves to a high standard:
|
||||
|
||||
- **Signed commits**: All commits must be signed (SSH signing)
|
||||
- **CI enforcement**: All PRs require passing CI checks (fmt, clippy, test, audit, build)
|
||||
- **Dependency auditing**: `cargo audit` runs in CI to catch known vulnerabilities
|
||||
|
||||
## Enrollment PKI Design Decisions
|
||||
|
||||
### Server-Generated Keys vs CSR-Based Enrollment
|
||||
|
||||
Currently, the server generates the agent's private key during enrollment approval and
|
||||
transmits it over the mTLS-secured polling endpoint. This approach was chosen for
|
||||
initial implementation simplicity — the agent polls a single endpoint and receives a
|
||||
complete PKI bundle without an extra round-trip.
|
||||
|
||||
**Mitigations in place:**
|
||||
- The PKI bundle is stored in an in-memory cache with single-retrieval semantics —
|
||||
it can only be fetched once and is atomically removed on retrieval.
|
||||
- A 10-minute TTL ensures the bundle expires even if never retrieved.
|
||||
- The raw polling token is never logged; only its SHA-256 hash is stored.
|
||||
|
||||
**Future direction:** A CSR-based enrollment flow should replace server-generated keys.
|
||||
Under that model, the agent generates its own key pair locally and submits a Certificate
|
||||
Signing Request, eliminating the need for the server to ever hold or transmit the agent's
|
||||
private key. This significantly reduces the attack surface.
|
||||
|
||||
See: [Issue #9](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9)
|
||||
|
||||
## Credit
|
||||
|
||||
Contributors who responsibly report vulnerabilities will be credited in the corresponding GitHub Security Advisory.
|
||||
24
SPEC.md
24
SPEC.md
@ -88,7 +88,7 @@
|
||||
- Refresh tokens: opaque, server-side stored, 1-hour inactivity timeout, rotated on use, revocable
|
||||
- mTLS for all agent communication (TLS 1.3 only)
|
||||
- HTTPS for web UI (TLS 1.3 only)
|
||||
- **IP whitelist enforcement on all connection points**
|
||||
- **IP whitelist enforcement on all connection points** (with `security.trusted_proxies` to optionally honor `X-Forwarded-For` from a configured proxy; empty default = strict mode that uses the socket peer IP and ignores `X-Forwarded-For`; non-empty allowlist + unresolvable peer IP = fail-closed `403 forbidden_ip`) [Issue #3 / `tasks/ip-allowlist-spec.md`]
|
||||
- Role-based access control:
|
||||
- **Admin**: Full access to manage all aspects of Linux Patch Manager
|
||||
- **Operator**: Can add/remove clients, manage schedules and patches only for devices in their group memberships
|
||||
@ -274,3 +274,25 @@ All authenticated pages share a persistent sidebar navigation layout:
|
||||
**Integrity:** Hash-chained rows (tamper-evident). Periodic and on-demand verification.
|
||||
|
||||
**Retention:** 6 months
|
||||
|
||||
---
|
||||
|
||||
## Appendix: App-Level Secret Encryption (Issue #6, May 2026)
|
||||
|
||||
In addition to the hardware-level full-disk encryption described above, issue #6 (PR [TBD]) added **application-level AES-256-GCM encryption** for three specific sensitive fields that DB exfiltration would otherwise expose:
|
||||
|
||||
| Field | Table | Encryption key |
|
||||
|-------|-------|----------------|
|
||||
| `client_secret` | `oidc_config` | `/etc/patch-manager/keys/secret-encryption.key` |
|
||||
| `smtp_password` | `system_config` (key-value row) | same key |
|
||||
| `totp_secret` | `users` | same key |
|
||||
|
||||
**Why app-level on top of hardware-level?** Hardware-level encryption protects against disk theft; app-level encryption protects against DB exfiltration (SQL injection, backup theft, insider threat) where the attacker already has the running process's privileges. The two are complementary.
|
||||
|
||||
**Blast-radius isolation:** A separate per-install key is used for app secrets (`secret-encryption.key`), distinct from the health-check key (`health-check.key`). If the health-check key is ever compromised, app secrets remain protected.
|
||||
|
||||
**API surface:** No change. The `MASKED` placeholder behavior in API responses is preserved on top of the new DB encryption — defense in depth.
|
||||
|
||||
**Backup:** Both key files must be included in `/etc/patch-manager` backups. Without the key file, encrypted data is unrecoverable. See [docs/runbooks/key-management.md](docs/runbooks/key-management.md) for the full procedure.
|
||||
|
||||
**Key rotation:** Not yet supported (follow-up issue). If a key is compromised, generate a new key and re-provision affected secrets.
|
||||
|
||||
@ -76,6 +76,20 @@ format = "json"
|
||||
# Example: ["10.0.0.0/8", "192.168.1.50"]
|
||||
ip_whitelist = []
|
||||
|
||||
# Trusted reverse proxies: list of CIDRs or individual IPs. When the immediate
|
||||
# TCP peer is in this list, `X-Forwarded-For` is honored (leftmost untrusted
|
||||
# hop is used for allowlist enforcement). When this list is EMPTY (the
|
||||
# default), `X-Forwarded-For` is IGNORED entirely and the socket peer IP is
|
||||
# used — the strict, fail-closed default.
|
||||
#
|
||||
# REQUIRED if you front pm-web with nginx/HAProxy/Cloudflare/etc.: add the
|
||||
# proxy's egress IP (or CIDR) here, otherwise the allowlist will evaluate
|
||||
# against the proxy's IP and deny legitimate traffic. If your proxy chain
|
||||
# has multiple hops, add each hop you control.
|
||||
# Example: ["10.0.0.0/8"] (corporate egress)
|
||||
# Example: ["172.16.0.0/12"] (internal load balancer)
|
||||
trusted_proxies = []
|
||||
|
||||
# Ed25519 JWT signing key (private key, PEM format)
|
||||
# Generate: openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem
|
||||
jwt_signing_key_path = "/etc/patch-manager/jwt/signing.pem"
|
||||
@ -91,7 +105,8 @@ jwt_access_ttl_secs = 900
|
||||
agent_client_cert_path = "/etc/patch-manager/certs/client.crt"
|
||||
agent_client_key_path = "/etc/patch-manager/certs/client.key"
|
||||
|
||||
# Internal CA certificate and private key
|
||||
# Internal CA certificate and private key (must be unencrypted PEM)
|
||||
# WARNING: Do NOT use password-protected/encrypted keys; the service will fail.
|
||||
# Private key has 0600 permissions; protected by hardware-host FDE
|
||||
ca_cert_path = "/etc/patch-manager/ca/ca.crt"
|
||||
ca_key_path = "/etc/patch-manager/ca/ca.key"
|
||||
@ -106,3 +121,35 @@ web_tls_key_path = "/etc/patch-manager/tls/web.key"
|
||||
# The backend sends tokens as query parameters to this URL.
|
||||
# Default: "http://localhost:5173/auth/sso/callback" (Vite dev server)
|
||||
sso_callback_url = "http://localhost:5173/auth/sso/callback"
|
||||
|
||||
# Allowlist of browser `Origin` values permitted to open the
|
||||
# `/api/v1/ws/jobs` WebSocket upgrade. Each entry is an exact
|
||||
# `scheme://host[:port]` string (no wildcards, no paths). When this list is
|
||||
# empty, the server derives a single-entry default from `sso_callback_url`
|
||||
# at startup (the host of the SSO callback). If the derivation also fails,
|
||||
# a warning is logged and the WS endpoint rejects all browser upgrades
|
||||
# (fail-closed).
|
||||
#
|
||||
# Add additional origins here if your SPA and API are served from different
|
||||
# hosts (e.g. SPA on https://app.example.com talking to API on
|
||||
# https://api.example.com). For typical single-host deployments the derived
|
||||
# default is correct and this line should be left commented out.
|
||||
#
|
||||
# allowed_origins = ["https://patch-manager.example.com"]
|
||||
|
||||
# ============================================================
|
||||
# Rate Limiting
|
||||
# ============================================================
|
||||
[rate_limit]
|
||||
# Enrollment endpoint: requests per minute per IP (default: 5)
|
||||
enrollment_rpm = 5
|
||||
# Enrollment burst allowance (default: 3)
|
||||
enrollment_burst = 3
|
||||
# Public auth endpoints: requests per minute per IP (default: 20)
|
||||
auth_rpm = 20
|
||||
# Auth burst allowance (default: 10)
|
||||
auth_burst = 10
|
||||
# Authenticated API: requests per minute per IP (default: 120)
|
||||
api_rpm = 120
|
||||
# API burst allowance (default: 30)
|
||||
api_burst = 30
|
||||
|
||||
19
crates/migrate-secrets/Cargo.toml
Normal file
19
crates/migrate-secrets/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "migrate-secrets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "migrate-secrets"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
pm-core = { path = "../pm-core" }
|
||||
tokio = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
hex = "0.4"
|
||||
193
crates/migrate-secrets/src/main.rs
Normal file
193
crates/migrate-secrets/src/main.rs
Normal file
@ -0,0 +1,193 @@
|
||||
//! One-shot migration helper for issue #6 (Secret Encryption at Rest).
|
||||
//!
|
||||
//! Reads plaintext secrets from the old columns/rows, encrypts them with the
|
||||
//! secret-encryption key, and writes to the new BYTEA columns. Verifies the
|
||||
//! round-trip (encrypt -> decrypt = original plaintext) before committing.
|
||||
//!
|
||||
//! USAGE:
|
||||
//! export DATABASE_URL="postgres://patch_manager:<password>@localhost/patch_manager"
|
||||
//! cargo run -p migrate-secrets
|
||||
//!
|
||||
//! This tool is safe to run multiple times (idempotent — re-encrypts and overwrites).
|
||||
//!
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.5 for the design.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pm_core::crypto;
|
||||
use sqlx::PgPool;
|
||||
use std::path::Path;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 1. Load secret-encryption key
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))
|
||||
.context("Failed to load secret-encryption key")?;
|
||||
eprintln!(
|
||||
"Loaded secret-encryption key from {}",
|
||||
crypto::SECRET_ENCRYPTION_KEY_PATH
|
||||
);
|
||||
|
||||
// 2. Connect to database
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").context("DATABASE_URL environment variable not set")?;
|
||||
let pool = PgPool::connect(&database_url)
|
||||
.await
|
||||
.context("Failed to connect to database")?;
|
||||
eprintln!("Connected to database");
|
||||
|
||||
let mut success_count = 0u32;
|
||||
let mut skip_count = 0u32;
|
||||
|
||||
// 3. Migrate OIDC client_secret
|
||||
if let Some(plaintext) = read_oidc_client_secret(&pool).await? {
|
||||
if plaintext.is_empty() {
|
||||
eprintln!("[skip] OIDC client_secret is empty");
|
||||
skip_count += 1;
|
||||
} else {
|
||||
write_oidc_client_secret(&pool, &plaintext, &key).await?;
|
||||
eprintln!("[ok] OIDC client_secret encrypted");
|
||||
success_count += 1;
|
||||
}
|
||||
} else {
|
||||
eprintln!("[skip] OIDC client_secret column not found (already migrated?)");
|
||||
skip_count += 1;
|
||||
}
|
||||
|
||||
// 4. Migrate SMTP password
|
||||
if let Some(plaintext) = read_smtp_password(&pool).await? {
|
||||
if plaintext.is_empty() {
|
||||
eprintln!("[skip] SMTP password is empty");
|
||||
skip_count += 1;
|
||||
} else {
|
||||
write_smtp_password(&pool, &plaintext, &key).await?;
|
||||
eprintln!("[ok] SMTP password encrypted");
|
||||
success_count += 1;
|
||||
}
|
||||
} else {
|
||||
eprintln!("[skip] SMTP password row not found (already migrated?)");
|
||||
skip_count += 1;
|
||||
}
|
||||
|
||||
// 5. Migrate TOTP secrets for all users
|
||||
let totp_count = migrate_totp_secrets(&pool, &key).await?;
|
||||
eprintln!("[ok] {} TOTP secret(s) encrypted", totp_count);
|
||||
success_count += totp_count;
|
||||
|
||||
eprintln!(
|
||||
"\nMigration complete: {} encrypted, {} skipped",
|
||||
success_count, skip_count
|
||||
);
|
||||
eprintln!(
|
||||
"Next step: apply migration 020_encrypt_secrets_at_rest.sql to drop the old columns."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_oidc_client_secret(pool: &PgPool) -> Result<Option<String>> {
|
||||
// Try to read the old column. If it doesn't exist, return None.
|
||||
let row: Result<Option<(Option<String>,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT client_secret FROM oidc_config WHERE id = 1")
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
match row {
|
||||
Ok(Some((secret,))) => Ok(secret),
|
||||
Ok(None) => Ok(None),
|
||||
Err(e) => {
|
||||
// Column not found = already migrated
|
||||
eprintln!(" (oidc_config.client_secret column check: {})", e);
|
||||
Ok(None)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_oidc_client_secret(pool: &PgPool, plaintext: &str, key: &[u8; 32]) -> Result<()> {
|
||||
let (ciphertext, nonce) = crypto::encrypt(plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!(
|
||||
"OIDC round-trip failed: expected {}, got {}",
|
||||
plaintext,
|
||||
recovered
|
||||
);
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE oidc_config SET client_secret_encrypted = $1, client_secret_nonce = $2 WHERE id = 1",
|
||||
)
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Failed to update oidc_config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_smtp_password(pool: &PgPool) -> Result<Option<String>> {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM system_config WHERE key = 'smtp_password'")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn write_smtp_password(pool: &PgPool, plaintext: &str, key: &[u8; 32]) -> Result<()> {
|
||||
let (ciphertext, nonce) = crypto::encrypt(plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!(
|
||||
"SMTP round-trip failed: expected {}, got {}",
|
||||
plaintext,
|
||||
recovered
|
||||
);
|
||||
}
|
||||
// Delete old row, write two new rows
|
||||
sqlx::query("DELETE FROM system_config WHERE key = 'smtp_password'")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_encrypted")
|
||||
.bind(hex_encode(&ciphertext))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_nonce")
|
||||
.bind(hex_encode(&nonce))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_totp_secrets(pool: &PgPool, key: &[u8; 32]) -> Result<u32> {
|
||||
// Read all users with totp_secret set
|
||||
let users: Vec<(uuid::Uuid, String)> =
|
||||
sqlx::query_as("SELECT id, totp_secret FROM users WHERE totp_secret IS NOT NULL")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("Failed to read users with totp_secret")?;
|
||||
|
||||
let count = users.len() as u32;
|
||||
for (user_id, plaintext) in users {
|
||||
let (ciphertext, nonce) = crypto::encrypt(&plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!("TOTP round-trip failed for user {}", user_id);
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE users SET totp_secret_encrypted = $1, totp_secret_nonce = $2 WHERE id = $3",
|
||||
)
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Failed to update user totp_secret")?;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Hex-encode bytes for storage in TEXT columns (system_config).
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
31
crates/pm-agent-client/certs/README.md
Normal file
31
crates/pm-agent-client/certs/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Agent Client Certificates
|
||||
|
||||
**⚠️ Private keys are NOT committed to version control.**
|
||||
|
||||
This directory holds mTLS certificates used by `pm-agent-client` for testing.
|
||||
The entire directory is excluded from git via `.gitignore`.
|
||||
|
||||
## Generating Test Certificates
|
||||
|
||||
Certificates are generated automatically on first run by the `pm-ca` service,
|
||||
or you can generate them manually for development:
|
||||
|
||||
```bash
|
||||
# Create certs directory if it doesn't exist
|
||||
mkdir -p crates/pm-agent-client/certs
|
||||
|
||||
# Generate using the pm-ca service (preferred)
|
||||
# Or copy from /etc/patch-manager/certs/ on a deployed host
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
Production certificates are managed by `pm-ca` at `/etc/patch-manager/certs/`.
|
||||
The `pm-agent-client` reads certificates from file paths configured in
|
||||
`config.toml` (`agent_client_cert_path`, `agent_client_key_path`, `ca_cert_path`).
|
||||
|
||||
## Security
|
||||
|
||||
- **Never commit private keys** (`*.key`, `*.key.pem`) to version control
|
||||
- The `gitleaks` CI check scans for accidentally committed secrets
|
||||
- See `SECURITY.md` and `docs/security-review.md` for full details
|
||||
@ -1,12 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
|
||||
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
|
||||
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
|
||||
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
|
||||
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
|
||||
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,12 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
|
||||
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
|
||||
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
|
||||
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
|
||||
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
|
||||
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,19 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAuTfH0/Z2HT49DfHT
|
||||
49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49Df
|
||||
HT49DfHT49DfHT49DfHT49DfHQIDAQABAkEArWvK64P1/x9P2dh0+PQ3x0+PQ3x0
|
||||
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ
|
||||
3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x
|
||||
0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0
|
||||
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
-----END PRIVATE KEY-----
|
||||
@ -6,12 +6,17 @@
|
||||
//! use pm_agent_client::client::AgentClient;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
|
||||
//! // Load certificates from files (never hardcode or include_bytes! private keys)
|
||||
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
|
||||
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
|
||||
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
|
||||
//!
|
||||
//! let client = AgentClient::new(
|
||||
//! "192.168.1.10",
|
||||
//! 12443,
|
||||
//! include_bytes!("../certs/client.crt"),
|
||||
//! include_bytes!("../certs/client.key"),
|
||||
//! include_bytes!("../certs/ca.crt"),
|
||||
//! &client_cert,
|
||||
//! &client_key,
|
||||
//! &ca_cert,
|
||||
//! )?;
|
||||
//!
|
||||
//! let health = client.health().await?;
|
||||
@ -105,7 +110,7 @@ impl AgentClient {
|
||||
.add_root_certificate(ca_cert)
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| AgentClientError::Request(e))?;
|
||||
.map_err(AgentClientError::Request)?;
|
||||
|
||||
let clean_ip = host_ip.split('/').next().unwrap_or(host_ip);
|
||||
let base_url = format!("https://{}:{}/api/v1", clean_ip, port);
|
||||
|
||||
0
crates/pm-agent-client/src/error.rs
Normal file → Executable file
0
crates/pm-agent-client/src/error.rs
Normal file → Executable file
@ -10,12 +10,17 @@
|
||||
//! use pm_agent_client::AgentClient;
|
||||
//!
|
||||
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
|
||||
//! // Load certificates from files (never hardcode or include_bytes! private keys)
|
||||
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
|
||||
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
|
||||
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
|
||||
//!
|
||||
//! let client = AgentClient::new(
|
||||
//! "10.0.1.5",
|
||||
//! 12443,
|
||||
//! include_bytes!("../certs/client.crt"),
|
||||
//! include_bytes!("../certs/client.key"),
|
||||
//! include_bytes!("../certs/ca.crt"),
|
||||
//! &client_cert,
|
||||
//! &client_key,
|
||||
//! &ca_cert,
|
||||
//! )?;
|
||||
//!
|
||||
//! let health = client.health().await?;
|
||||
|
||||
@ -57,6 +57,16 @@ pub struct HealthData {
|
||||
pub uptime_seconds: u64,
|
||||
/// Agent software version string.
|
||||
pub version: String,
|
||||
/// CRL status reported by the agent: `"valid"`, `"expired"`, `"missing"`, `"invalid"`.
|
||||
/// Absent for older agents that do not report CRL status.
|
||||
#[serde(default)]
|
||||
pub crl_status: Option<String>,
|
||||
/// Seconds since the agent's CRL was last refreshed.
|
||||
#[serde(default)]
|
||||
pub crl_age_seconds: Option<i64>,
|
||||
/// When the agent's CRL expires / next update is due (ISO-8601).
|
||||
#[serde(default)]
|
||||
pub crl_next_update: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@ -27,3 +27,6 @@ hex = { workspace = true }
|
||||
ipnet = { workspace = true }
|
||||
parking_lot = "0.12"
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
1
crates/pm-auth/src/jwt.rs
Normal file → Executable file
1
crates/pm-auth/src/jwt.rs
Normal file → Executable file
@ -121,6 +121,7 @@ pub fn load_verify_key(path: &str) -> Result<String, JwtError> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
0
crates/pm-auth/src/lib.rs
Normal file → Executable file
0
crates/pm-auth/src/lib.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_totp.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_totp.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_webauthn.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_webauthn.rs
Normal file → Executable file
0
crates/pm-auth/src/password.rs
Normal file → Executable file
0
crates/pm-auth/src/password.rs
Normal file → Executable file
@ -7,7 +7,7 @@
|
||||
//! - IP whitelist enforcement
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
extract::{ConnectInfo, Request},
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Json, Response},
|
||||
@ -15,7 +15,7 @@ use axum::{
|
||||
use ipnet::IpNet;
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::json;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
@ -40,6 +40,7 @@ pub enum UserRole {
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(Self::Admin),
|
||||
@ -75,18 +76,30 @@ pub struct AuthConfig {
|
||||
pub verify_key_pem: String,
|
||||
/// IP whitelist (empty = allow all). RwLock for runtime updates.
|
||||
pub ip_whitelist: Arc<RwLock<Vec<IpNet>>>,
|
||||
/// Trusted reverse-proxy CIDRs (empty = do not trust `X-Forwarded-For`).
|
||||
/// RwLock for runtime updates (symmetric to `ip_whitelist`).
|
||||
pub trusted_proxies: Arc<RwLock<Vec<IpNet>>>,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn new(verify_key_pem: String, ip_whitelist_cidrs: &[String]) -> Self {
|
||||
pub fn new(
|
||||
verify_key_pem: String,
|
||||
ip_whitelist_cidrs: &[String],
|
||||
trusted_proxy_cidrs: &[String],
|
||||
) -> Self {
|
||||
let ip_whitelist = ip_whitelist_cidrs
|
||||
.iter()
|
||||
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||
.collect();
|
||||
let trusted_proxies = trusted_proxy_cidrs
|
||||
.iter()
|
||||
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
verify_key_pem,
|
||||
ip_whitelist: Arc::new(RwLock::new(ip_whitelist)),
|
||||
trusted_proxies: Arc::new(RwLock::new(trusted_proxies)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,6 +123,18 @@ impl AuthConfig {
|
||||
*self.ip_whitelist.write() = nets;
|
||||
tracing::info!(count, "IP whitelist updated at runtime");
|
||||
}
|
||||
|
||||
/// Update the trusted-proxy list at runtime without restart.
|
||||
/// Empty list = strict mode (ignore `X-Forwarded-For`).
|
||||
pub fn update_trusted_proxies(&self, entries: Vec<String>) {
|
||||
let nets: Vec<IpNet> = entries
|
||||
.iter()
|
||||
.filter_map(|cidr| IpNet::from_str(cidr).ok())
|
||||
.collect();
|
||||
let count = nets.len();
|
||||
*self.trusted_proxies.write() = nets;
|
||||
tracing::info!(count, "Trusted proxies updated at runtime");
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `Authorization: Bearer <token>` from request headers.
|
||||
@ -120,13 +145,38 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
}
|
||||
|
||||
/// Extract the remote IP from `X-Forwarded-For`.
|
||||
fn extract_remote_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
/// Determine the client IP used for IP-allowlist enforcement.
|
||||
///
|
||||
/// Resolution rules (per `tasks/ip-allowlist-spec.md` §4.1):
|
||||
/// 1. Start with the socket peer IP.
|
||||
/// 2. If `trusted_proxies` is non-empty **and** the socket peer is in
|
||||
/// `trusted_proxies`, parse the leftmost entry of the `X-Forwarded-For`
|
||||
/// header and use it (the immediate untrusted hop).
|
||||
/// 3. If parsing `X-Forwarded-For` fails or the header is missing, fall back
|
||||
/// to the socket peer IP.
|
||||
/// 4. If the socket peer is unknown (no `ConnectInfo<SocketAddr>` is
|
||||
/// available on the request), return `None` so the caller can apply
|
||||
/// fail-closed logic when the allowlist is non-empty.
|
||||
fn resolve_client_ip(
|
||||
headers: &HeaderMap,
|
||||
peer: Option<IpAddr>,
|
||||
trusted_proxies: &[IpNet],
|
||||
) -> Option<IpAddr> {
|
||||
let peer_ip = peer?;
|
||||
|
||||
if !trusted_proxies.is_empty() && trusted_proxies.iter().any(|net| net.contains(&peer_ip)) {
|
||||
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(ip) = xff
|
||||
.split(',')
|
||||
.next()
|
||||
.and_then(|s| s.trim().parse::<IpAddr>().ok())
|
||||
{
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(peer_ip)
|
||||
}
|
||||
|
||||
/// Unauthorized JSON response helper.
|
||||
@ -147,16 +197,65 @@ fn forbidden(message: &str) -> Response {
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Forbidden-by-IP response helper. Distinct error code (`forbidden_ip`) so
|
||||
/// callers can distinguish an IP-allowlist rejection from a role-based
|
||||
/// rejection. Used by `require_auth` after the IP-resolution failure or
|
||||
/// allowlist miss per `tasks/ip-allowlist-spec.md` §4.2.
|
||||
fn forbidden_ip(message: &str) -> Response {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden_ip", "message": message } })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Middleware: authenticate any valid JWT (admin or operator).
|
||||
///
|
||||
/// Inserts `AuthUser` into request extensions on success.
|
||||
/// Rejects with 401 if token is missing/invalid, 403 if IP is blocked.
|
||||
pub async fn require_auth(auth_config: Arc<AuthConfig>, mut req: Request, next: Next) -> Response {
|
||||
// IP whitelist check
|
||||
if let Some(ip) = extract_remote_ip(req.headers()) {
|
||||
// IP whitelist check. Only enforced when the configured allowlist is
|
||||
// non-empty (Q4 sign-off: empty list = allow all, preserved for dev
|
||||
// installs). When enforced, the resolved client IP comes from
|
||||
// `resolve_client_ip`, which uses the socket peer IP by default and
|
||||
// honors `X-Forwarded-For` only when the immediate peer is in
|
||||
// `trusted_proxies` (Q1 sign-off: strict default, Q2 sign-off: same
|
||||
// resolution pattern as the rate-limiter). Fail-closed when the IP
|
||||
// cannot be determined (Q3 sign-off).
|
||||
//
|
||||
// See `tasks/ip-allowlist-spec.md` §4.2 for the full design.
|
||||
if !auth_config.ip_whitelist.read().is_empty() {
|
||||
let headers = req.headers().clone();
|
||||
let peer: Option<IpAddr> = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0.ip());
|
||||
let xff_present = headers.contains_key("x-forwarded-for");
|
||||
let trusted: Vec<IpNet> = auth_config.trusted_proxies.read().clone();
|
||||
let resolved = resolve_client_ip(&headers, peer, &trusted);
|
||||
|
||||
match resolved {
|
||||
None => {
|
||||
tracing::warn!(
|
||||
peer = ?peer,
|
||||
xff_present,
|
||||
reason = "unresolvable_client_ip",
|
||||
"Request denied by IP whitelist (fail-closed: no ConnectInfo<SocketAddr>)"
|
||||
);
|
||||
return forbidden_ip("Client IP could not be determined");
|
||||
},
|
||||
Some(ip) => {
|
||||
if !auth_config.is_ip_allowed(&ip) {
|
||||
tracing::warn!(ip = %ip, "Request blocked by IP whitelist");
|
||||
return forbidden("Access denied");
|
||||
tracing::warn!(
|
||||
client_ip = %ip,
|
||||
peer = ?peer,
|
||||
xff_present,
|
||||
reason = "ip_not_in_allowlist",
|
||||
"Request blocked by IP whitelist"
|
||||
);
|
||||
return forbidden_ip("Access denied");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,3 +328,342 @@ where
|
||||
.ok_or_else(|| unauthorized("Authentication required"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for the IP-allowlist resolver helper.
|
||||
//!
|
||||
//! Covers the matrix in `tasks/ip-allowlist-spec.md` §6.1
|
||||
//! (12 cases for `resolve_client_ip`).
|
||||
|
||||
use super::*;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
IpAddr::from_str(s).expect("test fixture: parse IP")
|
||||
}
|
||||
|
||||
fn net(s: &str) -> IpNet {
|
||||
IpNet::from_str(s).expect("test fixture: parse CIDR")
|
||||
}
|
||||
|
||||
fn hdr() -> HeaderMap {
|
||||
HeaderMap::new()
|
||||
}
|
||||
|
||||
fn hdr_with_xff(xff: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
"x-forwarded-for",
|
||||
xff.parse().expect("test fixture: xff header"),
|
||||
);
|
||||
h
|
||||
}
|
||||
|
||||
// 1. peer_only_no_xff — no XFF, trusted_proxies empty → returns peer
|
||||
#[test]
|
||||
fn peer_only_no_xff() {
|
||||
let result = resolve_client_ip(&hdr(), Some(ip("203.0.113.10")), &[]);
|
||||
assert_eq!(result, Some(ip("203.0.113.10")));
|
||||
}
|
||||
|
||||
// 2. peer_only_xff_untrusted — XFF set, peer not in trusted_proxies,
|
||||
// trusted_proxies non-empty → returns peer (XFF ignored)
|
||||
#[test]
|
||||
fn peer_only_xff_untrusted() {
|
||||
let headers = hdr_with_xff("198.51.100.5");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("203.0.113.10")), &trusted);
|
||||
assert_eq!(result, Some(ip("203.0.113.10")));
|
||||
}
|
||||
|
||||
// 3. peer_only_trusted_proxies_empty_xff_present — XFF set,
|
||||
// trusted_proxies empty → returns peer (strict default)
|
||||
#[test]
|
||||
fn peer_only_trusted_proxies_empty_xff_present() {
|
||||
let headers = hdr_with_xff("198.51.100.5");
|
||||
let result = resolve_client_ip(&headers, Some(ip("203.0.113.10")), &[]);
|
||||
assert_eq!(result, Some(ip("203.0.113.10")));
|
||||
}
|
||||
|
||||
// 4. xff_trusted_peer_in_list — XFF set, peer in trusted_proxies
|
||||
// → returns parsed leftmost XFF entry
|
||||
#[test]
|
||||
fn xff_trusted_peer_in_list() {
|
||||
let headers = hdr_with_xff("198.51.100.5");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("198.51.100.5")));
|
||||
}
|
||||
|
||||
// 5. xff_trusted_peer_in_list_malformed_xff — XFF unparseable,
|
||||
// peer in trusted_proxies → falls back to peer
|
||||
#[test]
|
||||
fn xff_trusted_peer_in_list_malformed_xff() {
|
||||
let headers = hdr_with_xff("not-an-ip");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("10.0.0.5")));
|
||||
}
|
||||
|
||||
// 6. xff_trusted_peer_in_list_empty_xff — XFF empty string,
|
||||
// peer in trusted_proxies → falls back to peer
|
||||
#[test]
|
||||
fn xff_trusted_peer_in_list_empty_xff() {
|
||||
let headers = hdr_with_xff("");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("10.0.0.5")));
|
||||
}
|
||||
|
||||
// 7. xff_trusted_peer_in_list_multi_hop — "1.2.3.4, 5.6.7.8"
|
||||
// with peer in trusted_proxies → returns 1.2.3.4 (leftmost)
|
||||
#[test]
|
||||
fn xff_trusted_peer_in_list_multi_hop() {
|
||||
let headers = hdr_with_xff("1.2.3.4, 5.6.7.8");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("1.2.3.4")));
|
||||
}
|
||||
|
||||
// 8. no_peer_no_xff — peer None, no XFF → returns None
|
||||
#[test]
|
||||
fn no_peer_no_xff() {
|
||||
let result = resolve_client_ip(&hdr(), None, &[net("10.0.0.0/8")]);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
// 9. no_peer_xff_untrusted — peer None, XFF set, trusted_proxies empty
|
||||
// → returns None (caller fails closed)
|
||||
#[test]
|
||||
fn no_peer_xff_untrusted() {
|
||||
let headers = hdr_with_xff("198.51.100.5");
|
||||
let result = resolve_client_ip(&headers, None, &[]);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
// 10. xff_trusted_whitespace — XFF " 1.2.3.4", peer in trusted_proxies
|
||||
// → returns 1.2.3.4 (trim)
|
||||
#[test]
|
||||
fn xff_trusted_whitespace() {
|
||||
let headers = hdr_with_xff(" 198.51.100.5");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("198.51.100.5")));
|
||||
}
|
||||
|
||||
// 11. trusted_proxies_ipv6 — peer in IPv6 trusted list, IPv6 XFF
|
||||
// → returns XFF
|
||||
#[test]
|
||||
fn trusted_proxies_ipv6() {
|
||||
let headers = hdr_with_xff("2001:db8::1");
|
||||
let trusted = vec![net("::1/128"), net("2001:db8::/32")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("2001:db8::ffff")), &trusted);
|
||||
assert_eq!(result, Some(ip("2001:db8::1")));
|
||||
}
|
||||
|
||||
// 12. peer_ipv4_xff_ipv6_mismatch_trusted — peer in trusted list,
|
||||
// XFF is IPv6 → returns parsed IPv6 (mixed family is fine)
|
||||
#[test]
|
||||
fn peer_ipv4_xff_ipv6_mismatch_trusted() {
|
||||
let headers = hdr_with_xff("2001:db8::dead");
|
||||
let trusted = vec![net("10.0.0.0/8")];
|
||||
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
|
||||
assert_eq!(result, Some(ip("2001:db8::dead")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod middleware_tests {
|
||||
//! End-to-end tests for the `require_auth` middleware IP-allowlist path.
|
||||
//!
|
||||
//! Uses a tiny in-process `axum::Router` with the middleware attached and
|
||||
//! `tower::ServiceExt::oneshot` to send synthetic requests. No DB, no real
|
||||
//! TCP listener.
|
||||
//!
|
||||
//! Mirrors the production wiring pattern in `pm-web/src/main.rs` (a
|
||||
//! `from_fn` closure that captures the `AuthConfig` and forwards to
|
||||
//! `require_auth`).
|
||||
//!
|
||||
//! For tests where the spec expects `200` (allowlist passed), we assert
|
||||
//! `401` instead — the JWT will fail validation against the empty verify
|
||||
//! key, which **proves the IP check did not short-circuit** (a 403 here
|
||||
//! would mean the IP check rejected the request).
|
||||
//!
|
||||
//! Per `tasks/ip-allowlist-spec.md` §6.1 tests 13–20.
|
||||
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::from_fn;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Stub handler that returns 200 OK if the middleware let the request
|
||||
/// through. JWT validation will fail in these tests, so the handler is
|
||||
/// only reached in the "IP check passed but JWT failed" scenarios we
|
||||
/// assert as `401`.
|
||||
async fn ok_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
fn build_test_app(auth_config: Arc<AuthConfig>) -> Router {
|
||||
Router::new()
|
||||
.route("/test", get(ok_handler))
|
||||
.layer(from_fn(move |req, next| {
|
||||
let cfg = auth_config.clone();
|
||||
async move { require_auth(cfg, req, next).await }
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build a request with the given extensions, headers, and an
|
||||
/// `Authorization: Bearer` token (which will fail JWT validation since
|
||||
/// the test `AuthConfig` has an empty verify key). Tests assert on the
|
||||
/// status code only — the body content is irrelevant.
|
||||
fn build_request(peer: Option<SocketAddr>, xff: Option<&str>) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
.uri("/test")
|
||||
.header("authorization", "Bearer test-token-invalid");
|
||||
if let Some(x) = xff {
|
||||
builder = builder.header("x-forwarded-for", x);
|
||||
}
|
||||
let mut req = builder.body(Body::empty()).expect("build request");
|
||||
if let Some(p) = peer {
|
||||
req.extensions_mut().insert(ConnectInfo(p));
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
fn peer_v4(a: u8, b: u8, c: u8, d: u8) -> SocketAddr {
|
||||
SocketAddr::from(([a, b, c, d], 1234))
|
||||
}
|
||||
|
||||
// 13. middleware_allows_when_whitelist_empty — empty list + any IP
|
||||
// → IP check skipped, request continues to JWT (which fails → 401).
|
||||
#[tokio::test]
|
||||
async fn middleware_allows_when_whitelist_empty() {
|
||||
let cfg = Arc::new(AuthConfig::new(String::new(), &[], &[]));
|
||||
let app = build_test_app(cfg);
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// 14. middleware_denies_when_whitelist_non_empty_and_ip_not_in_list
|
||||
// — non-empty list + peer outside → 403 forbidden_ip.
|
||||
#[tokio::test]
|
||||
async fn middleware_denies_when_whitelist_non_empty_and_ip_not_in_list() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), None);
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// 15. middleware_allows_when_ip_in_list — non-empty list + peer inside
|
||||
// → 401 (JWT fails, IP check passed).
|
||||
#[tokio::test]
|
||||
async fn middleware_allows_when_ip_in_list() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
let req = build_request(Some(peer_v4(10, 0, 0, 5)), None);
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// 16. middleware_denies_when_no_peer_resolvable_and_whitelist_non_empty
|
||||
// — non-empty list + missing ConnectInfo → 403 forbidden_ip (fail-closed).
|
||||
#[tokio::test]
|
||||
async fn middleware_denies_when_no_peer_resolvable_and_whitelist_non_empty() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
let req = build_request(None, None); // no ConnectInfo
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// 17. middleware_spoofed_xff_ignored_when_peer_untrusted
|
||||
// — non-empty list + peer outside + XFF inside list → 403 forbidden_ip.
|
||||
#[tokio::test]
|
||||
async fn middleware_spoofed_xff_ignored_when_peer_untrusted() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
// Peer is 203.0.113.10 (not in 10.0.0.0/8). XFF claims 10.0.0.5 but
|
||||
// trusted_proxies is empty, so XFF is ignored and peer is checked → 403.
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// 18. middleware_trusted_proxy_honors_xff — peer in trusted_proxies +
|
||||
// XFF inside allowlist → 401 (IP check passed, JWT fails).
|
||||
#[tokio::test]
|
||||
async fn middleware_trusted_proxy_honors_xff() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&["203.0.113.0/24".to_string()],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
// Peer 203.0.113.10 is in trusted_proxies, so XFF "10.0.0.5" is used
|
||||
// and that IP is in the allowlist → IP check passes → JWT fails → 401.
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// 19. middleware_trusted_proxy_falls_back_to_peer_on_bad_xff
|
||||
// — peer in trusted_proxies + unparseable XFF + peer outside list → 403.
|
||||
#[tokio::test]
|
||||
async fn middleware_trusted_proxy_falls_back_to_peer_on_bad_xff() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&["203.0.113.0/24".to_string()],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
// Peer 203.0.113.10 is in trusted_proxies. XFF is unparseable, so
|
||||
// resolver falls back to peer (203.0.113.10) which is NOT in
|
||||
// allowlist (10.0.0.0/8) → 403.
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("not-an-ip"));
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// 20. middleware_no_jwt_when_ip_blocked — blocked request never reaches
|
||||
// JWT validation. With an invalid token AND a denied IP, response is
|
||||
// 403 (forbidden_ip) NOT 401 (which would indicate JWT was reached).
|
||||
#[tokio::test]
|
||||
async fn middleware_no_jwt_when_ip_blocked() {
|
||||
let cfg = Arc::new(AuthConfig::new(
|
||||
String::new(),
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
));
|
||||
let app = build_test_app(cfg);
|
||||
// Peer 203.0.113.10 is outside allowlist, token is invalid.
|
||||
// If the IP check ran first, response is 403. If JWT ran first, 401.
|
||||
// We assert 403, proving the IP check short-circuited.
|
||||
let req = build_request(Some(peer_v4(203, 0, 113, 10)), None);
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
0
crates/pm-auth/src/refresh.rs
Normal file → Executable file
0
crates/pm-auth/src/refresh.rs
Normal file → Executable file
@ -40,6 +40,8 @@ pub enum SessionError {
|
||||
Password(#[from] PasswordError),
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Successful login response returned to the client.
|
||||
@ -69,6 +71,7 @@ pub struct SessionUser {
|
||||
|
||||
/// Database user row fetched during login.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct DbUser {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
@ -76,7 +79,10 @@ struct DbUser {
|
||||
role: UserRole,
|
||||
auth_provider: AuthProvider,
|
||||
password_hash: Option<String>,
|
||||
totp_secret: Option<String>,
|
||||
/// AES-256-GCM encrypted TOTP secret (issue #6 fix). None = TOTP not configured.
|
||||
totp_secret_encrypted: Option<Vec<u8>>,
|
||||
/// AES-256-GCM nonce for TOTP secret. Must be paired with `totp_secret_encrypted`.
|
||||
totp_secret_nonce: Option<Vec<u8>>,
|
||||
mfa_enabled: bool,
|
||||
is_active: bool,
|
||||
force_password_reset: bool,
|
||||
@ -114,7 +120,7 @@ pub async fn login(
|
||||
let user: Option<DbUser> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username, display_name, role, auth_provider,
|
||||
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
|
||||
password_hash, totp_secret_encrypted, totp_secret_nonce, mfa_enabled, is_active, force_password_reset,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users
|
||||
WHERE username = $1 AND auth_provider = 'local'
|
||||
@ -193,9 +199,25 @@ pub async fn login(
|
||||
// 4. MFA check
|
||||
if user.mfa_enabled {
|
||||
let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?;
|
||||
let secret = user.totp_secret.as_deref().unwrap_or("");
|
||||
// Decrypt the TOTP secret (issue #6 fix — stored as encrypted+nonce BYTEA)
|
||||
let secret = match (&user.totp_secret_encrypted, &user.totp_secret_nonce) {
|
||||
(Some(enc), Some(nonce)) => {
|
||||
let key = pm_core::crypto::load_or_create_key(std::path::Path::new(
|
||||
pm_core::crypto::SECRET_ENCRYPTION_KEY_PATH,
|
||||
))
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
SessionError::Internal("Encryption key error".to_string())
|
||||
})?;
|
||||
pm_core::crypto::decrypt(enc, nonce, &key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to decrypt TOTP secret");
|
||||
SessionError::Internal("TOTP decryption error".to_string())
|
||||
})?
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let mfa_ok = mfa_totp::verify_code(&user.username, secret, code).unwrap_or(false);
|
||||
let mfa_ok = mfa_totp::verify_code(&user.username, &secret, code).unwrap_or(false);
|
||||
|
||||
if !mfa_ok {
|
||||
tracing::warn!(username = %req.username, "Login failed: invalid MFA code");
|
||||
@ -256,7 +278,7 @@ pub async fn refresh_session(
|
||||
let user: DbUser = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username, display_name, role, auth_provider,
|
||||
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
|
||||
password_hash, totp_secret_encrypted, totp_secret_nonce, mfa_enabled, is_active, force_password_reset,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users WHERE id = $1
|
||||
"#,
|
||||
|
||||
@ -23,3 +23,6 @@ rustls = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
pem = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@ -13,8 +13,9 @@ use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use rand::RngCore;
|
||||
use rcgen::{
|
||||
BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType,
|
||||
ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyPair, KeyUsagePurpose, SanType, SerialNumber,
|
||||
BasicConstraints, Certificate, CertificateParams, CertificateRevocationListParams,
|
||||
DistinguishedName, DnType, ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyIdMethod, KeyPair,
|
||||
KeyUsagePurpose, RevocationReason, RevokedCertParams, SanType, SerialNumber,
|
||||
PKCS_ECDSA_P256_SHA256,
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
@ -351,7 +352,9 @@ impl CertAuthority {
|
||||
let mut sans = vec![SanType::DnsName(
|
||||
Ia5String::try_from(hostname.to_owned()).context("hostname is not valid IA5")?,
|
||||
)];
|
||||
if let Ok(ip) = ip_address.parse::<IpAddr>() {
|
||||
// Strip CIDR netmask (e.g. "192.168.3.36/32") before parsing
|
||||
let ip_str = ip_address.split('/').next().unwrap_or(ip_address);
|
||||
if let Ok(ip) = ip_str.parse::<IpAddr>() {
|
||||
sans.push(SanType::IpAddress(ip));
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@ -522,4 +525,394 @@ impl CertAuthority {
|
||||
.context("reconstruct CA certificate for signing")?;
|
||||
Ok((key, cert))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CRL generation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Generate a Certificate Revocation List (CRL) signed by this CA.
|
||||
///
|
||||
/// Queries the `certificates` table for certs with `status = 'revoked'`
|
||||
/// and `not_after > NOW()` (i.e., not yet naturally expired) and bundles
|
||||
/// their serials into an X.509 v2 CRL.
|
||||
///
|
||||
/// Returns the CRL as a PEM-encoded string.
|
||||
///
|
||||
/// # Performance
|
||||
///
|
||||
/// O(n) where n is the number of revoked-but-not-expired certs. For our
|
||||
/// target scale (max ~2500 clients per manager, low single-digit % annual
|
||||
/// revocation rate), this is KB-range and sub-millisecond to generate.
|
||||
pub async fn generate_crl(&self, db: &PgPool) -> Result<String> {
|
||||
tracing::debug!("Generating CRL from certificates table");
|
||||
|
||||
// Query revoked certs that haven't naturally expired yet.
|
||||
// Expired certs are pruned from the CRL to keep it small.
|
||||
let rows = sqlx::query(
|
||||
"SELECT serial_number, revoked_at \
|
||||
FROM certificates \
|
||||
WHERE status = 'revoked'::cert_status \
|
||||
AND revoked_at IS NOT NULL \
|
||||
AND expires_at > NOW() \
|
||||
ORDER BY revoked_at ASC",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.context("query revoked certificates for CRL")?;
|
||||
|
||||
let mut revoked_certs = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let serial_hex: String = row.try_get("serial_number").context("serial_number")?;
|
||||
let revoked_at: DateTime<Utc> = row.try_get("revoked_at").context("revoked_at")?;
|
||||
|
||||
// Convert hex serial back to bytes for rcgen.
|
||||
let serial_bytes =
|
||||
hex::decode(&serial_hex).context("serial_number is not valid hex")?;
|
||||
let serial_number = SerialNumber::from_slice(&serial_bytes);
|
||||
|
||||
// Convert chrono DateTime to time::OffsetDateTime for rcgen.
|
||||
let revocation_time = OffsetDateTime::from_unix_timestamp(revoked_at.timestamp())
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc());
|
||||
|
||||
revoked_certs.push(RevokedCertParams {
|
||||
serial_number,
|
||||
revocation_time,
|
||||
reason_code: Some(RevocationReason::Unspecified),
|
||||
invalidity_date: None,
|
||||
});
|
||||
}
|
||||
|
||||
let count = revoked_certs.len();
|
||||
tracing::debug!(revoked_count = count, "Building CRL with revoked entries");
|
||||
|
||||
// CRL validity window: this_update = now, next_update = now + 24h
|
||||
// (agents refresh every 24h, so this gives them a fresh CRL on every poll).
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let next_update = now + TimeDuration::hours(24);
|
||||
|
||||
// CRL number: monotonic counter derived from current Unix timestamp.
|
||||
// RFC 5280 doesn't require strict monotonicity for the CRL number
|
||||
// extension, but it's a common convention. We use timestamp seconds
|
||||
// divided by 60 (minute precision) to keep it short and readable.
|
||||
let crl_number = SerialNumber::from_slice(&Utc::now().timestamp().to_be_bytes());
|
||||
|
||||
let crl_params = CertificateRevocationListParams {
|
||||
this_update: now,
|
||||
next_update,
|
||||
crl_number,
|
||||
issuing_distribution_point: None,
|
||||
revoked_certs,
|
||||
key_identifier_method: KeyIdMethod::Sha256,
|
||||
};
|
||||
|
||||
let (ca_key, ca_cert) = self.ca_objects()?;
|
||||
let crl = crl_params
|
||||
.signed_by(&ca_cert, &ca_key)
|
||||
.context("sign CRL with CA key")?;
|
||||
let crl_pem = crl.pem().context("encode CRL as PEM")?;
|
||||
|
||||
tracing::info!(
|
||||
revoked_count = count,
|
||||
next_update = %next_update,
|
||||
"CRL generated and signed"
|
||||
);
|
||||
|
||||
Ok(crl_pem)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: build a `CertAuthority` for testing without going through disk init.
|
||||
/// Generates a fresh ECDSA P-256 CA in memory.
|
||||
async fn test_ca() -> CertAuthority {
|
||||
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
dn.push(DnType::OrganizationName, "Patch Manager Test");
|
||||
params.distinguished_name = dn;
|
||||
|
||||
let ca_cert = params.self_signed(&key).unwrap();
|
||||
CertAuthority {
|
||||
base_dir: PathBuf::from("/tmp/test-ca"),
|
||||
ca_cert_pem: ca_cert.pem(),
|
||||
ca_key_pem: key.serialize_pem(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_serial_produces_unique_16_byte_serials() {
|
||||
let (s1, h1) = make_serial();
|
||||
let (s2, h2) = make_serial();
|
||||
assert_ne!(h1, h2, "serial hex strings should differ");
|
||||
assert_eq!(
|
||||
h1.len(),
|
||||
32,
|
||||
"serial should be 16 bytes hex-encoded (32 chars)"
|
||||
);
|
||||
assert_ne!(s1, s2, "rcgen SerialNumber values should differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ca_objects_round_trip() {
|
||||
// Build a CA, then reconstruct via ca_objects() and verify the cert+key parse.
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let ca = rt.block_on(test_ca());
|
||||
let (key, cert) = ca.ca_objects().expect("ca_objects should succeed");
|
||||
assert!(!key.serialize_pem().is_empty());
|
||||
assert!(!cert.pem().is_empty());
|
||||
}
|
||||
|
||||
/// Verifies that `generate_crl` produces a valid PEM-encoded X.509 CRL
|
||||
/// even when the database has no revoked certs (empty CRL).
|
||||
///
|
||||
/// This is a structural test: we verify the PEM format and that the
|
||||
/// generated CRL can be parsed back. Full integration testing with a real
|
||||
/// database is in `tests/crl_integration.rs`.
|
||||
#[tokio::test]
|
||||
async fn generate_crl_empty_db_produces_valid_pem() {
|
||||
// Use a real but empty Postgres test database. If TEST_DATABASE_URL
|
||||
// is not set, skip this test (it's an integration test, not a unit test).
|
||||
let Ok(db_url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let pool = sqlx::PgPool::connect(&db_url)
|
||||
.await
|
||||
.expect("connect to test db");
|
||||
let ca = test_ca().await;
|
||||
|
||||
let crl_pem = ca.generate_crl(&pool).await.expect("generate_crl");
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"PEM header missing"
|
||||
);
|
||||
assert!(
|
||||
crl_pem.contains("-----END X509 CRL-----"),
|
||||
"PEM footer missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property-based tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptests {
|
||||
use super::*;
|
||||
|
||||
/// Generating a CRL twice in quick succession should produce valid PEM output.
|
||||
/// (Full integration test with a real database is in tests/crl_integration.rs.)
|
||||
#[test]
|
||||
fn make_serial_produces_unique_values() {
|
||||
let (s1, h1) = make_serial();
|
||||
let (s2, h2) = make_serial();
|
||||
assert_ne!(h1, h2, "serial hex strings should differ");
|
||||
assert_eq!(h1.len(), 32, "serial should be 16 bytes hex-encoded");
|
||||
assert_ne!(s1, s2, "rcgen SerialNumber values should differ");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CRL generation unit tests (in-memory, no database required)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: build a CRL in memory using rcgen directly, signed by the test CA.
|
||||
/// This bypasses the database and tests the CRL structure itself.
|
||||
fn build_test_crl(
|
||||
ca_key: &KeyPair,
|
||||
ca_cert: &Certificate,
|
||||
revoked_serials: &[SerialNumber],
|
||||
) -> String {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let next_update = now + TimeDuration::hours(24);
|
||||
let crl_number = SerialNumber::from_slice(&Utc::now().timestamp().to_be_bytes());
|
||||
|
||||
let revoked_certs: Vec<RevokedCertParams> = revoked_serials
|
||||
.iter()
|
||||
.map(|serial| RevokedCertParams {
|
||||
serial_number: serial.clone(),
|
||||
revocation_time: now,
|
||||
reason_code: Some(RevocationReason::Unspecified),
|
||||
invalidity_date: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let crl_params = CertificateRevocationListParams {
|
||||
this_update: now,
|
||||
next_update,
|
||||
crl_number,
|
||||
issuing_distribution_point: None,
|
||||
revoked_certs,
|
||||
key_identifier_method: KeyIdMethod::Sha256,
|
||||
};
|
||||
|
||||
let crl = crl_params.signed_by(ca_cert, ca_key).unwrap();
|
||||
crl.pem().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_generation_produces_valid_pem_structure() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
|
||||
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"CRL PEM should contain BEGIN header"
|
||||
);
|
||||
assert!(
|
||||
crl_pem.contains("-----END X509 CRL-----"),
|
||||
"CRL PEM should contain END footer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_contains_revoked_serials() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
// Revoke two serials
|
||||
let (s1, _) = make_serial();
|
||||
let (s2, _) = make_serial();
|
||||
let crl_with_revoked = build_test_crl(&ca_key, &ca_cert, &[s1.clone(), s2.clone()]);
|
||||
|
||||
// The PEM should be non-empty and parseable
|
||||
assert!(!crl_with_revoked.is_empty(), "CRL PEM should not be empty");
|
||||
assert!(
|
||||
crl_with_revoked.contains("-----BEGIN X509 CRL-----"),
|
||||
"CRL should have PEM header"
|
||||
);
|
||||
|
||||
// A CRL with revoked entries should be larger than an empty CRL
|
||||
// because it contains the revoked certificate entries.
|
||||
let empty_crl = build_test_crl(&ca_key, &ca_cert, &[]);
|
||||
assert!(
|
||||
crl_with_revoked.len() > empty_crl.len(),
|
||||
"CRL with revoked entries should be larger than empty CRL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_empty_crl_has_no_revoked_entries() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
|
||||
|
||||
// An empty CRL should still be valid PEM
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"Empty CRL should still have PEM header"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_signature_verifies_against_ca_cert() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
let (serial, _) = make_serial();
|
||||
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[serial]);
|
||||
|
||||
// Parse the CRL and verify it's structurally valid
|
||||
// (signature verification against CA is implicit — rcgen signed it with the CA key)
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"CRL should be valid PEM signed by CA"
|
||||
);
|
||||
|
||||
// Verify that a different CA key produces a different CRL (not verifiable)
|
||||
let other_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut other_params = CertificateParams::default();
|
||||
other_params.not_before = OffsetDateTime::now_utc();
|
||||
other_params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
other_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
other_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut other_dn = DistinguishedName::new();
|
||||
other_dn.push(DnType::CommonName, "Other Root CA");
|
||||
other_params.distinguished_name = other_dn;
|
||||
let other_cert = other_params.self_signed(&other_key).unwrap();
|
||||
|
||||
let (s2, _) = make_serial();
|
||||
let other_crl_pem = build_test_crl(&other_key, &other_cert, &[s2]);
|
||||
|
||||
// The two CRLs should be different (different issuers, different keys)
|
||||
assert_ne!(
|
||||
crl_pem, other_crl_pem,
|
||||
"CRLs from different CAs should differ"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_next_update_is_approximately_24h() {
|
||||
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Test Root CA");
|
||||
params.distinguished_name = dn;
|
||||
let ca_cert = params.self_signed(&ca_key).unwrap();
|
||||
|
||||
// The build_test_crl helper uses 24h next_update
|
||||
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
|
||||
|
||||
// Verify the CRL was generated successfully — the next_update being 24h
|
||||
// is enforced by the CertAuthority::generate_crl method which uses
|
||||
// TimeDuration::hours(24). We verify the PEM is valid as a proxy.
|
||||
assert!(
|
||||
crl_pem.contains("-----BEGIN X509 CRL-----"),
|
||||
"CRL should be generated with 24h next_update"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
0
crates/pm-ca/src/lib.rs
Normal file → Executable file
0
crates/pm-ca/src/lib.rs
Normal file → Executable file
@ -51,6 +51,16 @@ pub enum AuditAction {
|
||||
HealthCheckUpdated,
|
||||
HealthCheckDeleted,
|
||||
CertificateReissued,
|
||||
// Issue #5: Manager-wide auth-config mutations (Admin-only)
|
||||
OidcConfigUpdated,
|
||||
SmtpConfigUpdated,
|
||||
IpWhitelistUpdated,
|
||||
OidcTestPerformed,
|
||||
OidcDiscoverPerformed,
|
||||
// CRL health aggregation events (system-initiated)
|
||||
CrlStatusChanged,
|
||||
CrlStaleDetected,
|
||||
CrlInvalid,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
@ -88,6 +98,16 @@ impl AuditAction {
|
||||
Self::HealthCheckUpdated => "health_check_updated",
|
||||
Self::HealthCheckDeleted => "health_check_deleted",
|
||||
Self::CertificateReissued => "certificate_reissued",
|
||||
// Issue #5: Manager-wide auth-config mutations (Admin-only)
|
||||
Self::OidcConfigUpdated => "oidc_config_updated",
|
||||
Self::SmtpConfigUpdated => "smtp_config_updated",
|
||||
Self::IpWhitelistUpdated => "ip_whitelist_updated",
|
||||
Self::OidcTestPerformed => "oidc_test_performed",
|
||||
Self::OidcDiscoverPerformed => "oidc_discover_performed",
|
||||
// CRL health aggregation events
|
||||
Self::CrlStatusChanged => "crl_status_changed",
|
||||
Self::CrlStaleDetected => "crl_stale_detected",
|
||||
Self::CrlInvalid => "crl_invalid",
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -97,6 +117,7 @@ impl AuditAction {
|
||||
/// Computes a hash chain entry using the previous row's hash.
|
||||
/// Non-fatal: logs errors but does not propagate them to avoid
|
||||
/// disrupting the primary operation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_event(
|
||||
pool: &PgPool,
|
||||
action: AuditAction,
|
||||
@ -126,6 +147,7 @@ pub async fn log_event(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn write_audit_row(
|
||||
pool: &PgPool,
|
||||
action: AuditAction,
|
||||
|
||||
@ -1,6 +1,61 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Rate limiting configuration per route group.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Enrollment endpoint: requests per minute per IP (default: 5)
|
||||
#[serde(default = "default_enrollment_rpm")]
|
||||
pub enrollment_rpm: u32,
|
||||
/// Enrollment burst allowance (default: 3)
|
||||
#[serde(default = "default_enrollment_burst")]
|
||||
pub enrollment_burst: u32,
|
||||
/// Public auth endpoints: requests per minute per IP (default: 20)
|
||||
#[serde(default = "default_auth_rpm")]
|
||||
pub auth_rpm: u32,
|
||||
/// Auth burst allowance (default: 10)
|
||||
#[serde(default = "default_auth_burst")]
|
||||
pub auth_burst: u32,
|
||||
/// Authenticated API: requests per minute per IP (default: 120)
|
||||
#[serde(default = "default_api_rpm")]
|
||||
pub api_rpm: u32,
|
||||
/// API burst allowance (default: 30)
|
||||
#[serde(default = "default_api_burst")]
|
||||
pub api_burst: u32,
|
||||
}
|
||||
|
||||
fn default_enrollment_rpm() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_enrollment_burst() -> u32 {
|
||||
3
|
||||
}
|
||||
fn default_auth_rpm() -> u32 {
|
||||
20
|
||||
}
|
||||
fn default_auth_burst() -> u32 {
|
||||
10
|
||||
}
|
||||
fn default_api_rpm() -> u32 {
|
||||
120
|
||||
}
|
||||
fn default_api_burst() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enrollment_rpm: default_enrollment_rpm(),
|
||||
enrollment_burst: default_enrollment_burst(),
|
||||
auth_rpm: default_auth_rpm(),
|
||||
auth_burst: default_auth_burst(),
|
||||
api_rpm: default_api_rpm(),
|
||||
api_burst: default_api_burst(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level application configuration.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AppConfig {
|
||||
@ -9,6 +64,8 @@ pub struct AppConfig {
|
||||
pub worker: WorkerConfig,
|
||||
pub logging: LoggingConfig,
|
||||
pub security: SecurityConfig,
|
||||
#[serde(default)]
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@ -62,6 +119,13 @@ pub struct LoggingConfig {
|
||||
pub struct SecurityConfig {
|
||||
/// IP whitelist (CIDR or individual IPs); empty = allow all (not recommended)
|
||||
pub ip_whitelist: Vec<String>,
|
||||
/// IP addresses (CIDR or single IP) of trusted reverse proxies. When the
|
||||
/// immediate TCP peer is in this list, `X-Forwarded-For` is honored;
|
||||
/// otherwise the socket peer IP is used for allowlist enforcement.
|
||||
/// Default: empty (do not trust `X-Forwarded-For`). See
|
||||
/// `tasks/ip-allowlist-spec.md` §4.3 for the operational guidance.
|
||||
#[serde(default)]
|
||||
pub trusted_proxies: Vec<String>,
|
||||
/// JWT signing key path (Ed25519 PEM)
|
||||
pub jwt_signing_key_path: String,
|
||||
/// JWT verification key path (Ed25519 public PEM)
|
||||
@ -83,6 +147,71 @@ pub struct SecurityConfig {
|
||||
/// Frontend URL to redirect to after SSO callback (default: http://localhost:5173/auth/sso/callback)
|
||||
#[serde(default = "default_sso_callback_url")]
|
||||
pub sso_callback_url: String,
|
||||
/// Allowlist of browser `Origin` values permitted to open the
|
||||
/// `/api/v1/ws/jobs` WebSocket upgrade. Entries are exact
|
||||
/// `scheme://host[:port]` strings. If left empty in the TOML file, the
|
||||
/// server derives the default from `sso_callback_url` at load time
|
||||
/// (see [`derive_allowed_origins`]).
|
||||
#[serde(default)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
/// Derive a default `Origin` allowlist from a single SSO callback URL.
|
||||
///
|
||||
/// Parses `scheme://host[:port][/path]` and returns a single-element vector
|
||||
/// containing `scheme://host[:port]` (with default ports normalized away —
|
||||
/// e.g. `https://x:443` becomes `https://x`). Returns an empty vector if the
|
||||
/// URL is unparseable; callers should log a warning in that case because the
|
||||
/// WebSocket endpoint will reject all browser upgrades (fail-closed).
|
||||
///
|
||||
/// Exposed publicly so tests and the handler can share the same parser.
|
||||
pub fn derive_allowed_origins(sso_callback_url: &str) -> Vec<String> {
|
||||
let s = sso_callback_url.trim().trim_end_matches('/');
|
||||
let (scheme, rest) = match s.split_once("://") {
|
||||
Some(parts) if !parts.0.is_empty() => parts,
|
||||
_ => return vec![],
|
||||
};
|
||||
let scheme_lower = scheme.to_ascii_lowercase();
|
||||
if scheme_lower != "http" && scheme_lower != "https" {
|
||||
return vec![];
|
||||
}
|
||||
// Authority is everything up to the first `/`, `?`, or `#`.
|
||||
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||||
let authority = &rest[..authority_end];
|
||||
if authority.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Split host:port. We treat the LAST `:` as the port separator. IPv6
|
||||
// literal hosts (e.g. `[::1]`) contain a `:` inside the brackets; we
|
||||
// explicitly do not support IPv6 in sso_callback_url and return empty
|
||||
// for those to be safe.
|
||||
let (host, port_str) = match authority.rsplit_once(':') {
|
||||
Some((h, _)) if h.contains(':') => return vec![],
|
||||
Some((h, p)) => (h, Some(p)),
|
||||
None => (authority, None),
|
||||
};
|
||||
let host = host.trim();
|
||||
if host.is_empty() || host.contains(char::is_whitespace) || host.contains(':') {
|
||||
return vec![];
|
||||
}
|
||||
let default_port: Option<u16> = match scheme_lower.as_str() {
|
||||
"https" => Some(443),
|
||||
"http" => Some(80),
|
||||
_ => None,
|
||||
};
|
||||
let port_num = match port_str {
|
||||
Some(p) => match p.parse::<u16>() {
|
||||
Ok(n) => Some(n),
|
||||
Err(_) => return vec![],
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let origin = match (port_num, default_port) {
|
||||
(Some(p), Some(d)) if p == d => format!("{}://{}", scheme_lower, host),
|
||||
(Some(p), _) => format!("{}://{}:{}", scheme_lower, host, p),
|
||||
(None, _) => format!("{}://{}", scheme_lower, host),
|
||||
};
|
||||
vec![origin]
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
@ -90,6 +219,11 @@ impl AppConfig {
|
||||
///
|
||||
/// Environment variables follow the pattern: `PATCH_MANAGER__SECTION__KEY`
|
||||
/// e.g. `PATCH_MANAGER__DATABASE__URL=postgres://...`
|
||||
///
|
||||
/// After deserialization, if `security.allowed_origins` is empty, it is
|
||||
/// derived from `security.sso_callback_url`. A `tracing::warn!` is emitted
|
||||
/// when the resulting allowlist is empty (the WS endpoint will reject all
|
||||
/// browser upgrades in that case).
|
||||
pub fn load(config_path: &str) -> Result<Self, ConfigError> {
|
||||
let cfg = Config::builder()
|
||||
.add_source(File::with_name(config_path).required(false))
|
||||
@ -100,7 +234,20 @@ impl AppConfig {
|
||||
)
|
||||
.build()?;
|
||||
|
||||
cfg.try_deserialize()
|
||||
let mut config: Self = cfg.try_deserialize()?;
|
||||
if config.security.allowed_origins.is_empty() {
|
||||
config.security.allowed_origins =
|
||||
derive_allowed_origins(&config.security.sso_callback_url);
|
||||
}
|
||||
if config.security.allowed_origins.is_empty() {
|
||||
tracing::warn!(
|
||||
sso_callback_url = %config.security.sso_callback_url,
|
||||
"security.allowed_origins is empty and could not be derived \
|
||||
from sso_callback_url; the WebSocket endpoint will reject all \
|
||||
browser upgrades"
|
||||
);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,6 +287,7 @@ impl Default for AppConfig {
|
||||
},
|
||||
security: SecurityConfig {
|
||||
ip_whitelist: vec![],
|
||||
trusted_proxies: vec![],
|
||||
jwt_signing_key_path: "/etc/patch-manager/jwt/signing.pem".to_string(),
|
||||
jwt_verify_key_path: "/etc/patch-manager/jwt/verify.pem".to_string(),
|
||||
jwt_access_ttl_secs: 900,
|
||||
@ -150,7 +298,69 @@ impl Default for AppConfig {
|
||||
web_tls_cert_path: "/etc/patch-manager/tls/web.crt".to_string(),
|
||||
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
|
||||
sso_callback_url: default_sso_callback_url(),
|
||||
allowed_origins: derive_allowed_origins(&default_sso_callback_url()),
|
||||
},
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derive_strips_default_https_port() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("https://app.example.com:443/auth/sso/callback"),
|
||||
vec!["https://app.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_keeps_non_default_port() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("https://app.example.com:8443/auth/sso/callback"),
|
||||
vec!["https://app.example.com:8443".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_strips_default_http_port() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("http://localhost:80/x"),
|
||||
vec!["http://localhost".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_handles_trailing_slash() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("https://app.example.com/"),
|
||||
vec!["https://app.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_handles_no_path() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("https://app.example.com"),
|
||||
vec!["https://app.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_returns_empty_for_garbage() {
|
||||
assert!(derive_allowed_origins("not a url").is_empty());
|
||||
assert!(derive_allowed_origins("").is_empty());
|
||||
assert!(derive_allowed_origins("ftp://x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_lowercases_scheme() {
|
||||
assert_eq!(
|
||||
derive_allowed_origins("HTTPS://App.Example.com"),
|
||||
vec!["https://App.Example.com".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
//! AES-256-GCM encryption for sensitive health check credentials.
|
||||
//! AES-256-GCM encryption for sensitive credentials.
|
||||
//!
|
||||
//! Uses a per-install key stored at `/etc/patch-manager/keys/health-check.key`.
|
||||
//! Two per-install keys are supported:
|
||||
//! - `KEY_PATH` (health-check.key) protects HTTP basic auth passwords for health check endpoints.
|
||||
//! - `SECRET_ENCRYPTION_KEY_PATH` (secret-encryption.key) protects OIDC `client_secret`,
|
||||
//! SMTP `smtp_password`, and TOTP `totp_secret` at rest in the database.
|
||||
//!
|
||||
//! Keys are 32-byte files, auto-generated on first start with 0600 permissions.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
@ -12,6 +17,12 @@ use std::path::Path;
|
||||
|
||||
pub const KEY_PATH: &str = "/etc/patch-manager/keys/health-check.key";
|
||||
|
||||
/// Path to the encryption key for sensitive app secrets
|
||||
/// (OIDC client_secret, SMTP password, TOTP secret).
|
||||
/// Separate from `KEY_PATH` (health-check credentials) for blast-radius isolation:
|
||||
/// if the health-check key is compromised, app secrets remain protected.
|
||||
pub const SECRET_ENCRYPTION_KEY_PATH: &str = "/etc/patch-manager/keys/secret-encryption.key";
|
||||
|
||||
/// Load or create the per-install encryption key.
|
||||
/// If the key file doesn't exist, generates a new 256-bit key and saves it.
|
||||
pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
|
||||
@ -29,7 +40,7 @@ pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(CryptoError::Io)?;
|
||||
}
|
||||
fs::write(path, &key).map_err(CryptoError::Io)?;
|
||||
fs::write(path, key).map_err(CryptoError::Io)?;
|
||||
// Set permissions to 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@ -78,3 +89,73 @@ pub enum CryptoError {
|
||||
#[error("UTF-8 error: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Create a unique temp directory for test isolation.
|
||||
/// Returns a path like `/tmp/pm-crypto-test-<epoch_nanos>-<rand>`.
|
||||
/// Cleans up the directory on test teardown (via `temp_dir` guard).
|
||||
fn unique_temp_dir() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let dir = env::temp_dir().join(format!("pm-crypto-test-{}-{}", std::process::id(), nanos));
|
||||
fs::create_dir_all(&dir).expect("Failed to create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let key = [42u8; 32];
|
||||
let plaintext = "super-secret-client-credential-12345";
|
||||
let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt failed");
|
||||
// Ciphertext must differ from plaintext (encryption is non-trivial)
|
||||
assert_ne!(ciphertext.as_slice(), plaintext.as_bytes());
|
||||
// Nonce is 12 bytes (AES-GCM standard)
|
||||
assert_eq!(nonce.len(), 12);
|
||||
// Decrypting must return the original plaintext
|
||||
let recovered = decrypt(&ciphertext, &nonce, &key).expect("decrypt failed");
|
||||
assert_eq!(recovered, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_or_create_key_sets_0600_permissions() {
|
||||
let dir = unique_temp_dir();
|
||||
let key_path = dir.join("test-0600.key");
|
||||
let _key = load_or_create_key(&key_path).expect("load_or_create_key failed");
|
||||
// Verify file exists and has exactly 32 bytes
|
||||
let metadata = fs::metadata(&key_path).expect("key file not created");
|
||||
assert_eq!(metadata.len(), 32, "key file must be 32 bytes");
|
||||
// On Unix, verify permissions are 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "key file must be 0600, got {:o}", mode);
|
||||
}
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(&key_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_or_create_key_is_idempotent() {
|
||||
let dir = unique_temp_dir();
|
||||
let key_path = dir.join("test-idempotent.key");
|
||||
// First call creates the key
|
||||
let key1 = load_or_create_key(&key_path).expect("first call failed");
|
||||
// Second call should return the same key (not regenerate)
|
||||
let key2 = load_or_create_key(&key_path).expect("second call failed");
|
||||
assert_eq!(key1, key2, "second call must return the same key");
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(&key_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
9
crates/pm-core/src/db.rs
Normal file → Executable file
9
crates/pm-core/src/db.rs
Normal file → Executable file
@ -72,9 +72,9 @@ pub async fn create_enrollment_request(
|
||||
EnrollmentRequest,
|
||||
>(
|
||||
r#"
|
||||
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at
|
||||
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token, hostname)
|
||||
VALUES ($1, $2, $3::inet, $4, $5, $6)
|
||||
RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(req.machine_id)
|
||||
@ -82,6 +82,7 @@ pub async fn create_enrollment_request(
|
||||
.bind(req.ip_address)
|
||||
.bind(req.os_details)
|
||||
.bind(token_hash)
|
||||
.bind(&req.hostname)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
@ -90,7 +91,7 @@ pub async fn list_enrollment_requests(
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<EnrollmentRequest>, sqlx::Error> {
|
||||
sqlx::query_as::<_, EnrollmentRequest>(
|
||||
"SELECT id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC",
|
||||
"SELECT id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
|
||||
0
crates/pm-core/src/error.rs
Normal file → Executable file
0
crates/pm-core/src/error.rs
Normal file → Executable file
@ -9,7 +9,9 @@ pub mod request_id;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use config::AppConfig;
|
||||
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
|
||||
pub use crypto::{
|
||||
decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH, SECRET_ENCRYPTION_KEY_PATH,
|
||||
};
|
||||
pub use error::{AppError, ErrorResponse};
|
||||
pub use models::{
|
||||
AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest,
|
||||
|
||||
0
crates/pm-core/src/logging.rs
Normal file → Executable file
0
crates/pm-core/src/logging.rs
Normal file → Executable file
@ -94,6 +94,15 @@ pub struct Host {
|
||||
pub notes: String,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// CRL status reported by the agent: valid, expired, missing, invalid, or NULL for older agents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crl_status: Option<String>,
|
||||
/// Seconds since the agent's CRL was last refreshed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crl_age_seconds: Option<i64>,
|
||||
/// When the agent's CRL expires / next update is due.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crl_next_update: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Payload for registering a new host.
|
||||
@ -107,6 +116,14 @@ pub struct CreateHostRequest {
|
||||
pub group_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Payload for updating an existing host.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateHostRequest {
|
||||
pub fqdn: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Host list item (lighter projection for list views)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct HostSummary {
|
||||
@ -121,6 +138,9 @@ pub struct HostSummary {
|
||||
pub patches_missing: i32,
|
||||
pub health_check_status: Option<String>,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
/// CRL status reported by the agent: valid, expired, missing, invalid, or NULL for older agents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crl_status: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -135,6 +155,8 @@ pub struct EnrollmentRequest {
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
pub polling_token: String,
|
||||
/// Short hostname provided during enrollment (optional).
|
||||
pub hostname: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
@ -146,6 +168,8 @@ pub struct CreateEnrollmentRequest {
|
||||
pub fqdn: String,
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
/// Short hostname (from /etc/hostname, optional).
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -154,8 +178,10 @@ pub enum EnrollmentStatusResponse {
|
||||
Pending,
|
||||
Approved {
|
||||
ca_crt: String,
|
||||
ca_chain: String,
|
||||
server_crt: String,
|
||||
server_key: String,
|
||||
crl_pem: String,
|
||||
},
|
||||
Denied,
|
||||
NotFound,
|
||||
@ -163,9 +189,71 @@ pub enum EnrollmentStatusResponse {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PkiBundle {
|
||||
/// PEM-encoded CA certificate (leaf-most cert in the chain).
|
||||
/// For root mode, this is the self-signed root CA.
|
||||
/// For sub-CA mode, this is the intermediate CA cert.
|
||||
pub ca_crt: String,
|
||||
/// PEM-encoded full CA certificate chain (concatenated intermediates + root).
|
||||
/// For root mode, this contains just the root CA cert (same as ca_crt).
|
||||
/// For sub-CA mode, this contains the intermediate cert followed by the
|
||||
/// external root cert, enabling the agent to verify the full chain up to
|
||||
/// the trust anchor.
|
||||
///
|
||||
/// This field was added for CRL support (issue #7): the agent needs the
|
||||
/// full chain to verify CRL signatures that chain up to the root CA.
|
||||
#[serde(default)]
|
||||
pub ca_chain: String,
|
||||
/// PEM-encoded agent server certificate.
|
||||
pub server_crt: String,
|
||||
/// PEM-encoded agent server private key (PKCS#8).
|
||||
pub server_key: String,
|
||||
/// PEM-encoded Certificate Revocation List (CRL) signed by the CA.
|
||||
/// The agent uses this to reject revoked client certificates during mTLS
|
||||
/// handshakes. If CRL generation fails during enrollment, this field will
|
||||
/// be an empty string and the agent should fall back to WebPKI-only
|
||||
/// verification (degraded mode).
|
||||
///
|
||||
/// Added for CRL support (issue #7).
|
||||
#[serde(default)]
|
||||
pub crl_pem: String,
|
||||
}
|
||||
|
||||
/// Time-to-live for approved enrollment PKI bundles (10 minutes).
|
||||
///
|
||||
/// After approval, the agent has this duration to retrieve its PKI bundle
|
||||
/// via the polling endpoint. Once retrieved (single-use) or expired,
|
||||
/// the bundle is permanently removed from the in-memory cache.
|
||||
///
|
||||
/// This TTL balances security (limiting private key exposure in memory)
|
||||
/// against reliability (giving agents enough time to poll after approval).
|
||||
pub const ENROLLMENT_BUNDLE_TTL_SECS: u32 = 600; // 10 minutes
|
||||
|
||||
/// An approved enrollment PKI bundle awaiting single-use retrieval.
|
||||
///
|
||||
/// Stored in the in-memory cache between admin approval and agent pickup.
|
||||
/// The entry is removed atomically on first retrieval and expires after
|
||||
/// the configured TTL, whichever comes first.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovedEntry {
|
||||
pub pki: PkiBundle,
|
||||
pub approved_at: chrono::DateTime<Utc>,
|
||||
pub ttl: chrono::Duration,
|
||||
}
|
||||
|
||||
impl ApprovedEntry {
|
||||
/// Create a new entry with the current timestamp and default TTL.
|
||||
pub fn new(pki: PkiBundle) -> Self {
|
||||
Self {
|
||||
pki,
|
||||
approved_at: Utc::now(),
|
||||
ttl: chrono::Duration::seconds(ENROLLMENT_BUNDLE_TTL_SECS as i64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this entry has exceeded its TTL.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.approved_at + self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -455,6 +543,10 @@ pub struct PatchJobSummary {
|
||||
pub status: JobStatus,
|
||||
pub immediate: bool,
|
||||
pub host_count: i64,
|
||||
/// Display names of hosts targeted by this job (falls back to fqdn).
|
||||
#[serde(default)]
|
||||
#[sqlx(skip)]
|
||||
pub host_names: Vec<String>,
|
||||
pub succeeded_count: i64,
|
||||
pub failed_count: i64,
|
||||
pub notes: String,
|
||||
|
||||
0
crates/pm-core/src/request_id.rs
Normal file → Executable file
0
crates/pm-core/src/request_id.rs
Normal file → Executable file
16
crates/pm-reports/src/csv.rs
Normal file → Executable file
16
crates/pm-reports/src/csv.rs
Normal file → Executable file
@ -77,7 +77,7 @@ ORDER BY compliance_pct ASC
|
||||
};
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"host_id",
|
||||
"display_name",
|
||||
"fqdn",
|
||||
@ -115,7 +115,7 @@ ORDER BY compliance_pct ASC
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -152,7 +152,7 @@ ORDER BY pjh.started_at DESC
|
||||
.context("patch history query failed")?;
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"job_id",
|
||||
"job_kind",
|
||||
"job_status",
|
||||
@ -194,7 +194,7 @@ ORDER BY pjh.started_at DESC
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -203,7 +203,7 @@ ORDER BY pjh.started_at DESC
|
||||
|
||||
async fn vulnerability_csv(pool: &sqlx::PgPool, params: &ReportParams) -> anyhow::Result<Vec<u8>> {
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"host_id",
|
||||
"display_name",
|
||||
"fqdn",
|
||||
@ -279,7 +279,7 @@ ORDER BY
|
||||
},
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -312,7 +312,7 @@ LIMIT 10000
|
||||
.context("audit query failed")?;
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"id",
|
||||
"created_at",
|
||||
"action",
|
||||
@ -347,5 +347,5 @@ LIMIT 10000
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
0
crates/pm-reports/src/lib.rs
Normal file → Executable file
0
crates/pm-reports/src/lib.rs
Normal file → Executable file
1
crates/pm-reports/src/pdf.rs
Normal file → Executable file
1
crates/pm-reports/src/pdf.rs
Normal file → Executable file
@ -169,6 +169,7 @@ impl PdfBuilder {
|
||||
self.current_y -= ROW_H;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn embed_image(
|
||||
&self,
|
||||
raw_rgb: Vec<u8>,
|
||||
|
||||
@ -5,6 +5,10 @@ edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "pm_web"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "pm-web"
|
||||
path = "src/main.rs"
|
||||
@ -33,6 +37,8 @@ ulid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
ipnet = { workspace = true }
|
||||
dashmap = { version = "6" }
|
||||
tower_governor = { workspace = true }
|
||||
governor = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
|
||||
rand = { workspace = true }
|
||||
@ -42,3 +48,11 @@ sha2 = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
rcgen = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
248
crates/pm-web/src/lib.rs
Normal file
248
crates/pm-web/src/lib.rs
Normal file
@ -0,0 +1,248 @@
|
||||
//! pm-web — Linux Patch Manager web server (library crate).
|
||||
//!
|
||||
//! Re-exports [`AppState`], [`build_router`], and [`health_handler`] so that
|
||||
//! integration tests can construct a test application without depending on
|
||||
//! the binary entry-point.
|
||||
|
||||
pub mod routes;
|
||||
pub mod secret_key;
|
||||
|
||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
||||
use dashmap::DashMap;
|
||||
use pm_auth::{
|
||||
password::hash_password,
|
||||
rbac::{require_auth, AuthConfig},
|
||||
};
|
||||
use pm_core::{config::AppConfig, models::ApprovedEntry, request_id::request_id_middleware};
|
||||
use rand::Rng;
|
||||
use routes::sso::{OidcCache, SsoHandoff, SsoSession};
|
||||
use routes::ws::WsTicket;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tower_governor::{
|
||||
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
|
||||
};
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8).
|
||||
/// Detecting this prefix means the admin password has not been bootstrapped yet.
|
||||
const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA";
|
||||
|
||||
/// Bootstrap the default admin account with a random password.
|
||||
///
|
||||
/// On first startup after a fresh install, the `users` table contains the seed
|
||||
/// admin row with a clearly-invalid placeholder hash (cannot validate any password).
|
||||
/// This function detects that placeholder, generates a cryptographically random
|
||||
/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row.
|
||||
///
|
||||
/// The plaintext password is printed **once** to stderr (visible in `systemctl status`
|
||||
/// or `journalctl`) and is never stored on disk.
|
||||
///
|
||||
/// If the admin row already has a real hash, this function is a no-op.
|
||||
pub async fn bootstrap_admin_password(pool: &sqlx::PgPool) {
|
||||
let result: Option<String> = sqlx::query_scalar(
|
||||
"SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let current_hash = match result {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) {
|
||||
return;
|
||||
}
|
||||
|
||||
let password: String = rand::thread_rng()
|
||||
.sample_iter(&rand::distributions::Alphanumeric)
|
||||
.take(24)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let new_hash = match hash_password(&password) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to hash bootstrap admin password");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"UPDATE users
|
||||
SET password_hash = $1
|
||||
WHERE username = 'admin'
|
||||
AND auth_provider = 'local'
|
||||
AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#,
|
||||
)
|
||||
.bind(&new_hash)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
match rows {
|
||||
Ok(result) if result.rows_affected() == 1 => {
|
||||
eprintln!();
|
||||
eprintln!("========================================");
|
||||
eprintln!(" INITIAL ADMIN PASSWORD (shown once)");
|
||||
eprintln!(" Username: admin");
|
||||
eprintln!(" Password: {}", password);
|
||||
eprintln!();
|
||||
eprintln!(" You will be forced to change this on first login.");
|
||||
eprintln!(" If lost, restart the service to generate a new one.");
|
||||
eprintln!("========================================");
|
||||
eprintln!();
|
||||
tracing::info!("Bootstrap admin password generated and set");
|
||||
},
|
||||
Ok(_) => {
|
||||
tracing::info!("Admin password already bootstrapped (concurrent or prior)");
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to update admin password hash");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared application state threaded through Axum.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: sqlx::PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
pub signing_key_pem: String,
|
||||
pub auth_config: Arc<AuthConfig>,
|
||||
/// In-memory store for single-use WebSocket authentication tickets.
|
||||
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
|
||||
/// In-memory store for SSO PKCE sessions (state → code_verifier).
|
||||
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
|
||||
/// In-memory store for SSO handoff codes (single-use, 60s TTL).
|
||||
/// See `tasks/sso-token-handoff-spec.md` §4.1.
|
||||
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
|
||||
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
|
||||
pub oidc_cache: Arc<Mutex<OidcCache>>,
|
||||
/// Internal certificate authority for mTLS client cert issuance.
|
||||
pub ca: Arc<pm_ca::CertAuthority>,
|
||||
/// Short-lived cache for approved enrollment PKI bundles.
|
||||
///
|
||||
/// Entries are single-use (removed on retrieval) and expire after
|
||||
/// [`ENROLLMENT_BUNDLE_TTL_SECS`](pm_core::models::ENROLLMENT_BUNDLE_TTL_SECS).
|
||||
pub approved_enrollments: Arc<DashMap<String, ApprovedEntry>>,
|
||||
}
|
||||
|
||||
/// Construct the full Axum router.
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let static_dir = state.config.server.static_dir.clone();
|
||||
let auth_config = state.auth_config.clone();
|
||||
let rl = &state.config.rate_limit;
|
||||
|
||||
// Enrollment rate limiting: strict (5 req/min per IP, burst 3)
|
||||
let enrollment_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(12_000)
|
||||
.burst_size(rl.enrollment_burst)
|
||||
.finish()
|
||||
.expect("Invalid enrollment governor config"),
|
||||
);
|
||||
|
||||
// Auth rate limiting: moderate (20 req/min per IP, burst 10)
|
||||
let auth_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(3_000)
|
||||
.burst_size(rl.auth_burst)
|
||||
.finish()
|
||||
.expect("Invalid auth governor config"),
|
||||
);
|
||||
|
||||
// API rate limiting: normal (120 req/min per IP, burst 30)
|
||||
let api_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(500)
|
||||
.burst_size(rl.api_burst)
|
||||
.finish()
|
||||
.expect("Invalid API governor config"),
|
||||
);
|
||||
|
||||
// Enrollment routes with strict per-IP rate limiting
|
||||
let enrollment_router =
|
||||
routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor));
|
||||
|
||||
// Public auth routes with moderate per-IP rate limiting
|
||||
let auth_public_router =
|
||||
routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
|
||||
|
||||
// SSO routes with moderate per-IP rate limiting
|
||||
let sso_public_router =
|
||||
routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
|
||||
let sso_azure_router =
|
||||
routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor));
|
||||
|
||||
// All protected API routes — require valid JWT, with normal per-IP rate limiting
|
||||
let protected_api = Router::new()
|
||||
.nest("/auth", routes::auth::protected_router())
|
||||
.nest("/hosts", routes::hosts::router())
|
||||
.nest("/hosts", routes::ca::host_cert_router())
|
||||
.nest("/groups", routes::groups::router())
|
||||
.nest("/users", routes::users::router())
|
||||
.nest("/discovery", routes::discovery::router())
|
||||
.nest("/status", routes::status::router())
|
||||
.nest("/jobs", routes::jobs::router())
|
||||
.nest(
|
||||
"/hosts/{host_id}/maintenance-windows",
|
||||
routes::maintenance_windows::router(),
|
||||
)
|
||||
.nest(
|
||||
"/maintenance-windows",
|
||||
routes::maintenance_windows::all_windows_router(),
|
||||
)
|
||||
.nest("/ca", routes::ca::ca_router())
|
||||
.nest("/certificates", routes::ca::certs_router())
|
||||
.merge(routes::ws::ticket_router())
|
||||
.nest("/reports", routes::reports::router())
|
||||
.nest(
|
||||
"/hosts/{host_id}/health-checks",
|
||||
routes::health_checks::router(),
|
||||
)
|
||||
.nest("/settings", routes::settings::router())
|
||||
.nest("/admin", routes::enrollment::admin_router())
|
||||
.layer(GovernorLayer::new(api_governor))
|
||||
.route_layer(middleware::from_fn(move |req, next| {
|
||||
let auth_config = auth_config.clone();
|
||||
require_auth(auth_config, req, next)
|
||||
}));
|
||||
|
||||
Router::new()
|
||||
.route("/status/health", get(health_handler))
|
||||
.nest("/api/v1/auth", auth_public_router)
|
||||
.nest("/api/v1", enrollment_router)
|
||||
.nest("/api/v1", routes::pki::router())
|
||||
.nest("/api/v1/auth/sso", sso_public_router)
|
||||
.nest("/api/v1/auth/azure", sso_azure_router)
|
||||
.nest("/api/v1", protected_api)
|
||||
.merge(routes::ws::ws_router())
|
||||
.fallback_service(
|
||||
ServeDir::new(&static_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeFile::new(format!("{}/index.html", static_dir))),
|
||||
)
|
||||
.layer(middleware::from_fn(request_id_middleware))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
||||
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let status = if db_ok { "healthy" } else { "degraded" };
|
||||
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
|
||||
if db_ok {
|
||||
Ok(Json(body))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
@ -1,51 +1,13 @@
|
||||
//! pm-web — Linux Patch Manager web server.
|
||||
//! pm-web — Linux Patch Manager web server (binary entry-point).
|
||||
|
||||
mod routes;
|
||||
|
||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use dashmap::DashMap;
|
||||
use pm_auth::{
|
||||
jwt,
|
||||
rbac::{require_auth, AuthConfig},
|
||||
};
|
||||
use pm_core::{
|
||||
config::AppConfig, db, logging, models::PkiBundle, request_id::request_id_middleware,
|
||||
};
|
||||
use routes::sso::{OidcCache, SsoSession};
|
||||
use routes::ws::WsTicket;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use pm_auth::{jwt, rbac::AuthConfig};
|
||||
use pm_core::{config::AppConfig, db, models::ApprovedEntry};
|
||||
use pm_web::routes::sso::{OidcCache, SsoHandoff, SsoSession};
|
||||
use pm_web::routes::ws::WsTicket;
|
||||
use pm_web::{bootstrap_admin_password, build_router, AppState};
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tokio::sync::Mutex;
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
/// Shared application state threaded through Axum.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: sqlx::PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
pub signing_key_pem: String,
|
||||
pub auth_config: Arc<AuthConfig>,
|
||||
/// In-memory store for single-use WebSocket authentication tickets.
|
||||
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
|
||||
/// In-memory store for SSO PKCE sessions (state → code_verifier).
|
||||
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
|
||||
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
|
||||
pub oidc_cache: Arc<Mutex<OidcCache>>,
|
||||
/// Internal certificate authority for mTLS client cert issuance.
|
||||
pub ca: Arc<pm_ca::CertAuthority>,
|
||||
/// IP-based rate limits for enrollment requests.
|
||||
pub enrollment_rate_limits: Arc<DashMap<IpAddr, Instant>>,
|
||||
/// Short-lived cache for approved enrollment PKI bundles.
|
||||
pub approved_enrollments: Arc<DashMap<String, PkiBundle>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@ -62,7 +24,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
AppConfig::default()
|
||||
});
|
||||
|
||||
logging::init(&config.logging);
|
||||
pm_core::logging::init(&config.logging);
|
||||
tracing::info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
"patch-manager-web starting"
|
||||
@ -83,14 +45,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
let auth_config = Arc::new(AuthConfig::new(
|
||||
verify_key_pem,
|
||||
&config.security.ip_whitelist,
|
||||
&config.security.trusted_proxies,
|
||||
));
|
||||
|
||||
let pool = db::init_pool(&config.database).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
|
||||
// Initialise the internal CA. Panics in production if CA files are missing
|
||||
// or corrupt — this is intentional; the service cannot operate without mTLS.
|
||||
let ca_base = std::path::Path::new("/etc/patch-manager/ca");
|
||||
// Bootstrap admin password if the seed admin still has the placeholder hash.
|
||||
bootstrap_admin_password(&pool).await;
|
||||
|
||||
// Initialise the internal CA using the configured certificate paths.
|
||||
let ca_base = std::path::Path::new(&config.security.ca_cert_path)
|
||||
.parent()
|
||||
.expect("CA certificate path must have a parent directory");
|
||||
let ca = pm_ca::CertAuthority::init(ca_base, &pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
@ -100,9 +67,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
|
||||
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
|
||||
let sso_handoffs: Arc<DashMap<String, SsoHandoff>> = Arc::new(DashMap::new());
|
||||
let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default()));
|
||||
let enrollment_rate_limits: Arc<DashMap<IpAddr, Instant>> = Arc::new(DashMap::new());
|
||||
let approved_enrollments: Arc<DashMap<String, PkiBundle>> = Arc::new(DashMap::new());
|
||||
let approved_enrollments: Arc<DashMap<String, ApprovedEntry>> = Arc::new(DashMap::new());
|
||||
|
||||
// Background task: purge expired WS tickets every 30 seconds.
|
||||
{
|
||||
@ -122,7 +89,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: purge expired SSO sessions every 60 seconds (sessions older than 10 minutes).
|
||||
// Background task: purge expired SSO sessions every 60 seconds.
|
||||
{
|
||||
let sessions = sso_sessions.clone();
|
||||
tokio::spawn(async move {
|
||||
@ -141,27 +108,37 @@ async fn main() -> anyhow::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: purge expired enrollment rate limits every 5 minutes.
|
||||
// Background task: purge expired approved enrollment PKI bundles.
|
||||
{
|
||||
let limits = enrollment_rate_limits.clone();
|
||||
let approved = approved_enrollments.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = Instant::now();
|
||||
limits.retain(|_, v| now.duration_since(*v) < Duration::from_secs(3600));
|
||||
let before = approved.len();
|
||||
approved.retain(|_, entry| !entry.is_expired());
|
||||
let removed = before.saturating_sub(approved.len());
|
||||
if removed > 0 {
|
||||
tracing::debug!(removed, "Purged expired enrollment PKI bundles");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: purge approved enrollment PKI bundles every 10 minutes.
|
||||
// Background task: purge expired SSO handoff codes every 60 seconds.
|
||||
{
|
||||
let approved = approved_enrollments.clone();
|
||||
let handoffs = sso_handoffs.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(600));
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
approved.clear();
|
||||
let now = std::time::Instant::now();
|
||||
let before = handoffs.len();
|
||||
handoffs.retain(|_, v| v.expires_at > now);
|
||||
let removed = before.saturating_sub(handoffs.len());
|
||||
if removed > 0 {
|
||||
tracing::debug!(removed, "Purged expired SSO handoff codes");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -173,8 +150,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
auth_config,
|
||||
ws_tickets,
|
||||
sso_sessions,
|
||||
sso_handoffs,
|
||||
ca: Arc::new(ca),
|
||||
enrollment_rate_limits,
|
||||
approved_enrollments,
|
||||
oidc_cache,
|
||||
};
|
||||
@ -190,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let tls_key = std::path::Path::new(&config.security.web_tls_key_path);
|
||||
|
||||
if tls_cert.exists() && tls_key.exists() {
|
||||
let tls_config = RustlsConfig::from_pem_file(
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||
&config.security.web_tls_cert_path,
|
||||
&config.security.web_tls_key_path,
|
||||
)
|
||||
@ -202,7 +179,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
tracing::info!(%addr, "Listening (HTTPS)");
|
||||
axum_server::bind_rustls(addr, tls_config)
|
||||
.serve(app.into_make_service())
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await?;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@ -213,95 +190,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
tracing::info!(%addr, "Listening (HTTP — no TLS)");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Construct the full Axum router.
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let static_dir = state.config.server.static_dir.clone();
|
||||
let auth_config = state.auth_config.clone();
|
||||
|
||||
// All protected API routes — require valid JWT
|
||||
let protected_api = Router::new()
|
||||
// Auth: MFA setup/verify
|
||||
// Auth: MFA setup/verify/disable (nested under /auth so paths are /api/v1/auth/mfa/*)
|
||||
.nest("/auth", routes::auth::protected_router())
|
||||
// Hosts
|
||||
.nest("/hosts", routes::hosts::router())
|
||||
// Host-scoped certificate endpoints (merged separately to avoid conflict)
|
||||
.nest("/hosts", routes::ca::host_cert_router())
|
||||
// Groups
|
||||
.nest("/groups", routes::groups::router())
|
||||
// Users
|
||||
.nest("/users", routes::users::router())
|
||||
// Discovery
|
||||
.nest("/discovery", routes::discovery::router())
|
||||
// Fleet status
|
||||
.nest("/status", routes::status::router())
|
||||
// Patch jobs
|
||||
.nest("/jobs", routes::jobs::router())
|
||||
// Maintenance windows (nested under hosts path param)
|
||||
.nest(
|
||||
"/hosts/{host_id}/maintenance-windows",
|
||||
routes::maintenance_windows::router(),
|
||||
)
|
||||
// CA root certificate download
|
||||
.nest("/ca", routes::ca::ca_router())
|
||||
// Certificate list / renew / revoke
|
||||
.nest("/certificates", routes::ca::certs_router())
|
||||
// WS ticket issuance (JWT-protected — ticket returned to browser, then used for WS upgrade)
|
||||
.merge(routes::ws::ticket_router())
|
||||
// Reports
|
||||
.nest("/reports", routes::reports::router())
|
||||
.nest(
|
||||
"/hosts/{host_id}/health-checks",
|
||||
routes::health_checks::router(),
|
||||
)
|
||||
// Settings (admin-only)
|
||||
.nest("/settings", routes::settings::router())
|
||||
// Admin enrollment routes (JWT protected, Admin role enforced)
|
||||
.nest("/admin", routes::enrollment::admin_router())
|
||||
// Apply auth middleware to all the above
|
||||
.route_layer(middleware::from_fn(move |req, next| {
|
||||
let auth_config = auth_config.clone();
|
||||
require_auth(auth_config, req, next)
|
||||
}));
|
||||
|
||||
Router::new()
|
||||
.route("/status/health", get(health_handler))
|
||||
// Public auth routes (no JWT needed)
|
||||
.nest("/api/v1/auth", routes::auth::public_router())
|
||||
// Public enrollment endpoints (rate-limited, no JWT)
|
||||
.nest("/api/v1", routes::enrollment::router())
|
||||
// Public SSO routes (no JWT needed)
|
||||
.nest("/api/v1/auth/sso", routes::sso::public_router())
|
||||
// Public Azure SSO routes (no JWT needed)
|
||||
.nest("/api/v1/auth/azure", routes::sso::azure_compat_router())
|
||||
// Protected API routes (JWT required)
|
||||
.nest("/api/v1", protected_api)
|
||||
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware
|
||||
.merge(routes::ws::ws_router())
|
||||
// Serve React SPA
|
||||
.fallback_service(
|
||||
ServeDir::new(&static_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeFile::new(format!("{}/index.html", static_dir))),
|
||||
)
|
||||
.layer(middleware::from_fn(request_id_middleware))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
||||
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let status = if db_ok { "healthy" } else { "degraded" };
|
||||
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
|
||||
if db_ok {
|
||||
Ok(Json(body))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
@ -360,8 +360,26 @@ async fn mfa_verify_handler(
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE users SET totp_secret = $1, mfa_enabled = TRUE WHERE id = $2")
|
||||
.bind(&req.secret_base32)
|
||||
// Encrypt the TOTP secret before persisting (issue #6 fix)
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({ "error": { "code": "internal_error", "message": "Encryption key error" } }),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(&req.secret_base32, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt TOTP secret");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query("UPDATE users SET totp_secret_encrypted = $1, totp_secret_nonce = $2, mfa_enabled = TRUE WHERE id = $3")
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@ -417,7 +435,7 @@ async fn disable_mfa(
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE WHERE id = $1")
|
||||
sqlx::query("UPDATE users SET totp_secret_encrypted = NULL, totp_secret_nonce = NULL, mfa_enabled = FALSE WHERE id = $1")
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
|
||||
0
crates/pm-web/src/routes/ca.rs
Normal file → Executable file
0
crates/pm-web/src/routes/ca.rs
Normal file → Executable file
2
crates/pm-web/src/routes/discovery.rs
Normal file → Executable file
2
crates/pm-web/src/routes/discovery.rs
Normal file → Executable file
@ -174,7 +174,7 @@ async fn probe_and_store(pool: sqlx::PgPool, scan_id: Uuid, ip: IpAddr, port: u1
|
||||
/// Simple reverse DNS lookup.
|
||||
fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
let addr = SocketAddr::new(ip, 0);
|
||||
let _addr = SocketAddr::new(ip, 0);
|
||||
// Standard library doesn't have reverse lookup; use getaddrinfo via format
|
||||
let host = format!("{ip}");
|
||||
// Best-effort: try to resolve numeric address to hostname
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
@ -11,13 +11,12 @@ use pm_auth::AuthUser;
|
||||
use pm_core::{
|
||||
db,
|
||||
models::{
|
||||
CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host, PkiBundle,
|
||||
ApprovedEntry, CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host,
|
||||
PkiBundle,
|
||||
},
|
||||
};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use serde::Serialize;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HostConflict {
|
||||
@ -34,43 +33,12 @@ pub fn router() -> Router<AppState> {
|
||||
|
||||
/// POST /api/v1/enroll
|
||||
/// Initiates host self-enrollment.
|
||||
/// Rate limiting is handled by tower-governor middleware (per-IP, configurable).
|
||||
async fn enroll_host(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateEnrollmentRequest>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
// 1. IP-based Rate Limiting
|
||||
// Prefer real IP from headers if behind proxy (e.g., X-Forwarded-For)
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.split(',').next())
|
||||
.and_then(|h| h.trim().parse::<IpAddr>().ok())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"No X-Forwarded-For header found for enrollment request from public endpoint"
|
||||
);
|
||||
// Default to a placeholder IP since we can't extract the socket addr without the ConnectInfo layer
|
||||
"0.0.0.0".parse().unwrap()
|
||||
});
|
||||
|
||||
{
|
||||
let mut rate_limits = state
|
||||
.enrollment_rate_limits
|
||||
.entry(ip)
|
||||
.or_insert(Instant::now() - std::time::Duration::from_secs(3600));
|
||||
let last_request = rate_limits.value();
|
||||
if last_request.elapsed().as_secs() < 60 {
|
||||
// 1 request per minute per IP
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(serde_json::json!({ "error": "Rate limit exceeded. Try again in a minute." })),
|
||||
));
|
||||
}
|
||||
*rate_limits = Instant::now();
|
||||
}
|
||||
|
||||
// 2. Generate secure random polling token
|
||||
// Generate secure random polling token
|
||||
let polling_token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(64)
|
||||
@ -109,7 +77,10 @@ async fn enroll_status(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
) -> Result<Json<EnrollmentStatusResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Hash the provided token to match DB
|
||||
// Hash the provided token to match DB.
|
||||
// Security note: the raw polling token is intentionally never logged.
|
||||
// Only the SHA-256 hash is stored and compared; all tracing calls in
|
||||
// this module log error contexts only, never the token itself.
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
@ -131,11 +102,19 @@ async fn enroll_status(
|
||||
}
|
||||
|
||||
// 2. If not in pending, check if it was recently approved.
|
||||
if let Some(pki) = state.approved_enrollments.get(&token_hash) {
|
||||
// Single-retrieval: remove() atomically consumes the entry, ensuring
|
||||
// the private key can only be fetched once regardless of concurrent requests.
|
||||
if let Some((_, entry)) = state.approved_enrollments.remove(&token_hash) {
|
||||
if entry.is_expired() {
|
||||
// Bundle TTL expired — treat as not found. Entry is already removed.
|
||||
return Ok(Json(EnrollmentStatusResponse::NotFound));
|
||||
}
|
||||
return Ok(Json(EnrollmentStatusResponse::Approved {
|
||||
ca_crt: pki.ca_crt.clone(),
|
||||
server_crt: pki.server_crt.clone(),
|
||||
server_key: pki.server_key.clone(),
|
||||
ca_crt: entry.pki.ca_crt.clone(),
|
||||
ca_chain: entry.pki.ca_chain.clone(),
|
||||
server_crt: entry.pki.server_crt.clone(),
|
||||
server_key: entry.pki.server_key.clone(),
|
||||
crl_pem: entry.pki.crl_pem.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -167,7 +146,7 @@ async fn list_admin_enrollments(
|
||||
|
||||
db::list_enrollment_requests(&state.db)
|
||||
.await
|
||||
.map(|requests| Json(requests))
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to list enrollment requests");
|
||||
(
|
||||
@ -209,10 +188,10 @@ async fn approve_enrollment(
|
||||
|
||||
// Check for FQDN/IP collision in hosts table
|
||||
if let Some(existing_host) = sqlx::query_as::<_, Host>(
|
||||
"SELECT id, fqdn, ip_address, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2"
|
||||
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at, crl_status, crl_age_seconds, crl_next_update FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
|
||||
)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(&enrollment_request.ip_address.to_string())
|
||||
.bind(enrollment_request.ip_address.to_string())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@ -225,7 +204,63 @@ async fn approve_enrollment(
|
||||
));
|
||||
}
|
||||
|
||||
// Generate PKI bundle using CA
|
||||
// Move to hosts table FIRST (certificates table has FK reference to hosts)
|
||||
let os_family = enrollment_request
|
||||
.os_details
|
||||
.get("os")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let os_name = enrollment_request
|
||||
.os_details
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
// Build os_name from os + os_version if "name" is absent
|
||||
let os = enrollment_request
|
||||
.os_details
|
||||
.get("os")
|
||||
.and_then(|v| v.as_str())?;
|
||||
let ver = enrollment_request
|
||||
.os_details
|
||||
.get("os_version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
Some(format!("{} {}", os, ver).trim().to_string())
|
||||
});
|
||||
let arch = enrollment_request
|
||||
.os_details
|
||||
.get("architecture")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let display_name = enrollment_request
|
||||
.hostname
|
||||
.clone()
|
||||
.unwrap_or_else(|| enrollment_request.fqdn.clone());
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO hosts (id, fqdn, ip_address, os_family, os_name, arch, display_name, registered_at, updated_at)
|
||||
VALUES ($1, $2, $3::inet, $4, $5, $6, $7, NOW(), NOW())
|
||||
"#,
|
||||
)
|
||||
.bind(enrollment_request.id)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(enrollment_request.ip_address.to_string())
|
||||
.bind(&os_family)
|
||||
.bind(&os_name)
|
||||
.bind(&arch)
|
||||
.bind(&display_name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to insert host after approval");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Generate PKI bundle using CA (after host row exists)
|
||||
let issued = state
|
||||
.ca
|
||||
.issue_client_cert(
|
||||
@ -243,33 +278,6 @@ async fn approve_enrollment(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Move to hosts table
|
||||
let os_name = enrollment_request
|
||||
.os_details
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO hosts (id, fqdn, ip_address, os_name, registered_at, updated_at, machine_id)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW(), $5)
|
||||
"#,
|
||||
)
|
||||
.bind(enrollment_request.id)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(&enrollment_request.ip_address.to_string())
|
||||
.bind(os_name)
|
||||
.bind(enrollment_request.machine_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to insert host after approval");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Delete from enrollment_requests table
|
||||
db::delete_enrollment_request(&state.db, id)
|
||||
.await
|
||||
@ -281,15 +289,38 @@ async fn approve_enrollment(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Store PKI bundle in cache for client retrieval
|
||||
// Store PKI bundle in cache for single-use client retrieval.
|
||||
//
|
||||
// Design decision — server-generated keys vs CSR-based enrollment:
|
||||
// Currently the server generates the agent's private key and transmits it
|
||||
// over the (already mTLS-secured) polling endpoint. This approach was chosen
|
||||
// for initial implementation simplicity: the agent only needs to poll one
|
||||
// endpoint and receives a complete PKI bundle without an extra round-trip.
|
||||
//
|
||||
// A future enhancement should adopt CSR-based enrollment where the agent
|
||||
// generates its own key pair locally and submits a Certificate Signing
|
||||
// Request, eliminating the need for the server to ever hold or transmit
|
||||
// the agent's private key. This reduces the attack surface significantly
|
||||
// — the private key never traverses the network and never resides in
|
||||
// server memory beyond the signing operation.
|
||||
//
|
||||
// See: https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9
|
||||
//
|
||||
// Include the full CA chain (for root mode, same as ca_crt; for sub-CA,
|
||||
// includes intermediate + root) and the current CRL.
|
||||
let ca_chain = issued.ca_root_pem.clone(); // Root mode: chain is just the root cert
|
||||
let crl_pem = state.ca.generate_crl(&state.db).await.unwrap_or_default(); // Empty string on failure: agent falls back to WebPKI-only
|
||||
let pki = PkiBundle {
|
||||
ca_crt: issued.ca_root_pem,
|
||||
ca_chain,
|
||||
server_crt: issued.server_cert_pem,
|
||||
server_key: issued.server_key_pem,
|
||||
crl_pem,
|
||||
};
|
||||
state
|
||||
.approved_enrollments
|
||||
.insert(enrollment_request.polling_token.clone(), pki);
|
||||
state.approved_enrollments.insert(
|
||||
enrollment_request.polling_token.clone(),
|
||||
ApprovedEntry::new(pki),
|
||||
);
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
2
crates/pm-web/src/routes/groups.rs
Normal file → Executable file
2
crates/pm-web/src/routes/groups.rs
Normal file → Executable file
@ -12,7 +12,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
|
||||
9
crates/pm-web/src/routes/health_checks.rs
Normal file → Executable file
9
crates/pm-web/src/routes/health_checks.rs
Normal file → Executable file
@ -11,7 +11,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
@ -24,7 +24,7 @@ use pm_core::{
|
||||
},
|
||||
};
|
||||
use reqwest::tls::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
@ -631,7 +631,6 @@ async fn update_health_check(
|
||||
set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if set_clauses.is_empty() {
|
||||
@ -644,7 +643,7 @@ async fn update_health_check(
|
||||
}
|
||||
|
||||
// Always update updated_at
|
||||
set_clauses.push(format!("updated_at = NOW()"));
|
||||
set_clauses.push("updated_at = NOW()".to_string());
|
||||
|
||||
// Use a simpler approach: query the current row, apply changes, update
|
||||
// This avoids complex dynamic SQL binding issues
|
||||
@ -945,7 +944,7 @@ async fn run_service_check(check: &HealthCheck, state: &AppState) -> CheckResult
|
||||
}
|
||||
CheckResult {
|
||||
healthy: false,
|
||||
detail: format!("Failed to parse agent response"),
|
||||
detail: "Failed to parse agent response".to_string(),
|
||||
latency_ms: Some(latency),
|
||||
}
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
//! POST /api/v1/hosts — register new host (admin only)
|
||||
//! GET /api/v1/hosts/{id} — get host detail
|
||||
//! DELETE /api/v1/hosts/{id} — remove host (admin only)
|
||||
//! PUT /api/v1/hosts/{id} — update host (write access)
|
||||
//! GET /api/v1/hosts/{id}/groups — list groups for host
|
||||
//! POST /api/v1/hosts/{id}/groups — assign host to group
|
||||
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
|
||||
@ -19,7 +20,7 @@ use axum::{
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
models::{CreateHostRequest, Group, HostSummary},
|
||||
models::{CreateHostRequest, Group, HostSummary, UpdateHostRequest},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@ -30,7 +31,7 @@ use crate::AppState;
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_hosts).post(register_host))
|
||||
.route("/{id}", get(get_host).delete(remove_host))
|
||||
.route("/{id}", get(get_host).put(update_host).delete(remove_host))
|
||||
.route(
|
||||
"/{id}/groups",
|
||||
get(list_host_groups).post(add_host_to_group),
|
||||
@ -42,6 +43,7 @@ pub fn router() -> Router<AppState> {
|
||||
// ── Query params ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct HostListQuery {
|
||||
pub group_id: Option<Uuid>,
|
||||
pub health_status: Option<String>,
|
||||
@ -130,7 +132,8 @@ async fn list_hosts(
|
||||
THEN 'some_unhealthy'
|
||||
ELSE 'all_healthy'
|
||||
END AS health_check_status,
|
||||
h.registered_at
|
||||
h.registered_at,
|
||||
h.crl_status
|
||||
FROM hosts h
|
||||
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
||||
ORDER BY h.fqdn
|
||||
@ -163,7 +166,8 @@ async fn list_hosts(
|
||||
THEN 'some_unhealthy'
|
||||
ELSE 'all_healthy'
|
||||
END AS health_check_status,
|
||||
h.registered_at
|
||||
h.registered_at,
|
||||
h.crl_status
|
||||
FROM hosts h
|
||||
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
||||
WHERE
|
||||
@ -317,7 +321,8 @@ async fn get_host(
|
||||
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
||||
os_family, os_name, arch, agent_version, health_status,
|
||||
last_health_at, last_patch_at, agent_port, notes,
|
||||
registered_at, updated_at
|
||||
registered_at, updated_at,
|
||||
crl_status, crl_age_seconds, crl_next_update
|
||||
FROM hosts WHERE id = $1
|
||||
) h
|
||||
"#,
|
||||
@ -398,6 +403,69 @@ async fn remove_host(
|
||||
Ok(Json(json!({ "message": "Host removed" })))
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/hosts/:id ─────────────────────────────────────────────────────
|
||||
|
||||
async fn update_host(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateHostRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
if !auth.role.can_write() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||
));
|
||||
}
|
||||
|
||||
// Update only fields that were provided; COALESCE preserves existing values.
|
||||
let host = sqlx::query_scalar(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE hosts SET
|
||||
fqdn = COALESCE($1, fqdn),
|
||||
ip_address = COALESCE($2::inet, ip_address),
|
||||
display_name = COALESCE($3, display_name),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
RETURNING id
|
||||
)
|
||||
SELECT row_to_json(h) FROM (
|
||||
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
||||
os_family, os_name, arch, agent_version, health_status,
|
||||
last_health_at, last_patch_at, agent_port, notes,
|
||||
registered_at, updated_at, crl_status, crl_age_seconds, crl_next_update
|
||||
FROM hosts WHERE id = (SELECT id FROM updated)
|
||||
) h
|
||||
"#,
|
||||
)
|
||||
.bind(&req.fqdn)
|
||||
.bind(&req.ip_address)
|
||||
.bind(&req.display_name)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, host_id = %id, "Failed to update host");
|
||||
let msg = if e.to_string().contains("unique") {
|
||||
"A host with this FQDN and IP already exists".to_string()
|
||||
} else {
|
||||
"Database error".to_string()
|
||||
};
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": { "code": "conflict", "message": msg } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
host.map(Json).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── GET /api/v1/hosts/:id/groups ──────────────────────────────────────────────
|
||||
|
||||
async fn list_host_groups(
|
||||
|
||||
@ -20,6 +20,7 @@ use pm_core::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
@ -52,6 +53,13 @@ struct JobListResponse {
|
||||
offset: i64,
|
||||
}
|
||||
|
||||
/// Helper struct for the host_names aggregation query.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct JobHostNames {
|
||||
id: Uuid,
|
||||
host_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// Per-host row included in `GET /api/v1/jobs/{id}` response.
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
struct JobHostRow {
|
||||
@ -229,7 +237,7 @@ async fn list_jobs(
|
||||
let limit = q.limit.unwrap_or(50).min(200);
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
|
||||
let jobs: Vec<PatchJobSummary> = if auth.role.is_admin() {
|
||||
let mut jobs: Vec<PatchJobSummary> = if auth.role.is_admin() {
|
||||
// Admins see every job.
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
@ -298,6 +306,40 @@ async fn list_jobs(
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
})?;
|
||||
|
||||
// Fetch host names for all jobs in this page.
|
||||
let job_ids: Vec<Uuid> = jobs.iter().map(|j| j.id).collect();
|
||||
let host_names_rows: Vec<JobHostNames> = if job_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT pjh.job_id AS id,
|
||||
array_agg(COALESCE(NULLIF(h.display_name, ''), h.fqdn)
|
||||
ORDER BY h.fqdn) AS host_names
|
||||
FROM patch_job_hosts pjh
|
||||
JOIN hosts h ON h.id = pjh.host_id
|
||||
WHERE pjh.job_id = ANY($1)
|
||||
GROUP BY pjh.job_id
|
||||
"#,
|
||||
)
|
||||
.bind(&job_ids)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "list_jobs: host_names query failed, using empty defaults");
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
|
||||
// Merge host_names into summaries.
|
||||
let mut host_names_map: HashMap<Uuid, Vec<String>> = host_names_rows
|
||||
.into_iter()
|
||||
.map(|r| (r.id, r.host_names))
|
||||
.collect();
|
||||
for job in &mut jobs {
|
||||
job.host_names = host_names_map.remove(&job.id).unwrap_or_default();
|
||||
}
|
||||
|
||||
// Total count for pagination metadata.
|
||||
let total: i64 = if auth.role.is_admin() {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM patch_jobs")
|
||||
@ -438,7 +480,7 @@ async fn cancel_job(
|
||||
|
||||
// Only admin or the job creator may cancel.
|
||||
if !auth.role.can_write() {
|
||||
let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id);
|
||||
let is_creator = creator_id == Some(auth.user_id);
|
||||
if !is_creator {
|
||||
return Err(err(
|
||||
StatusCode::FORBIDDEN,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! Maintenance window management routes.
|
||||
//!
|
||||
//! GET /api/v1/hosts/{id}/maintenance-windows — list windows for host
|
||||
//! GET /api/v1/maintenance-windows — list ALL windows (bulk)
|
||||
//! POST /api/v1/hosts/{id}/maintenance-windows — create window for host
|
||||
//! PUT /api/v1/hosts/{id}/maintenance-windows/{win_id} — update window
|
||||
//! DELETE /api/v1/hosts/{id}/maintenance-windows/{win_id} — delete window
|
||||
@ -32,6 +33,41 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/{win_id}", put(update_window).delete(delete_window))
|
||||
}
|
||||
|
||||
/// Top-level router for `/api/v1/maintenance-windows` — bulk list-all endpoint.
|
||||
pub fn all_windows_router() -> Router<AppState> {
|
||||
Router::new().route("/", get(list_all_windows))
|
||||
}
|
||||
|
||||
// ── GET /api/v1/maintenance-windows ──────────────────────────────────────────
|
||||
|
||||
/// Bulk endpoint: return every maintenance window across all hosts.
|
||||
/// Eliminates N+1 queries from the frontend (one request instead of one per host).
|
||||
async fn list_all_windows(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthUser,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let windows: Vec<MaintenanceWindow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
|
||||
recurrence_day, enabled, auto_apply, created_at, updated_at
|
||||
FROM maintenance_windows
|
||||
ORDER BY host_id, created_at ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "list_all_windows: query failed");
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({ "windows": windows })))
|
||||
}
|
||||
|
||||
// ── Error helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[inline]
|
||||
|
||||
@ -8,10 +8,10 @@ pub mod health_checks;
|
||||
pub mod hosts;
|
||||
pub mod jobs;
|
||||
pub mod maintenance_windows;
|
||||
pub mod pki;
|
||||
pub mod reports;
|
||||
pub mod settings;
|
||||
pub mod sso;
|
||||
pub mod status;
|
||||
pub mod users;
|
||||
pub mod ws;
|
||||
|
||||
pub mod reports;
|
||||
|
||||
262
crates/pm-web/src/routes/pki.rs
Normal file
262
crates/pm-web/src/routes/pki.rs
Normal file
@ -0,0 +1,262 @@
|
||||
//! PKI endpoints for certificate revocation list (CRL) distribution.
|
||||
//!
|
||||
//! This module exposes the CRL endpoint that agents poll every 24 hours to
|
||||
//! check for revoked certificates. The CRL is signed by the internal CA and
|
||||
//! is publicly accessible (CRLs are self-authenticating — they carry the CA
|
||||
//! signature and do not require client authentication).
|
||||
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
/// Define public PKI routes.
|
||||
///
|
||||
/// These endpoints are **unauthenticated** because CRLs are self-authenticating:
|
||||
/// the agent verifies the CRL signature against its pinned CA certificate.
|
||||
/// No client certificate or API key is required.
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/pki/crl.pem", get(get_crl))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/pki/crl.pem`
|
||||
///
|
||||
/// Returns the current Certificate Revocation List (CRL) as a PEM-encoded
|
||||
/// X.509 CRL. The CRL is signed by the internal CA and contains the serial
|
||||
/// numbers of all revoked certificates that have not yet expired.
|
||||
///
|
||||
/// # Cache headers
|
||||
///
|
||||
/// The response includes `Cache-Control: max-age=3600` (1 hour) to allow
|
||||
/// intermediate caches to serve the CRL. Agents refresh every 24 hours,
|
||||
/// so a 1-hour cache is a reasonable balance between freshness and load.
|
||||
///
|
||||
/// # CRL generation
|
||||
///
|
||||
/// The CRL is generated on demand from the `certificates` table. For our
|
||||
/// target scale (max ~2500 clients), this is a fast query and the resulting
|
||||
/// CRL is KB-range. If performance becomes a concern, the CRL can be cached
|
||||
/// in memory and regenerated on a schedule (see background task in main.rs).
|
||||
async fn get_crl(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.ca.generate_crl(&state.db).await {
|
||||
Ok(crl_pem) => (
|
||||
StatusCode::OK,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
"application/x-pem-file; charset=utf-8",
|
||||
)],
|
||||
[(header::CACHE_CONTROL, "max-age=3600")],
|
||||
crl_pem,
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to generate CRL");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate CRL").into_response()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::Router;
|
||||
use dashmap::DashMap;
|
||||
use pm_auth::rbac::AuthConfig;
|
||||
use pm_core::config::AppConfig;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Helper: create a test AppState with a real CA and database pool.
|
||||
/// Returns None if TEST_DATABASE_URL is not set (tests are skipped).
|
||||
async fn setup_app_state() -> Option<(PgPool, AppState)> {
|
||||
let db_url = std::env::var("TEST_DATABASE_URL").ok()?;
|
||||
let pool = PgPool::connect(&db_url).await.ok()?;
|
||||
|
||||
// Run migrations to ensure schema is up to date.
|
||||
sqlx::migrate!("../../migrations").run(&pool).await.ok()?;
|
||||
|
||||
// Create a temp directory for the CA.
|
||||
let tmp_dir = tempfile::tempdir().ok()?;
|
||||
let ca_dir = tmp_dir.path().to_path_buf();
|
||||
|
||||
let ca = pm_ca::CertAuthority::init(&ca_dir, &pool).await.ok()?;
|
||||
|
||||
let config = Arc::new(AppConfig::default());
|
||||
|
||||
use crate::routes::sso::OidcCache;
|
||||
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
config,
|
||||
signing_key_pem: String::new(),
|
||||
auth_config: Arc::new(AuthConfig::new(String::new(), &[], &[])),
|
||||
ws_tickets: Arc::new(DashMap::new()),
|
||||
sso_sessions: Arc::new(DashMap::new()),
|
||||
sso_handoffs: Arc::new(DashMap::new()),
|
||||
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
|
||||
ca: Arc::new(ca),
|
||||
approved_enrollments: Arc::new(DashMap::new()),
|
||||
};
|
||||
|
||||
Some((pool, state))
|
||||
}
|
||||
|
||||
/// Build an Axum app with just the PKI routes for testing.
|
||||
fn test_app(state: AppState) -> Router {
|
||||
Router::new().nest("/api/v1", router()).with_state(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crl_endpoint_returns_200_with_valid_pem() {
|
||||
let Some((pool, state)) = setup_app_state().await else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let app = test_app(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pki/crl.pem")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"CRL endpoint should return 200 OK"
|
||||
);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), 10_000)
|
||||
.await
|
||||
.expect("body should be readable");
|
||||
|
||||
let body_str = String::from_utf8(body.to_vec()).expect("body should be UTF-8");
|
||||
|
||||
assert!(
|
||||
body_str.contains("-----BEGIN X509 CRL-----"),
|
||||
"Response should contain CRL PEM header"
|
||||
);
|
||||
assert!(
|
||||
body_str.contains("-----END X509 CRL-----"),
|
||||
"Response should contain CRL PEM footer"
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crl_endpoint_returns_cache_control_header() {
|
||||
let Some((pool, state)) = setup_app_state().await else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let app = test_app(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pki/crl.pem")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let cache_control = response
|
||||
.headers()
|
||||
.get("cache-control")
|
||||
.expect("Cache-Control header should be present");
|
||||
|
||||
assert_eq!(
|
||||
cache_control.to_str().unwrap(),
|
||||
"max-age=3600",
|
||||
"Cache-Control should be max-age=3600"
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crl_endpoint_works_without_authentication() {
|
||||
let Some((pool, state)) = setup_app_state().await else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let app = test_app(state);
|
||||
|
||||
// Make request without any auth headers — CRL endpoint is public.
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pki/crl.pem")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
// Should return 200, not 401 Unauthorized.
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"CRL endpoint should be accessible without authentication"
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crl_endpoint_returns_pem_content_type() {
|
||||
let Some((pool, state)) = setup_app_state().await else {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let app = test_app(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pki/crl.pem")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.expect("Content-Type header should be present");
|
||||
|
||||
assert!(
|
||||
content_type
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("application/x-pem-file"),
|
||||
"Content-Type should be application/x-pem-file, got: {:?}",
|
||||
content_type
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
}
|
||||
}
|
||||
0
crates/pm-web/src/routes/reports.rs
Normal file → Executable file
0
crates/pm-web/src/routes/reports.rs
Normal file → Executable file
@ -115,6 +115,7 @@ pub struct OidcDiscoveryRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OidcDiscoveryResult {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
@ -179,6 +180,28 @@ fn write_access_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gate Manager-wide authentication configuration (OIDC, SMTP, IP allowlist,
|
||||
/// OIDC discover/test) behind the **Admin** role. Operators can still
|
||||
/// access per-host settings (see `write_access_required`).
|
||||
///
|
||||
/// Returns `403 forbidden_role` if the user is not an Admin. The distinct
|
||||
/// error code (vs `forbidden` from `write_access_required`) lets the SPA
|
||||
/// differentiate "you don't have write access at all" from "you have
|
||||
/// write access but not for this specific resource".
|
||||
///
|
||||
/// See issue #5 and `tasks/authz-gate-spec.md` for the full design.
|
||||
fn admin_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
|
||||
if !auth.role.is_admin() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(
|
||||
json!({ "error": { "code": "forbidden_role", "message": "Admin role required to modify this resource" } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_system_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
|
||||
@ -250,11 +273,23 @@ async fn update_config_key(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tuple type for SELECT from oidc_config table (used by fetch_oidc_config).
|
||||
type OidcConfigRow = (
|
||||
bool,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<Vec<u8>>,
|
||||
Option<Vec<u8>>,
|
||||
String,
|
||||
String,
|
||||
);
|
||||
async fn fetch_oidc_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<OidcConfigResponse, (StatusCode, Json<Value>)> {
|
||||
let row: Option<(bool, String, String, String, String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
let row: Option<OidcConfigRow> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret_encrypted, client_secret_nonce, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
@ -273,7 +308,8 @@ async fn fetch_oidc_config(
|
||||
display_name,
|
||||
discovery_url,
|
||||
client_id,
|
||||
client_secret,
|
||||
client_secret_encrypted,
|
||||
_client_secret_nonce,
|
||||
redirect_uri,
|
||||
scopes,
|
||||
)) => OidcConfigResponse {
|
||||
@ -282,7 +318,7 @@ async fn fetch_oidc_config(
|
||||
display_name,
|
||||
discovery_url,
|
||||
client_id,
|
||||
client_secret: if client_secret.is_empty() {
|
||||
client_secret: if client_secret_encrypted.is_none() {
|
||||
String::new()
|
||||
} else {
|
||||
MASKED.to_string()
|
||||
@ -332,7 +368,7 @@ async fn update_settings(
|
||||
auth: AuthUser,
|
||||
Json(req): Json<UpdateSettingsRequest>,
|
||||
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
||||
write_access_required(&auth)?;
|
||||
admin_required(&auth)?;
|
||||
|
||||
// Update OIDC config
|
||||
if let Some(oidc) = req.oidc {
|
||||
@ -342,6 +378,22 @@ async fn update_settings(
|
||||
.is_some_and(|s| s != MASKED && !s.is_empty());
|
||||
|
||||
let result = if update_secret {
|
||||
// Encrypt the client_secret before persisting
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption key error" } })),
|
||||
)
|
||||
})?;
|
||||
let plaintext = oidc.client_secret.as_deref().unwrap_or("");
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(plaintext, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt OIDC client_secret");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query(
|
||||
"UPDATE oidc_config SET \
|
||||
enabled = COALESCE($1, enabled), \
|
||||
@ -349,9 +401,10 @@ async fn update_settings(
|
||||
display_name = COALESCE($3, display_name), \
|
||||
discovery_url = COALESCE($4, discovery_url), \
|
||||
client_id = COALESCE($5, client_id), \
|
||||
client_secret = $6, \
|
||||
redirect_uri = COALESCE($7, redirect_uri), \
|
||||
scopes = COALESCE($8, scopes), \
|
||||
client_secret_encrypted = $6, \
|
||||
client_secret_nonce = $7, \
|
||||
redirect_uri = COALESCE($8, redirect_uri), \
|
||||
scopes = COALESCE($9, scopes), \
|
||||
updated_at = NOW() \
|
||||
WHERE id = 1",
|
||||
)
|
||||
@ -360,7 +413,8 @@ async fn update_settings(
|
||||
.bind(&oidc.display_name)
|
||||
.bind(&oidc.discovery_url)
|
||||
.bind(&oidc.client_id)
|
||||
.bind(oidc.client_secret.as_deref().unwrap_or(""))
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(&oidc.redirect_uri)
|
||||
.bind(&oidc.scopes)
|
||||
.execute(&state.db)
|
||||
@ -399,7 +453,7 @@ async fn update_settings(
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::ConfigChanged,
|
||||
AuditAction::OidcConfigUpdated,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("oidc"),
|
||||
@ -427,7 +481,59 @@ async fn update_settings(
|
||||
}
|
||||
if let Some(ref v) = smtp.password {
|
||||
if v != MASKED {
|
||||
update_config_key(&state.db, "smtp_password", v).await?;
|
||||
// Encrypt the SMTP password before persisting
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption key error" } })),
|
||||
)
|
||||
})?;
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(v, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt SMTP password");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
// Delete old plaintext row, write two new rows (encrypted + nonce)
|
||||
sqlx::query("DELETE FROM system_config WHERE key = 'smtp_password'")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to delete old smtp_password row");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
// Store as hex in TEXT columns (system_config uses TEXT)
|
||||
let enc_hex: String = ciphertext.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let nonce_hex: String = nonce.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_encrypted")
|
||||
.bind(&enc_hex)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to write smtp_password_encrypted");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_nonce")
|
||||
.bind(&nonce_hex)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to write smtp_password_nonce");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if let Some(ref v) = smtp.from {
|
||||
@ -439,7 +545,7 @@ async fn update_settings(
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::ConfigChanged,
|
||||
AuditAction::SmtpConfigUpdated,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("smtp"),
|
||||
@ -484,7 +590,7 @@ async fn update_settings(
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::ConfigChanged,
|
||||
AuditAction::IpWhitelistUpdated,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("ip_whitelist"),
|
||||
@ -562,7 +668,7 @@ async fn discover_oidc(
|
||||
auth: AuthUser,
|
||||
Json(req): Json<OidcDiscoveryRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
write_access_required(&auth)?;
|
||||
admin_required(&auth)?;
|
||||
|
||||
if req.discovery_url.is_empty() {
|
||||
return Err((
|
||||
@ -587,6 +693,20 @@ async fn discover_oidc(
|
||||
match client.get(&req.discovery_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: Value = resp.json().await.unwrap_or(json!({}));
|
||||
// Audit log: Admin probed the OIDC discovery endpoint (issue #5).
|
||||
// Non-fatal: log_event logs errors internally and does not propagate.
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::OidcDiscoverPerformed,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("oidc"),
|
||||
Some(&req.discovery_url),
|
||||
json!({ "discovery_url": req.discovery_url }),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
@ -619,7 +739,7 @@ async fn test_oidc(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
write_access_required(&auth)?;
|
||||
admin_required(&auth)?;
|
||||
|
||||
let row: Option<(bool, String, String)> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
|
||||
@ -678,6 +798,23 @@ async fn test_oidc(
|
||||
"azure" => "Azure AD",
|
||||
_ => "OIDC",
|
||||
};
|
||||
// Audit log: Admin tested the OIDC provider connection (issue #5).
|
||||
// Non-fatal: log_event logs errors internally and does not propagate.
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::OidcTestPerformed,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("oidc"),
|
||||
Some(&discovery_url),
|
||||
json!({
|
||||
"discovery_url": discovery_url,
|
||||
"provider_type": provider_type,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": format!("{} provider verified successfully", provider_label),
|
||||
@ -696,6 +833,9 @@ async fn test_oidc(
|
||||
}
|
||||
}
|
||||
|
||||
// Note: OIDC test audit log is emitted in the success path below.
|
||||
// The above error cases don't persist, so no audit log is needed for them.
|
||||
|
||||
// ============================================================
|
||||
// POST /api/v1/settings/azure-sso/test (backward-compatible alias)
|
||||
// ============================================================
|
||||
@ -733,7 +873,32 @@ async fn test_smtp(
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(587);
|
||||
let username = cfg.get("smtp_username").cloned().unwrap_or_default();
|
||||
let password = cfg.get("smtp_password").cloned().unwrap_or_default();
|
||||
// Decrypt the SMTP password (issue #6 fix — stored as two rows in system_config:
|
||||
// `smtp_password_encrypted` (hex) and `smtp_password_nonce` (hex))
|
||||
let password = match (
|
||||
cfg.get("smtp_password_encrypted"),
|
||||
cfg.get("smtp_password_nonce"),
|
||||
) {
|
||||
(Some(enc_hex), Some(nonce_hex)) => {
|
||||
let key = match crate::secret_key::get() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({ "error": { "code": "internal_error", "message": "Encryption key error" } }),
|
||||
),
|
||||
));
|
||||
},
|
||||
};
|
||||
// Decode hex to bytes (hex_decode returns empty Vec on invalid input)
|
||||
let enc_bytes = hex_decode(enc_hex);
|
||||
let nonce_bytes = hex_decode(nonce_hex);
|
||||
pm_core::crypto::decrypt(&enc_bytes, &nonce_bytes, key).unwrap_or_default()
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
let from_addr = cfg.get("smtp_from").cloned().unwrap_or_default();
|
||||
let tls_mode = cfg
|
||||
.get("smtp_tls_mode")
|
||||
@ -898,7 +1063,7 @@ async fn update_ip_whitelist(
|
||||
auth: AuthUser,
|
||||
Json(req): Json<IpWhitelistUpdate>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
write_access_required(&auth)?;
|
||||
admin_required(&auth)?;
|
||||
|
||||
// Validate each entry
|
||||
for entry in &req.entries {
|
||||
@ -920,7 +1085,7 @@ async fn update_ip_whitelist(
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::ConfigChanged,
|
||||
AuditAction::IpWhitelistUpdated,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("ip_whitelist"),
|
||||
@ -974,3 +1139,70 @@ async fn audit_integrity(
|
||||
})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Decode a hex string to bytes. Returns an empty Vec on invalid input.
|
||||
/// Used by the SMTP password decryption logic (issue #6 fix).
|
||||
fn hex_decode(s: &str) -> Vec<u8> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return Vec::new();
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.filter_map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(unused_imports)]
|
||||
use super::*;
|
||||
use axum::http::StatusCode;
|
||||
use pm_auth::jwt::AccessClaims;
|
||||
use pm_auth::rbac::{AuthUser, UserRole};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Build a minimal `AuthUser` for role-gate testing.
|
||||
/// The `admin_required` gate only inspects `auth.role`, so all other
|
||||
/// fields can be placeholder values.
|
||||
#[allow(dead_code)]
|
||||
fn test_auth_user(role: UserRole) -> AuthUser {
|
||||
let claims = AccessClaims {
|
||||
sub: Uuid::new_v4().to_string(),
|
||||
iat: 0,
|
||||
exp: i64::MAX,
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
role: role.as_str().to_string(),
|
||||
username: "test-user".to_string(),
|
||||
};
|
||||
AuthUser {
|
||||
user_id: Uuid::new_v4(),
|
||||
username: "test-user".to_string(),
|
||||
role,
|
||||
claims,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_required_admin_passes() {
|
||||
let auth = test_auth_user(UserRole::Admin);
|
||||
admin_required(&auth).expect("Admin should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_required_operator_denied() {
|
||||
let auth = test_auth_user(UserRole::Operator);
|
||||
let err = admin_required(&auth).expect_err("Operator should be denied");
|
||||
let (status, body) = err;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_required_reporter_denied() {
|
||||
let auth = test_auth_user(UserRole::Reporter);
|
||||
let err = admin_required(&auth).expect_err("Reporter should be denied");
|
||||
let (status, body) = err;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +12,12 @@ use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Redirect},
|
||||
routing::get,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
|
||||
use pm_auth::{jwt::issue_access_token, refresh};
|
||||
use pm_core::audit::{log_event, AuditAction};
|
||||
@ -40,6 +41,140 @@ pub struct SsoSession {
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Single-use, short-lived payload that the SSO callback hands to the SPA
|
||||
/// via a `?handoff=<code>` query param. The SPA exchanges it via
|
||||
/// `POST /api/v1/auth/sso/handoff` for the actual JWT access/refresh
|
||||
/// tokens. Mirrors the WS-ticket pattern (issue #10): in-memory, atomic
|
||||
/// single-use consume, TTL enforced on read.
|
||||
///
|
||||
/// See `tasks/sso-token-handoff-spec.md` §4.1 for the full design.
|
||||
#[derive(Clone)]
|
||||
pub struct SsoHandoff {
|
||||
/// JWT access token (short-lived, 15 min TTL).
|
||||
pub access_token: String,
|
||||
/// Opaque refresh token (long-lived, rotating).
|
||||
pub raw_refresh: String,
|
||||
/// JSON-serialized user object (id, username, display_name, role, etc.).
|
||||
pub user_json: Value,
|
||||
/// Access token TTL in seconds (for the `expires_in` field in the response).
|
||||
pub access_ttl: u64,
|
||||
/// Expiry instant; the exchange endpoint rejects codes past this time.
|
||||
pub expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// TTL for SSO handoff codes. Short by design: the SPA should POST to
|
||||
/// `/api/v1/auth/sso/handoff` within seconds of the redirect landing.
|
||||
///
|
||||
/// `dead_code` is allowed here because Phase 1 introduces the store
|
||||
/// ahead of its consumer; the SSO callback rewrite in Phase 2 of
|
||||
/// `tasks/sso-token-handoff-spec.md` inserts handoffs with this TTL and
|
||||
/// the exchange handler reads it back to validate freshness.
|
||||
#[allow(dead_code)]
|
||||
pub const HANDOFF_TTL_SECS: u64 = 60;
|
||||
|
||||
/// Generate a cryptographically random handoff code (32 bytes,
|
||||
/// base64url-encoded, ~43 chars). Uses the same `rand` crate family as
|
||||
/// the WS-ticket path.
|
||||
///
|
||||
/// `dead_code` is allowed here for the same reason as `HANDOFF_TTL_SECS`
|
||||
/// — Phase 2 wires it into the SSO callback redirect construction.
|
||||
#[allow(dead_code)]
|
||||
pub fn generate_handoff_code() -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/v1/auth/sso/handoff`.
|
||||
///
|
||||
/// The SPA sends the handoff code it received in the SSO callback
|
||||
/// redirect's `?handoff=...` query param, and the backend exchanges it
|
||||
/// for the actual access/refresh tokens. The code is single-use and
|
||||
/// 60-second TTL.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HandoffRequest {
|
||||
pub handoff_code: String,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Handoff exchange handler
|
||||
// ============================================================
|
||||
|
||||
/// `POST /api/v1/auth/sso/handoff` — exchange a single-use handoff code
|
||||
/// for the JWT access/refresh tokens + user object. Public route (no
|
||||
/// JWT required) — the handoff code IS the credential.
|
||||
///
|
||||
/// See `tasks/sso-token-handoff-spec.md` §4.2 for the full design.
|
||||
async fn sso_handoff_exchange(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<HandoffRequest>,
|
||||
) -> (StatusCode, Json<Value>) {
|
||||
sso_handoff_exchange_inner(&state.sso_handoffs, &req.handoff_code).await
|
||||
}
|
||||
|
||||
/// Core exchange logic, separated from the HTTP handler so tests can
|
||||
/// drive it with a bare `DashMap` (no need to construct a full
|
||||
/// `AppState` with a real `sqlx::PgPool` and `Arc<AppConfig>`).
|
||||
///
|
||||
/// Marked `async` so the race test can use `tokio::join!` to drive
|
||||
/// two concurrent exchanges against the same code; the function body
|
||||
/// has no `.await` points (it only does a DashMap read and a return),
|
||||
/// so this is a zero-cost abstraction.
|
||||
async fn sso_handoff_exchange_inner(
|
||||
handoffs: &DashMap<String, SsoHandoff>,
|
||||
code: &str,
|
||||
) -> (StatusCode, Json<Value>) {
|
||||
// Atomically remove the entry (single-use guarantee). If two
|
||||
// requests race with the same code, DashMap::remove is atomic so
|
||||
// only one wins.
|
||||
let removed = handoffs.remove(code);
|
||||
let Some((_code, handoff)) = removed else {
|
||||
tracing::warn!(
|
||||
reason = "unknown_or_already_consumed",
|
||||
"SSO handoff exchange failed"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" }
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
// Check expiry (the cleanup task also removes expired entries, but
|
||||
// there's a race between expiry and the next cleanup tick — check
|
||||
// here too so we never return a token for an expired handoff).
|
||||
if handoff.expires_at <= std::time::Instant::now() {
|
||||
tracing::warn!(reason = "expired", "SSO handoff exchange failed");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" }
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Log success without leaking the handoff code or the tokens
|
||||
let user_id = handoff
|
||||
.user_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
tracing::info!(user_id = %user_id, "SSO handoff exchanged");
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"access_token": handoff.access_token,
|
||||
"refresh_token": handoff.raw_refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": handoff.access_ttl,
|
||||
"user": handoff.user_json,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
#[allow(dead_code)]
|
||||
@ -78,11 +213,29 @@ pub struct OidcConfig {
|
||||
pub display_name: String,
|
||||
pub discovery_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
/// AES-256-GCM encrypted client_secret. `None` if not set or public client.
|
||||
pub client_secret_encrypted: Option<Vec<u8>>,
|
||||
/// AES-256-GCM nonce for client_secret. Must be paired with `client_secret_encrypted`.
|
||||
pub client_secret_nonce: Option<Vec<u8>>,
|
||||
pub redirect_uri: String,
|
||||
pub scopes: String,
|
||||
}
|
||||
|
||||
impl OidcConfig {
|
||||
/// Decrypt the client_secret using the provided key.
|
||||
/// Returns `Ok(String::new())` if the secret is not set (public client).
|
||||
/// Returns `Err(CryptoError)` if decryption fails or nonce is missing.
|
||||
pub fn decrypt_client_secret(
|
||||
&self,
|
||||
key: &[u8; 32],
|
||||
) -> Result<String, pm_core::crypto::CryptoError> {
|
||||
match (&self.client_secret_encrypted, &self.client_secret_nonce) {
|
||||
(Some(enc), Some(nonce)) => pm_core::crypto::decrypt(enc, nonce, key),
|
||||
_ => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached OIDC discovery document.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcDiscovery {
|
||||
@ -95,22 +248,13 @@ pub struct OidcDiscovery {
|
||||
}
|
||||
|
||||
/// Cache for OIDC discovery documents and JWKS with TTL-based refresh.
|
||||
#[derive(Default)]
|
||||
pub struct OidcCache {
|
||||
pub discovery: Option<OidcDiscovery>,
|
||||
pub jwks: Option<serde_json::Value>,
|
||||
pub jwks_fetched_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Default for OidcCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
discovery: None,
|
||||
jwks: None,
|
||||
jwks_fetched_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JWKS cache TTL in seconds (1 hour).
|
||||
const JWKS_CACHE_TTL_SECS: i64 = 3600;
|
||||
/// Discovery cache TTL in seconds (1 hour).
|
||||
@ -125,6 +269,12 @@ pub fn public_router() -> Router<AppState> {
|
||||
.route("/login", get(sso_login))
|
||||
.route("/callback", get(sso_callback))
|
||||
.route("/config", get(sso_config))
|
||||
// Issue #4: single-use handoff exchange. The SPA POSTs the
|
||||
// `?handoff=<code>` it received from the SSO callback redirect
|
||||
// and gets the JWT access/refresh tokens in the JSON response.
|
||||
// Public route (no JWT) — the handoff code IS the credential.
|
||||
// See `tasks/sso-token-handoff-spec.md` §4.2.
|
||||
.route("/handoff", post(sso_handoff_exchange))
|
||||
}
|
||||
|
||||
/// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints.
|
||||
@ -332,8 +482,28 @@ async fn sso_callback(
|
||||
];
|
||||
|
||||
// For confidential clients (Azure AD), include client_secret
|
||||
if !config.client_secret.is_empty() {
|
||||
params_vec.push(("client_secret", config.client_secret.clone()));
|
||||
let key = match crate::secret_key::get() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
return Err(error_redirect(
|
||||
"internal_error",
|
||||
"Failed to load encryption key",
|
||||
));
|
||||
},
|
||||
};
|
||||
let client_secret = match config.decrypt_client_secret(key) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to decrypt OIDC client_secret");
|
||||
return Err(error_redirect(
|
||||
"internal_error",
|
||||
"Failed to decrypt client_secret",
|
||||
));
|
||||
},
|
||||
};
|
||||
if !client_secret.is_empty() {
|
||||
params_vec.push(("client_secret", client_secret));
|
||||
}
|
||||
|
||||
let token_resp = match client
|
||||
@ -492,10 +662,11 @@ async fn sso_callback(
|
||||
DbUserForSso {
|
||||
id: existing.id,
|
||||
username: existing.username.clone(),
|
||||
display_name: name
|
||||
.is_empty()
|
||||
.then(|| existing.display_name.clone())
|
||||
.unwrap_or(name),
|
||||
display_name: if name.is_empty() {
|
||||
existing.display_name.clone()
|
||||
} else {
|
||||
name
|
||||
},
|
||||
role: existing.role.clone(),
|
||||
is_active: existing.is_active,
|
||||
mfa_enabled: existing.mfa_enabled,
|
||||
@ -612,13 +783,32 @@ async fn sso_callback(
|
||||
"mfa_enabled": user.mfa_enabled,
|
||||
});
|
||||
|
||||
let redirect_url = format!(
|
||||
"{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}",
|
||||
callback_url,
|
||||
urlencoding::encode(&access_token),
|
||||
urlencoding::encode(&raw_refresh.0),
|
||||
access_ttl,
|
||||
urlencoding::encode(&user_json.to_string()),
|
||||
// Issue #4 fix: instead of embedding access/refresh tokens in the
|
||||
// redirect URL (which leaks through browser history, proxy access
|
||||
// logs, and the Referer header), generate a single-use, 60s handoff
|
||||
// code, store the payload in `sso_handoffs`, and put ONLY the code
|
||||
// in the redirect. The SPA POSTs to `/api/v1/auth/sso/handoff` to
|
||||
// exchange the code for tokens. See `tasks/sso-token-handoff-spec.md`
|
||||
// §4.1.
|
||||
let handoff_code = generate_handoff_code();
|
||||
state.sso_handoffs.insert(
|
||||
handoff_code.clone(),
|
||||
SsoHandoff {
|
||||
access_token: access_token.clone(),
|
||||
raw_refresh: raw_refresh.0.clone(),
|
||||
user_json: user_json.clone(),
|
||||
access_ttl: access_ttl as u64,
|
||||
expires_at: std::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(HANDOFF_TTL_SECS),
|
||||
},
|
||||
);
|
||||
|
||||
let redirect_url = format!("{}?handoff={}", callback_url, handoff_code);
|
||||
|
||||
tracing::info!(
|
||||
user_id = %user.id,
|
||||
auth_provider = %auth_provider,
|
||||
"SSO handoff issued"
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&redirect_url))
|
||||
@ -647,7 +837,9 @@ async fn azure_callback_redirect(
|
||||
|
||||
async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode, Json<Value>)> {
|
||||
let row: Option<OidcConfig> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, \
|
||||
client_secret_encrypted, client_secret_nonce, redirect_uri, scopes \
|
||||
FROM oidc_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
@ -665,7 +857,8 @@ async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode
|
||||
display_name: "Azure AD".to_string(),
|
||||
discovery_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
client_secret_encrypted: None,
|
||||
client_secret_nonce: None,
|
||||
redirect_uri: String::new(),
|
||||
scopes: "openid profile email".to_string(),
|
||||
}))
|
||||
@ -844,3 +1037,168 @@ async fn fetch_jwks(jwks_uri: &str) -> Result<serde_json::Value, String> {
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse JWKS response: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for the SSO handoff exchange endpoint and cleanup task.
|
||||
//!
|
||||
//! Per `tasks/sso-token-handoff-spec.md` §6.1–6.2.
|
||||
//!
|
||||
//! The tests call `sso_handoff_exchange_inner` directly with a bare
|
||||
//! `DashMap<String, SsoHandoff>`. This avoids the need to construct
|
||||
//! a full `AppState` (which has `sqlx::PgPool` and `Arc<AppConfig>`
|
||||
//! fields that can't be cheaply mocked) and keeps the tests focused
|
||||
//! on the exchange logic. The HTTP handler is a thin wrapper that
|
||||
//! extracts the code from the request body and delegates.
|
||||
|
||||
use super::*;
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn fresh_handoffs() -> Arc<DashMap<String, SsoHandoff>> {
|
||||
Arc::new(DashMap::new())
|
||||
}
|
||||
|
||||
fn make_handoff(access: &str, refresh: &str, user_id: &str) -> SsoHandoff {
|
||||
SsoHandoff {
|
||||
access_token: access.to_string(),
|
||||
raw_refresh: refresh.to_string(),
|
||||
user_json: json!({ "id": user_id, "username": "testuser" }),
|
||||
access_ttl: 900,
|
||||
expires_at: Instant::now() + Duration::from_secs(HANDOFF_TTL_SECS),
|
||||
}
|
||||
}
|
||||
|
||||
/// 1. handoff_exchange_success — create a handoff, exchange it,
|
||||
/// expect 200 with the access/refresh/user fields.
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_success() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let code = generate_handoff_code();
|
||||
handoffs.insert(
|
||||
code.clone(),
|
||||
make_handoff("jwt-access", "refresh-raw", "user-123"),
|
||||
);
|
||||
|
||||
let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["access_token"], "jwt-access");
|
||||
assert_eq!(body["refresh_token"], "refresh-raw");
|
||||
assert_eq!(body["token_type"], "Bearer");
|
||||
assert_eq!(body["expires_in"], 900);
|
||||
assert_eq!(body["user"]["id"], "user-123");
|
||||
}
|
||||
|
||||
/// 2. handoff_exchange_single_use — exchange once (success),
|
||||
/// exchange the same code again (expect 400 invalid_handoff).
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_single_use() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let code = generate_handoff_code();
|
||||
handoffs.insert(code.clone(), make_handoff("a", "r", "u"));
|
||||
|
||||
// First exchange succeeds
|
||||
let (status1, _) = sso_handoff_exchange_inner(&handoffs, &code).await;
|
||||
assert_eq!(status1, StatusCode::OK);
|
||||
|
||||
// Second exchange with the same code fails (entry was removed)
|
||||
let (status2, body2) = sso_handoff_exchange_inner(&handoffs, &code).await;
|
||||
assert_eq!(status2, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body2["error"]["code"], "invalid_handoff");
|
||||
}
|
||||
|
||||
/// 3. handoff_exchange_unknown_code — exchange a code that was
|
||||
/// never issued (expect 400 invalid_handoff).
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_unknown_code() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let (status, body) = sso_handoff_exchange_inner(&handoffs, "never-issued-code").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["code"], "invalid_handoff");
|
||||
}
|
||||
|
||||
/// 4. handoff_exchange_expired_code — create a handoff with
|
||||
/// expires_at in the past, exchange (expect 400 invalid_handoff).
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_expired_code() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let code = generate_handoff_code();
|
||||
let mut h = make_handoff("a", "r", "u");
|
||||
h.expires_at = Instant::now() - Duration::from_secs(1); // already expired
|
||||
handoffs.insert(code.clone(), h);
|
||||
|
||||
let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["code"], "invalid_handoff");
|
||||
}
|
||||
|
||||
/// 5. handoff_exchange_race — two concurrent exchanges with the
|
||||
/// same code; exactly one succeeds, the other gets 400.
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_race() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let code = generate_handoff_code();
|
||||
handoffs.insert(code.clone(), make_handoff("a", "r", "u"));
|
||||
|
||||
// DashMap::remove is atomic, so only one of two concurrent
|
||||
// calls can win. The other gets None and returns 400.
|
||||
let h1 = handoffs.clone();
|
||||
let h2 = handoffs.clone();
|
||||
let c1 = code.clone();
|
||||
let c2 = code.clone();
|
||||
let (r1, r2) = tokio::join!(
|
||||
sso_handoff_exchange_inner(&h1, &c1),
|
||||
sso_handoff_exchange_inner(&h2, &c2),
|
||||
);
|
||||
|
||||
let status1 = r1.0;
|
||||
let status2 = r2.0;
|
||||
let successes = [status1, status2]
|
||||
.iter()
|
||||
.filter(|s| **s == StatusCode::OK)
|
||||
.count();
|
||||
let failures = [status1, status2]
|
||||
.iter()
|
||||
.filter(|s| **s == StatusCode::BAD_REQUEST)
|
||||
.count();
|
||||
assert_eq!(successes, 1, "exactly one exchange should succeed");
|
||||
assert_eq!(failures, 1, "exactly one exchange should fail");
|
||||
}
|
||||
|
||||
/// 6. handoff_exchange_malformed_body — exchange with an empty
|
||||
/// code (expect 400 invalid_handoff).
|
||||
#[tokio::test]
|
||||
async fn handoff_exchange_malformed_body() {
|
||||
let handoffs = fresh_handoffs();
|
||||
let (status, body) = sso_handoff_exchange_inner(&handoffs, "").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["code"], "invalid_handoff");
|
||||
}
|
||||
|
||||
/// 7. handoff_cleanup_removes_expired — create 3 handoffs with
|
||||
/// varying `expires_at`, run one tick of the cleanup task,
|
||||
/// assert only the non-expired ones remain.
|
||||
#[tokio::test]
|
||||
async fn handoff_cleanup_removes_expired() {
|
||||
let handoffs = fresh_handoffs();
|
||||
// 2 expired, 1 fresh
|
||||
for (i, expired) in [true, false, true].iter().enumerate() {
|
||||
let mut h = make_handoff(&format!("a{}", i), "r", "u");
|
||||
if *expired {
|
||||
h.expires_at = Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
handoffs.insert(format!("code-{}", i), h);
|
||||
}
|
||||
assert_eq!(handoffs.len(), 3);
|
||||
|
||||
// Simulate one tick of the cleanup task (mirrors the logic
|
||||
// in main.rs lines 174-188)
|
||||
let now = Instant::now();
|
||||
handoffs.retain(|_, v| v.expires_at > now);
|
||||
|
||||
assert_eq!(handoffs.len(), 1);
|
||||
assert!(handoffs.contains_key("code-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,16 @@ pub struct FleetStatus {
|
||||
pub total_pending_patches: i64,
|
||||
pub hosts_requiring_reboot: i64,
|
||||
pub compliance_pct: f64,
|
||||
/// Hosts with CRL status 'valid'.
|
||||
pub crl_valid: i64,
|
||||
/// Hosts with CRL status 'expired'.
|
||||
pub crl_expired: i64,
|
||||
/// Hosts with CRL status 'missing' (agent reports missing CRL).
|
||||
pub crl_missing: i64,
|
||||
/// Hosts with CRL status 'invalid' (security event — needs immediate attention).
|
||||
pub crl_invalid: i64,
|
||||
/// Hosts not reporting CRL status (older agents or no data yet).
|
||||
pub crl_not_reporting: i64,
|
||||
}
|
||||
|
||||
// ── GET /api/v1/status/fleet ──────────────────────────────────────────────────
|
||||
@ -132,6 +142,34 @@ pub async fn fleet_status(
|
||||
// Round to one decimal place.
|
||||
let compliance_pct = (compliance_pct * 10.0).round() / 10.0;
|
||||
|
||||
// ── 5. CRL status counts ────────────────────────────────────────────────
|
||||
let (crl_valid, crl_expired, crl_missing, crl_invalid, crl_not_reporting): (
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN crl_status = 'valid' THEN 1 END), 0),
|
||||
COALESCE(SUM(CASE WHEN crl_status = 'expired' THEN 1 END), 0),
|
||||
COALESCE(SUM(CASE WHEN crl_status = 'missing' THEN 1 END), 0),
|
||||
COALESCE(SUM(CASE WHEN crl_status = 'invalid' THEN 1 END), 0),
|
||||
COALESCE(SUM(CASE WHEN crl_status IS NULL THEN 1 END), 0)
|
||||
FROM hosts
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "fleet_status: failed to query CRL status counts");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(FleetStatus {
|
||||
total_hosts,
|
||||
healthy,
|
||||
@ -141,5 +179,10 @@ pub async fn fleet_status(
|
||||
total_pending_patches,
|
||||
hosts_requiring_reboot,
|
||||
compliance_pct,
|
||||
crl_valid,
|
||||
crl_expired,
|
||||
crl_missing,
|
||||
crl_invalid,
|
||||
crl_not_reporting,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -534,7 +534,7 @@ async fn admin_disable_mfa(
|
||||
));
|
||||
}
|
||||
|
||||
let rows = sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1")
|
||||
let rows = sqlx::query("UPDATE users SET totp_secret_encrypted = NULL, totp_secret_nonce = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
|
||||
@ -6,14 +6,14 @@
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket},
|
||||
extract::{Query, State, WebSocketUpgrade},
|
||||
http::StatusCode,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Json, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::postgres::PgListener;
|
||||
use ulid::Ulid;
|
||||
@ -57,6 +57,160 @@ fn err(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Origin parsing & allowlist matching ───────────────────────────────────────
|
||||
|
||||
/// Parsed browser `Origin` header value.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Origin {
|
||||
scheme: String,
|
||||
host: String,
|
||||
/// `None` means "use scheme default" (80 for http, 443 for https).
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
/// Render back to canonical `scheme://host[:port]` form with default
|
||||
/// ports normalized away (so `https://x:443` becomes `https://x`).
|
||||
fn canonical(&self) -> String {
|
||||
let default_port: Option<u16> = match self.scheme.as_str() {
|
||||
"https" => Some(443),
|
||||
"http" => Some(80),
|
||||
_ => None,
|
||||
};
|
||||
match (self.port, default_port) {
|
||||
(Some(p), Some(d)) if p == d => format!("{}://{}", self.scheme, self.host),
|
||||
(Some(p), _) => format!("{}://{}:{}", self.scheme, self.host, p),
|
||||
(None, _) => format!("{}://{}", self.scheme, self.host),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a raw `Origin` header value. Returns `None` for missing scheme,
|
||||
/// unsupported schemes (only `http`/`https`), empty host, or whitespace in
|
||||
/// the host. IPv6 literal hosts are explicitly rejected to keep the parser
|
||||
/// simple — WebSocket connections from IPv6 browser origins are not a
|
||||
/// realistic deployment for this product.
|
||||
fn parse_origin_header(value: &str) -> Option<Origin> {
|
||||
let s = value.trim().trim_end_matches('/');
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (scheme, rest) = s.split_once("://")?;
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return None;
|
||||
}
|
||||
// Authority is everything up to the first `/`, `?`, or `#`.
|
||||
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||||
let authority = &rest[..authority_end];
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Treat the LAST `:` as the port separator. IPv6 literal hosts (e.g.
|
||||
// `[::1]`) contain a `:` inside the brackets; reject those.
|
||||
let (host, port_str) = match authority.rsplit_once(':') {
|
||||
Some((h, _)) if h.contains(':') => return None,
|
||||
Some((h, p)) => (h, Some(p)),
|
||||
None => (authority, None),
|
||||
};
|
||||
let host = host.trim();
|
||||
if host.is_empty() || host.contains(char::is_whitespace) || host.contains(':') {
|
||||
return None;
|
||||
}
|
||||
let port = match port_str {
|
||||
Some(p) => match p.parse::<u16>() {
|
||||
Ok(n) => Some(n),
|
||||
Err(_) => return None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Some(Origin {
|
||||
scheme,
|
||||
host: host.to_ascii_lowercase(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
/// Match a parsed `Origin` against an allowlist. Each allowlist entry is
|
||||
/// itself parsed with [`parse_origin_header`] and compared by its canonical
|
||||
/// string form, so entry syntax is forgiving (`https://x:443` matches an
|
||||
/// incoming `https://x`). The host comparison is case-insensitive (the
|
||||
/// parser lowercases the host); scheme and port are exact.
|
||||
///
|
||||
/// An empty allowlist returns `false` (fail-closed).
|
||||
fn is_origin_allowed(origin: &Origin, allowlist: &[String]) -> bool {
|
||||
if allowlist.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let incoming = origin.canonical();
|
||||
allowlist
|
||||
.iter()
|
||||
.any(|entry| match parse_origin_header(entry) {
|
||||
Some(parsed) => parsed.canonical() == incoming,
|
||||
None => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the `Origin` header from a request and check it against the
|
||||
/// configured allowlist. Returns `Ok(())` when the request may proceed; on
|
||||
/// rejection returns the appropriate `(StatusCode, Json)` error tuple and
|
||||
/// the reason string (for logging).
|
||||
fn check_origin(
|
||||
headers: &HeaderMap,
|
||||
allowlist: &[String],
|
||||
) -> Result<(), ((StatusCode, Json<Value>), &'static str)> {
|
||||
let raw = match headers.get(axum::http::header::ORIGIN) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Err((
|
||||
err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden_origin",
|
||||
"Origin header required",
|
||||
),
|
||||
"missing",
|
||||
));
|
||||
},
|
||||
};
|
||||
let raw_str = match raw.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return Err((
|
||||
err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden_origin",
|
||||
"Origin header not valid ASCII",
|
||||
),
|
||||
"non-ascii",
|
||||
));
|
||||
},
|
||||
};
|
||||
let origin = match parse_origin_header(raw_str) {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
return Err((
|
||||
err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden_origin",
|
||||
"Malformed Origin header",
|
||||
),
|
||||
"malformed",
|
||||
));
|
||||
},
|
||||
};
|
||||
if !is_origin_allowed(&origin, allowlist) {
|
||||
return Err((
|
||||
err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden_origin",
|
||||
"Origin not allowed",
|
||||
),
|
||||
"not-allowlisted",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── POST /api/v1/ws/ticket ────────────────────────────────────────────────────
|
||||
|
||||
/// Issue a single-use WebSocket authentication ticket (60 s expiry).
|
||||
@ -93,11 +247,40 @@ pub struct WsQuery {
|
||||
}
|
||||
|
||||
/// Browser WebSocket upgrade endpoint — authenticates via single-use ticket.
|
||||
///
|
||||
/// The handler enforces two independent gates, in this order:
|
||||
///
|
||||
/// 1. `Origin` header allowlist (CSWSH defense-in-depth). Performed first so
|
||||
/// that a cross-origin probe with a leaked/stolen ticket does not consume
|
||||
/// the legitimate user's ticket.
|
||||
/// 2. Single-use, 60-second ticket (existing behavior, unchanged).
|
||||
pub async fn ws_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<WsQuery>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Result<Response, (StatusCode, Json<Value>)> {
|
||||
// Gate 1: Origin allowlist (CSWSH defense-in-depth).
|
||||
let allowlist = &state.config.security.allowed_origins;
|
||||
if let Err((http_err, reason)) = check_origin(&headers, allowlist) {
|
||||
let raw_origin = headers
|
||||
.get(axum::http::header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<absent>");
|
||||
// Never log the ticket value.
|
||||
tracing::warn!(
|
||||
reason = reason,
|
||||
origin = %raw_origin,
|
||||
"WebSocket upgrade rejected: forbidden origin"
|
||||
);
|
||||
return Err(http_err);
|
||||
}
|
||||
let allowed_origin = headers
|
||||
.get(axum::http::header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Validate and consume the ticket atomically.
|
||||
let ticket = {
|
||||
let entry = state.ws_tickets.get(&q.ticket);
|
||||
@ -129,6 +312,7 @@ pub async fn ws_handler(
|
||||
tracing::info!(
|
||||
user_id = %ticket.user_id,
|
||||
role = %ticket.role,
|
||||
origin = %allowed_origin,
|
||||
"Browser WebSocket connection upgraded"
|
||||
);
|
||||
|
||||
@ -188,11 +372,9 @@ async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTi
|
||||
tracing::info!(user_id = %ticket.user_id, "Browser WS closed by client");
|
||||
break;
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
if socket.send(Message::Pong(data)).await.is_err() {
|
||||
Some(Ok(Message::Ping(data))) if socket.send(Message::Pong(data.clone())).await.is_err() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!(error = %e, user_id = %ticket.user_id, "Browser WS recv error");
|
||||
break;
|
||||
@ -205,3 +387,289 @@ async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTi
|
||||
|
||||
tracing::info!(user_id = %ticket.user_id, "Browser WS handler exiting");
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_origin_header ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_basic_https() {
|
||||
assert_eq!(
|
||||
parse_origin_header("https://app.example.com"),
|
||||
Some(Origin {
|
||||
scheme: "https".into(),
|
||||
host: "app.example.com".into(),
|
||||
port: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_explicit_port() {
|
||||
assert_eq!(
|
||||
parse_origin_header("https://app.example.com:8443"),
|
||||
Some(Origin {
|
||||
scheme: "https".into(),
|
||||
host: "app.example.com".into(),
|
||||
port: Some(8443),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lowercases_scheme() {
|
||||
assert_eq!(
|
||||
parse_origin_header("HTTPS://App.Example.com")
|
||||
.unwrap()
|
||||
.scheme,
|
||||
"https"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lowercases_host() {
|
||||
assert_eq!(
|
||||
parse_origin_header("https://App.Example.com").unwrap().host,
|
||||
"app.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ignores_path_query_fragment() {
|
||||
let o = parse_origin_header("https://app.example.com:443/some/path?q=1#frag").unwrap();
|
||||
assert_eq!(o.host, "app.example.com");
|
||||
assert_eq!(o.port, Some(443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_strips_trailing_slash() {
|
||||
assert_eq!(
|
||||
parse_origin_header("https://app.example.com/"),
|
||||
Some(Origin {
|
||||
scheme: "https".into(),
|
||||
host: "app.example.com".into(),
|
||||
port: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_empty() {
|
||||
assert!(parse_origin_header("").is_none());
|
||||
assert!(parse_origin_header(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unsupported_scheme() {
|
||||
assert!(parse_origin_header("ftp://x").is_none());
|
||||
assert!(parse_origin_header("file:///etc/passwd").is_none());
|
||||
assert!(parse_origin_header("javascript:alert(1)").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_empty_host() {
|
||||
assert!(parse_origin_header("https://").is_none());
|
||||
assert!(parse_origin_header("https:///path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_host_with_whitespace() {
|
||||
assert!(parse_origin_header("https://bad host").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_malformed_port() {
|
||||
assert!(parse_origin_header("https://x:notaport").is_none());
|
||||
assert!(parse_origin_header("https://x:99999").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_ipv6_literal() {
|
||||
assert!(parse_origin_header("https://[::1]").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_no_scheme_separator() {
|
||||
assert!(parse_origin_header("app.example.com").is_none());
|
||||
}
|
||||
|
||||
// ── canonical ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn canonical_strips_default_https_port() {
|
||||
let o = Origin {
|
||||
scheme: "https".into(),
|
||||
host: "x".into(),
|
||||
port: Some(443),
|
||||
};
|
||||
assert_eq!(o.canonical(), "https://x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_strips_default_http_port() {
|
||||
let o = Origin {
|
||||
scheme: "http".into(),
|
||||
host: "x".into(),
|
||||
port: Some(80),
|
||||
};
|
||||
assert_eq!(o.canonical(), "http://x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_keeps_non_default_port() {
|
||||
let o = Origin {
|
||||
scheme: "https".into(),
|
||||
host: "x".into(),
|
||||
port: Some(8443),
|
||||
};
|
||||
assert_eq!(o.canonical(), "https://x:8443");
|
||||
}
|
||||
|
||||
// ── is_origin_allowed ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn allowed_exact_match() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_default_port_normalization_incoming() {
|
||||
let o = parse_origin_header("https://app.example.com:443").unwrap();
|
||||
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_default_port_normalization_allowlist() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(is_origin_allowed(
|
||||
&o,
|
||||
&["https://app.example.com:443".into()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_case_insensitive_host() {
|
||||
let o = parse_origin_header("https://App.Example.com").unwrap();
|
||||
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_different_host() {
|
||||
let o = parse_origin_header("https://evil.example").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_different_scheme() {
|
||||
let o = parse_origin_header("http://app.example.com").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_different_port() {
|
||||
let o = parse_origin_header("https://app.example.com:8443").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_empty_allowlist() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_garbage_in_allowlist() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(!is_origin_allowed(&o, &["not a url".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_multi_entry_allowlist() {
|
||||
let o = parse_origin_header("https://app.example.com").unwrap();
|
||||
assert!(is_origin_allowed(
|
||||
&o,
|
||||
&[
|
||||
"https://other.example".into(),
|
||||
"https://app.example.com".into(),
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
// ── check_origin (integration of parse + allow) ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn check_rejects_missing_header() {
|
||||
let h = HeaderMap::new();
|
||||
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
|
||||
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1, "missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_malformed_header() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(axum::http::header::ORIGIN, "not a url".parse().unwrap());
|
||||
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
|
||||
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1, "malformed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_disallowed_origin() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://evil.example".parse().unwrap(),
|
||||
);
|
||||
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
|
||||
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1, "not-allowlisted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_empty_allowlist() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://app.example.com".parse().unwrap(),
|
||||
);
|
||||
let err = check_origin(&h, &[]).unwrap_err();
|
||||
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1, "not-allowlisted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allows_valid_origin() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://app.example.com".parse().unwrap(),
|
||||
);
|
||||
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allows_default_port_normalization() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://app.example.com:443".parse().unwrap(),
|
||||
);
|
||||
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allows_case_insensitive_host() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
"https://App.Example.com".parse().unwrap(),
|
||||
);
|
||||
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
44
crates/pm-web/src/secret_key.rs
Normal file
44
crates/pm-web/src/secret_key.rs
Normal file
@ -0,0 +1,44 @@
|
||||
//! Secret-encryption key loader for pm-web.
|
||||
//!
|
||||
//! Lazily loads the per-install AES-256-GCM key from
|
||||
//! `/etc/patch-manager/keys/secret-encryption.key` on first use, caches it
|
||||
//! in process memory, and returns a `&'static [u8; 32]` for all subsequent calls.
|
||||
//!
|
||||
//! Uses `std::sync::OnceLock` (stable since Rust 1.70) to avoid the `once_cell` dependency.
|
||||
//!
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.4 for the design rationale.
|
||||
|
||||
use pm_core::crypto;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SECRET_KEY: OnceLock<[u8; 32]> = OnceLock::new();
|
||||
|
||||
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
|
||||
/// Returns `CryptoError` if the key file is missing or invalid.
|
||||
///
|
||||
/// Auto-generates the key file on first start (with 0600 permissions) if it doesn't exist.
|
||||
#[allow(dead_code)]
|
||||
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
|
||||
if let Some(key) = SECRET_KEY.get() {
|
||||
return Ok(key);
|
||||
}
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))?;
|
||||
// _ = ignore error if another thread won the race (already set by them)
|
||||
let _ = SECRET_KEY.set(key);
|
||||
Ok(SECRET_KEY.get().expect("key was just set"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[test]
|
||||
fn once_lock_caches_value() {
|
||||
let cell: OnceLock<u32> = OnceLock::new();
|
||||
let v1 = cell.get_or_init(|| 42);
|
||||
let v2 = cell.get_or_init(|| 99); // Should return 42, not 99
|
||||
assert_eq!(*v1, 42);
|
||||
assert_eq!(*v2, 42);
|
||||
}
|
||||
}
|
||||
530
crates/pm-web/tests/integration/authz_gate.rs
Normal file
530
crates/pm-web/tests/integration/authz_gate.rs
Normal file
@ -0,0 +1,530 @@
|
||||
//! Integration tests for the authz gate that restricts auth config mutations
|
||||
//! (OIDC, SMTP, IP whitelist) to the Admin role only.
|
||||
//!
|
||||
//! See Issue #15 for the full specification.
|
||||
//!
|
||||
//! ## Test organization
|
||||
//!
|
||||
//! The 403 (forbidden_role) tests verify that the authorization middleware
|
||||
//! rejects non-admin roles BEFORE any handler or database logic runs. These
|
||||
//! tests use a lazy PgPool (no live database required) and pre-generated CA
|
||||
//! files, so they always pass in CI.
|
||||
//!
|
||||
//! The 200 (admin allowed) tests verify the full handler path including audit
|
||||
//! logging. They require a live PostgreSQL database and are marked `#[ignore]`
|
||||
//! so they only run when `DATABASE_URL` is set and `--ignored` is passed.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use dashmap::DashMap;
|
||||
use http_body_util::BodyExt;
|
||||
use pm_auth::jwt;
|
||||
use pm_auth::rbac::AuthConfig;
|
||||
use pm_core::config::AppConfig;
|
||||
use pm_web::routes::sso::OidcCache;
|
||||
use pm_web::{build_router, AppState};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Ed25519 test key pair ────────────────────────────────────────────────────
|
||||
const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIBrWiMMcgpPXwtGDSSBl01fcQyb5Vh4CMzEmxcSXvcrJ
|
||||
-----END PRIVATE KEY-----
|
||||
";
|
||||
|
||||
const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEACgE6fMDCcG11NOpPKSO/ASpPUSntB7XsF5sBFBYDjFo=
|
||||
-----END PUBLIC KEY-----
|
||||
";
|
||||
|
||||
// ── Fixed test user IDs (so we can seed matching rows in the DB) ─────────────
|
||||
const ADMIN_USER_ID: &str = "00000000-0000-4000-8000-000000000001";
|
||||
const OPERATOR_USER_ID: &str = "00000000-0000-4000-8000-000000000002";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a valid JWT authorization header for the given role.
|
||||
fn auth_header(role: &str) -> String {
|
||||
let user_id = match role {
|
||||
"admin" => Uuid::parse_str(ADMIN_USER_ID).unwrap(),
|
||||
_ => Uuid::parse_str(OPERATOR_USER_ID).unwrap(),
|
||||
};
|
||||
let username = format!("test-{}", role);
|
||||
let token = jwt::issue_access_token(user_id, &username, role, 900, TEST_SIGNING_KEY)
|
||||
.expect("failed to issue test JWT");
|
||||
format!("Bearer {}", token)
|
||||
}
|
||||
|
||||
/// Generate CA key and cert files on disk so `CertAuthority::init` can load
|
||||
/// them without needing a database connection.
|
||||
fn generate_ca_files(ca_dir: &std::path::Path) {
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256,
|
||||
};
|
||||
|
||||
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("generate CA key");
|
||||
let mut params = CertificateParams::default();
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, "Test Root CA");
|
||||
|
||||
let cert = params.self_signed(&key).expect("self-sign CA cert");
|
||||
|
||||
std::fs::create_dir_all(ca_dir).expect("create CA dir");
|
||||
std::fs::write(ca_dir.join("ca.key"), key.serialize_pem()).expect("write ca.key");
|
||||
std::fs::write(ca_dir.join("ca.crt"), cert.pem()).expect("write ca.crt");
|
||||
}
|
||||
|
||||
/// Build a minimal `AppState` suitable for 403 authz gate tests.
|
||||
///
|
||||
/// Uses a lazy PgPool (no live database connection required) and pre-generated
|
||||
/// CA files. This works because the authorization middleware rejects non-admin
|
||||
/// requests BEFORE any handler or database logic runs.
|
||||
async fn setup_state_no_db() -> AppState {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://test:test@localhost:5432/test")
|
||||
.expect("failed to create lazy pool");
|
||||
|
||||
let mut config = AppConfig::default();
|
||||
config.server.static_dir = "/tmp".to_string();
|
||||
|
||||
let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[]));
|
||||
|
||||
let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA");
|
||||
let ca_dir_path = ca_dir.path().to_path_buf();
|
||||
generate_ca_files(&ca_dir_path);
|
||||
std::mem::forget(ca_dir);
|
||||
|
||||
let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool)
|
||||
.await
|
||||
.expect("CA init failed");
|
||||
|
||||
AppState {
|
||||
db: pool,
|
||||
config: Arc::new(config),
|
||||
signing_key_pem: TEST_SIGNING_KEY.to_string(),
|
||||
auth_config,
|
||||
ws_tickets: Arc::new(DashMap::new()),
|
||||
sso_sessions: Arc::new(DashMap::new()),
|
||||
sso_handoffs: Arc::new(DashMap::new()),
|
||||
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
|
||||
ca: Arc::new(ca),
|
||||
approved_enrollments: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed test users into the database so that audit_log foreign-key
|
||||
/// constraints on `actor_user_id` are satisfied.
|
||||
async fn seed_test_users(pool: &PgPool) {
|
||||
let placeholder_hash = "$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder";
|
||||
for (user_id, username, role) in [
|
||||
(ADMIN_USER_ID, "test-admin", "admin"),
|
||||
(OPERATOR_USER_ID, "test-operator", "operator"),
|
||||
] {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO users (id, username, display_name, email, role, auth_provider, password_hash)
|
||||
VALUES ($1, $2, $3, $4, $5::user_role, 'local', $6)
|
||||
ON CONFLICT (id) DO NOTHING"#,
|
||||
)
|
||||
.bind(Uuid::parse_str(user_id).unwrap())
|
||||
.bind(username)
|
||||
.bind(username)
|
||||
.bind(format!("{}@test.example.com", username))
|
||||
.bind(role)
|
||||
.bind(placeholder_hash)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("failed to seed test user");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a full `AppState` with a live database connection.
|
||||
async fn setup_state(pool: PgPool) -> AppState {
|
||||
seed_test_users(&pool).await;
|
||||
|
||||
let mut config = AppConfig::default();
|
||||
config.server.static_dir = "/tmp".to_string();
|
||||
|
||||
let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[]));
|
||||
|
||||
let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA");
|
||||
let ca_dir_path = ca_dir.path().to_path_buf();
|
||||
std::mem::forget(ca_dir);
|
||||
|
||||
let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool)
|
||||
.await
|
||||
.expect("CA init failed");
|
||||
|
||||
AppState {
|
||||
db: pool,
|
||||
config: Arc::new(config),
|
||||
signing_key_pem: TEST_SIGNING_KEY.to_string(),
|
||||
auth_config,
|
||||
ws_tickets: Arc::new(DashMap::new()),
|
||||
sso_sessions: Arc::new(DashMap::new()),
|
||||
sso_handoffs: Arc::new(DashMap::new()),
|
||||
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
|
||||
ca: Arc::new(ca),
|
||||
approved_enrollments: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a request through the full Axum router and return the response.
|
||||
async fn send_request(
|
||||
state: AppState,
|
||||
method: axum::http::Method,
|
||||
uri: &str,
|
||||
auth_header: Option<&str>,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> (StatusCode, serde_json::Value) {
|
||||
let router = build_router(state);
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(auth) = auth_header {
|
||||
builder = builder.header("authorization", auth);
|
||||
}
|
||||
builder = builder.header("content-type", "application/json");
|
||||
|
||||
let req = if let Some(b) = body {
|
||||
builder.body(Body::from(b.to_string())).unwrap()
|
||||
} else {
|
||||
builder.body(Body::empty()).unwrap()
|
||||
};
|
||||
|
||||
// Insert ConnectInfo so tower_governor's SmartIpKeyExtractor can resolve the client IP.
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts
|
||||
.extensions
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let req = Request::from_parts(parts, body);
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_else(|_| {
|
||||
let raw = String::from_utf8_lossy(&body_bytes);
|
||||
json!({ "_raw": raw.to_string() })
|
||||
});
|
||||
(status, body_json)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 403 Forbidden Role tests — no database required
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// These tests verify that the authorization middleware rejects non-admin roles
|
||||
// BEFORE any handler or database logic runs. They use a lazy PgPool and
|
||||
// pre-generated CA files, so they always pass in CI.
|
||||
|
||||
/// 1. PUT /api/v1/settings with operator role → 403 forbidden_role
|
||||
#[tokio::test]
|
||||
async fn update_settings_operator_denied() {
|
||||
let state = setup_state_no_db().await;
|
||||
let auth = auth_header("operator");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::PUT,
|
||||
"/api/v1/settings",
|
||||
Some(&auth),
|
||||
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
/// 3. PUT /api/v1/settings/ip-whitelist with operator role → 403 forbidden_role
|
||||
#[tokio::test]
|
||||
async fn update_ip_whitelist_operator_denied() {
|
||||
let state = setup_state_no_db().await;
|
||||
let auth = auth_header("operator");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::PUT,
|
||||
"/api/v1/settings/ip-whitelist",
|
||||
Some(&auth),
|
||||
Some(json!({ "entries": ["10.0.0.0/8"] })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
/// 5. POST /api/v1/settings/sso/discover with operator role → 403 forbidden_role
|
||||
#[tokio::test]
|
||||
async fn discover_oidc_operator_denied() {
|
||||
let state = setup_state_no_db().await;
|
||||
let auth = auth_header("operator");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/settings/sso/discover",
|
||||
Some(&auth),
|
||||
Some(json!({ "discovery_url": "https://example.com/.well-known/openid-configuration" })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
/// 7. POST /api/v1/settings/sso/test with operator role → 403 forbidden_role
|
||||
#[tokio::test]
|
||||
async fn test_oidc_operator_denied() {
|
||||
let state = setup_state_no_db().await;
|
||||
let auth = auth_header("operator");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/settings/sso/test",
|
||||
Some(&auth),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "forbidden_role");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 200 Admin Allowed tests — require live database
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// These tests verify the full handler path including audit logging.
|
||||
// They require a live PostgreSQL database and are marked `#[ignore]` so they
|
||||
// only run when DATABASE_URL is set and `--ignored` is passed.
|
||||
|
||||
/// 2. PUT /api/v1/settings with admin role → 200 + audit log
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
#[ignore]
|
||||
async fn update_settings_admin_allowed(pool: PgPool) {
|
||||
let state = setup_state(pool).await;
|
||||
let pool = state.db.clone();
|
||||
let auth = auth_header("admin");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::PUT,
|
||||
"/api/v1/settings",
|
||||
Some(&auth),
|
||||
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"expected 200, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT action::text FROM audit_log WHERE action::text = 'config_changed' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("audit log query failed");
|
||||
assert!(row.is_some(), "expected audit log entry for config_changed");
|
||||
}
|
||||
|
||||
/// 4. PUT /api/v1/settings/ip-whitelist with admin role → 200 + audit log
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
#[ignore]
|
||||
async fn update_ip_whitelist_admin_allowed(pool: PgPool) {
|
||||
let state = setup_state(pool).await;
|
||||
let pool = state.db.clone();
|
||||
let auth = auth_header("admin");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::PUT,
|
||||
"/api/v1/settings/ip-whitelist",
|
||||
Some(&auth),
|
||||
Some(json!({ "entries": ["10.0.0.0/8"] })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"expected 200, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT action::text FROM audit_log WHERE action::text = 'ip_whitelist_updated' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("audit log query failed");
|
||||
assert!(
|
||||
row.is_some(),
|
||||
"expected audit log entry for ip_whitelist_updated"
|
||||
);
|
||||
}
|
||||
|
||||
/// 6. POST /api/v1/settings/sso/discover with admin role → 200 + audit log
|
||||
/// Uses mockito to simulate an OIDC discovery endpoint.
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
#[ignore]
|
||||
async fn discover_oidc_admin_allowed(pool: PgPool) {
|
||||
let state = setup_state(pool).await;
|
||||
let pool = state.db.clone();
|
||||
let auth = auth_header("admin");
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/.well-known/openid-configuration")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
json!({
|
||||
"issuer": "https://mock-oidc.example.com",
|
||||
"authorization_endpoint": "https://mock-oidc.example.com/auth",
|
||||
"token_endpoint": "https://mock-oidc.example.com/token",
|
||||
"jwks_uri": "https://mock-oidc.example.com/jwks",
|
||||
"userinfo_endpoint": "https://mock-oidc.example.com/userinfo"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/settings/sso/discover",
|
||||
Some(&auth),
|
||||
Some(json!({ "discovery_url": discovery_url })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"expected 200, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["success"], true);
|
||||
|
||||
mock.assert_async().await;
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_discover_performed' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("audit log query failed");
|
||||
assert!(
|
||||
row.is_some(),
|
||||
"expected audit log entry for oidc_discover_performed"
|
||||
);
|
||||
}
|
||||
|
||||
/// 8. POST /api/v1/settings/sso/test with admin role → 200 + audit log
|
||||
/// Uses mockito to simulate an OIDC discovery endpoint.
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
#[ignore]
|
||||
async fn test_oidc_admin_allowed(pool: PgPool) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/.well-known/openid-configuration")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
json!({
|
||||
"issuer": "https://mock-oidc.example.com",
|
||||
"authorization_endpoint": "https://mock-oidc.example.com/auth",
|
||||
"token_endpoint": "https://mock-oidc.example.com/token",
|
||||
"jwks_uri": "https://mock-oidc.example.com/jwks"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
|
||||
|
||||
// Seed the oidc_config table with an enabled provider pointing to mockito.
|
||||
sqlx::query("UPDATE oidc_config SET enabled = true, discovery_url = $1 WHERE id = 1")
|
||||
.bind(&discovery_url)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("failed to seed oidc_config");
|
||||
|
||||
let state = setup_state(pool).await;
|
||||
let pool = state.db.clone();
|
||||
let auth = auth_header("admin");
|
||||
|
||||
let (status, body) = send_request(
|
||||
state,
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/settings/sso/test",
|
||||
Some(&auth),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"expected 200, got {}: {:?}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert_eq!(body["success"], true);
|
||||
|
||||
mock.assert_async().await;
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_test_performed' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("audit log query failed");
|
||||
assert!(
|
||||
row.is_some(),
|
||||
"expected audit log entry for oidc_test_performed"
|
||||
);
|
||||
}
|
||||
1
crates/pm-web/tests/integration/main.rs
Normal file
1
crates/pm-web/tests/integration/main.rs
Normal file
@ -0,0 +1 @@
|
||||
mod authz_gate;
|
||||
@ -15,13 +15,13 @@ pm-agent-client = { path = "../pm-agent-client" }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
sqlx = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
tokio-rustls = { version = "0.26" }
|
||||
|
||||
0
crates/pm-worker/src/agent_loader.rs
Normal file → Executable file
0
crates/pm-worker/src/agent_loader.rs
Normal file → Executable file
0
crates/pm-worker/src/audit_verifier.rs
Normal file → Executable file
0
crates/pm-worker/src/audit_verifier.rs
Normal file → Executable file
@ -9,7 +9,6 @@ use lettre::{
|
||||
transport::smtp::authentication::Credentials,
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
};
|
||||
use serde_json;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use pm_core::audit::{log_event, AuditAction};
|
||||
@ -33,11 +32,16 @@ struct NotificationSettings {
|
||||
}
|
||||
|
||||
/// Load SMTP settings from the `system_config` table.
|
||||
///
|
||||
/// Issue #6 fix: SMTP password is stored as two rows:
|
||||
/// - `smtp_password_encrypted` (hex of AES-256-GCM ciphertext)
|
||||
/// - `smtp_password_nonce` (hex of AES-256-GCM nonce)
|
||||
async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT key, value FROM system_config WHERE key IN (
|
||||
'smtp_enabled', 'smtp_host', 'smtp_port', 'smtp_username',
|
||||
'smtp_password', 'smtp_from', 'smtp_tls_mode'
|
||||
'smtp_password_encrypted', 'smtp_password_nonce',
|
||||
'smtp_from', 'smtp_tls_mode'
|
||||
)",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@ -51,17 +55,46 @@ async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// Decrypt the SMTP password
|
||||
let enc_hex = get("smtp_password_encrypted");
|
||||
let nonce_hex = get("smtp_password_nonce");
|
||||
let password = if !enc_hex.is_empty() && !nonce_hex.is_empty() {
|
||||
match (
|
||||
hex_decode(&enc_hex),
|
||||
hex_decode(&nonce_hex),
|
||||
crate::secret_key::get(),
|
||||
) {
|
||||
(Some(enc), Some(nonce), Ok(key)) => {
|
||||
pm_core::crypto::decrypt(&enc, &nonce, key).unwrap_or_default()
|
||||
},
|
||||
_ => String::new(),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
SmtpSettings {
|
||||
enabled: get("smtp_enabled") == "true",
|
||||
host: get("smtp_host"),
|
||||
port: get("smtp_port").parse().unwrap_or(587),
|
||||
username: get("smtp_username"),
|
||||
password: get("smtp_password"),
|
||||
password,
|
||||
from: get("smtp_from"),
|
||||
tls_mode: get("smtp_tls_mode"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a hex string to bytes. Returns None on invalid input.
|
||||
fn hex_decode(s: &str) -> Option<Vec<u8>> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load notification preferences from `system_config`.
|
||||
async fn load_notification_settings(pool: &PgPool) -> NotificationSettings {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
@ -290,6 +323,7 @@ pub async fn send_job_completion_email(
|
||||
}
|
||||
|
||||
/// Send a maintenance window reminder email.
|
||||
#[allow(dead_code)]
|
||||
pub async fn send_maintenance_window_reminder_email(
|
||||
pool: &PgPool,
|
||||
host_fqdn: &str,
|
||||
|
||||
1
crates/pm-worker/src/health_check_poller.rs
Normal file → Executable file
1
crates/pm-worker/src/health_check_poller.rs
Normal file → Executable file
@ -22,6 +22,7 @@ use pm_agent_client::{AgentClient, AgentClientError};
|
||||
|
||||
/// Row fetched for each enabled health check, joined with host connection info.
|
||||
#[derive(FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct HealthCheckRow {
|
||||
id: Uuid,
|
||||
host_id: Uuid,
|
||||
|
||||
@ -2,12 +2,26 @@
|
||||
//!
|
||||
//! Polls every host via the agent `/health` endpoint on each tick of
|
||||
//! `health_poll_interval_secs`, with bounded concurrency controlled by a
|
||||
//! [`tokio::sync::Semaphore`].
|
||||
//! [`tokio::sync::Semaphore`]. Also calls `/system/info` to refresh
|
||||
//! `os_family`, `os_name`, `arch`, and `agent_version` in the hosts table.
|
||||
//!
|
||||
//! CRL health aggregation rules (PR 5):
|
||||
//! - `crl_status = "invalid"` → host health_status overridden to `unreachable`
|
||||
//! - `crl_status = "expired"` → host health_status overridden to `degraded` (if currently healthy)
|
||||
//! - `crl_status = "missing"` AND registered > 24h ago → host health_status overridden to `degraded` (if currently healthy)
|
||||
//! - `crl_status = "valid"` or NULL → no override
|
||||
//!
|
||||
//! Audit events are logged for CRL state transitions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use pm_agent_client::{AgentClient, AgentClientError};
|
||||
use pm_core::{config::AppConfig, models::HostHealthStatus};
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
config::AppConfig,
|
||||
models::HostHealthStatus,
|
||||
};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use tokio::{sync::Semaphore, time};
|
||||
use uuid::Uuid;
|
||||
@ -20,6 +34,10 @@ struct HostRow {
|
||||
id: Uuid,
|
||||
ip_address: String,
|
||||
agent_port: i32,
|
||||
/// Current CRL status from the hosts table (for transition detection).
|
||||
crl_status: Option<String>,
|
||||
/// When the host was first registered (for enrollment age checks).
|
||||
registered_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Run the health poller loop indefinitely.
|
||||
@ -49,9 +67,9 @@ pub async fn run_health_poller(pool: PgPool, config: Arc<AppConfig>) {
|
||||
let client_key = Arc::new(certs.client_key);
|
||||
let ca_cert = Arc::new(certs.ca_cert);
|
||||
|
||||
// Fetch all hosts.
|
||||
// Fetch all hosts with CRL status and registration time.
|
||||
let hosts: Vec<HostRow> = match sqlx::query_as(
|
||||
"SELECT id, host(ip_address)::text AS ip_address, agent_port FROM hosts ORDER BY id",
|
||||
"SELECT id, host(ip_address)::text AS ip_address, agent_port, crl_status, registered_at FROM hosts ORDER BY id",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
@ -114,6 +132,11 @@ pub async fn run_health_poller(pool: PgPool, config: Arc<AppConfig>) {
|
||||
}
|
||||
|
||||
/// Poll a single host, persist the result, and return the determined status.
|
||||
///
|
||||
/// Also updates `agent_version` from the health response,
|
||||
/// `os_family`/`os_name`/`arch` from the `/system/info` endpoint when available,
|
||||
/// CRL status fields from the health response when reported by the agent,
|
||||
/// and applies CRL health aggregation rules.
|
||||
async fn poll_host_health(
|
||||
pool: PgPool,
|
||||
host: HostRow,
|
||||
@ -121,8 +144,16 @@ async fn poll_host_health(
|
||||
client_key: &[u8],
|
||||
ca_cert: &[u8],
|
||||
) -> HostHealthStatus {
|
||||
// Determine status and optional health payload.
|
||||
let (status, payload) = match AgentClient::new(
|
||||
// Determine status, payload, agent version, optional system info, and CRL fields.
|
||||
let (
|
||||
natural_status,
|
||||
payload,
|
||||
agent_version,
|
||||
sys_info,
|
||||
crl_status,
|
||||
crl_age_seconds,
|
||||
crl_next_update,
|
||||
) = match AgentClient::new(
|
||||
&host.ip_address,
|
||||
host.agent_port as u16,
|
||||
client_cert,
|
||||
@ -138,18 +169,41 @@ async fn poll_host_health(
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
Ok(client) => match client.health().await {
|
||||
Ok(client) => {
|
||||
let (status, payload, version, crl_status, crl_age, crl_next) = match client
|
||||
.health()
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
let payload = serde_json::to_value(&data).unwrap_or_default();
|
||||
(HostHealthStatus::Healthy, payload)
|
||||
let crl_status = data.crl_status.clone();
|
||||
let crl_age = data.crl_age_seconds;
|
||||
let crl_next = data.crl_next_update.clone();
|
||||
(
|
||||
HostHealthStatus::Healthy,
|
||||
payload,
|
||||
Some(data.version),
|
||||
crl_status,
|
||||
crl_age,
|
||||
crl_next,
|
||||
)
|
||||
},
|
||||
Err(AgentClientError::Timeout) => {
|
||||
tracing::warn!(host_id = %host.id, "Health poller: agent timed out");
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
Err(AgentClientError::Connect(_)) => {
|
||||
@ -157,6 +211,10 @@ async fn poll_host_health(
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
@ -164,12 +222,42 @@ async fn poll_host_health(
|
||||
(
|
||||
HostHealthStatus::Degraded,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Insert into host_health_data.
|
||||
// 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,
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
// Apply CRL health aggregation rules to determine the effective status.
|
||||
// Only apply when the agent reported a CRL status (non-NULL).
|
||||
let effective_status = apply_crl_health_rules(&natural_status, &crl_status, host.registered_at);
|
||||
|
||||
// Insert into host_health_data with the natural (pre-aggregation) status.
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO host_health_data (host_id, status, payload)
|
||||
@ -177,7 +265,7 @@ async fn poll_host_health(
|
||||
"#,
|
||||
)
|
||||
.bind(host.id)
|
||||
.bind(&status)
|
||||
.bind(&natural_status)
|
||||
.bind(&payload)
|
||||
.execute(&pool)
|
||||
.await
|
||||
@ -185,21 +273,403 @@ async fn poll_host_health(
|
||||
tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to insert health data");
|
||||
}
|
||||
|
||||
// Update hosts table.
|
||||
// Build OS name from system info components (e.g. "Ubuntu 24.04").
|
||||
let os_name_from_sysinfo = sys_info
|
||||
.as_ref()
|
||||
.map(|i| format!("{} {}", i.os, i.os_version));
|
||||
|
||||
// Parse CRL next_update from ISO-8601 string to DateTime if present.
|
||||
let crl_next_update_dt: Option<chrono::DateTime<chrono::Utc>> = crl_next_update
|
||||
.as_ref()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.to_utc());
|
||||
|
||||
// Update hosts table with the effective (post-aggregation) health status,
|
||||
// agent version, OS details, and CRL fields.
|
||||
// COALESCE preserves existing values when new data is unavailable.
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
UPDATE hosts
|
||||
SET health_status = $2, last_health_at = NOW()
|
||||
SET health_status = $2, last_health_at = NOW(),
|
||||
agent_version = COALESCE($3, agent_version),
|
||||
os_family = COALESCE($4, os_family),
|
||||
os_name = COALESCE($5, os_name),
|
||||
arch = COALESCE($6, arch),
|
||||
crl_status = COALESCE($7, crl_status),
|
||||
crl_age_seconds = COALESCE($8, crl_age_seconds),
|
||||
crl_next_update = COALESCE($9, crl_next_update)
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(host.id)
|
||||
.bind(&status)
|
||||
.bind(&effective_status)
|
||||
.bind(&agent_version)
|
||||
.bind(sys_info.as_ref().map(|i| i.os.as_str()))
|
||||
.bind(os_name_from_sysinfo)
|
||||
.bind(sys_info.as_ref().map(|i| i.architecture.as_str()))
|
||||
.bind(&crl_status)
|
||||
.bind(crl_age_seconds)
|
||||
.bind(crl_next_update_dt)
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to update host status");
|
||||
// Don't log audit events if the DB update failed.
|
||||
return effective_status;
|
||||
}
|
||||
|
||||
status
|
||||
// Log CRL audit events after successful database update.
|
||||
if let Some(ref new_crl) = crl_status {
|
||||
log_crl_audit_events(
|
||||
&pool,
|
||||
host.id,
|
||||
host.crl_status.as_deref(),
|
||||
new_crl,
|
||||
crl_age_seconds,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
effective_status
|
||||
}
|
||||
|
||||
/// Apply CRL health aggregation rules to determine the effective health status.
|
||||
///
|
||||
/// Rules:
|
||||
/// - `crl_status = "invalid"` → `Unreachable` (security event, always overrides)
|
||||
/// - `crl_status = "expired"` → `Degraded` (only if natural status is `Healthy`)
|
||||
/// - `crl_status = "missing"` AND registered > 24h ago → `Degraded` (only if natural status is `Healthy`)
|
||||
/// - `crl_status = "valid"` or NULL → no override
|
||||
fn apply_crl_health_rules(
|
||||
natural_status: &HostHealthStatus,
|
||||
crl_status: &Option<String>,
|
||||
registered_at: DateTime<Utc>,
|
||||
) -> HostHealthStatus {
|
||||
let Some(crl) = crl_status else {
|
||||
// Older agent not reporting CRL — don't modify health status.
|
||||
return natural_status.clone();
|
||||
};
|
||||
|
||||
match crl.as_str() {
|
||||
"invalid" => HostHealthStatus::Unreachable,
|
||||
"expired" => {
|
||||
if *natural_status == HostHealthStatus::Healthy {
|
||||
HostHealthStatus::Degraded
|
||||
} else {
|
||||
natural_status.clone()
|
||||
}
|
||||
},
|
||||
"missing" => {
|
||||
let age = Utc::now() - registered_at;
|
||||
if age > Duration::hours(24) && *natural_status == HostHealthStatus::Healthy {
|
||||
HostHealthStatus::Degraded
|
||||
} else {
|
||||
natural_status.clone()
|
||||
}
|
||||
},
|
||||
// "valid" or any other value — no override
|
||||
_ => natural_status.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log audit events for CRL state transitions.
|
||||
///
|
||||
/// Called after the hosts table has been successfully updated.
|
||||
/// Logs:
|
||||
/// - `CrlStatusChanged` when the CRL status transitions to a different value
|
||||
/// - `CrlStaleDetected` when CRL status becomes "expired"
|
||||
/// - `CrlInvalid` when CRL status becomes "invalid"
|
||||
async fn log_crl_audit_events(
|
||||
pool: &PgPool,
|
||||
host_id: Uuid,
|
||||
old_crl_status: Option<&str>,
|
||||
new_crl_status: &str,
|
||||
crl_age_seconds: Option<i64>,
|
||||
) {
|
||||
let host_id_str = host_id.to_string();
|
||||
let old_str = old_crl_status.unwrap_or("null");
|
||||
|
||||
// Log a transition event if the status changed.
|
||||
if old_crl_status != Some(new_crl_status) {
|
||||
let details = serde_json::json!({
|
||||
"host_id": host_id_str,
|
||||
"old_crl_status": old_str,
|
||||
"new_crl_status": new_crl_status,
|
||||
"crl_age_seconds": crl_age_seconds,
|
||||
});
|
||||
log_event(
|
||||
pool,
|
||||
AuditAction::CrlStatusChanged,
|
||||
None, // actor_user_id — system-initiated
|
||||
None, // actor_username
|
||||
Some("host"), // target_type
|
||||
Some(&host_id_str), // target_id
|
||||
details,
|
||||
None, // ip_address
|
||||
None, // request_id
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Log specific events for problematic CRL states.
|
||||
match new_crl_status {
|
||||
"expired" => {
|
||||
let details = serde_json::json!({
|
||||
"host_id": host_id_str,
|
||||
"old_crl_status": old_str,
|
||||
"new_crl_status": new_crl_status,
|
||||
"crl_age_seconds": crl_age_seconds,
|
||||
});
|
||||
log_event(
|
||||
pool,
|
||||
AuditAction::CrlStaleDetected,
|
||||
None,
|
||||
None,
|
||||
Some("host"),
|
||||
Some(&host_id_str),
|
||||
details,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
},
|
||||
"invalid" => {
|
||||
let details = serde_json::json!({
|
||||
"host_id": host_id_str,
|
||||
"old_crl_status": old_str,
|
||||
"new_crl_status": new_crl_status,
|
||||
"crl_age_seconds": crl_age_seconds,
|
||||
});
|
||||
log_event(
|
||||
pool,
|
||||
AuditAction::CrlInvalid,
|
||||
None,
|
||||
None,
|
||||
Some("host"),
|
||||
Some(&host_id_str),
|
||||
details,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — CRL health aggregation rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_crl_health {
|
||||
use super::*;
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
/// Helper: create a DateTime that is `hours` hours in the past.
|
||||
fn hours_ago(h: i64) -> DateTime<Utc> {
|
||||
Utc::now() - Duration::hours(h)
|
||||
}
|
||||
|
||||
// ---- crl_status = "invalid" → Unreachable (always overrides) ----
|
||||
|
||||
#[test]
|
||||
fn crl_invalid_overrides_healthy_to_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("invalid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_invalid_overrides_degraded_to_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Degraded,
|
||||
&Some("invalid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_invalid_overrides_unreachable_stays_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Unreachable,
|
||||
&Some("invalid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- crl_status = "expired" → Degraded (only if currently Healthy) ----
|
||||
|
||||
#[test]
|
||||
fn crl_expired_downgrades_healthy_to_degraded() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("expired".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_expired_does_not_override_degraded() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Degraded,
|
||||
&Some("expired".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_expired_does_not_downgrade_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Unreachable,
|
||||
&Some("expired".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- crl_status = "missing" AND registered > 24h → Degraded (if Healthy) ----
|
||||
|
||||
#[test]
|
||||
fn crl_missing_old_registration_downgrades_healthy() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("missing".to_string()),
|
||||
hours_ago(25),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_missing_recent_registration_no_override() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("missing".to_string()),
|
||||
hours_ago(12),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_missing_does_not_override_degraded() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Degraded,
|
||||
&Some("missing".to_string()),
|
||||
hours_ago(25),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_missing_does_not_override_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Unreachable,
|
||||
&Some("missing".to_string()),
|
||||
hours_ago(25),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- crl_status = "valid" → no override ----
|
||||
|
||||
#[test]
|
||||
fn crl_valid_does_not_override_healthy() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("valid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_valid_preserves_degraded() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Degraded,
|
||||
&Some("valid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_valid_preserves_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Unreachable,
|
||||
&Some("valid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- NULL crl_status → no override (backward compat) ----
|
||||
|
||||
#[test]
|
||||
fn null_crl_status_preserves_healthy() {
|
||||
let result = apply_crl_health_rules(&HostHealthStatus::Healthy, &None, hours_ago(0));
|
||||
assert_eq!(result, HostHealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_crl_status_preserves_degraded() {
|
||||
let result = apply_crl_health_rules(&HostHealthStatus::Degraded, &None, hours_ago(0));
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_crl_status_preserves_unreachable() {
|
||||
let result = apply_crl_health_rules(&HostHealthStatus::Unreachable, &None, hours_ago(0));
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
|
||||
// ---- Edge cases ----
|
||||
|
||||
#[test]
|
||||
fn crl_missing_just_under_24h_no_override() {
|
||||
// 23h 59m old — should NOT trigger degraded (threshold is > 24h)
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("missing".to_string()),
|
||||
Utc::now() - Duration::hours(23) - Duration::minutes(59),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_missing_just_over_24h_triggers_degraded() {
|
||||
// 24h + 1 minute old — should trigger degraded
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Healthy,
|
||||
&Some("missing".to_string()),
|
||||
Utc::now() - Duration::hours(24) - Duration::minutes(1),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_pending_status_preserved_with_valid_crl() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Pending,
|
||||
&Some("valid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crl_invalid_overrides_pending_to_unreachable() {
|
||||
let result = apply_crl_health_rules(
|
||||
&HostHealthStatus::Pending,
|
||||
&Some("invalid".to_string()),
|
||||
hours_ago(0),
|
||||
);
|
||||
assert_eq!(result, HostHealthStatus::Unreachable);
|
||||
}
|
||||
}
|
||||
|
||||
0
crates/pm-worker/src/job_executor.rs
Normal file → Executable file
0
crates/pm-worker/src/job_executor.rs
Normal file → Executable file
1
crates/pm-worker/src/main.rs
Normal file → Executable file
1
crates/pm-worker/src/main.rs
Normal file → Executable file
@ -12,6 +12,7 @@ mod job_executor;
|
||||
mod maintenance_scheduler;
|
||||
mod patch_poller;
|
||||
mod refresh_listener;
|
||||
mod secret_key;
|
||||
mod ws_relay;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
1
crates/pm-worker/src/maintenance_scheduler.rs
Normal file → Executable file
1
crates/pm-worker/src/maintenance_scheduler.rs
Normal file → Executable file
@ -45,6 +45,7 @@ struct AutoApplyWindow {
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct PendingPatchHost {
|
||||
host_id: Uuid,
|
||||
patch_count: i32,
|
||||
|
||||
0
crates/pm-worker/src/patch_poller.rs
Normal file → Executable file
0
crates/pm-worker/src/patch_poller.rs
Normal file → Executable file
0
crates/pm-worker/src/refresh_listener.rs
Normal file → Executable file
0
crates/pm-worker/src/refresh_listener.rs
Normal file → Executable file
29
crates/pm-worker/src/secret_key.rs
Normal file
29
crates/pm-worker/src/secret_key.rs
Normal file
@ -0,0 +1,29 @@
|
||||
//! Secret-encryption key loader for pm-worker.
|
||||
//!
|
||||
//! Lazily loads the per-install AES-256-GCM key from
|
||||
//! `/etc/patch-manager/keys/secret-encryption.key` on first use, caches it
|
||||
//! in process memory, and returns a `&'static [u8; 32]` for all subsequent calls.
|
||||
//!
|
||||
//! The pm-worker crate uses the same key file as pm-web (filesystem-shared).
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.4 for the design rationale.
|
||||
|
||||
use pm_core::crypto;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SECRET_KEY: OnceLock<[u8; 32]> = OnceLock::new();
|
||||
|
||||
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
|
||||
/// Returns `CryptoError` if the key file is missing or invalid.
|
||||
///
|
||||
/// Auto-generates the key file on first start (with 0600 permissions) if it doesn't exist.
|
||||
#[allow(dead_code)]
|
||||
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
|
||||
if let Some(key) = SECRET_KEY.get() {
|
||||
return Ok(key);
|
||||
}
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))?;
|
||||
// _ = ignore error if another thread won the race (already set by them)
|
||||
let _ = SECRET_KEY.set(key);
|
||||
Ok(SECRET_KEY.get().expect("key was just set"))
|
||||
}
|
||||
0
crates/pm-worker/src/ws_relay.rs
Normal file → Executable file
0
crates/pm-worker/src/ws_relay.rs
Normal file → Executable file
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
|
||||
|
||||
* Host Self-Enrollment: Added REST API and UI for automated agent enrollment
|
||||
|
||||
5
debian/control
vendored
5
debian/control
vendored
@ -1,9 +1,10 @@
|
||||
Package: linux-patch-manager
|
||||
Version: 1.0.0-1
|
||||
Version: 1.1.4-1
|
||||
Architecture: amd64
|
||||
Maintainer: Moon Dragon <echo@moon-dragon.us>
|
||||
Installed-Size: 45000
|
||||
Depends: postgresql-16, libssl3, libc6 (>= 2.39), libfontconfig1
|
||||
Pre-Depends: postgresql-16
|
||||
Depends: postgresql-16, argon2, libssl3, libc6 (>= 2.39), libfontconfig1
|
||||
Recommends: postgresql-client-16, fonts-dejavu-core
|
||||
Suggests: gpg
|
||||
Section: admin
|
||||
|
||||
377
debian/postinst
vendored
Normal file → Executable file
377
debian/postinst
vendored
Normal file → Executable file
@ -4,91 +4,348 @@ set -e
|
||||
# =============================================================================
|
||||
# Linux Patch Manager — Post-install script
|
||||
# =============================================================================
|
||||
# Fully automated: apt install ./linux-patch-manager_X.X.X-1_amd64.deb
|
||||
# results in a running service with a printed admin password.
|
||||
# All steps are idempotent (safe to re-run on upgrade).
|
||||
# =============================================================================
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Create service user if not exists
|
||||
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
|
||||
}
|
||||
|
||||
# Create required directories
|
||||
mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
|
||||
/etc/patch-manager/jwt /etc/patch-manager/tls \
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 \
|
||||
/etc/patch-manager /var/log/patch-manager \
|
||||
"${CONFIG_DIR}" /var/log/patch-manager \
|
||||
/opt/patch-manager /usr/share/patch-manager/frontend
|
||||
|
||||
chmod 750 /etc/patch-manager/ca /etc/patch-manager/jwt
|
||||
chmod 750 "${CONFIG_DIR}/ca" "${CONFIG_DIR}/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
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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 -
|
||||
# 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
|
||||
|
||||
# Reload systemd
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# Enable the target (which pulls in web + worker)
|
||||
systemctl enable patch-manager.target 2>/dev/null || true
|
||||
|
||||
# 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>"
|
||||
# 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
|
||||
}
|
||||
|
||||
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 ""
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Install backup cron (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
install_backup_cron() {
|
||||
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
|
||||
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/patch-manager/backup.log 2>&1") | crontab -
|
||||
info "Nightly backup cron installed."
|
||||
fi
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
case "$1" in
|
||||
configure)
|
||||
create_service_user
|
||||
create_directories
|
||||
wait_for_postgresql
|
||||
setup_database
|
||||
apply_migrations
|
||||
generate_admin_password
|
||||
write_config
|
||||
generate_jwt_keys
|
||||
enable_and_start_services
|
||||
install_backup_cron
|
||||
|
||||
# Clean up temp file
|
||||
rm -f /tmp/.pm-db-password-new
|
||||
|
||||
info "Linux Patch Manager installation complete."
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
|
||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@ -0,0 +1,58 @@
|
||||
# =============================================================================
|
||||
# Linux Patch Manager — Docker Compose Deployment
|
||||
# =============================================================================
|
||||
# Usage:
|
||||
# cp .env.example .env # Edit DB_PASSWORD
|
||||
# docker compose up -d
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: patch_manager
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: patch_manager
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U patch_manager -d patch_manager"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
networks:
|
||||
- patch-manager-net
|
||||
|
||||
app:
|
||||
image: ghcr.io/draco-lunaris/linux-patch-manager:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "443:443"
|
||||
environment:
|
||||
DATABASE_URL: postgres://patch_manager:${DB_PASSWORD}@db:5432/patch_manager
|
||||
PATCH_MANAGER_CONFIG: /etc/patch-manager/config.toml
|
||||
volumes:
|
||||
- pm-config:/etc/patch-manager
|
||||
- pm-logs:/var/log/patch-manager
|
||||
- pm-data:/opt/patch-manager
|
||||
networks:
|
||||
- patch-manager-net
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
pm-config:
|
||||
driver: local
|
||||
pm-logs:
|
||||
driver: local
|
||||
pm-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
patch-manager-net:
|
||||
driver: bridge
|
||||
232
docker/entrypoint.sh
Executable file
232
docker/entrypoint.sh
Executable file
@ -0,0 +1,232 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# Linux Patch Manager — Docker Entrypoint
|
||||
# =============================================================================
|
||||
# Handles first-run: wait for DB, run migrations, generate admin password,
|
||||
# start pm-web and pm-worker services.
|
||||
# =============================================================================
|
||||
|
||||
MIGRATION_DIR="/usr/share/patch-manager/migrations"
|
||||
CONFIG_DIR="/etc/patch-manager"
|
||||
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse DATABASE_URL into PG* env vars for psql compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
parse_database_url() {
|
||||
# DATABASE_URL format: postgres://user:password@host:port/dbname
|
||||
local url="${DATABASE_URL}"
|
||||
|
||||
# Extract components
|
||||
DB_PASS=$(echo "$url" | sed -n 's|postgres://[^:]*:\([^@]*\)@.*|\1|p')
|
||||
DB_HOST=$(echo "$url" | sed -n 's|.*@\([^:/]*\).*|\1|p')
|
||||
DB_PORT=$(echo "$url" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
|
||||
DB_USER=$(echo "$url" | sed -n 's|postgres://\([^:]*\):.*|\1|p')
|
||||
DB_NAME=$(echo "$url" | sed -n 's|.*/\([^?]*\).*|\1|p')
|
||||
|
||||
# Default port
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
|
||||
export PGHOST="${DB_HOST}"
|
||||
export PGPORT="${DB_PORT}"
|
||||
export PGUSER="${DB_USER}"
|
||||
export PGPASSWORD="${DB_PASS}"
|
||||
export PGDATABASE="${DB_NAME}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wait for PostgreSQL to be ready
|
||||
# ---------------------------------------------------------------------------
|
||||
wait_for_db() {
|
||||
echo "[entrypoint] Waiting for PostgreSQL at ${PGHOST}:${DB_PORT}..."
|
||||
local retries=60
|
||||
local delay=2
|
||||
local i
|
||||
for ((i = 1; i <= retries; i++)); do
|
||||
if pg_isready -q -h "${PGHOST}" -p "${DB_PORT}" -U "${DB_USER}" 2>/dev/null; then
|
||||
echo "[entrypoint] PostgreSQL is ready."
|
||||
return 0
|
||||
fi
|
||||
echo "[entrypoint] PostgreSQL not ready (attempt ${i}/${retries}), waiting ${delay}s..."
|
||||
sleep "${delay}"
|
||||
done
|
||||
echo "[entrypoint] ERROR: PostgreSQL did not become ready after $((retries * delay)) seconds." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run database migrations (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
run_migrations() {
|
||||
echo "[entrypoint] Applying database migrations..."
|
||||
|
||||
# Ensure pgcrypto extension
|
||||
psql -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
|
||||
|
||||
# Create migration tracking table
|
||||
psql -v ON_ERROR_STOP=1 <<'EOSQL'
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename TEXT NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
EOSQL
|
||||
|
||||
# Handle upgrade from pre-migration-tracking versions
|
||||
local migration_count
|
||||
migration_count=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
|
||||
migration_count="${migration_count// /}"
|
||||
|
||||
local tables_exist
|
||||
tables_exist=$(psql -t -A -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" 2>/dev/null || echo "0")
|
||||
tables_exist="${tables_exist// /}"
|
||||
|
||||
if [[ "${migration_count}" == "0" && "${tables_exist}" -gt 0 ]]; then
|
||||
echo "[entrypoint] Existing database detected — marking all shipped migrations as already applied."
|
||||
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
|
||||
local fname
|
||||
fname=$(basename "${sql_file}")
|
||||
psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Apply each migration in sorted order
|
||||
local applied=0
|
||||
local skipped=0
|
||||
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
|
||||
local fname
|
||||
fname=$(basename "${sql_file}")
|
||||
|
||||
local already_applied
|
||||
already_applied=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations WHERE filename='${fname}';" 2>/dev/null || echo "0")
|
||||
already_applied="${already_applied// /}"
|
||||
|
||||
if [[ "${already_applied}" -gt 0 ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Applying migration: ${fname}"
|
||||
if psql -v ON_ERROR_STOP=1 -f "${sql_file}"; then
|
||||
psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
|
||||
applied=$((applied + 1))
|
||||
else
|
||||
echo "[entrypoint] ERROR: Failed to apply migration: ${fname}" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${applied}" -gt 0 ]]; then
|
||||
echo "[entrypoint] Applied ${applied} new migration(s), skipped ${skipped}."
|
||||
else
|
||||
echo "[entrypoint] All migrations up to date (${skipped} already applied)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate admin password on first run
|
||||
# ---------------------------------------------------------------------------
|
||||
generate_admin_password() {
|
||||
echo "[entrypoint] Checking admin password status..."
|
||||
|
||||
# Generate a random 24-character password
|
||||
local admin_password
|
||||
admin_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*' | head -c 24)
|
||||
|
||||
# Hash with argon2 (PHC format)
|
||||
local password_hash
|
||||
password_hash=$(echo -n "${admin_password}" | argon2 salt -id -t 3 -m 65536 -p 1 -l 32 -e)
|
||||
|
||||
# Update admin user — only if placeholder hash is still present
|
||||
# The placeholder starts with: $argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA
|
||||
# Using single-quoted variable to preserve $ signs in the SQL LIKE pattern
|
||||
local placeholder_pattern
|
||||
placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'
|
||||
|
||||
local updated
|
||||
updated=$(psql -t -A -c \
|
||||
"UPDATE users SET password_hash = '${password_hash}', force_password_reset = TRUE \
|
||||
WHERE username = 'admin' AND password_hash LIKE '${placeholder_pattern}' \
|
||||
RETURNING id;" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "${updated}" ]]; then
|
||||
echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}"
|
||||
chmod 600 "${ADMIN_PASSWORD_FILE}"
|
||||
chown root:root "${ADMIN_PASSWORD_FILE}"
|
||||
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo " Linux Patch Manager — Admin Credentials"
|
||||
echo "============================================="
|
||||
echo " Username: admin"
|
||||
echo " Password: ${admin_password}"
|
||||
echo ""
|
||||
echo " IMPORTANT: Save this password! It will not be shown again."
|
||||
echo " Password also saved to: ${ADMIN_PASSWORD_FILE}"
|
||||
echo "============================================="
|
||||
echo ""
|
||||
else
|
||||
echo "[entrypoint] Admin password already set (not a fresh install)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate JWT keys if not present
|
||||
# ---------------------------------------------------------------------------
|
||||
generate_jwt_keys() {
|
||||
if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then
|
||||
echo "[entrypoint] Generating Ed25519 JWT signing key..."
|
||||
openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null
|
||||
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
|
||||
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem"
|
||||
chmod 600 "${CONFIG_DIR}/jwt/signing.pem"
|
||||
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
|
||||
echo "[entrypoint] JWT keys generated."
|
||||
else
|
||||
echo "[entrypoint] JWT signing key already exists."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write config.toml if not present
|
||||
# ---------------------------------------------------------------------------
|
||||
write_config() {
|
||||
local config_file="${CONFIG_DIR}/config.toml"
|
||||
|
||||
if [[ -f "${config_file}" ]]; then
|
||||
echo "[entrypoint] Config file already exists, not overwriting."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Writing configuration file..."
|
||||
cp /usr/share/patch-manager/config.example.toml "${config_file}"
|
||||
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|${DATABASE_URL}|" "${config_file}"
|
||||
chown patch-manager:patch-manager "${config_file}"
|
||||
chmod 640 "${config_file}"
|
||||
echo "[entrypoint] Configuration written."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[entrypoint] Linux Patch Manager Docker entrypoint starting..."
|
||||
|
||||
parse_database_url
|
||||
wait_for_db
|
||||
run_migrations
|
||||
generate_admin_password
|
||||
generate_jwt_keys
|
||||
write_config
|
||||
|
||||
echo "[entrypoint] Starting pm-web and pm-worker..."
|
||||
|
||||
# Start pm-worker in background
|
||||
pm-worker &
|
||||
WORKER_PID=$!
|
||||
|
||||
# Start pm-web in foreground (main process)
|
||||
export PATCH_MANAGER_CONFIG="${CONFIG_DIR}/config.toml"
|
||||
|
||||
exec pm-web
|
||||
@ -14,6 +14,16 @@ Security: JWT Bearer Token (except Public Endpoints)
|
||||
| POST | `/auth/mfa/verify` | Verify MFA code |
|
||||
| DELETE | `/auth/mfa` | Disable MFA for user |
|
||||
|
||||
## 1b. SSO (Single Sign-On)
|
||||
*No authentication required.* These endpoints implement the OIDC Authorization Code + PKCE flow. See `tasks/sso-token-handoff-spec.md` for the full design.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/auth/sso/login` | Initiate OIDC login: redirects browser to the configured IdP's authorization URL |
|
||||
| GET | `/auth/sso/callback` | OIDC redirect URI: handles the IdP response, issues a single-use 60s `handoff_code`, stores the JWT access/refresh tokens in memory, and 302-redirects to the SPA with `?handoff=<code>` in the URL (no tokens in the URL — see issue #4) |
|
||||
| GET | `/auth/sso/config` | Returns minimal SSO configuration for the login page (`enabled`, `display_name`, `auth_url`). No secrets exposed |
|
||||
| POST | `/auth/sso/handoff` | **(new in issue #4)** Exchange a single-use `handoff_code` for the JWT access/refresh tokens. The SPA calls this from `SsoCallbackPage` after the OIDC callback redirect. Returns `{ access_token, refresh_token, token_type, expires_in, user }`. The code is single-use, 60s TTL, and atomically removed on exchange (concurrent attempts: exactly one wins). `400 invalid_handoff` on unknown/expired/already-consumed codes |
|
||||
|
||||
## 2. Public Endpoints (Self-Enrollment)
|
||||
*No authentication required.*
|
||||
| Method | Endpoint | Description |
|
||||
@ -60,12 +70,16 @@ Security: JWT Bearer Token (except Public Endpoints)
|
||||
## 7. Jobs & Patch Deployment
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/jobs` | List patch jobs |
|
||||
| GET | `/jobs` | List patch jobs (includes `host_names` per job) |
|
||||
| POST | `/jobs` | Create new patch job |
|
||||
| GET | `/jobs/{id}` | Get job status/details |
|
||||
| POST | `/jobs/{id}/cancel` | Cancel running job |
|
||||
| POST | `/jobs/{id}/rollback` | Rollback completed job |
|
||||
|
||||
### GET /jobs Response Fields
|
||||
Each job summary object includes:
|
||||
- `host_names`: Array of display names for hosts targeted by this job. Falls back to `fqdn` when `display_name` is empty. Single-host jobs show one name; multi-host jobs show all names sorted alphabetically.
|
||||
|
||||
## 8. Maintenance Windows
|
||||
*Scoped to host.*
|
||||
| Method | Endpoint | Description |
|
||||
@ -102,13 +116,15 @@ Security: JWT Bearer Token (except Public Endpoints)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/settings` | Get system settings |
|
||||
| PUT | `/settings` | Update system settings |
|
||||
| PUT | `/settings` | Update system settings **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||
| POST | `/settings/smtp/test` | Test SMTP configuration |
|
||||
| POST | `/settings/sso/discover` | Discover OIDC provider config |
|
||||
| POST | `/settings/sso/test` | Test SSO connection |
|
||||
| POST | `/settings/sso/discover` | Discover OIDC provider config **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||
| POST | `/settings/sso/test` | Test SSO connection **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||
| POST | `/settings/azure-sso/test` | Test Azure SSO compatibility |
|
||||
| POST | `/settings/audit-integrity` | Verify audit log integrity |
|
||||
|
||||
> **Note (issue #6):** As of May 2026, sensitive fields (`oidc.client_secret`, `smtp.password`) are encrypted at rest in the database (AES-256-GCM). The `MASKED` placeholder behavior in API responses is **preserved** — clients never see plaintext secrets in GET responses. See [docs/runbooks/key-management.md](runbooks/key-management.md) for key management procedures.
|
||||
|
||||
## 12. Single Sign-On (SSO)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
@ -125,6 +141,53 @@ Security: JWT Bearer Token (except Public Endpoints)
|
||||
| GET | `/reports/vulnerability` | Generate vulnerability exposure report |
|
||||
| GET | `/reports/audit` | Generate audit trail report |
|
||||
|
||||
### CRL Status Fields
|
||||
|
||||
Host list and detail responses include CRL (Certificate Revocation List) status fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `crl_status` | `string?` | CRL status: `valid`, `expired`, `missing`, `invalid`, or `null` (older agents) |
|
||||
| `crl_age_seconds` | `integer?` | Seconds since the agent's CRL was last refreshed |
|
||||
| `crl_next_update` | `datetime?` | When the agent's CRL expires (ISO-8601) |
|
||||
|
||||
Fleet status response includes CRL counts:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `crl_valid` | `integer` | Hosts with CRL status `valid` |
|
||||
| `crl_expired` | `integer` | Hosts with CRL status `expired` |
|
||||
| `crl_missing` | `integer` | Hosts with CRL status `missing` |
|
||||
| `crl_invalid` | `integer` | Hosts with CRL status `invalid` (security event) |
|
||||
| `crl_not_reporting` | `integer` | Hosts not reporting CRL status (older agents) |
|
||||
|
||||
### CRL Audit Events
|
||||
|
||||
The health poller logs the following system-initiated audit events when a host's CRL status changes:
|
||||
|
||||
| Audit Action | Trigger | Details Fields |
|
||||
|---|---|---|
|
||||
| `crl_status_changed` | Any CRL status transition | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
|
||||
| `crl_stale_detected` | CRL status becomes `expired` | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
|
||||
| `crl_invalid` | CRL status becomes `invalid` | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
|
||||
|
||||
All CRL audit events use `target_type = "host"` and `target_id = <host_id>`. Actor fields (`actor_user_id`, `actor_username`) are `null` because these are system-initiated events.
|
||||
|
||||
### CRL Health Aggregation Rules
|
||||
|
||||
The health poller applies the following rules to determine a host's effective health status based on CRL state:
|
||||
|
||||
| CRL Status | Condition | Effective Health Status |
|
||||
|---|---|---|
|
||||
| `invalid` | Always | `unreachable` (security event) |
|
||||
| `expired` | If natural status is `healthy` | `degraded` |
|
||||
| `missing` | Registered > 24h ago AND natural status is `healthy` | `degraded` |
|
||||
| `missing` | Registered ≤ 24h ago | Natural status (new agent enrollment) |
|
||||
| `valid` | Any | Natural status (no override) |
|
||||
| `null` | Any | Natural status (older agent, not reporting CRL) |
|
||||
|
||||
When CRL status transitions from `invalid`/`expired`/`missing` back to `valid`, the next health poll cycle restores the host to its natural health status based on the agent's health response.
|
||||
|
||||
## 14. Real-Time Updates (WebSocket)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
|
||||
128
docs/runbooks/key-management.md
Normal file
128
docs/runbooks/key-management.md
Normal file
@ -0,0 +1,128 @@
|
||||
# Key Management Runbook
|
||||
|
||||
**Applies to:** Linux Patch Manager production deployments (issue #6 — secret encryption at rest)
|
||||
**Last updated:** 2026-06-03
|
||||
**Owner:** SRE / Security
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Linux Patch Manager uses two per-install AES-256-GCM encryption keys for protecting sensitive data at rest. Both keys are auto-generated on first start of the service, stored as 32-byte files with `0600` permissions (owner read/write only).
|
||||
|
||||
| Key file | Path | Protects | Used by |
|
||||
|----------|------|----------|---------|
|
||||
| `health-check.key` | `/etc/patch-manager/keys/health-check.key` | HTTP basic-auth passwords for health check endpoints | `pm-web`, `pm-worker` |
|
||||
| `secret-encryption.key` | `/etc/patch-manager/keys/secret-encryption.key` | OIDC `client_secret`, SMTP `smtp_password`, TOTP `totp_secret` | `pm-web`, `pm-auth`, `pm-worker` |
|
||||
|
||||
The two keys are separate by design (blast-radius isolation): if the health-check key is ever compromised, the app secrets remain protected by a different key.
|
||||
|
||||
---
|
||||
|
||||
## Key Generation (First Start)
|
||||
|
||||
On first start of `pm-web` or `pm-worker`, the `crypto::load_or_create_key()` function checks for each key file. If missing, it:
|
||||
|
||||
1. Creates the `/etc/patch-manager/keys/` directory (mode `0700`)
|
||||
2. Generates 32 cryptographically random bytes via `OsRng` (the OS CSPRNG)
|
||||
3. Writes the key to disk
|
||||
4. Sets permissions to `0600` (owner read/write only)
|
||||
5. Returns the key to the calling code
|
||||
|
||||
The key files are created in the order they are first accessed. If `pm-worker` starts before `pm-web`, it creates the same key file (filesystem-shared). Both processes can read the same key.
|
||||
|
||||
---
|
||||
|
||||
## Backup
|
||||
|
||||
**Both key files MUST be included in `/etc/patch-manager` backups.** Without the key files, encrypted data is unrecoverable. Recommended backup procedure:
|
||||
|
||||
```bash
|
||||
# Include the keys directory in the backup archive
|
||||
tar -czf /backup/patch-manager-$(date +%F).tar.gz \
|
||||
/etc/patch-manager/config.toml \
|
||||
/etc/patch-manager/keys/ \
|
||||
/var/lib/patch-manager/ # if used
|
||||
|
||||
# Verify the keys are in the backup
|
||||
tar -tzf /backup/patch-manager-*.tar.gz | grep -E 'keys/.*\.key$'
|
||||
```
|
||||
|
||||
The existing `scripts/backup.sh` already excludes secrets from unencrypted backups and supports GPG encryption for the archive. Ensure the backup includes the keys directory.
|
||||
|
||||
---
|
||||
|
||||
## Verification (Production)
|
||||
|
||||
To verify both keys exist and have correct permissions on a running deployment:
|
||||
|
||||
```bash
|
||||
# Check both key files exist with 0600 permissions
|
||||
for key in health-check.key secret-encryption.key; do
|
||||
path="/etc/patch-manager/keys/${key}"
|
||||
if [ -f "$path" ]; then
|
||||
mode=$(stat -c '%a' "$path")
|
||||
size=$(stat -c '%s' "$path")
|
||||
echo "[OK] $path mode=$mode size=$size"
|
||||
else
|
||||
echo "[FAIL] $path missing"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
[OK] /etc/patch-manager/keys/health-check.key mode=600 size=32
|
||||
[OK] /etc/patch-manager/keys/secret-encryption.key mode=600 size=32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recovery (Disaster Scenario)
|
||||
|
||||
If a key file is lost (disk failure, accidental deletion):
|
||||
|
||||
1. **All encrypted data becomes unrecoverable.** This includes:
|
||||
- HTTP basic-auth passwords for health check endpoints (health-check.key)
|
||||
- OIDC `client_secret` (secret-encryption.key)
|
||||
- SMTP `smtp_password` (secret-encryption.key)
|
||||
- TOTP `totp_secret` for all users (secret-encryption.key)
|
||||
|
||||
2. **If you have a backup** of the key files: restore them to `/etc/patch-manager/keys/` with `0600` permissions. The service will read the restored keys on next start.
|
||||
|
||||
3. **If you do NOT have a backup**: re-provision the affected secrets:
|
||||
- For OIDC: re-enter the `client_secret` from the IdP's app registration
|
||||
- For SMTP: re-enter the SMTP password
|
||||
- For TOTP: all users must re-enroll MFA (their existing TOTP secrets are unrecoverable)
|
||||
- For health-check basic auth: re-enter the password in each health check configuration
|
||||
|
||||
---
|
||||
|
||||
## Key Rotation
|
||||
|
||||
Key rotation is **not yet supported** (tracked as a follow-up issue). If a key is compromised:
|
||||
|
||||
1. Generate a new key: `rm /etc/patch-manager/keys/secret-encryption.key` (service will auto-generate on next start)
|
||||
2. Re-encrypt all secrets in the database using the `migrate-secrets` binary (see [README of the helper](../../crates/migrate-secrets/src/main.rs))
|
||||
3. Update any external systems that depended on the old secrets (e.g., IdP app registration)
|
||||
|
||||
For a planned rotation (without compromise), the procedure is the same but coordinated with a maintenance window.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Never** log the key bytes or include them in error messages. The `crypto::load_or_create_key()` function returns the key but callers should never `tracing::error!` the value.
|
||||
- **Never** commit key files to git. The `/etc/patch-manager/keys/` directory should be in `.gitignore` or outside the repo entirely (recommended).
|
||||
- **Never** copy key files between machines (e.g., for "easy migration"). Each deployment must generate its own key.
|
||||
- **The `MASKED` placeholder in API responses** (e.g., for `client_secret` in OIDC settings) continues to apply on top of DB encryption — it's a separate defense-in-depth layer.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Secret encryption spec](../../tasks/secret-encryption-spec.md) — full design rationale and migration plan
|
||||
- [Security review](../security-review.md) §4.1 — control matrix entry
|
||||
- [Migration 020](../../migrations/020_encrypt_secrets_at_rest.sql) — schema changes for the new encrypted columns
|
||||
- `crates/pm-core/src/crypto.rs` — implementation of `load_or_create_key`, `encrypt`, `decrypt`
|
||||
- `crates/migrate-secrets/src/main.rs` — one-shot helper for migrating plaintext → encrypted
|
||||
142
docs/runbooks/reverse-proxy-deployment.md
Normal file
142
docs/runbooks/reverse-proxy-deployment.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Reverse Proxy Deployment Runbook
|
||||
|
||||
**Audience:** Operators deploying `pm-web` behind a reverse proxy (nginx,
|
||||
HAProxy, Cloudflare, AWS ALB, etc.).
|
||||
|
||||
**Related:**
|
||||
- `docs/security-review.md` §1.3 (IP Whitelist Enforcement)
|
||||
- `tasks/ip-allowlist-spec.md` §7 (Risk Analysis)
|
||||
- Issue [#3](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/3)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
If you front `pm-web` with a reverse proxy, you **MUST** add the proxy's
|
||||
IP address (or CIDR) to `security.trusted_proxies` in
|
||||
`/etc/patch-manager/config.toml`. If you do not, the IP allowlist will
|
||||
evaluate against the proxy's IP (not the real client) and will return
|
||||
`403 forbidden_ip` for legitimate traffic.
|
||||
|
||||
## Why
|
||||
|
||||
Starting with the IP-allowlist hardening in issue #3, `pm-web` no longer
|
||||
trusts `X-Forwarded-For` by default. The default behavior is **strict**:
|
||||
|
||||
1. The server reads the socket peer IP from `ConnectInfo<SocketAddr>`.
|
||||
2. The server checks that IP against `security.ip_whitelist`.
|
||||
3. `X-Forwarded-For` is **ignored** unless the socket peer is in
|
||||
`security.trusted_proxies`.
|
||||
|
||||
When you put a reverse proxy in front, every connection's socket peer IP
|
||||
is the proxy's address. Without `trusted_proxies` set, the proxy's IP is
|
||||
checked against your allowlist — and unless your allowlist happens to
|
||||
include the proxy (which would defeat the purpose of the allowlist),
|
||||
the request is denied.
|
||||
|
||||
## How to Fix
|
||||
|
||||
1. Identify the **egress IP** of your reverse proxy (the IP `pm-web`
|
||||
sees as the immediate TCP peer). This is typically:
|
||||
- nginx: the IP nginx binds to internally, or the host's IP if nginx
|
||||
runs on the same host as `pm-web` (port forward).
|
||||
- Cloudflare: see
|
||||
[Cloudflare IP ranges](https://www.cloudflare.com/ips/).
|
||||
- AWS ALB / NLB: the ALB/NLB's private IP from the VPC.
|
||||
- HAProxy: the bind address.
|
||||
|
||||
2. Add the IP (or CIDR for multiple hops) to `trusted_proxies` in
|
||||
`/etc/patch-manager/config.toml`:
|
||||
|
||||
```toml
|
||||
[security]
|
||||
ip_whitelist = ["10.0.0.0/8"] # example: corporate clients
|
||||
trusted_proxies = ["172.16.5.10/32"] # example: reverse proxy egress
|
||||
```
|
||||
|
||||
3. **Restart `pm-web`** for the config to take effect. The
|
||||
`trusted_proxies` field is read at startup; runtime updates are
|
||||
supported via `AuthConfig::update_trusted_proxies` but not yet
|
||||
exposed through a settings endpoint.
|
||||
|
||||
4. Verify by tailing the logs and confirming that requests with
|
||||
`X-Forwarded-For: <allowed-client-ip>` succeed (status 200/401, NOT
|
||||
403) when the request comes through the proxy.
|
||||
|
||||
## Multi-hop Proxy Chains
|
||||
|
||||
If you have multiple proxies in front of `pm-web` (e.g., Cloudflare →
|
||||
nginx → pm-web), add **each hop you control** to `trusted_proxies`:
|
||||
|
||||
```toml
|
||||
trusted_proxies = [
|
||||
"172.16.5.10/32", # nginx egress (immediate peer)
|
||||
"10.0.0.0/8", # internal network (in case nginx runs on a different host)
|
||||
]
|
||||
```
|
||||
|
||||
The resolver picks the leftmost entry of `X-Forwarded-For` when the
|
||||
immediate peer is in `trusted_proxies`. With two trusted hops, the
|
||||
resolver will pick the leftmost untrusted IP (the real client).
|
||||
|
||||
## Reverse Proxy Headers (recommended)
|
||||
|
||||
In addition to the `trusted_proxies` config, configure your reverse
|
||||
proxy to:
|
||||
|
||||
- **Append** to `X-Forwarded-For` (not replace) so the chain is
|
||||
preserved through multiple hops.
|
||||
- Set `X-Real-IP` (optional, informational; pm-web currently uses
|
||||
`X-Forwarded-For`).
|
||||
- Forward the original `Host` header so SAML/OIDC redirects work
|
||||
correctly.
|
||||
- Do **not** strip the `Authorization` header.
|
||||
|
||||
### nginx example
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:12443;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
The `proxy_add_x_forwarded_for` directive appends, which is what you want.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### All requests return 403 forbidden_ip
|
||||
|
||||
- Check that `trusted_proxies` is set and contains the proxy's IP.
|
||||
- Check that the proxy's IP is correct (run `ss -tnp` on the pm-web
|
||||
host to see the actual peer address).
|
||||
- Check `tracing` logs for `reason = "unresolvable_client_ip"` — this
|
||||
means the `ConnectInfo<SocketAddr>` extension is missing (the
|
||||
listener wasn't built with `into_make_service_with_connect_info`).
|
||||
|
||||
### XFF is being ignored
|
||||
|
||||
- Check that the immediate peer's IP is in `trusted_proxies`. If the
|
||||
immediate peer is NOT in `trusted_proxies`, XFF is ignored (correct
|
||||
behavior).
|
||||
- Check the XFF format: pm-web parses the leftmost entry, trimmed of
|
||||
whitespace. A malformed leftmost entry falls back to the socket peer.
|
||||
|
||||
### Multiple IPs in XFF and only the last hop is trusted
|
||||
|
||||
- If you have one trusted proxy and one untrusted, the resolver will
|
||||
only use XFF when the immediate peer (the trusted one) is in the
|
||||
list. The XFF is parsed leftmost-first, so the real client IP (leftmost
|
||||
untrusted hop) is used.
|
||||
- If neither hop is in `trusted_proxies`, XFF is ignored and the
|
||||
socket peer IP (the immediate proxy) is used. Add the immediate
|
||||
proxy to `trusted_proxies` to fix.
|
||||
|
||||
## See Also
|
||||
|
||||
- `config/config.example.toml` — inline documentation on `trusted_proxies`.
|
||||
- `tasks/ip-allowlist-spec.md` §3 (Design Decisions) for the rationale.
|
||||
- `crates/pm-auth/src/rbac.rs` — the resolver implementation.
|
||||
@ -31,9 +31,25 @@ verifying that all mandated security controls are implemented and operational.
|
||||
### 1.3 IP Whitelist Enforcement
|
||||
| Control | Status | Evidence |
|
||||
|---------|--------|----------|
|
||||
| IP whitelist on all connection points | ✅ Verified | Middleware extracts `X-Forwarded-For` / `X-Real-IP`; checks against `AuthConfig.ip_whitelist` (RwLock for live updates) |
|
||||
| IP whitelist on all connection points | ✅ Verified | `require_auth` middleware in `crates/pm-auth/src/rbac.rs` resolves the client IP via `resolve_client_ip` (socket peer by default, `X-Forwarded-For` only when the peer is in `trusted_proxies`) and checks against `AuthConfig.ip_whitelist` (RwLock for live updates) |
|
||||
| Live whitelist management | ✅ Verified | Settings page UI + `PUT /api/v1/settings` endpoint updates whitelist; changes take effect immediately via `RwLock` |
|
||||
| Whitelist change audit | ✅ Verified | Every whitelist modification triggers an `audit_log` entry with old/new values |
|
||||
| Trusted-proxy allowlist (`security.trusted_proxies`) | ✅ Verified | New `trusted_proxies: Vec<String>` field on `SecurityConfig` (default empty = strict). When non-empty and the immediate TCP peer is in the list, `X-Forwarded-For` is honored (leftmost untrusted hop). Documented in `config/config.example.toml`. `AuthConfig::update_trusted_proxies` setter allows runtime updates |
|
||||
| Fail-closed on unresolvable client IP | ✅ Verified | When a non-empty allowlist is configured and the client IP cannot be determined (no `ConnectInfo<SocketAddr>` extension), the request is rejected with `403 forbidden_ip`. `tracing::warn!` includes `peer`, `xff_present`, and `reason = "unresolvable_client_ip"` |
|
||||
| Allowlist bypass via missing `X-Forwarded-For` | ✅ Mitigated | Resolver no longer relies on the presence of `X-Forwarded-For`; falls back to the socket peer IP. Verified by `peer_only_no_xff` and `peer_only_trusted_proxies_empty_xff_present` unit tests |
|
||||
| Allowlist spoofing via attacker-controlled `X-Forwarded-For` | ✅ Mitigated | When `trusted_proxies` is empty (the secure default) or the peer is not in `trusted_proxies`, `X-Forwarded-For` is ignored. Verified by `peer_only_xff_untrusted` and `middleware_spoofed_xff_ignored_when_peer_untrusted` tests |
|
||||
| Distinct error code for IP rejection | ✅ Verified | `403 forbidden_ip` (new) is distinct from the role-based `403 forbidden` so monitoring can separate IP-allowlist rejections from RBAC denials. Documented in `tasks/ip-allowlist-spec.md` §4.5 |
|
||||
|
||||
### 1.4 WebSocket Origin Allowlist (CSWSH Defense-in-Depth)
|
||||
| Control | Status | Evidence |
|
||||
|---------|--------|----------|
|
||||
| `Origin` header allowlist on browser WS upgrade | ✅ Verified | `crates/pm-web/src/routes/ws.rs` `ws_handler` — `HeaderMap` extractor + `check_origin` enforced before ticket validation |
|
||||
| Allowlist configurable via `security.allowed_origins` | ✅ Verified | `crates/pm-core/src/config.rs` `SecurityConfig::allowed_origins`; documented in `config/config.example.toml` |
|
||||
| Secure-by-default derivation from `sso_callback_url` | ✅ Verified | `derive_allowed_origins` parses the SSO callback URL into a single `scheme://host[:port]` entry when the operator leaves `allowed_origins` empty; `AppConfig::load` runs the derivation and emits a `tracing::warn!` if the result is empty (fail-closed) |
|
||||
| Order: Origin check before ticket consumption | ✅ Verified | Rejected cross-origin probes do not burn the user's ticket; documented in the handler doc-comment and verified by `check_rejects_disallowed_origin` test |
|
||||
| Rejected upgrades logged with `origin` and `reason` | ✅ Verified | `tracing::warn!` in `ws_handler`; ticket value is never logged |
|
||||
|
||||
**Note:** The browser WebSocket endpoint (`GET /api/v1/ws/jobs`) is the only browser-reachable WS server in the codebase. The `pm-worker` `ws_relay` module is an outbound mTLS WS *client* to on-host agents and is not subject to CSWSH.
|
||||
|
||||
---
|
||||
|
||||
@ -69,6 +85,7 @@ verifying that all mandated security controls are implemented and operational.
|
||||
| Admin: full rights | ✅ Verified | Admin role bypasses group scoping; access to all resources |
|
||||
| Operator: group-scoped | ✅ Verified | Operators can only manage hosts in their assigned groups; middleware enforces on every request |
|
||||
| RBAC middleware | ✅ Verified | Axum middleware extracts role from JWT; enforces before route handler execution |
|
||||
| **Manager-wide auth config is Admin-only (issue #5 fix)** | ✅ Verified | `admin_required` gate in `crates/pm-web/src/routes/settings.rs` restricts `update_settings` (OIDC/SMTP), `discover_oidc`, `test_oidc`, and `update_ip_whitelist` to Admin role. Operators receive `403 forbidden_role`. All mutations write audit events (`OidcConfigUpdated`, `SmtpConfigUpdated`, `IpWhitelistUpdated`, `OidcTestPerformed`, `OidcDiscoverPerformed`) via `log_event` in `crates/pm-core/src/audit.rs`. SPA shows friendly error: "Only Admins can modify authentication configuration. Contact an Admin to make this change." Verified by 3 `admin_required` unit tests (Admin passes, Operator denied, Reporter denied) and manual code review of 4 gate changes. Full integration tests deferred to [issue #15](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/15). |
|
||||
|
||||
### 2.5 Azure SSO
|
||||
| Control | Status | Evidence |
|
||||
@ -76,6 +93,9 @@ verifying that all mandated security controls are implemented and operational.
|
||||
| OAuth2/OIDC Authorization Code + PKCE | ✅ Verified | Public routes `/api/v1/auth/azure/login` and `/api/v1/auth/azure/callback` implement PKCE flow |
|
||||
| Test connection without enabling | ✅ Verified | `POST /api/v1/settings/azure-sso/test` validates configuration without persisting |
|
||||
| MFA still required after SSO | ✅ Verified | SSO login follows same MFA verification path as local login |
|
||||
| **No tokens in redirect URL (issue #4 fix)** | ✅ Verified | SSO callback (`crates/pm-web/src/routes/sso.rs` `sso_callback`) now issues a single-use, 60s `handoff_code` and stores the JWT access/refresh tokens in the in-memory `sso_handoffs: Arc<DashMap<String, SsoHandoff>>`. The redirect URL contains only `?handoff=<code>`. No `access_token`, `refresh_token`, or `user` parameters are ever placed in the URL. The SPA exchanges the code via `POST /api/v1/auth/sso/handoff`. See `tasks/sso-token-handoff-spec.md` for the full design. |
|
||||
| **Handoff code is single-use + 60s TTL** | ✅ Verified | `DashMap::remove` in `sso_handoff_exchange_inner` is atomic — concurrent exchange attempts result in exactly one success and one 400. Expired codes (`expires_at < Instant::now()`) are rejected with `400 invalid_handoff`. A background cleanup task removes expired entries every 60s. Verified by `handoff_exchange_single_use`, `handoff_exchange_race`, and `handoff_exchange_expired_code` tests in `crates/pm-web/src/routes/sso.rs`. |
|
||||
| **Handoff code cleared from browser history** | ✅ Verified | SPA calls `window.history.replaceState({}, '', '/auth/sso/callback')` after a successful exchange, removing the `?handoff=` param from the address bar. Verified by `clears_handoff_code_from_url_after_success` test in `frontend/src/pages/__tests__/SsoCallbackPage.test.tsx`. |
|
||||
|
||||
---
|
||||
|
||||
@ -105,7 +125,8 @@ verifying that all mandated security controls are implemented and operational.
|
||||
| Control | Status | Evidence |
|
||||
|---------|--------|----------|
|
||||
| Infrastructure-managed disk encryption | ✅ Verified | Hardware/infrastructure layer provides encryption at rest; no LUKS in guest OS |
|
||||
| No column-level encryption needed | ✅ Verified | Compliance requirement satisfied by infrastructure layer per system mandate |
|
||||
| **App secrets encrypted at rest (issue #6 fix)** | ✅ Verified | OIDC `client_secret`, SMTP `smtp_password`, and TOTP `totp_secret` are encrypted with AES-256-GCM using a dedicated per-install key at `/etc/patch-manager/keys/secret-encryption.key` (auto-generated on first start, 0600 permissions). Separate from the health-check key for blast-radius isolation. Encryption/decryption via `pm-core::crypto::encrypt`/`decrypt`. Schema migration `020_encrypt_secrets_at_rest.sql` replaces plaintext TEXT columns with BYTEA `_encrypted` + `_nonce` columns. All 6 read/write sites updated: `sso.rs`, `settings.rs` (OIDC + SMTP), `session.rs` (TOTP read), `auth.rs` (TOTP write), `users.rs` (TOTP NULL), `pm-worker/email.rs` (SMTP read). The `MASKED` placeholder behavior in API responses is preserved. |
|
||||
| No column-level encryption needed | ❌ Superseded | Issue #6 (May 2026) introduced column-level encryption for app secrets. Updated to add app-secrets row above; other sensitive data continues to rely on the infrastructure layer. |
|
||||
|
||||
### 4.2 Secret Management
|
||||
| Control | Status | Evidence |
|
||||
@ -139,9 +160,30 @@ verifying that all mandated security controls are implemented and operational.
|
||||
|
||||
## 6. Findings & Recommendations
|
||||
|
||||
### No Critical or High Findings
|
||||
### 🔴 CRITICAL: Committed Private Key Material (Issue #12) — RESOLVED
|
||||
|
||||
All security controls are implemented as specified in the system requirements.
|
||||
**Description:**
|
||||
Private key file `client.key` and public certificates (`client.crt`, `ca.crt`) were committed
|
||||
to version control in `crates/pm-agent-client/certs/`. Committed private keys are a critical
|
||||
security risk: anyone with repository access can impersonate agents or decrypt captured TLS traffic.
|
||||
|
||||
**Status:** ✅ RESOLVED
|
||||
|
||||
**Remediation Applied:**
|
||||
1. Removed all cert files from git tracking (`git rm --cached`)
|
||||
2. Added `*.key`, `*.key.pem` and `crates/pm-agent-client/certs/` to `.gitignore`
|
||||
3. Updated `pm-agent-client` doc examples to use `std::fs::read()` instead of `include_bytes!`
|
||||
4. Added `gitleaks` secret scanning to CI pipeline
|
||||
5. Added README to `crates/pm-agent-client/certs/` explaining runtime cert generation
|
||||
6. Git history will be purged with `git filter-repo` after PR merge
|
||||
|
||||
**Key Rotation:**
|
||||
These keys were dev/test only. No production key rotation is needed. All committed keys
|
||||
should be considered compromised and must not be used in production.
|
||||
|
||||
### No Other Critical or High Findings
|
||||
|
||||
All other security controls are implemented as specified in the system requirements.
|
||||
|
||||
### Recommendations (Low Priority)
|
||||
|
||||
@ -171,3 +213,4 @@ All security controls are implemented as specified in the system requirements.
|
||||
- [x] Backup encryption supported (GPG)
|
||||
- [x] Azure SSO with PKCE flow
|
||||
- [x] No plaintext credential storage
|
||||
- [x] Committed private key material removed from repository (Issue #12)
|
||||
|
||||
2149
frontend/package-lock.json
generated
2149
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "patch-manager-ui",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "1.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/ --ext .ts,.tsx --max-warnings 0",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@ -25,6 +27,9 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.30.0",
|
||||
@ -32,7 +37,9 @@
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3"
|
||||
"vite": "^6.3.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
CreateHostRequest,
|
||||
CreateJobRequest,
|
||||
CreateMaintenanceWindowRequest,
|
||||
MaintenanceWindow,
|
||||
UpdateMaintenanceWindowRequest,
|
||||
Certificate,
|
||||
IssuedCert,
|
||||
@ -152,6 +153,8 @@ export const hostsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
|
||||
get: (id: string) => apiClient.get(`/hosts/${id}`),
|
||||
register: (body: CreateHostRequest) => apiClient.post('/hosts', body),
|
||||
update: (id: string, body: Record<string, string | undefined>) =>
|
||||
apiClient.put(`/hosts/${id}`, body),
|
||||
delete: (id: string) => apiClient.delete(`/hosts/${id}`),
|
||||
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
|
||||
}
|
||||
@ -174,6 +177,10 @@ export const patchesApi = {
|
||||
|
||||
// ── Maintenance Windows API ───────────────────────────────────────────────────
|
||||
export const maintenanceWindowsApi = {
|
||||
/** Bulk: fetch ALL maintenance windows across every host in one request. */
|
||||
listAll: () =>
|
||||
apiClient.get<{ windows: MaintenanceWindow[] }>('/maintenance-windows'),
|
||||
/** Per-host: fetch windows for a single host. */
|
||||
list: (hostId: string) =>
|
||||
apiClient.get(`/hosts/${hostId}/maintenance-windows`),
|
||||
create: (hostId: string, body: CreateMaintenanceWindowRequest) =>
|
||||
|
||||
@ -86,7 +86,7 @@ export default function AppLayout() {
|
||||
|
||||
const drawer = (
|
||||
<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={{
|
||||
background: 'linear-gradient(135deg, #42A5F5 30%, #26C6DA 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
@ -94,6 +94,9 @@ export default function AppLayout() {
|
||||
}}>
|
||||
🐉 Patch Manager
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', opacity: 0.7, mt: -0.5 }}>
|
||||
v{__APP_VERSION__}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
RestartAlt,
|
||||
Refresh as RefreshIcon,
|
||||
Security as SecurityIcon,
|
||||
VerifiedUser as VerifiedUserIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { fleetApi, certsApi } from '../api/client'
|
||||
import type { FleetStatus } from '../types'
|
||||
@ -237,6 +238,57 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* ── Row 4: CRL Status ── */}
|
||||
<Card variant="outlined" sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<VerifiedUserIcon color="primary" />
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
CRL Status
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#2e7d32' }}>
|
||||
{status.crl_valid}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Valid</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#ed6c02' }}>
|
||||
{status.crl_expired}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Expired</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#ed6c02' }}>
|
||||
{status.crl_missing}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Missing</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#d32f2f' }}>
|
||||
{status.crl_invalid}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Invalid</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{status.crl_not_reporting > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
{status.crl_not_reporting} host{status.crl_not_reporting !== 1 ? 's' : ''} not reporting CRL status
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@ -46,6 +46,9 @@ import {
|
||||
Schedule as ScheduleIcon,
|
||||
VpnKey as VpnKeyIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
VerifiedUser as VerifiedUserIcon,
|
||||
Security as SecurityIcon,
|
||||
WarningAmber as WarningAmberIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@ -614,6 +617,46 @@ export default function HostDetailPage() {
|
||||
// Hosts list for target_host_id dropdown
|
||||
const [hosts, setHosts] = useState<{ id: string; display_name: string; fqdn: string }[]>([])
|
||||
|
||||
// ── Host editing state ────────────────────────────────────────────────────
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editFqdn, setEditFqdn] = useState('')
|
||||
const [editIp, setEditIp] = useState('')
|
||||
const [editDisplayName, setEditDisplayName] = useState('')
|
||||
const [savingHost, setSavingHost] = useState(false)
|
||||
|
||||
const enterEdit = () => {
|
||||
setEditFqdn(String(host?.fqdn ?? ''))
|
||||
setEditIp(String(host?.ip_address ?? ''))
|
||||
setEditDisplayName(String(host?.display_name ?? ''))
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditing(false)
|
||||
setSavingHost(false)
|
||||
}
|
||||
|
||||
const handleSaveHost = async () => {
|
||||
if (!id) return
|
||||
setSavingHost(true)
|
||||
try {
|
||||
const res = await hostsApi.update(id, {
|
||||
fqdn: editFqdn !== String(host?.fqdn ?? '') ? editFqdn : undefined,
|
||||
ip_address: editIp !== String(host?.ip_address ?? '') ? editIp : undefined,
|
||||
display_name: editDisplayName !== String(host?.display_name ?? '') ? editDisplayName : undefined,
|
||||
})
|
||||
setHost(res.data)
|
||||
setEditing(false)
|
||||
showSnack('Host updated', 'success')
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message ?? 'Failed to update host'
|
||||
showSnack(msg, 'error')
|
||||
} finally {
|
||||
setSavingHost(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch host ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (id === 'new') { setLoading(false); return }
|
||||
@ -899,7 +942,39 @@ export default function HostDetailPage() {
|
||||
{String(host?.fqdn ?? '')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{canWrite && !certExists && (
|
||||
{canWrite && !editing && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={enterEdit}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && editing && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
onClick={handleSaveHost}
|
||||
disabled={savingHost}
|
||||
>
|
||||
{savingHost ? <CircularProgress size={16} /> : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={cancelEdit}
|
||||
disabled={savingHost}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing && canWrite && !certExists && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
@ -909,7 +984,7 @@ export default function HostDetailPage() {
|
||||
Issue Certificate
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && certExists && (
|
||||
{!editing && canWrite && certExists && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@ -920,12 +995,36 @@ export default function HostDetailPage() {
|
||||
Re-issue Certificate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Grid container spacing={2}>
|
||||
{host && Object.entries(host).map(([k, v]) =>
|
||||
{host && (<>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">FQDN</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editFqdn} onChange={e => setEditFqdn(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.fqdn)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">IP ADDRESS</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editIp} onChange={e => setEditIp(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.ip_address)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">DISPLAY NAME</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editDisplayName} onChange={e => setEditDisplayName(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.display_name)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
{Object.entries(host).filter(([k]) => !['fqdn','ip_address','display_name'].includes(k)).map(([k, v]) =>
|
||||
v !== null && v !== '' ? (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
@ -935,9 +1034,57 @@ export default function HostDetailPage() {
|
||||
</Grid>
|
||||
) : null
|
||||
)}
|
||||
</>)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* ── CRL Status ─────────────────────────────────────────────────── */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<VerifiedUserIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight={600}>CRL Status</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
{host?.crl_status === undefined || host?.crl_status === null ? (
|
||||
<Alert severity="info">
|
||||
CRL status not available (agent version does not support CRL)
|
||||
</Alert>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Status</Typography>
|
||||
{host.crl_status === 'valid' ? (
|
||||
<Chip icon={<VerifiedUserIcon />} label="Valid" color="success" size="small" />
|
||||
) : host.crl_status === 'expired' ? (
|
||||
<Chip icon={<WarningAmberIcon />} label="Expired" color="warning" size="small" />
|
||||
) : host.crl_status === 'missing' ? (
|
||||
<Chip icon={<WarningAmberIcon />} label="Missing" color="warning" size="small" />
|
||||
) : host.crl_status === 'invalid' ? (
|
||||
<Chip icon={<SecurityIcon />} label="Invalid" color="error" size="small" />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.crl_status)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">CRL Age</Typography>
|
||||
<Typography variant="body2">
|
||||
{host.crl_age_seconds !== null
|
||||
? (() => { const s = Number(host.crl_age_seconds); return s < 3600 ? `${Math.round(s / 60)} minutes ago` : s < 86400 ? `${Math.round(s / 3600)} hours ago` : `${Math.round(s / 86400)} days ago`; })()
|
||||
: '—'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Next Update</Typography>
|
||||
<Typography variant="body2">
|
||||
{host.crl_next_update
|
||||
? new Date(host.crl_next_update as string).toLocaleString()
|
||||
: '—'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Maintenance Windows ──────────────────────────────────────────── */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user