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
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:
@ -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"
|
||||
|
||||
136
ARCHITECTURE.md
136
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 = <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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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})}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user