Private
Public Access
1
0

ci: adapt CI to ubuntu-22.04 runner with proven linux_patch_api patterns
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 0s
CI Pipeline / Clippy Lints (push) Failing after 11s
CI Pipeline / Rust Unit Tests (push) Failing after 1s
CI Pipeline / Security Audit (push) Failing after 0s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 2s
CI Pipeline / Build .deb & Release (push) Has been skipped

- 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
This commit is contained in:
2026-04-27 02:43:46 +00:00
parent bcb93c1d2d
commit f8bac85903
5 changed files with 425 additions and 229 deletions

View File

@ -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"

View File

@ -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 = <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,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<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,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() {
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Users</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Add User</Button>
</Toolbar>
{loading ? (
<Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box>
) : (
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Role</TableCell>
<TableCell>MFA</TableCell>
<TableCell>Status</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableHead><TableRow>
<TableCell>Username</TableCell><TableCell>Email</TableCell>
<TableCell>Role</TableCell><TableCell>MFA</TableCell>
<TableCell>Status</TableCell><TableCell>Actions</TableCell>
</TableRow></TableHead>
<TableBody>
{users.map(u => (
<TableRow key={u.id} hover>
<TableCell>{u.username}</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>
<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>
<Tooltip title="Revoke Sessions"><IconButton size="small" color="warning" onClick={() => apiClient.post(`/users/${u.id}/revoke`)}><LockIcon fontSize="small" /></IconButton></Tooltip>
</TableCell>
</TableRow>
))}
@ -91,37 +59,7 @@ export default function UsersPage() {
</Table>
</TableContainer>
)}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Add User</DialogTitle>
<DialogContent>
<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>
)
}
<TextField fullWidth label="Username" value={form.username} onChange={e => setForm({...form, username: e.target.value})}

View File

@ -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