Compare commits
8 Commits
v0.2.2
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| d6f0c23dd8 | |||
| ae15404496 | |||
| 5fa1fef6c8 | |||
| e6dd1b8489 | |||
| dd6961265d | |||
| 40ba483d35 | |||
| 192ebbd47f | |||
| 050439ee14 |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@ -57,6 +57,18 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- run: cargo install cargo-audit && cargo audit
|
- 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:
|
frontend-lint:
|
||||||
name: Frontend Lint
|
name: Frontend Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -28,5 +28,9 @@ frontend/dist
|
|||||||
*.deb
|
*.deb
|
||||||
package-build/
|
package-build/
|
||||||
|
|
||||||
# TLS certificates - generated on first run
|
# Private key material - NEVER commit
|
||||||
crates/pm-agent-client/certs/
|
*.key
|
||||||
|
*.key.pem
|
||||||
|
crates/pm-agent-client/certs/*.crt
|
||||||
|
crates/pm-agent-client/certs/*.key
|
||||||
|
crates/pm-agent-client/certs/*.pem
|
||||||
|
|||||||
82
Cargo.lock
generated
82
Cargo.lock
generated
@ -139,6 +139,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert-json-diff"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@ -475,6 +485,15 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colored"
|
||||||
|
version = "3.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@ -2043,7 +2062,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "migrate-secrets"
|
name = "migrate-secrets"
|
||||||
version = "0.1.9"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hex",
|
"hex",
|
||||||
@ -2096,6 +2115,31 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mockito"
|
||||||
|
version = "1.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||||
|
dependencies = [
|
||||||
|
"assert-json-diff",
|
||||||
|
"bytes",
|
||||||
|
"colored",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"regex",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"similar",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moxcms"
|
name = "moxcms"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@ -2548,7 +2592,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-agent-client"
|
name = "pm-agent-client"
|
||||||
version = "0.1.9"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -2565,7 +2609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-auth"
|
name = "pm-auth"
|
||||||
version = "0.1.9"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@ -2593,7 +2637,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-ca"
|
name = "pm-ca"
|
||||||
version = "0.1.9"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -2617,7 +2661,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-core"
|
name = "pm-core"
|
||||||
version = "0.1.9"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@ -2641,7 +2685,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-reports"
|
name = "pm-reports"
|
||||||
version = "0.1.9"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -2661,7 +2705,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-web"
|
name = "pm-web"
|
||||||
version = "0.1.9"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@ -2672,14 +2716,17 @@ dependencies = [
|
|||||||
"dashmap 6.1.0",
|
"dashmap 6.1.0",
|
||||||
"governor 0.6.3",
|
"governor 0.6.3",
|
||||||
"hex",
|
"hex",
|
||||||
|
"http-body-util",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
|
"mockito",
|
||||||
"pm-auth",
|
"pm-auth",
|
||||||
"pm-ca",
|
"pm-ca",
|
||||||
"pm-core",
|
"pm-core",
|
||||||
"pm-reports",
|
"pm-reports",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
@ -2688,6 +2735,7 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@ -2702,7 +2750,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pm-worker"
|
name = "pm-worker"
|
||||||
version = "0.1.9"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -3082,6 +3130,18 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@ -3521,6 +3581,12 @@ version = "0.3.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simple_asn1"
|
name = "simple_asn1"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.2"
|
version = "1.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Echo <echo@moon-dragon.us>"]
|
authors = ["Echo <echo@moon-dragon.us>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
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-----
|
|
||||||
11
crates/pm-agent-client/src/client.rs
Executable file → Normal file
11
crates/pm-agent-client/src/client.rs
Executable file → Normal file
@ -6,12 +6,17 @@
|
|||||||
//! use pm_agent_client::client::AgentClient;
|
//! use pm_agent_client::client::AgentClient;
|
||||||
//!
|
//!
|
||||||
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
|
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
|
||||||
|
//! // Load certificates from files (never hardcode or include_bytes! private keys)
|
||||||
|
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
|
||||||
|
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
|
||||||
|
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
|
||||||
|
//!
|
||||||
//! let client = AgentClient::new(
|
//! let client = AgentClient::new(
|
||||||
//! "192.168.1.10",
|
//! "192.168.1.10",
|
||||||
//! 12443,
|
//! 12443,
|
||||||
//! include_bytes!("../certs/client.crt"),
|
//! &client_cert,
|
||||||
//! include_bytes!("../certs/client.key"),
|
//! &client_key,
|
||||||
//! include_bytes!("../certs/ca.crt"),
|
//! &ca_cert,
|
||||||
//! )?;
|
//! )?;
|
||||||
//!
|
//!
|
||||||
//! let health = client.health().await?;
|
//! let health = client.health().await?;
|
||||||
|
|||||||
11
crates/pm-agent-client/src/lib.rs
Executable file → Normal file
11
crates/pm-agent-client/src/lib.rs
Executable file → Normal file
@ -10,12 +10,17 @@
|
|||||||
//! use pm_agent_client::AgentClient;
|
//! use pm_agent_client::AgentClient;
|
||||||
//!
|
//!
|
||||||
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
|
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
|
||||||
|
//! // Load certificates from files (never hardcode or include_bytes! private keys)
|
||||||
|
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
|
||||||
|
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
|
||||||
|
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
|
||||||
|
//!
|
||||||
//! let client = AgentClient::new(
|
//! let client = AgentClient::new(
|
||||||
//! "10.0.1.5",
|
//! "10.0.1.5",
|
||||||
//! 12443,
|
//! 12443,
|
||||||
//! include_bytes!("../certs/client.crt"),
|
//! &client_cert,
|
||||||
//! include_bytes!("../certs/client.key"),
|
//! &client_key,
|
||||||
//! include_bytes!("../certs/ca.crt"),
|
//! &ca_cert,
|
||||||
//! )?;
|
//! )?;
|
||||||
//!
|
//!
|
||||||
//! let health = client.health().await?;
|
//! let health = client.health().await?;
|
||||||
|
|||||||
@ -178,8 +178,10 @@ pub enum EnrollmentStatusResponse {
|
|||||||
Pending,
|
Pending,
|
||||||
Approved {
|
Approved {
|
||||||
ca_crt: String,
|
ca_crt: String,
|
||||||
|
ca_chain: String,
|
||||||
server_crt: String,
|
server_crt: String,
|
||||||
server_key: String,
|
server_key: String,
|
||||||
|
crl_pem: String,
|
||||||
},
|
},
|
||||||
Denied,
|
Denied,
|
||||||
NotFound,
|
NotFound,
|
||||||
|
|||||||
@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "pm_web"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pm-web"
|
name = "pm-web"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
@ -46,4 +50,9 @@ url = { workspace = true }
|
|||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
mockito = "1"
|
||||||
tempfile = "3"
|
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,145 +1,13 @@
|
|||||||
//! pm-web — Linux Patch Manager web server.
|
//! pm-web — Linux Patch Manager web server (binary entry-point).
|
||||||
|
|
||||||
mod routes;
|
|
||||||
|
|
||||||
mod secret_key;
|
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
|
||||||
use axum_server::tls_rustls::RustlsConfig;
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use pm_auth::{
|
use pm_auth::{jwt, rbac::AuthConfig};
|
||||||
jwt,
|
use pm_core::{config::AppConfig, db, models::ApprovedEntry};
|
||||||
password::hash_password,
|
use pm_web::routes::sso::{OidcCache, SsoHandoff, SsoSession};
|
||||||
rbac::{require_auth, AuthConfig},
|
use pm_web::routes::ws::WsTicket;
|
||||||
};
|
use pm_web::{bootstrap_admin_password, build_router, AppState};
|
||||||
use pm_core::{
|
|
||||||
config::AppConfig, db, logging, 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::{net::SocketAddr, sync::Arc, time::Duration};
|
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||||
use tokio::sync::Mutex;
|
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.
|
|
||||||
async fn bootstrap_admin_password(pool: &sqlx::PgPool) {
|
|
||||||
// Check if the admin account still has the placeholder hash.
|
|
||||||
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, // No admin row — nothing to bootstrap.
|
|
||||||
};
|
|
||||||
|
|
||||||
if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) {
|
|
||||||
// Admin already has a real password — nothing to do.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a 24-character random alphanumeric password.
|
|
||||||
let password: String = rand::thread_rng()
|
|
||||||
.sample_iter(&rand::distributions::Alphanumeric)
|
|
||||||
.take(24)
|
|
||||||
.map(char::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Hash it with the application's Argon2id parameters.
|
|
||||||
let new_hash = match hash_password(&password) {
|
|
||||||
Ok(h) => h,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %e, "Failed to hash bootstrap admin password");
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Replace the placeholder hash with the real one.
|
|
||||||
// The WHERE clause matches the placeholder prefix to ensure idempotency.
|
|
||||||
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(_) => {
|
|
||||||
// Rows affected != 1 — concurrent bootstrap or already replaced.
|
|
||||||
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>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@ -156,7 +24,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
AppConfig::default()
|
AppConfig::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
logging::init(&config.logging);
|
pm_core::logging::init(&config.logging);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
version = env!("CARGO_PKG_VERSION"),
|
version = env!("CARGO_PKG_VERSION"),
|
||||||
"patch-manager-web starting"
|
"patch-manager-web starting"
|
||||||
@ -183,12 +51,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let pool = db::init_pool(&config.database).await?;
|
let pool = db::init_pool(&config.database).await?;
|
||||||
db::run_migrations(&pool).await?;
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
// Bootstrap admin password if the seed admin still has the placeholder hash (issue #8).
|
// Bootstrap admin password if the seed admin still has the placeholder hash.
|
||||||
bootstrap_admin_password(&pool).await;
|
bootstrap_admin_password(&pool).await;
|
||||||
|
|
||||||
// Initialise the internal CA using the configured certificate paths.
|
// Initialise the internal CA using the configured certificate paths.
|
||||||
// The CA certificate and key must exist at the configured locations and be
|
|
||||||
// unencrypted PEM. If absent, a new CA is generated in that directory.
|
|
||||||
let ca_base = std::path::Path::new(&config.security.ca_cert_path)
|
let ca_base = std::path::Path::new(&config.security.ca_cert_path)
|
||||||
.parent()
|
.parent()
|
||||||
.expect("CA certificate path must have a parent directory");
|
.expect("CA certificate path must have a parent directory");
|
||||||
@ -223,7 +89,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background task: purge expired SSO sessions every 60 seconds (sessions older than 10 minutes).
|
// Background task: purge expired SSO sessions every 60 seconds.
|
||||||
{
|
{
|
||||||
let sessions = sso_sessions.clone();
|
let sessions = sso_sessions.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@ -243,8 +109,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Background task: purge expired approved enrollment PKI bundles.
|
// Background task: purge expired approved enrollment PKI bundles.
|
||||||
// Entries are also removed on first retrieval (single-use), so this
|
|
||||||
// task only cleans up bundles that were never picked up by the agent.
|
|
||||||
{
|
{
|
||||||
let approved = approved_enrollments.clone();
|
let approved = approved_enrollments.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@ -262,9 +126,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Background task: purge expired SSO handoff codes every 60 seconds.
|
// Background task: purge expired SSO handoff codes every 60 seconds.
|
||||||
// See `tasks/sso-token-handoff-spec.md` §4.3. Handoffs are also
|
|
||||||
// atomically removed on exchange (single-use), so this task only
|
|
||||||
// cleans up codes that the SPA never POSTed back for.
|
|
||||||
{
|
{
|
||||||
let handoffs = sso_handoffs.clone();
|
let handoffs = sso_handoffs.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@ -306,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let tls_key = std::path::Path::new(&config.security.web_tls_key_path);
|
let tls_key = std::path::Path::new(&config.security.web_tls_key_path);
|
||||||
|
|
||||||
if tls_cert.exists() && tls_key.exists() {
|
if tls_cert.exists() && tls_key.exists() {
|
||||||
let tls_config = RustlsConfig::from_pem_file(
|
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||||
&config.security.web_tls_cert_path,
|
&config.security.web_tls_cert_path,
|
||||||
&config.security.web_tls_key_path,
|
&config.security.web_tls_key_path,
|
||||||
)
|
)
|
||||||
@ -338,149 +199,3 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct the full Axum router.
|
|
||||||
pub fn build_router(state: AppState) -> Router {
|
|
||||||
let static_dir = state.config.server.static_dir.clone();
|
|
||||||
let auth_config = state.auth_config.clone();
|
|
||||||
let rl = &state.config.rate_limit;
|
|
||||||
|
|
||||||
// Enrollment rate limiting: strict (5 req/min per IP, burst 3)
|
|
||||||
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
|
|
||||||
// governor quota: 1 request per 12_000ms = ~5/min sustained
|
|
||||||
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)
|
|
||||||
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
|
|
||||||
// governor quota: 1 request per 3_000ms = ~20/min sustained
|
|
||||||
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)
|
|
||||||
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
|
|
||||||
// governor quota: 1 request per 500ms = ~120/min sustained
|
|
||||||
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()
|
|
||||||
// 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(),
|
|
||||||
)
|
|
||||||
// Maintenance windows — bulk list-all endpoint
|
|
||||||
.nest(
|
|
||||||
"/maintenance-windows",
|
|
||||||
routes::maintenance_windows::all_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 rate limiting then auth middleware
|
|
||||||
.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))
|
|
||||||
// Public auth routes (rate-limited, no JWT)
|
|
||||||
.nest("/api/v1/auth", auth_public_router)
|
|
||||||
// Public enrollment endpoints (rate-limited, no JWT)
|
|
||||||
.nest("/api/v1", enrollment_router)
|
|
||||||
// Public PKI endpoints (CRL distribution, no JWT — CRLs are self-authenticating)
|
|
||||||
.nest("/api/v1", routes::pki::router())
|
|
||||||
// Public SSO routes (rate-limited, no JWT)
|
|
||||||
.nest("/api/v1/auth/sso", sso_public_router)
|
|
||||||
// Public Azure SSO routes (rate-limited, no JWT)
|
|
||||||
.nest("/api/v1/auth/azure", sso_azure_router)
|
|
||||||
// Protected API routes (JWT required, rate-limited)
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -111,8 +111,10 @@ async fn enroll_status(
|
|||||||
}
|
}
|
||||||
return Ok(Json(EnrollmentStatusResponse::Approved {
|
return Ok(Json(EnrollmentStatusResponse::Approved {
|
||||||
ca_crt: entry.pki.ca_crt.clone(),
|
ca_crt: entry.pki.ca_crt.clone(),
|
||||||
|
ca_chain: entry.pki.ca_chain.clone(),
|
||||||
server_crt: entry.pki.server_crt.clone(),
|
server_crt: entry.pki.server_crt.clone(),
|
||||||
server_key: entry.pki.server_key.clone(),
|
server_key: entry.pki.server_key.clone(),
|
||||||
|
crl_pem: entry.pki.crl_pem.clone(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,7 +188,7 @@ async fn approve_enrollment(
|
|||||||
|
|
||||||
// Check for FQDN/IP collision in hosts table
|
// Check for FQDN/IP collision in hosts table
|
||||||
if let Some(existing_host) = sqlx::query_as::<_, Host>(
|
if let Some(existing_host) = sqlx::query_as::<_, Host>(
|
||||||
"SELECT id, fqdn, ip_address::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 FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
|
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at, crl_status, crl_age_seconds, crl_next_update FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
|
||||||
)
|
)
|
||||||
.bind(&enrollment_request.fqdn)
|
.bind(&enrollment_request.fqdn)
|
||||||
.bind(enrollment_request.ip_address.to_string())
|
.bind(enrollment_request.ip_address.to_string())
|
||||||
|
|||||||
@ -132,7 +132,7 @@ async fn list_hosts(
|
|||||||
THEN 'some_unhealthy'
|
THEN 'some_unhealthy'
|
||||||
ELSE 'all_healthy'
|
ELSE 'all_healthy'
|
||||||
END AS health_check_status,
|
END AS health_check_status,
|
||||||
h.registered_at
|
h.registered_at,
|
||||||
h.crl_status
|
h.crl_status
|
||||||
FROM hosts h
|
FROM hosts h
|
||||||
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
||||||
@ -166,7 +166,7 @@ async fn list_hosts(
|
|||||||
THEN 'some_unhealthy'
|
THEN 'some_unhealthy'
|
||||||
ELSE 'all_healthy'
|
ELSE 'all_healthy'
|
||||||
END AS health_check_status,
|
END AS health_check_status,
|
||||||
h.registered_at
|
h.registered_at,
|
||||||
h.crl_status
|
h.crl_status
|
||||||
FROM hosts h
|
FROM hosts h
|
||||||
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
|
||||||
@ -434,7 +434,7 @@ async fn update_host(
|
|||||||
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
||||||
os_family, os_name, arch, agent_version, health_status,
|
os_family, os_name, arch, agent_version, health_status,
|
||||||
last_health_at, last_patch_at, agent_port, notes,
|
last_health_at, last_patch_at, agent_port, notes,
|
||||||
registered_at, updated_at
|
registered_at, updated_at, crl_status, crl_age_seconds, crl_next_update
|
||||||
FROM hosts WHERE id = (SELECT id FROM updated)
|
FROM hosts WHERE id = (SELECT id FROM updated)
|
||||||
) h
|
) h
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
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;
|
||||||
6
debian/changelog
vendored
6
debian/changelog
vendored
@ -1,3 +1,9 @@
|
|||||||
|
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
|
linux-patch-manager (0.1.9-1) noble; urgency=medium
|
||||||
|
|
||||||
* Fix: Replace broken DashMap rate limiting with tower-governor middleware
|
* Fix: Replace broken DashMap rate limiting with tower-governor middleware
|
||||||
|
|||||||
@ -160,9 +160,30 @@ verifying that all mandated security controls are implemented and operational.
|
|||||||
|
|
||||||
## 6. Findings & Recommendations
|
## 6. Findings & Recommendations
|
||||||
|
|
||||||
### No Critical or High Findings
|
### 🔴 CRITICAL: Committed Private Key Material (Issue #12) — RESOLVED
|
||||||
|
|
||||||
All security controls are implemented as specified in the system requirements.
|
**Description:**
|
||||||
|
Private key file `client.key` and public certificates (`client.crt`, `ca.crt`) were committed
|
||||||
|
to version control in `crates/pm-agent-client/certs/`. Committed private keys are a critical
|
||||||
|
security risk: anyone with repository access can impersonate agents or decrypt captured TLS traffic.
|
||||||
|
|
||||||
|
**Status:** ✅ RESOLVED
|
||||||
|
|
||||||
|
**Remediation Applied:**
|
||||||
|
1. Removed all cert files from git tracking (`git rm --cached`)
|
||||||
|
2. Added `*.key`, `*.key.pem` and `crates/pm-agent-client/certs/` to `.gitignore`
|
||||||
|
3. Updated `pm-agent-client` doc examples to use `std::fs::read()` instead of `include_bytes!`
|
||||||
|
4. Added `gitleaks` secret scanning to CI pipeline
|
||||||
|
5. Added README to `crates/pm-agent-client/certs/` explaining runtime cert generation
|
||||||
|
6. Git history will be purged with `git filter-repo` after PR merge
|
||||||
|
|
||||||
|
**Key Rotation:**
|
||||||
|
These keys were dev/test only. No production key rotation is needed. All committed keys
|
||||||
|
should be considered compromised and must not be used in production.
|
||||||
|
|
||||||
|
### No Other Critical or High Findings
|
||||||
|
|
||||||
|
All other security controls are implemented as specified in the system requirements.
|
||||||
|
|
||||||
### Recommendations (Low Priority)
|
### Recommendations (Low Priority)
|
||||||
|
|
||||||
@ -192,3 +213,4 @@ All security controls are implemented as specified in the system requirements.
|
|||||||
- [x] Backup encryption supported (GPG)
|
- [x] Backup encryption supported (GPG)
|
||||||
- [x] Azure SSO with PKCE flow
|
- [x] Azure SSO with PKCE flow
|
||||||
- [x] No plaintext credential storage
|
- [x] No plaintext credential storage
|
||||||
|
- [x] Committed private key material removed from repository (Issue #12)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "patch-manager-ui",
|
"name": "patch-manager-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.7",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@ -22,7 +22,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|||||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
VERSION="0.1.9"
|
VERSION="1.0.0"
|
||||||
RELEASE="1"
|
RELEASE="1"
|
||||||
PKG_NAME="linux-patch-manager"
|
PKG_NAME="linux-patch-manager"
|
||||||
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"
|
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"
|
||||||
|
|||||||
@ -30,7 +30,7 @@ mv "$TEMP_CHANGELOG" debian/changelog
|
|||||||
echo "[2/5] debian/changelog: Added entry for $NEW_VERSION"
|
echo "[2/5] debian/changelog: Added entry for $NEW_VERSION"
|
||||||
|
|
||||||
# 3. debian/control - Update Version field
|
# 3. debian/control - Update Version field
|
||||||
if grep -q "^Version:" debian/control 2>/dev/null; then
|
if grep -q "^Version:" debian/control 2>/dev/null || true; then
|
||||||
sed -i "s/^Version: .*/Version: $NEW_VERSION-1/" debian/control
|
sed -i "s/^Version: .*/Version: $NEW_VERSION-1/" debian/control
|
||||||
echo "[3/5] debian/control: -> $NEW_VERSION-1"
|
echo "[3/5] debian/control: -> $NEW_VERSION-1"
|
||||||
else
|
else
|
||||||
@ -71,7 +71,7 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Stale references check:"
|
echo "Stale references check:"
|
||||||
grep -r "$OLD_VERSION" --include='*.toml' --include='*.sh' --include='*.json' --include='control' . 2>/dev/null | grep -v 'target/' | grep -v '.git/' | grep -v 'node_modules/' | grep -v 'bump-version.sh' || echo " No stale references found"
|
grep -r "$OLD_VERSION" --include='*.toml' --include='*.sh' --include='*.json' --include='control' . 2>/dev/null || true | grep -v 'target/' | grep -v '.git/' | grep -v 'node_modules/' | grep -v 'bump-version.sh' || echo " No stale references found"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
|
|||||||
@ -15,6 +15,13 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BusyBox-compatible millisecond timing (_now_ms not available)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_now_ms() {
|
||||||
|
python3 -c "import time; print(int(time.time()*1000))"
|
||||||
|
}
|
||||||
|
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
@ -72,10 +79,10 @@ api_call() {
|
|||||||
time_api_call() {
|
time_api_call() {
|
||||||
local method="$1" endpoint="$2" shift; shift
|
local method="$1" endpoint="$2" shift; shift
|
||||||
local start end elapsed
|
local start end elapsed
|
||||||
start=$(date +%s%N)
|
start=$(_now_ms)
|
||||||
api_call "${method}" "${endpoint}" -o /dev/null "$@" 2>/dev/null || true
|
api_call "${method}" "${endpoint}" -o /dev/null "$@" 2>/dev/null || true
|
||||||
end=$(date +%s%N)
|
end=$(_now_ms)
|
||||||
elapsed=$(( (end - start) / 1000000 )) # milliseconds
|
elapsed=$(( end - start )) # milliseconds
|
||||||
echo "$(echo "scale=3; ${elapsed}/1000" | bc)"
|
echo "$(echo "scale=3; ${elapsed}/1000" | bc)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,10 +104,10 @@ test_dashboard_load() {
|
|||||||
|
|
||||||
# Also measure frontend static asset load
|
# Also measure frontend static asset load
|
||||||
info "Measuring frontend index.html load time..."
|
info "Measuring frontend index.html load time..."
|
||||||
start=$(date +%s%N)
|
start=$(_now_ms)
|
||||||
curl -sk -o /dev/null "${BASE_URL}/" 2>/dev/null || true
|
curl -sk -o /dev/null "${BASE_URL}/" 2>/dev/null || true
|
||||||
end=$(date +%s%N)
|
end=$(_now_ms)
|
||||||
elapsed=$(( (end - start) / 1000000 ))
|
elapsed=$(( end - start ))
|
||||||
FRONTEND_TIME=$(echo "scale=3; ${elapsed}/1000" | bc)
|
FRONTEND_TIME=$(echo "scale=3; ${elapsed}/1000" | bc)
|
||||||
info "Frontend load time: ${FRONTEND_TIME}s"
|
info "Frontend load time: ${FRONTEND_TIME}s"
|
||||||
pass "Frontend static load: ${FRONTEND_TIME}s"
|
pass "Frontend static load: ${FRONTEND_TIME}s"
|
||||||
@ -169,14 +176,14 @@ test_bulk_host_operations() {
|
|||||||
|
|
||||||
# 4.2 Sequential host creation (measure throughput)
|
# 4.2 Sequential host creation (measure throughput)
|
||||||
info "4.2 Sequential host creation (10 hosts)"
|
info "4.2 Sequential host creation (10 hosts)"
|
||||||
local start=$(date +%s%N)
|
local start=$(_now_ms)
|
||||||
for i in $(seq 1 10); do
|
for i in $(seq 1 10); do
|
||||||
api_call POST /api/v1/hosts \
|
api_call POST /api/v1/hosts \
|
||||||
-d "{\"fqdn\": \"perf-test-${i}.example.com\", \"ip_address\": \"10.99.0.${i}\"}" \
|
-d "{\"fqdn\": \"perf-test-${i}.example.com\", \"ip_address\": \"10.99.0.${i}\"}" \
|
||||||
-o /dev/null 2>/dev/null || true
|
-o /dev/null 2>/dev/null || true
|
||||||
done
|
done
|
||||||
local end=$(date +%s%N)
|
local end=$(_now_ms)
|
||||||
local total_ms=$(( (end - start) / 1000000 ))
|
local total_ms=$(( end - start ))
|
||||||
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
||||||
local per_host=$(echo "scale=3; ${total_s}/10" | bc)
|
local per_host=$(echo "scale=3; ${total_s}/10" | bc)
|
||||||
info "10 hosts created in ${total_s}s (${per_host}s per host)"
|
info "10 hosts created in ${total_s}s (${per_host}s per host)"
|
||||||
@ -199,11 +206,11 @@ test_cidr_scan() {
|
|||||||
# Note: This test initiates a real CIDR scan which may not complete quickly
|
# Note: This test initiates a real CIDR scan which may not complete quickly
|
||||||
# without reachable hosts. We measure the API response time for initiating.
|
# without reachable hosts. We measure the API response time for initiating.
|
||||||
info "5.1 CIDR scan initiation time"
|
info "5.1 CIDR scan initiation time"
|
||||||
local start=$(date +%s%N)
|
local start=$(_now_ms)
|
||||||
SCAN_RESP=$(api_call POST /api/v1/discovery/cidr \
|
SCAN_RESP=$(api_call POST /api/v1/discovery/cidr \
|
||||||
-d '{"cidr": "10.0.0.0/30", "timeout": 1.5}' 2>/dev/null || true)
|
-d '{"cidr": "10.0.0.0/30", "timeout": 1.5}' 2>/dev/null || true)
|
||||||
local end=$(date +%s%N)
|
local end=$(_now_ms)
|
||||||
local elapsed_ms=$(( (end - start) / 1000000 ))
|
local elapsed_ms=$(( end - start ))
|
||||||
local elapsed_s=$(echo "scale=3; ${elapsed_ms}/1000" | bc)
|
local elapsed_s=$(echo "scale=3; ${elapsed_ms}/1000" | bc)
|
||||||
|
|
||||||
info "CIDR scan initiation: ${elapsed_s}s"
|
info "CIDR scan initiation: ${elapsed_s}s"
|
||||||
@ -240,13 +247,13 @@ test_concurrent_load() {
|
|||||||
|
|
||||||
# Fire 20 concurrent requests and measure total time
|
# Fire 20 concurrent requests and measure total time
|
||||||
info "6.1 20 concurrent fleet status requests"
|
info "6.1 20 concurrent fleet status requests"
|
||||||
local start=$(date +%s%N)
|
local start=$(_now_ms)
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
|
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
|
||||||
done
|
done
|
||||||
wait
|
wait
|
||||||
local end=$(date +%s%N)
|
local end=$(_now_ms)
|
||||||
local total_ms=$(( (end - start) / 1000000 ))
|
local total_ms=$(( end - start ))
|
||||||
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
||||||
local per_req=$(echo "scale=3; ${total_s}/20" | bc)
|
local per_req=$(echo "scale=3; ${total_s}/20" | bc)
|
||||||
|
|
||||||
@ -259,7 +266,7 @@ test_concurrent_load() {
|
|||||||
|
|
||||||
# 6.2 Mixed endpoint concurrent load
|
# 6.2 Mixed endpoint concurrent load
|
||||||
info "6.2 20 concurrent mixed-endpoint requests"
|
info "6.2 20 concurrent mixed-endpoint requests"
|
||||||
start=$(date +%s%N)
|
start=$(_now_ms)
|
||||||
for i in $(seq 1 5); do
|
for i in $(seq 1 5); do
|
||||||
api_call GET /api/v1/hosts -o /dev/null 2>/dev/null &
|
api_call GET /api/v1/hosts -o /dev/null 2>/dev/null &
|
||||||
api_call GET /api/v1/groups -o /dev/null 2>/dev/null &
|
api_call GET /api/v1/groups -o /dev/null 2>/dev/null &
|
||||||
@ -267,8 +274,8 @@ test_concurrent_load() {
|
|||||||
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
|
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
|
||||||
done
|
done
|
||||||
wait
|
wait
|
||||||
end=$(date +%s%N)
|
end=$(_now_ms)
|
||||||
total_ms=$(( (end - start) / 1000000 ))
|
total_ms=$(( end - start ))
|
||||||
total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
||||||
per_req=$(echo "scale=3; ${total_s}/20" | bc)
|
per_req=$(echo "scale=3; ${total_s}/20" | bc)
|
||||||
info "Mixed concurrent: ${total_s}s total, ${per_req}s avg"
|
info "Mixed concurrent: ${total_s}s total, ${per_req}s avg"
|
||||||
@ -282,12 +289,12 @@ test_ws_ticket_performance() {
|
|||||||
echo -e "\n${CYAN}=== Test 7: WebSocket Ticket Issuance ===${NC}"
|
echo -e "\n${CYAN}=== Test 7: WebSocket Ticket Issuance ===${NC}"
|
||||||
|
|
||||||
info "7.1 Sequential ticket creation (10 tickets)"
|
info "7.1 Sequential ticket creation (10 tickets)"
|
||||||
local start=$(date +%s%N)
|
local start=$(_now_ms)
|
||||||
for i in $(seq 1 10); do
|
for i in $(seq 1 10); do
|
||||||
api_call POST /api/v1/ws/ticket -o /dev/null 2>/dev/null || true
|
api_call POST /api/v1/ws/ticket -o /dev/null 2>/dev/null || true
|
||||||
done
|
done
|
||||||
local end=$(date +%s%N)
|
local end=$(_now_ms)
|
||||||
local total_ms=$(( (end - start) / 1000000 ))
|
local total_ms=$(( end - start ))
|
||||||
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
||||||
local per_ticket=$(echo "scale=3; ${total_s}/10" | bc)
|
local per_ticket=$(echo "scale=3; ${total_s}/10" | bc)
|
||||||
info "10 tickets in ${total_s}s (${per_ticket}s per ticket)"
|
info "10 tickets in ${total_s}s (${per_ticket}s per ticket)"
|
||||||
|
|||||||
Reference in New Issue
Block a user