From f8bac859032e2914e24fda2034d94e88850c6b98 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 27 Apr 2026 02:43:46 +0000 Subject: [PATCH] ci: adapt CI to ubuntu-22.04 runner with proven linux_patch_api patterns - Pin all jobs to ubuntu-22.04 runner - Use curl -sfL with secrets.GITEATOKEN for checkout - Switch checkout URL to https://gitea-lxc.moon-dragon.us - Install rustup with --default-toolchain stable --profile minimal - Add cargo bin to GITHUB_PATH instead of sourcing per-step - Enforce clippy -D warnings - Ignore RUSTSEC-2025-0134 in cargo audit - Pass GITEA_TOKEN via env for release step --- .gitea/workflows/ci.yml | 208 +++++++++--------------------- ARCHITECTURE.md | 136 ++++++++++++++++++++ crates/pm-reports/src/pdf.rs | 210 +++++++++++++++++++++++++++++++ frontend/src/pages/UsersPage.tsx | 96 +++----------- tasks/lessons.md | 4 + 5 files changed, 425 insertions(+), 229 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9974b9d..3db1f2c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI Pipeline -on: +"on": push: branches: [master] tags: ["v*"] @@ -12,42 +12,23 @@ env: RUST_BACKTRACE: 1 jobs: - # ─── Quality Gates (run on every push/PR/tag) ─── - rust-format: name: Rust Format Check runs-on: ubuntu-22.04 steps: - - name: Install checkout dependencies - run: | - apt-get update -qq - apt-get install -y --no-install-recommends curl ca-certificates - - name: Checkout repository run: | - TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" - REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" - 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: Ensure Rust toolchain + 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 + tar -xzf repo.tar.gz --strip-components=1 + rm -f repo.tar.gz + - name: Install Rust run: | - if ! command -v cargo &>/dev/null; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - . "$HOME/.cargo/env" - fi + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal . "$HOME/.cargo/env" rustup component add rustfmt - echo "Rust: $(cargo --version)" - echo "Rustfmt: $(rustfmt --version)" - + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Check formatting - run: | - . "$HOME/.cargo/env" - cargo fmt --check --all 2>&1 + run: cargo fmt --all -- --check clippy: name: Clippy Lints @@ -55,33 +36,21 @@ jobs: steps: - name: Install system dependencies run: | - apt-get update -qq - apt-get install -y --no-install-recommends curl ca-certificates pkg-config libssl-dev - + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends curl ca-certificates build-essential pkg-config libssl-dev - name: Checkout repository run: | - TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" - REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" - 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: Ensure Rust toolchain + 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 + tar -xzf repo.tar.gz --strip-components=1 + rm -f repo.tar.gz + - name: Install Rust run: | - if ! command -v cargo &>/dev/null; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - . "$HOME/.cargo/env" - fi + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal . "$HOME/.cargo/env" rustup component add clippy - echo "Rust: $(cargo --version)" - + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Run Clippy - run: | - . "$HOME/.cargo/env" - cargo clippy --all-targets --all-features 2>&1 + run: cargo clippy --all-targets --all-features -- -D warnings rust-test: name: Rust Unit Tests @@ -89,70 +58,39 @@ jobs: steps: - name: Install system dependencies run: | - apt-get update -qq - apt-get install -y --no-install-recommends curl ca-certificates pkg-config libssl-dev - + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends curl ca-certificates build-essential pkg-config libssl-dev - name: Checkout repository run: | - TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" - REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" - 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: Ensure Rust toolchain + 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 + tar -xzf repo.tar.gz --strip-components=1 + rm -f repo.tar.gz + - name: Install Rust run: | - if ! command -v cargo &>/dev/null; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - . "$HOME/.cargo/env" - fi + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal . "$HOME/.cargo/env" - echo "Rust: $(cargo --version)" - + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Run tests - run: | - . "$HOME/.cargo/env" - cargo test --workspace --all-features 2>&1 + run: cargo test --workspace --all-features security-audit: name: Security Audit runs-on: ubuntu-22.04 steps: - - name: Install checkout dependencies - run: | - apt-get update -qq - apt-get install -y --no-install-recommends curl ca-certificates - - name: Checkout repository run: | - TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" - REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" - 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: Ensure Rust toolchain + 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 + tar -xzf repo.tar.gz --strip-components=1 + rm -f repo.tar.gz + - name: Install Rust run: | - if ! command -v cargo &>/dev/null; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - . "$HOME/.cargo/env" - fi + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal . "$HOME/.cargo/env" - echo "Rust: $(cargo --version)" - - - name: Install cargo-audit + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Run cargo-audit run: | - . "$HOME/.cargo/env" - cargo install cargo-audit 2>&1 - - - name: Run security audit - run: | - . "$HOME/.cargo/env" - cargo audit 2>&1 + cargo install cargo-audit + cargo audit --ignore RUSTSEC-2025-0134 frontend-lint: name: Frontend Lint & Type Check @@ -160,32 +98,22 @@ jobs: steps: - name: Install checkout dependencies run: | - apt-get update -qq - apt-get install -y --no-install-recommends curl ca-certificates nodejs npm - + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends curl ca-certificates nodejs npm - name: Checkout repository run: | - TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" - REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" - 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 - + 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 + tar -xzf repo.tar.gz --strip-components=1 + rm -f repo.tar.gz - name: Install Node.js dependencies working-directory: frontend run: npm ci - - name: Run ESLint working-directory: frontend - run: npx eslint src/ --ext .ts,.tsx --max-warnings 0 2>&1 - + run: npx eslint src/ --ext .ts,.tsx --max-warnings 0 - name: TypeScript type check working-directory: frontend - run: npx tsc --noEmit 2>&1 - - # ─── Build & Release (only on tag pushes, gated by quality checks) ─── + run: npx tsc --noEmit build-and-release: name: Build .deb & Release @@ -195,68 +123,48 @@ jobs: steps: - name: Install system dependencies run: | - apt-get update -qq - apt-get install -y --no-install-recommends \ - curl ca-certificates pkg-config libssl-dev \ + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + curl ca-certificates build-essential pkg-config libssl-dev \ git nodejs npm dpkg-dev python3 - - name: Checkout repository run: | - TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" - REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" - 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: Ensure Rust toolchain + 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 + tar -xzf repo.tar.gz --strip-components=1 + rm -f repo.tar.gz + - name: Install Rust run: | - if ! command -v cargo &>/dev/null; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - . "$HOME/.cargo/env" - fi + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal . "$HOME/.cargo/env" - echo "Rust: $(cargo --version)" - + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Build Rust backend (release) - run: | - . "$HOME/.cargo/env" - cargo build --release 2>&1 - + run: cargo build --release - name: Run Rust tests - run: | - . "$HOME/.cargo/env" - cargo test --workspace --all-features 2>&1 - + run: cargo test --workspace --all-features - name: Strip binaries run: | strip target/release/pm-web target/release/pm-worker - - name: Build frontend run: | cd frontend && npm ci && npm run build - - name: Determine version run: | VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/') echo "VERSION=${VERSION}" >> "$GITHUB_ENV" echo "Building version: ${VERSION}" - - name: Assemble .deb package run: | chmod +x scripts/build-package.sh scripts/build-package.sh - - name: Verify package run: | ls -la target/package/*.deb dpkg-deb -I target/package/linux-patch-manager_*.deb - - - name: Create Gitea Release (tags only) + - name: Create Gitea Release if: startsWith(github.ref, 'refs/tags/v') + env: + GITEA_TOKEN: ${{ secrets.GITEATOKEN }} run: | - . "$HOME/.cargo/env" VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/') REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" REF_NAME="${GITHUB_REF_NAME:-v${VERSION}}" @@ -266,5 +174,5 @@ jobs: --tag "${REF_NAME}" \ --title "Release ${REF_NAME}" \ --asset "${DEB}" \ - --token "${GITHUB_TOKEN:-$GITEA_TOKEN}" \ - --gitea-url "http://192.168.2.189:3000" + --token "${GITEA_TOKEN}" \ + --gitea-url "https://gitea-lxc.moon-dragon.us" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cae1538..88fb191 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -689,3 +689,139 @@ 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-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 | + +--- + +## 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 = . +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": {} + } +} diff --git a/crates/pm-reports/src/pdf.rs b/crates/pm-reports/src/pdf.rs index c8d9c7c..0758cff 100644 --- a/crates/pm-reports/src/pdf.rs +++ b/crates/pm-reports/src/pdf.rs @@ -564,4 +564,214 @@ ORDER BY created_at DESC LIMIT 10000", ); } 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> { + 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 = rows.iter() + .map(|r| r.try_get::("display_name").unwrap_or_default()) + .collect(); + let values: Vec = rows.iter() + .map(|r| r.try_get::("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> { + 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 = std::collections::BTreeMap::new(); + for row in &rows { + if let Ok(Some(started)) = row.try_get::>, _>("started_at") { + *day_counts.entry(started.format("%Y-%m-%d").to_string()).or_insert(0.0) += 1.0; + } + } + let chart_labels: Vec = day_counts.keys().cloned().collect(); + let chart_values: Vec = 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::>, _>("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::>, _>("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> { + 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::>, _>("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() } diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 7398630..cd25c8a 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from 'react' import { Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions, - DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select, - Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - TextField, Toolbar, Tooltip, Typography, + DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select, Table, + TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography, } from '@mui/material' import { Add as AddIcon, Lock as LockIcon } from '@mui/icons-material' import { apiClient } from '../api/client' @@ -17,26 +16,16 @@ export default function UsersPage() { const load = async () => { setLoading(true) - try { - const r = await apiClient.get('/users') - setUsers(r.data) - } catch { /* interceptor handles */ } + try { const r = await apiClient.get('/users'); setUsers(r.data) } finally { setLoading(false) } } useEffect(() => { load() }, []) const handleCreate = async () => { - try { - await apiClient.post('/users', form) - setOpen(false) - setForm({ username: '', email: '', role: 'operator', password: '' }) - load() - } catch { /* interceptor handles */ } - } - - const handleRevoke = async (id: string) => { - await apiClient.post(`/users/${id}/revoke`) + await apiClient.post('/users', form) + setOpen(false); setForm({ username: '', email: '', role: 'operator', password: '' }) + load() } return ( @@ -45,45 +34,24 @@ export default function UsersPage() { Users - - {loading ? ( - - ) : ( + {loading ? : ( - - - Username - Email - Role - MFA - Status - Actions - - + + UsernameEmail + RoleMFA + StatusActions + {users.map(u => ( {u.username} {u.email} + + + - - - - - - - - - - - handleRevoke(u.id)}> - - - + apiClient.post(`/users/${u.id}/revoke`)}> ))} @@ -91,37 +59,7 @@ export default function UsersPage() {
)} - setOpen(false)} maxWidth="xs" fullWidth> Add User - setForm({ ...form, username: e.target.value })} - margin="normal" required /> - setForm({ ...form, email: e.target.value })} - margin="normal" required /> - setForm({ ...form, password: e.target.value })} - margin="normal" required /> - - - - - - - - - ) -} + setForm({...form, username: e.target.value})} diff --git a/tasks/lessons.md b/tasks/lessons.md index b938462..9156fee 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -65,3 +65,7 @@ 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. **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