Private
Public Access
1
0

Revert "ci: adapt CI to ubuntu-22.04 runner with proven linux_patch_api patterns"

This reverts commit f8bac85903.
This commit is contained in:
2026-04-27 03:02:53 +00:00
parent f8bac85903
commit 8a27b136b7
5 changed files with 229 additions and 425 deletions

View File

@ -1,6 +1,6 @@
name: CI Pipeline name: CI Pipeline
"on": on:
push: push:
branches: [master] branches: [master]
tags: ["v*"] tags: ["v*"]
@ -12,23 +12,42 @@ env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
jobs: jobs:
# ─── Quality Gates (run on every push/PR/tag) ───
rust-format: rust-format:
name: Rust Format Check name: Rust Format Check
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Install checkout dependencies
run: |
apt-get update -qq
apt-get install -y --no-install-recommends curl ca-certificates
- name: Checkout repository - name: Checkout repository
run: | run: |
curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}"
tar -xzf repo.tar.gz --strip-components=1 REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}"
rm -f repo.tar.gz curl -sf -H "Authorization: token ${TOKEN}" \
- name: Install Rust "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \
-o repo.tar.gz
tar xzf repo.tar.gz --strip-components=1
rm repo.tar.gz
- name: Ensure Rust toolchain
run: | run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal if ! command -v cargo &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env"
fi
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
rustup component add rustfmt rustup component add rustfmt
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" echo "Rust: $(cargo --version)"
echo "Rustfmt: $(rustfmt --version)"
- name: Check formatting - name: Check formatting
run: cargo fmt --all -- --check run: |
. "$HOME/.cargo/env"
cargo fmt --check --all 2>&1
clippy: clippy:
name: Clippy Lints name: Clippy Lints
@ -36,21 +55,33 @@ jobs:
steps: steps:
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update -qq apt-get update -qq
sudo apt-get install -y --no-install-recommends curl ca-certificates build-essential pkg-config libssl-dev apt-get install -y --no-install-recommends curl ca-certificates pkg-config libssl-dev
- name: Checkout repository - name: Checkout repository
run: | run: |
curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}"
tar -xzf repo.tar.gz --strip-components=1 REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}"
rm -f repo.tar.gz curl -sf -H "Authorization: token ${TOKEN}" \
- name: Install Rust "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \
-o repo.tar.gz
tar xzf repo.tar.gz --strip-components=1
rm repo.tar.gz
- name: Ensure Rust toolchain
run: | run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal if ! command -v cargo &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env"
fi
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
rustup component add clippy rustup component add clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" echo "Rust: $(cargo --version)"
- name: Run Clippy - name: Run Clippy
run: cargo clippy --all-targets --all-features -- -D warnings run: |
. "$HOME/.cargo/env"
cargo clippy --all-targets --all-features 2>&1
rust-test: rust-test:
name: Rust Unit Tests name: Rust Unit Tests
@ -58,39 +89,70 @@ jobs:
steps: steps:
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update -qq apt-get update -qq
sudo apt-get install -y --no-install-recommends curl ca-certificates build-essential pkg-config libssl-dev apt-get install -y --no-install-recommends curl ca-certificates pkg-config libssl-dev
- name: Checkout repository - name: Checkout repository
run: | run: |
curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}"
tar -xzf repo.tar.gz --strip-components=1 REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}"
rm -f repo.tar.gz curl -sf -H "Authorization: token ${TOKEN}" \
- name: Install Rust "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \
-o repo.tar.gz
tar xzf repo.tar.gz --strip-components=1
rm repo.tar.gz
- name: Ensure Rust toolchain
run: | run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal if ! command -v cargo &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" fi
. "$HOME/.cargo/env"
echo "Rust: $(cargo --version)"
- name: Run tests - name: Run tests
run: cargo test --workspace --all-features run: |
. "$HOME/.cargo/env"
cargo test --workspace --all-features 2>&1
security-audit: security-audit:
name: Security Audit name: Security Audit
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Install checkout dependencies
run: |
apt-get update -qq
apt-get install -y --no-install-recommends curl ca-certificates
- name: Checkout repository - name: Checkout repository
run: | run: |
curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}"
tar -xzf repo.tar.gz --strip-components=1 REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}"
rm -f repo.tar.gz curl -sf -H "Authorization: token ${TOKEN}" \
- name: Install Rust "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \
-o repo.tar.gz
tar xzf repo.tar.gz --strip-components=1
rm repo.tar.gz
- name: Ensure Rust toolchain
run: | run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal if ! command -v cargo &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" fi
- name: Run cargo-audit . "$HOME/.cargo/env"
echo "Rust: $(cargo --version)"
- name: Install cargo-audit
run: | run: |
cargo install cargo-audit . "$HOME/.cargo/env"
cargo audit --ignore RUSTSEC-2025-0134 cargo install cargo-audit 2>&1
- name: Run security audit
run: |
. "$HOME/.cargo/env"
cargo audit 2>&1
frontend-lint: frontend-lint:
name: Frontend Lint & Type Check name: Frontend Lint & Type Check
@ -98,22 +160,32 @@ jobs:
steps: steps:
- name: Install checkout dependencies - name: Install checkout dependencies
run: | run: |
sudo apt-get update -qq apt-get update -qq
sudo apt-get install -y --no-install-recommends curl ca-certificates nodejs npm apt-get install -y --no-install-recommends curl ca-certificates nodejs npm
- name: Checkout repository - name: Checkout repository
run: | run: |
curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}"
tar -xzf repo.tar.gz --strip-components=1 REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}"
rm -f repo.tar.gz curl -sf -H "Authorization: token ${TOKEN}" \
"http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \
-o repo.tar.gz
tar xzf repo.tar.gz --strip-components=1
rm repo.tar.gz
- name: Install Node.js dependencies - name: Install Node.js dependencies
working-directory: frontend working-directory: frontend
run: npm ci run: npm ci
- name: Run ESLint - name: Run ESLint
working-directory: frontend working-directory: frontend
run: npx eslint src/ --ext .ts,.tsx --max-warnings 0 run: npx eslint src/ --ext .ts,.tsx --max-warnings 0 2>&1
- name: TypeScript type check - name: TypeScript type check
working-directory: frontend working-directory: frontend
run: npx tsc --noEmit run: npx tsc --noEmit 2>&1
# ─── Build & Release (only on tag pushes, gated by quality checks) ───
build-and-release: build-and-release:
name: Build .deb & Release name: Build .deb & Release
@ -123,48 +195,68 @@ jobs:
steps: steps:
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update -qq apt-get update -qq
sudo apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
curl ca-certificates build-essential pkg-config libssl-dev \ curl ca-certificates pkg-config libssl-dev \
git nodejs npm dpkg-dev python3 git nodejs npm dpkg-dev python3
- name: Checkout repository - name: Checkout repository
run: | run: |
curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}"
tar -xzf repo.tar.gz --strip-components=1 REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}"
rm -f repo.tar.gz curl -sf -H "Authorization: token ${TOKEN}" \
- name: Install Rust "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \
-o repo.tar.gz
tar xzf repo.tar.gz --strip-components=1
rm repo.tar.gz
- name: Ensure Rust toolchain
run: | run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal if ! command -v cargo &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" fi
. "$HOME/.cargo/env"
echo "Rust: $(cargo --version)"
- name: Build Rust backend (release) - name: Build Rust backend (release)
run: cargo build --release run: |
. "$HOME/.cargo/env"
cargo build --release 2>&1
- name: Run Rust tests - name: Run Rust tests
run: cargo test --workspace --all-features run: |
. "$HOME/.cargo/env"
cargo test --workspace --all-features 2>&1
- name: Strip binaries - name: Strip binaries
run: | run: |
strip target/release/pm-web target/release/pm-worker strip target/release/pm-web target/release/pm-worker
- name: Build frontend - name: Build frontend
run: | run: |
cd frontend && npm ci && npm run build cd frontend && npm ci && npm run build
- name: Determine version - name: Determine version
run: | run: |
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/') VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/')
echo "VERSION=${VERSION}" >> "$GITHUB_ENV" echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "Building version: ${VERSION}" echo "Building version: ${VERSION}"
- name: Assemble .deb package - name: Assemble .deb package
run: | run: |
chmod +x scripts/build-package.sh chmod +x scripts/build-package.sh
scripts/build-package.sh scripts/build-package.sh
- name: Verify package - name: Verify package
run: | run: |
ls -la target/package/*.deb ls -la target/package/*.deb
dpkg-deb -I target/package/linux-patch-manager_*.deb dpkg-deb -I target/package/linux-patch-manager_*.deb
- name: Create Gitea Release
- name: Create Gitea Release (tags only)
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: | run: |
. "$HOME/.cargo/env"
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/') VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/')
REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}"
REF_NAME="${GITHUB_REF_NAME:-v${VERSION}}" REF_NAME="${GITHUB_REF_NAME:-v${VERSION}}"
@ -174,5 +266,5 @@ jobs:
--tag "${REF_NAME}" \ --tag "${REF_NAME}" \
--title "Release ${REF_NAME}" \ --title "Release ${REF_NAME}" \
--asset "${DEB}" \ --asset "${DEB}" \
--token "${GITEA_TOKEN}" \ --token "${GITHUB_TOKEN:-$GITEA_TOKEN}" \
--gitea-url "https://gitea-lxc.moon-dragon.us" --gitea-url "http://192.168.2.189:3000"

View File

@ -689,139 +689,3 @@ HTTP status codes follow standard REST semantics (`400`, `401`, `403`, `404`, `4
| C-21 | Added §14 Design Rationale, §15 Risks and Trade-offs, §16 Open Issues, §17 Future Considerations | IEEE 1016 §7 (Design Rationale) was missing; risks and open issues give reviewers a clear audit surface | | C-21 | Added §14 Design Rationale, §15 Risks and Trade-offs, §16 Open Issues, §17 Future Considerations | IEEE 1016 §7 (Design Rationale) was missing; risks and open issues give reviewers a clear audit surface |
| C-22 | Replaced the Email Notifier arrow that pointed back into the web server's mTLS client on the original diagram with a correct component placement in §4.2 | Original diagram implied email flowed through the mTLS client, which is not the design | | C-22 | Replaced the Email Notifier arrow that pointed back into the web server's mTLS client on the original diagram with a correct component placement in §4.2 | Original diagram implied email flowed through the mTLS client, which is not the design |
| C-23 | Added C-X change IDs throughout this log | Enables traceability in future reviews | | C-23 | Added C-X change IDs throughout this log | Enables traceability in future reviews |
---
## 6. Data Flow
### 6.1 Host Registration
```
1. Admin enters FQDN / IP -> Web validates and resolves FQDN to IP.
2. Web inserts row in `hosts` (status = pending).
3. Web NOTIFYs `host_registered` -> Worker performs initial mTLS health check.
4. Worker updates `hosts.health_status` and `host_health_data` -> visible in Dashboard.
```
### 6.2 Auto-Discovery (CIDR scan)
```
1. Admin triggers CIDR scan -> Web inserts a discovery job and NOTIFYs `discovery_enqueued`.
2. Worker scans the subnet for agents listening on port 12443 (bounded concurrency, TLS probe).
3. Discovered agents written to a transient `discovery_results` table.
4. Admin reviews and selects which to register; each selection follows the 6.1 flow.
```
### 6.3 Patch Deployment — Queued
```
1. Operator selects hosts + patches -> "Queue for next window".
2. Web creates `patch_jobs` row (status = queued) and `patch_job_hosts` rows.
3. Job Scheduler detects the next applicable maintenance window per host.
4. At window open, Worker calls the Agent API to start patch operations.
5. Worker polls agent job status (and/or consumes WebSocket events) and updates rows.
6. WebSocket Relay pushes updates to subscribed browsers in real time.
7. Failed hosts are auto-retried once if still within the window (see §8).
```
### 6.4 Patch Deployment — Immediate
```
1. Operator selects hosts + patches -> "Apply Now".
2. Web creates `patch_jobs` row (status = pending) and NOTIFYs `job_enqueued`.
3. Worker wakes immediately and triggers the agent calls.
4. Same monitoring and retry logic as the queued flow.
```
### 6.5 Rollback
```
1. Operator opens a completed or failed job and clicks "Rollback".
2. Web creates a `patch_jobs` row with kind = rollback, parent_job_id = <original>.
3. Worker calls `POST /api/v1/jobs/{id}/rollback` on each affected agent.
4. Results are tracked like any other job; audit log records the rollback actor.
```
### 6.6 Health / Patch Polling
```
1. Worker polls each agent on schedule (5 min health, 30 min patches).
2. Results cached in `host_health_data` and `host_patch_data`.
3. Unhealthy agents are flagged with visual alerts in the Dashboard.
4. On-demand refresh: operator clicks refresh -> Web NOTIFYs `refresh_requested`; Worker queries immediately.
```
---
## 7. Security Architecture
### 7.1 Authentication
- **Local accounts:** Argon2id-hashed passwords; TOTP or WebAuthn for MFA (enforced).
- **Azure SSO:** OAuth2 / OIDC Authorization Code flow with PKCE; Azure's built-in MFA satisfies the MFA requirement.
- **Access tokens:** JWT, signed with a rotating HS256 or EdDSA key (implementation choice); 15-minute TTL.
- **Refresh tokens:** Opaque, 256-bit, stored hashed in `refresh_tokens`; **1-hour sliding inactivity timeout** (rotated on use; revocable).
- **Revocation:** Admins can force-revoke a user's refresh tokens; next access-token expiry terminates all sessions.
### 7.2 Authorization (RBAC)
- **Admin** — Full access to all resources and settings.
- **Operator** — Can add / remove hosts and manage schedules / patches only for devices in their assigned groups.
- **Group scoping** — Enforced by middleware at every API endpoint that touches host-scoped data.
- **Ungrouped hosts** — Accessible by any operator or admin (explicit product decision).
### 7.3 Agent Communication
- **mTLS** — Client certificate authentication for every agent call and WebSocket.
- **TLS 1.3 only** — Older TLS versions are refused at the Rustls configuration layer.
- **Internal CA** — Manager issues and renews client certificates.
- **Manual distribution** — Server administrators install certs on managed clients; the Manager holds no credentials for managed hosts and cannot push files to them.
### 7.4 Data Protection
- **Encryption at rest** — LUKS full-disk encryption, managed by the underlying infrastructure. This is the single mechanism of record; column-level encryption is **not** used (contrasts with an earlier `REQUIREMENTS.md` wording; see §14 Open Issues).
- **Encryption in transit** — TLS 1.3 for all agent and browser connections.
- **Audit log integrity** — Hash-chained rows (`audit_log.prev_hash`, `audit_log.row_hash`); integrity verified by a periodic check job and on-demand from the UI.
- **Password storage** — Argon2id with per-user salt and parameters calibrated for ~250 ms on the deployment hardware.
- **Secrets on disk** — Configuration secrets (JWT key, CA private key, DB password) are stored in `/etc/patch-manager/secrets/` with `0600` permissions, owned by the service user; not committed to the repository.
### 7.5 Compliance Mapping
- **HIPAA §164.312:** Audit controls (§7.4), access controls (§7.2 + MFA), integrity controls (hash-chained audit), transmission security (TLS 1.3 / mTLS), automatic logoff (1-hour inactivity).
- **PCI-DSS:** Requirement 6 (vulnerability management — the core function), Requirement 7 (need-to-know via group scoping), Requirement 8 (MFA, unique IDs), Requirement 10 (audit with 6-month retention), Requirements 3 & 4 (encryption at rest and in transit).
---
## 8. Error Handling and Reliability
### 8.1 Agent Communication Failures
- Mark host as **unhealthy** in the Dashboard.
- Retry with **exponential backoff**: up to **3 retries**, capped at **30 minutes** between attempts (example schedule: 1 min, 5 min, 30 min).
- Continue processing other hosts without blocking.
- After exhausting retries, the host is flagged and reported in the next compliance report.
### 8.2 Patch Job Failures
- Auto-retry a failed patch job **once** if still within the maintenance window.
- If the retry fails, or the window has closed, surface the failure prominently in the Jobs view and in any configured email notifications.
### 8.3 Batch Operations with Partial Failures
- Auto-retry failed hosts **once**.
- If retry fails, report the failed hosts in the job detail view and let the operator decide next steps.
- Successful hosts complete normally regardless of failures elsewhere in the batch.
### 8.4 API Error Response Format
All Manager API errors use a consistent JSON envelope:
```json
{
"error": {
"code": "host_not_found",
"message": "No host with id 42 in any group you can access.",
"request_id": "01JF8Q…",
"details": {}
}
}

View File

@ -564,214 +564,4 @@ ORDER BY created_at DESC LIMIT 10000",
); );
} }
pdf.save() pdf.save()
}
if let Some(gid) = params.group_id {
pdf.write_text(
&format!("Group: {}", gid),
10.0, MARGIN, 128.0, false,
);
}
pdf.new_page();
}
// ---------------------------------------------------------------------------
// Compliance PDF
// ---------------------------------------------------------------------------
async fn compliance_pdf(
pool: &sqlx::PgPool,
params: &ReportParams,
) -> anyhow::Result<Vec<u8>> {
use sqlx::Row;
let rows = if let Some(gid) = params.group_id {
sqlx::query("
SELECT h.display_name, h.fqdn,
COALESCE(pd.total_packages, 0) AS total_packages,
COALESCE(pd.pending_patches, 0) AS pending_patches,
CASE WHEN COALESCE(pd.total_packages,0) = 0 THEN 100.0
ELSE ROUND((1.0 - pd.pending_patches::float / NULLIF(pd.total_packages,0)) * 100, 1)
END AS compliance_pct,
h.health_status::text AS health_status
FROM hosts h
LEFT JOIN host_patch_data pd ON pd.host_id = h.id
WHERE h.id IN (SELECT host_id FROM host_groups WHERE group_id = $1)
GROUP BY h.id, pd.total_packages, pd.pending_patches
ORDER BY compliance_pct ASC")
.bind(gid).fetch_all(pool).await
.context("compliance PDF query (group) failed")?
} else {
sqlx::query("
SELECT h.display_name, h.fqdn,
COALESCE(pd.total_packages, 0) AS total_packages,
COALESCE(pd.pending_patches, 0) AS pending_patches,
CASE WHEN COALESCE(pd.total_packages,0) = 0 THEN 100.0
ELSE ROUND((1.0 - pd.pending_patches::float / NULLIF(pd.total_packages,0)) * 100, 1)
END AS compliance_pct,
h.health_status::text AS health_status
FROM hosts h
LEFT JOIN host_patch_data pd ON pd.host_id = h.id
GROUP BY h.id, pd.total_packages, pd.pending_patches
ORDER BY compliance_pct ASC")
.fetch_all(pool).await
.context("compliance PDF query failed")?
};
let labels: Vec<String> = rows.iter()
.map(|r| r.try_get::<String, _>("display_name").unwrap_or_default())
.collect();
let values: Vec<f64> = rows.iter()
.map(|r| r.try_get::<f64, _>("compliance_pct").unwrap_or(0.0))
.collect();
let mut pdf = PdfBuilder::new("Compliance Report")?;
write_title_page(&mut pdf, "Compliance Report", params);
let col_x: &[f32] = &[MARGIN, 65.0, 130.0, 165.0, 195.0, 230.0];
pdf.table_row(&["Host", "FQDN", "Total Pkgs", "Pending", "Compliance %", "Status"], col_x, 9.0, true);
for row in &rows {
let name: String = row.try_get("display_name").unwrap_or_default();
let fqdn: String = row.try_get("fqdn").unwrap_or_default();
let total: i64 = row.try_get("total_packages").unwrap_or(0);
let pending: i64 = row.try_get("pending_patches").unwrap_or(0);
let pct: f64 = row.try_get("compliance_pct").unwrap_or(0.0);
let status: String = row.try_get("health_status").unwrap_or_default();
pdf.table_row(
&[&name, &fqdn, &total.to_string(), &pending.to_string(), &format!("{:.1}%", pct), &status],
col_x, 8.0, false,
);
}
if !labels.is_empty() {
match render_bar_chart(&labels, &values, "Compliance % by Host") {
Ok(png) => {
pdf.new_page();
pdf.write_text("Compliance Chart", 16.0, MARGIN, 200.0, true);
if let Err(e) = pdf.embed_image(&png, MARGIN, 10.0, 0.18, 0.18) {
tracing::warn!(error = %e, "compliance chart embed failed");
}
}
Err(e) => tracing::warn!(error = %e, "compliance chart render failed"),
}
}
pdf.save()
}
// ---------------------------------------------------------------------------
// Patch history PDF
// ---------------------------------------------------------------------------
async fn patch_history_pdf(
pool: &sqlx::PgPool,
params: &ReportParams,
) -> anyhow::Result<Vec<u8>> {
use sqlx::Row;
let rows = sqlx::query("
SELECT pj.kind::text AS job_kind, pj.status::text AS job_status,
h.display_name, h.fqdn, pjh.started_at, pjh.completed_at,
EXTRACT(EPOCH FROM (pjh.completed_at - pjh.started_at))::bigint AS duration_seconds,
COALESCE(u.username, 'system') AS operator
FROM patch_job_hosts pjh
JOIN patch_jobs pj ON pj.id = pjh.job_id
JOIN hosts h ON h.id = pjh.host_id
LEFT JOIN users u ON u.id = pj.created_by_user_id
WHERE ($1::timestamptz IS NULL OR pjh.started_at >= $1)
AND ($2::timestamptz IS NULL OR pjh.started_at <= $2)
ORDER BY pjh.started_at DESC")
.bind(params.from).bind(params.to)
.fetch_all(pool).await
.context("patch history PDF query failed")?;
let mut day_counts: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new();
for row in &rows {
if let Ok(Some(started)) = row.try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("started_at") {
*day_counts.entry(started.format("%Y-%m-%d").to_string()).or_insert(0.0) += 1.0;
}
}
let chart_labels: Vec<String> = day_counts.keys().cloned().collect();
let chart_values: Vec<f64> = day_counts.values().cloned().collect();
let mut pdf = PdfBuilder::new("Patch History Report")?;
write_title_page(&mut pdf, "Patch History Report", params);
let col_x: &[f32] = &[MARGIN, 45.0, 80.0, 115.0, 155.0, 200.0, 245.0, 270.0];
pdf.table_row(&["Kind","Status","Host","FQDN","Started","Completed","Dur(s)","Operator"], col_x, 9.0, true);
for row in &rows {
let kind: String = row.try_get("job_kind").unwrap_or_default();
let status: String = row.try_get("job_status").unwrap_or_default();
let name: String = row.try_get("display_name").unwrap_or_default();
let fqdn: String = row.try_get("fqdn").unwrap_or_default();
let started: String = row.try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("started_at")
.unwrap_or(None).map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default();
let completed: String = row.try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("completed_at")
.unwrap_or(None).map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default();
let dur: i64 = row.try_get("duration_seconds").unwrap_or(0);
let op: String = row.try_get("operator").unwrap_or_default();
pdf.table_row(&[&kind,&status,&name,&fqdn,&started,&completed,&dur.to_string(),&op], col_x, 8.0, false);
}
if !chart_labels.is_empty() {
match render_bar_chart(&chart_labels, &chart_values, "Jobs per Day") {
Ok(png) => {
pdf.new_page();
pdf.write_text("Patch Activity Chart", 16.0, MARGIN, 200.0, true);
if let Err(e) = pdf.embed_image(&png, MARGIN, 10.0, 0.18, 0.18) {
tracing::warn!(error = %e, "patch history chart embed failed");
}
}
Err(e) => tracing::warn!(error = %e, "patch history chart render failed"),
}
}
pdf.save()
}
// ---------------------------------------------------------------------------
// Vulnerability PDF
// ---------------------------------------------------------------------------
async fn vulnerability_pdf(
pool: &sqlx::PgPool,
params: &ReportParams,
) -> anyhow::Result<Vec<u8>> {
use sqlx::Row;
let mut pdf = PdfBuilder::new("Vulnerability Report")?;
write_title_page(&mut pdf, "Vulnerability Exposure Report", params);
let col_x: &[f32] = &[MARGIN, 55.0, 100.0, 130.0, 175.0, 215.0, 255.0];
pdf.table_row(&["Host","FQDN","CVE ID","Package","Severity","Fix Version","Last Seen"], col_x, 9.0, true);
match sqlx::query("
SELECT h.display_name, h.fqdn,
cve.cve_id, cve.package_name, cve.severity, cve.available_version,
pd.updated_at AS last_seen_at
FROM hosts h
JOIN host_patch_data pd ON pd.host_id = h.id
CROSS JOIN LATERAL jsonb_to_recordset(COALESCE(pd.cve_data, '[]'::jsonb))
AS cve(cve_id text, package_name text, severity text, available_version text)
WHERE ($1::timestamptz IS NULL OR pd.updated_at >= $1)
AND ($2::timestamptz IS NULL OR pd.updated_at <= $2)
ORDER BY CASE cve.severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, h.display_name")
.bind(params.from).bind(params.to)
.fetch_all(pool).await
{
Ok(rows) => {
for row in &rows {
let name: String = row.try_get("display_name").unwrap_or_default();
let fqdn: String = row.try_get("fqdn").unwrap_or_default();
let cve_id: String = row.try_get("cve_id").unwrap_or_default();
let pkg: String = row.try_get("package_name").unwrap_or_default();
let sev: String = row.try_get("severity").unwrap_or_default();
let fix: String = row.try_get("available_version").unwrap_or_default();
let seen: String = row.try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("last_seen_at")
.unwrap_or(None).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default();
pdf.table_row(&[&name,&fqdn,&cve_id,&pkg,&sev,&fix,&seen], col_x, 8.0, false);
}
}
Err(e) => {
tracing::warn!(error = %e, "vulnerability PDF query failed");
let y = pdf.current_y;
pdf.write_text(&format!("No data available: {}", e), 10.0, MARGIN, y, false);
}
}
pdf.save()
} }

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions, Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions,
DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select, Table, DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select,
TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
TextField, Toolbar, Tooltip, Typography,
} from '@mui/material' } from '@mui/material'
import { Add as AddIcon, Lock as LockIcon } from '@mui/icons-material' import { Add as AddIcon, Lock as LockIcon } from '@mui/icons-material'
import { apiClient } from '../api/client' import { apiClient } from '../api/client'
@ -16,16 +17,26 @@ export default function UsersPage() {
const load = async () => { const load = async () => {
setLoading(true) setLoading(true)
try { const r = await apiClient.get('/users'); setUsers(r.data) } try {
const r = await apiClient.get('/users')
setUsers(r.data)
} catch { /* interceptor handles */ }
finally { setLoading(false) } finally { setLoading(false) }
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
const handleCreate = async () => { const handleCreate = async () => {
try {
await apiClient.post('/users', form) await apiClient.post('/users', form)
setOpen(false); setForm({ username: '', email: '', role: 'operator', password: '' }) setOpen(false)
setForm({ username: '', email: '', role: 'operator', password: '' })
load() load()
} catch { /* interceptor handles */ }
}
const handleRevoke = async (id: string) => {
await apiClient.post(`/users/${id}/revoke`)
} }
return ( return (
@ -34,24 +45,45 @@ export default function UsersPage() {
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Users</Typography> <Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Users</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Add User</Button> <Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Add User</Button>
</Toolbar> </Toolbar>
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
{loading ? (
<Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box>
) : (
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
<TableHead><TableRow> <TableHead>
<TableCell>Username</TableCell><TableCell>Email</TableCell> <TableRow>
<TableCell>Role</TableCell><TableCell>MFA</TableCell> <TableCell>Username</TableCell>
<TableCell>Status</TableCell><TableCell>Actions</TableCell> <TableCell>Email</TableCell>
</TableRow></TableHead> <TableCell>Role</TableCell>
<TableCell>MFA</TableCell>
<TableCell>Status</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody> <TableBody>
{users.map(u => ( {users.map(u => (
<TableRow key={u.id} hover> <TableRow key={u.id} hover>
<TableCell>{u.username}</TableCell> <TableCell>{u.username}</TableCell>
<TableCell>{u.email}</TableCell> <TableCell>{u.email}</TableCell>
<TableCell><Chip size="small" label={u.role} color={u.role === 'admin' ? 'primary' : 'default'} /></TableCell>
<TableCell><Chip size="small" label={u.mfa_enabled ? 'On' : 'Off'} color={u.mfa_enabled ? 'success' : 'warning'} /></TableCell>
<TableCell><Chip size="small" label={u.is_active ? 'Active' : 'Disabled'} color={u.is_active ? 'success' : 'error'} /></TableCell>
<TableCell> <TableCell>
<Tooltip title="Revoke Sessions"><IconButton size="small" color="warning" onClick={() => apiClient.post(`/users/${u.id}/revoke`)}><LockIcon fontSize="small" /></IconButton></Tooltip> <Chip size="small" label={u.role}
color={u.role === 'admin' ? 'primary' : 'default'} />
</TableCell>
<TableCell>
<Chip size="small" label={u.mfa_enabled ? 'On' : 'Off'}
color={u.mfa_enabled ? 'success' : 'warning'} />
</TableCell>
<TableCell>
<Chip size="small" label={u.is_active ? 'Active' : 'Disabled'}
color={u.is_active ? 'success' : 'error'} />
</TableCell>
<TableCell>
<Tooltip title="Revoke All Sessions">
<IconButton size="small" color="warning" onClick={() => handleRevoke(u.id)}>
<LockIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -59,7 +91,37 @@ export default function UsersPage() {
</Table> </Table>
</TableContainer> </TableContainer>
)} )}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Add User</DialogTitle> <DialogTitle>Add User</DialogTitle>
<DialogContent> <DialogContent>
<TextField fullWidth label="Username" value={form.username} onChange={e => setForm({...form, username: e.target.value})} <TextField fullWidth label="Username"
value={form.username}
onChange={e => setForm({ ...form, username: e.target.value })}
margin="normal" required />
<TextField fullWidth label="Email" type="email"
value={form.email}
onChange={e => setForm({ ...form, email: e.target.value })}
margin="normal" required />
<TextField fullWidth label="Password" type="password"
value={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
margin="normal" required />
<Select fullWidth value={form.role}
onChange={e => setForm({ ...form, role: e.target.value })}
sx={{ mt: 1 }}>
<MenuItem value="operator">Operator</MenuItem>
<MenuItem value="admin">Admin</MenuItem>
</Select>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate}
disabled={!form.username || !form.email || !form.password}>
Create
</Button>
</DialogActions>
</Dialog>
</Container>
)
}

View File

@ -65,7 +65,3 @@ The Docker container intercepted some jobs and ran them in its Alpine environmen
**Fix:** Stopped Docker container runner. Switched to runs-on: ubuntu-latest with docker://ubuntu:24.04 containers. **Fix:** Stopped Docker container runner. Switched to runs-on: ubuntu-latest with docker://ubuntu:24.04 containers.
**Lesson:** Check for multiple runners with same name. Stop after 2 attempts and diagnose root cause. **Lesson:** Check for multiple runners with same name. Stop after 2 attempts and diagnose root cause.
## CI/CD Runner Dual-Registration Root Cause (2026-04-24)
**Problem:** CI jobs kept failing with