477 Commits

Author SHA1 Message Date
Jordan Ramos
416bfd2e28 Add Infoblox DNS lookup, Scan Posture page, and Ivanti OS fields
- Infoblox WAPI integration (read-only) for resolving IPs to FQDNs
  - backend/helpers/infobloxApi.js — Basic auth, host/PTR/IPv6 lookups
  - backend/routes/infoblox.js — /api/infoblox endpoints
  - Globe icon on Reporting page hostName/dns columns for one-click DNS lookup
  - Pending: WAPI credentials with API access permissions

- Scan Posture page for Access Ops platform/version tracking
  - backend/routes/scanPosture.js
  - frontend/src/components/pages/ScanPosturePage.js
  - Page visibility and nav drawer entries

- Ivanti findings OS field enrichment
  - Migration to add os_name/os_class/os_version columns
  - Backfill script for existing findings
  - ivantiFindings route updates to persist OS data on sync
2026-08-21 10:33:46 -06:00
Jordan Ramos
7484e81b9a Add interactive report viewer to Exports page
Transform Exports page from download-only to interactive report workbench.
All export buttons now open a full-page ReportViewer with sort, filter,
inline edit, row deletion, and xlsx download of the curated data.

New features:
- ReportViewer component with multi-sheet tab support
- Atlas Commitment Dates report (overdue highlighting, hostname resolution from DB)
- Scan Type Coverage report (agent/network/mixed per host with summary bar)
- GET /api/atlas/commitments endpoint (JOINs atlas cache with findings for hostnames)
2026-08-18 14:58:27 -06:00
Jordan Ramos
a178e5e772 Make legacy Jira migrations skip gracefully when tickets table exists
After the unify_tickets_table migration runs, jira_tickets is renamed to
jira_tickets_legacy. The four earlier Jira migrations now detect the unified
tickets table and exit cleanly instead of failing with 'table does not exist'.
2026-08-18 14:26:05 -06:00
Jordan Ramos
04d96730e2 Fix migration: add OVERRIDING SYSTEM VALUE and reset sequence before Archer insert
The Jira tickets preserve their original IDs via OVERRIDING SYSTEM VALUE,
then the sequence is reset to max(id) before Archer rows auto-increment.
Without this, Archer inserts collide with Jira IDs on the primary key.
2026-08-18 14:15:26 -06:00
Jordan Ramos
5ef535426d Unify Archer and Jira tickets into single tickets table
Add unified tickets table with ticket_type discriminator column ('archer'|'jira').
Migration preserves Jira ticket IDs for FK integrity, updates junction table FK,
and renames old tables to *_legacy for rollback path.

New /api/tickets router provides full CRUD with type-aware validation, plus all
Jira integration endpoints (lookup, sync, create-in-jira). Old routes
(/api/jira-tickets, /api/archer-tickets) refactored as backward-compatible
proxies querying the unified table.

Updated ivantiTodoQueue ticket-links JOIN and server.js CVE cascade queries
to reference the new tickets table.
2026-08-18 14:04:27 -06:00
Jordan Ramos
9d1d4cdeb5 Fix scan_type migration: add process.exit() for standalone execution
run-all.js executes each migration as a child process via node. The migration
must call process.exit() or the pool keeps it alive and the runner times out
(exit code null).
2026-08-18 11:25:16 -06:00
Jordan Ramos
abebc99914 Fix deploy after_script: install git and use grep -E in Alpine
Alpine containers lack git and BusyBox grep doesn't support -P (Perl regex).
Install git in after_script and use grep -oE for POSIX extended regex instead.
2026-08-18 11:14:45 -06:00
Jordan Ramos
d3adc8e1fc Add scan type indicator (agent vs network appliance) to Ivanti findings
Detect whether a finding was discovered by Qualys Cloud Agent (authenticated)
or a network appliance scan (unauthenticated) based on the presence of
'Agent ID' in hostAdditionalDetails from the Ivanti API response.

- Add scan_type column to ivanti_findings table (migration)
- Extract scanType in extractFinding() during sync
- Include scan_type in upsert and API response
- Add ScanTypeBadge component (green AGT / orange NET) on ReportingPage
- Add /raw-inspect diagnostic endpoint for inspecting raw Ivanti data
2026-08-18 10:54:23 -06:00
Jordan Ramos
3aa7a6e49e Surface NetBox IPAM records when IP has no assigned device
The /devices/by-ip/:ip endpoint previously returned 404 when an IP existed
in NetBox IPAM but wasn't assigned to a device interface. Now returns the
IPAM record (dns_name, status, role, description, comments) with a 200
response and ipamOnly flag.

Frontend NetBoxBadge gains a new 'ipam_only' state — purple 'IP' pill badge
with a dedicated tooltip showing available IPAM metadata (address, status,
DNS name, FQDN, role, VRF, tenant). Distinguishes clearly from full device
records while still surfacing useful infrastructure context.
2026-08-10 15:40:53 -06:00
Jordan Ramos
9d0e4b910e Release v2.6.0 — Granite Loader enhancements, NetBox badge, compliance fixes
New features:
- Granite supplemental workbook ingest with reconciliation
- Drag-fill and bulk default cascade in Granite Loader
- OS type searchable dropdown in Granite Loader
- Persist selections across pages + Select All
- NetBox badge on Reporting page with CARD cross-check
- CARD API connection retry for DNS round-robin dead nodes
- Granite Loader export to supplemental hygiene section

Fixes:
- Compliance reconcile stale core columns (type field vs string matching)
- Compliance upload case-insensitive column matching
- Compliance summary query NULL vertical handling
- Compliance page state persistence across uploads
- TLS for GitLab feedback integration after v19 upgrade

Also: updated user guide with all new features, added release comms template.
v2.6.0
2026-08-10 14:52:37 -06:00
Jordan Ramos
ab8a14bf1b Sync frontend complianceCategories.json with pruned backend config
The compliance_config.json was pruned to remove metrics no longer tracked,
but the frontend category map was not updated in sync. This caused the
compliance-drift-metric-coverage test to fail in CI.
2026-08-10 14:37:18 -06:00
Jordan Ramos
bd5eb2b47a Add connection retry to CARD API for DNS round-robin dead nodes
nidl.charter.com resolves to multiple A records via round-robin but not all
are reachable from this network. Both acquireToken() and cardRequest() now
retry up to 2 times on ETIMEDOUT/ECONNREFUSED/ECONNRESET before failing.

Also prunes obsolete metric categories from compliance_config.json and fixes
a comment referencing the correct CARD API hostname.
2026-08-10 14:32:23 -06:00
Jordan Ramos
1d42e09b9b Add OS type searchable dropdown to Granite Loader
Map the OS (UDA#CONTROLLER CONFIG#OS) and OSTYPE (UDA#EQUIP MIGRATION#OSTYPE)
columns to a picklist of 38 standard OS types extracted from the Granite
Team_Device Loader reference sheet. Both columns now render as searchable
dropdowns in the bulk defaults row and inline cell editing.
2026-08-10 14:30:53 -06:00
Jordan Ramos
b146bbb0fb Update summary query mock to match combined vertical IS NULL OR NTS_AEO query
The /summary endpoint now uses a single query instead of two-step fallback.
Update the test mock to match the new SQL pattern while keeping legacy
patterns as fallback for compatibility.
2026-08-10 14:00:48 -06:00
Jordan Ramos
d0f6460c0a Fix compliance upload: case-insensitive column matching, summary query, page persistence
- Parser (parse_compliance_xlsx.py): column name matching is now
  case-insensitive via _resolve_col() helper. New reports using lowercase
  headers (e.g. 'preferred - hostname') are parsed correctly instead of
  silently returning 0 items.

- Drift checker (driftChecker.js): core column presence check uses
  case-insensitive comparison so lowercase headers no longer trigger
  false 'missing from all detail sheets' breaking findings.

- Summary endpoint (compliance.js): query now selects the most recent
  upload where vertical IS NULL OR vertical = 'NTS_AEO' in a single
  query, instead of preferring NULL-vertical legacy uploads that are
  outdated.

- Page persistence (App.js): localStorage page restore no longer checks
  canAccessPage during useState init (user is null at that point).
  An effect validates the page once auth resolves.

Closes #45
2026-08-10 13:55:10 -06:00
Jordan Ramos
ef6a389875 Document driftChecker string-matching bug in steering notes
Adds a Known Bugs & Lessons Learned section to the workflow steering
file documenting the reconcileConfig message-matching failure pattern
and the lesson: use type fields, not message substring matching, for
programmatic finding identification.
2026-08-10 10:53:49 -06:00
Jordan Ramos
70a718a8df Fix compliance reconcile not removing stale core columns
The reconcileConfig() function used string matching 'is missing core column'
to identify core column findings, but compareSchemaToDrift() produces messages
like 'Core column "X" is missing from all N detail sheet(s)'. The mismatch
meant reconciliation never touched core_cols — stale columns persisted and
blocked uploads indefinitely.

Additionally, the old logic counted findings per column (always 1 since the
detection aggregates) and compared against detailSheetCount. Since the finding
already confirms the column is missing from ALL sheets, the count check was
redundant and always failed.

Fix: match on the actual message format and trust the detection's all-sheets
assertion directly.
2026-08-10 10:51:11 -06:00
Jordan Ramos
405f63cbfc Fix TLS for GitLab feedback integration after v19 upgrade
The feedback route had a typo: rejectAuthorized (no-op) instead of
rejectUnauthorized. With GitLab now on HTTPS with a self-signed cert,
this caused issue creation and screenshot uploads to fail.

- Fix rejectAuthorized → rejectUnauthorized in both request blocks
- Update GITLAB_URL default from http:// to https:// in .env.example,
  configure.js, README, and reference manual
2026-08-10 09:41:40 -06:00
Jordan Ramos
68431f2d3f Persist selections across pages, add Select All for Granite Loader
- Selections persist when paging through the device table
- Select All button fetches all hostnames for the active sheet
- Granite Loader button fetches full device data for all selected
  (not just current page) before opening the modal
- Clear selection button when items are selected across pages
- Page checkbox adds to existing selection instead of replacing
2026-08-05 11:51:00 -06:00
Jordan Ramos
c7a4e411ce Add drag-fill and bulk default cascade to Granite Loader
- Drag-fill: hover bottom-right corner of a cell to get a fill handle,
  drag down to fill that value into rows below (Excel-style)
- Bulk default deletion now cascades: clearing a default also removes
  per-row overrides that matched that value
- initialDevices now passes equip_inst_id through from supplemental data
2026-08-04 15:30:58 -06:00
Jordan Ramos
4852ea3c1a Add Granite Loader export to supplemental hygiene section
Select devices via checkboxes in the Granite Hygiene device table and
generate Granite Loader sheets directly — equip_inst_id is already
present from the supplemental data so no CARD enrichment needed.
2026-08-04 12:57:59 -06:00
Jordan Ramos
eb0d55a35b Add NetBoxBadge to reporting page, CARD hostname extraction, docs
- NetBoxBadge inline pill on ReportingPage host column
- CARD owner endpoint now returns hostname from card_flags/ivanti_assets
- card-to-granite-field-mapping reference doc
- Supplemental workbook sample in docs
- netbox-dry-run diagnostic script
2026-08-03 11:57:35 -06:00
Jordan Ramos
e3dce7dbbc Add Granite supplemental workbook ingest with reconciliation
Separate upload path for the NTS_AEO_(supp only) workbook that tracks
Granite CMDB hygiene findings (Missing OS, Missing App ID, Missing Device
Function, Retired App ID) with weekly history and team scoping.

Backend:
- Python parser for supplemental xlsx (4 recognized sheets)
- supplemental_uploads and supplemental_items tables with per-sheet scoped resolution
- supplemental_notes table for per-device notes
- resolution_date and remediation_plan on supplemental items
- Full route module: preview, commit, summary, items, trends, uploads, rollback,
  device detail, metadata PATCH, notes CRUD

Frontend:
- SupplementalUploadModal with per-sheet diff preview
- GraniteHygieneSection on CompliancePage with cards, device drill-down, pagination
- SupplementalDetailPanel slide-out with findings, metadata editing, and notes
- Upload Supplemental button in CompliancePage header
2026-08-03 11:54:12 -06:00
Jordan Ramos
94718cbdde Release v2.5.0 — NetBox integration, quick-create CVE, User Guide, Admin tools
New features:
- NetBox device inventory API framework (future Granite replacement)
- Quick-create CVE from tooltip on missing CVEs
- Auto-convert .msg attachments to PDF for FP workflows
- APPID + APPREFID searchable picker in Granite Loader
- BU Lookup tool in Admin panel
- In-app User Guide with TOC navigation
- User Management search/filter/sort
- Admin scope toggle for team-filtered views
- Dual-port TLS support for FQDN access

Fixes:
- EQUIP_INST_ID extraction fallbacks
- APPID picker crash
- BU reassignment banner accuracy
- User Guide TOC scroll navigation
- CI verify jobs HTTPS support
v2.5.0
2026-07-06 15:22:06 -06:00
Jordan Ramos
121d044fb6 Add NetBox device inventory integration framework
Adds API helper and route module for NetBox DCIM integration as a future
replacement for Granite inventory management.

Helper (helpers/netboxApi.js):
- Token-based auth (Authorization: Token <token>)
- Device CRUD: list, get, create, update, delete
- IP cross-reference: find device by IP via IPAM
- Reference data: sites, device types, device roles
- Connection test, TLS skip support

Routes (routes/netbox.js) mounted at /api/netbox:
- GET /status — config and connectivity check
- GET /devices — list with filters and pagination
- GET /devices/search — general search
- GET /devices/by-ip/:ip — cross-reference IP to device
- GET /devices/:id — single device detail
- POST /devices — create (Admin, Standard_User)
- PATCH /devices/:id — partial update
- DELETE /devices/:id — delete (Admin only)
- GET /sites, /device-types, /device-roles — reference data

Integration is optional — gracefully returns 503 if env vars are unset.
New env vars: NETBOX_API_URL, NETBOX_API_TOKEN, NETBOX_SKIP_TLS
2026-07-06 15:19:33 -06:00
Jordan Ramos
e8a5bdc196 Fix EQUIP_INST_ID extraction fallbacks and minor cleanups
- Add additional fallback sources for EQUIP_INST_ID in CARD extractGraniteFields
  (card_flags, ivanti_assets, top-level asset fields)
- Add CARD_DEBUG env var gate for troubleshooting extraction logic
- Clarify anomaly/latest endpoint comment (only significant rows returned)
- Remove stale convention comment in LoaderModal.js
2026-07-06 15:18:56 -06:00
Jordan Ramos
ffaaf961e8 Add quick-create CVE option to tooltip for missing CVEs
When hovering a CVE badge on the Reporting Page that doesn't exist in the
database, the tooltip now shows a 'Not yet tracked' message with an 'Add CVE'
button (permission-gated to Admin/Standard_User). Clicking opens a modal that
auto-populates metadata from NVD and submits via POST /api/cves.

Features:
- Create_Tooltip variant in CveTooltip for exists:false responses
- CveCreateModal with NVD auto-population, form validation, error handling
- 150ms hover bridge so users can reliably click the tooltip button
- Cache invalidation on successful creation
- Success toast notification (4s auto-dismiss)
2026-07-02 09:59:51 -06:00
Jordan Ramos
92096f2b66 Auto-convert .msg attachments to PDF for Ivanti FP workflow
Ivanti rejects .msg (Outlook email) file uploads. Now when a user
attaches a .msg file, the backend:
1. Accepts the upload (added .msg to ALLOWED_EXTENSIONS)
2. Converts it to PDF via Python (extract-msg + reportlab)
3. Sends the PDF to Ivanti instead

The PDF preserves subject, sender, date, recipients, and body.
Conversion applies to both initial FP submission and add-attachments
resubmit endpoint. Returns 422 if conversion fails.

Dependencies: extract-msg, reportlab (pip3 install)
2026-06-30 15:27:29 -06:00
Jordan Ramos
a5ef696993 Exclude backend/certs from rsync deploy to preserve CA certs
The rsync --delete was wiping CA-signed certificates on every deploy.
Add backend/certs to the exclude list alongside .env and uploads so
certs installed on the target host are never overwritten by deploys.
The fallback cert generation step still runs if no cert exists (fresh
deploy), but will not replace an existing CA-signed cert.
2026-06-30 14:58:21 -06:00
Jordan Ramos
043a20f63d Skip already-enriched devices on CARD retry
On retry, only send devices still missing EQUIP_INST_ID to CARD.
Devices that already came back with data on the first pass are
skipped, reducing API calls and wait time when only a subset of
devices timed out.
2026-06-30 14:56:11 -06:00
Jordan Ramos
3d9481d95b Pin common APPIDs to top of picker with ★ prefix
Add 10 most-used APPIDs (CMTS, Jump Servers, Console Servers, RANCID,
CableOS, NDI, NTP, Vecima, Tools Infra, Adtran) as starred entries
at the top of the dropdown for quick access. Full CMDB list follows
below. Star prefix is stripped during value extraction so cell still
stores clean 'APP_ID - APPREFID' format.
2026-06-30 14:45:41 -06:00
Jordan Ramos
cb49b16f0b Extract APP_ID - APPREFID from display string on selection
The SearchableSelect shows full context for searching (app name, env)
but when a selection is made, only the 'APP_ID - APPREFID' portion is
stored as the cell value. Applies to both bulk defaults and per-cell
inline edits.
2026-06-30 14:41:27 -06:00
Jordan Ramos
0680a82aaf Fix APPID picker crash — use plain strings for SearchableSelect
SearchableSelect expects string arrays, not objects. Convert CMDB
options to strings in format 'APP_NAME | APP_ID - APPREFID [ENV]'
so filtering and display work correctly.
2026-06-30 14:22:27 -06:00
Jordan Ramos
c338e6fa3b Add APPID + APPREFID searchable picker to Granite Loader
Load CMDB app instance data (4797 entries) from static JSON and present
a searchable dropdown in the APP_ID_ASSET_TAG column. Users search by
app name or APP ID, select an entry, and the field populates with the
'APP_ID - APPREFID' format required by Granite.

- Convert Cherwell CMDB CSV export to frontend/public/cmdb-appids.json
- LoaderModal: load APPID options on open, provide via getPicklist()
  helper that overrides COLUMN_PICKLISTS for the APP_ID_ASSET_TAG field
- Options display as 'APP_NAME (APP_ID) [ENV]' for easy identification
- Works in both bulk defaults and per-cell inline editing
2026-06-30 14:17:21 -06:00
Jordan Ramos
93d68bccfe Fix BU reassignment detail — fetch by count instead of time window
The BU history records may have been written days before the anomaly
detected them leaving scope. Instead of a fragile time window, fetch
the N most recent BU change records where N matches the classification
count shown in the banner. This ensures the detail always matches
the summary.
2026-06-29 12:04:18 -06:00
Jordan Ramos
5037d68613 Fix anomaly banner BU reassignment detail display
- Backend: /anomaly/latest now returns the most recent SIGNIFICANT
  anomaly instead of the absolute latest (non-significant ones are
  useless to the banner which skips them anyway)
- Frontend: expand BU detail time window from 60 minutes to 25 hours
  before the anomaly timestamp to capture changes from the full sync
  cycle (syncs run once per 24h)
2026-06-29 12:01:03 -06:00
Jordan Ramos
61e4cc5f32 Add dual-port TLS support for FQDN access
When ALSO_LISTEN_PORT is set (e.g., 443), the server binds to both
the primary PORT (3001) and the additional port using the same TLS
certs. This enables clean FQDN access (https://aegis-uat.charterlab.com)
on port 443 while keeping the existing IP:3001 URL working as a
fallback during the DNS transition period.
2026-06-29 11:27:46 -06:00
Jordan Ramos
6f9a5885e1 Add search, filter, and sort to Admin Panel user management
Same UX improvements as the modal — search bar, group filter chips,
sortable column headers, and filtered count. Applied to the in-page
UserManagementPanel in AdminPage.js so the Admin Panel tab has
parity with the modal.
2026-06-26 10:06:54 -06:00
Jordan Ramos
80761022b7 Add search, filter, and sort to User Management table
- Search bar filters users by name, email, or team assignment
- Group filter chips: All, Admin, Standard_User, Leadership, Read_Only, No Teams
- Sortable column headers (User, Group, Teams, Status, Last Login)
- Shows filtered count (e.g., '5 of 12 users')
- 'No Teams' filter quickly identifies users needing team assignment
2026-06-26 09:53:27 -06:00
Jordan Ramos
93de7bbca7 Add SVP org hierarchy discovery to BU Lookup panel
Backend:
- GET /api/ivanti/findings/org-hierarchy — returns all SVP names and
  all BU names from Ivanti suggest API (Admin only)
- GET /api/ivanti/findings/bus-by-svp?svp=<name> — returns BUs under
  a specific SVP with finding counts (Admin only)

Frontend (Admin > BU Lookup tab):
- 'Load Org Hierarchy' button fetches all SVPs and BUs
- SVP dropdown (sorted by finding count) to filter BUs by org leader
- Results table shows BU name, finding count, and configured status
  (green badge for BUs already in the dashboard, grey for unconfigured)
- Enables admin to discover which BUs roll up under an SVP for correct
  team assignment when onboarding new users
2026-06-26 09:33:45 -06:00
Jordan Ramos
58996cf4cf Add BU Lookup tool to Admin panel
Backend:
- GET /api/ivanti/findings/bu-lookup?q=<hostname|ip> — queries the
  live Ivanti API to discover which BU a host is assigned to. Returns
  deduplicated results with hostName, ipAddress, BU, and hostId.
  Admin-only endpoint.

Frontend:
- Add 'BU Lookup' tab to Admin page with search input and results table
- Shows BU assignment badge (blue for tagged, amber for untagged)
- Supports Enter key to search, loading state, error display
- Useful for verifying team assignments when onboarding new users
2026-06-26 09:20:53 -06:00
Jordan Ramos
dab3784742 Add DNS migration plan for aegis.charterlab.com
Document the step-by-step plan to transition from IP:port access to
friendly DNS names (aegis.charterlab.com for production,
aegis-uat.charterlab.com for UAT). Covers cert regen, port change,
CORS update, CI/CD changes, and rollback procedure.
2026-06-26 09:04:05 -06:00
Jordan Ramos
7866a8577a Fix User Guide TOC — add heading IDs and smooth scroll anchor links
Headings now render with id attributes derived from their text so
TOC anchor links (#getting-started, etc.) have targets. Internal
anchor clicks use scrollIntoView with smooth behavior instead of
full page navigation.
2026-06-24 17:52:37 -06:00
Jordan Ramos
627d957c96 Add in-app User Guide modal accessible from user menu
- UserGuideModal: full-screen overlay rendering user-guide.md as
  styled markdown with search filtering
- UserMenu: add 'User Guide' item with BookOpen icon between
  Feature Request and Sign Out
- Serve user-guide.md as static file from frontend/public/
- Guide renders with dark theme styling matching the dashboard
  aesthetic (blue headers, slate text, amber blockquotes)
2026-06-24 17:48:30 -06:00
Jordan Ramos
fea2127893 Add comprehensive end-user guide for security engineers
Create docs/user-guide.md covering all dashboard features from the
perspective of a security engineer. Includes getting started, all
page workflows (Home, Triage, Compliance, CCP Metrics, Knowledge Base,
Exports, Jira, Archer Templates), user settings, admin features
(View As, scope toggle), and tips/best practices for daily triage.

No code references, deployment steps, or technical implementation
details — purely operational guidance for end users.
2026-06-24 17:45:37 -06:00
Jordan Ramos
b078289c1c Replace React favicon with AEGIS shield logo
Convert shieldlogo.jpeg to favicon.ico (16x16, 32x32), logo192.png,
and logo512.png for the browser tab and PWA manifest.
2026-06-24 17:41:21 -06:00
Jordan Ramos
5d3d4b1eab Allow Admin scope toggle to filter data via ?teams= param
requireTeam() now respects an optional ?teams= query param from Admin
users as a voluntary scope filter. When the Admin Scope Toggle is set
to 'My Teams', the frontend sends ?teams=STEAM,ACCESS-ENG and the
backend applies the filter. When set to 'All BUs' (no param), Admin
gets the full unfiltered view.

Non-admin users continue to be enforced by their bu_teams assignment
regardless of any query param.
2026-06-24 17:04:06 -06:00
Jordan Ramos
221eb6a1a1 Hide admin-only actions from non-Admin activity feed
Non-Admin users should not see user management events (create, delete,
group changes, password resets), impersonation events, or admin-only
compliance operations (config reconcile, upload rollback) in the
Recent Activity panel.
2026-06-24 17:01:49 -06:00
Jordan Ramos
c060133aeb Fix verify jobs to support HTTPS with fallback to HTTP
Health checks now try HTTPS first (with -k for self-signed certs),
then fall back to HTTP. Handles both TLS-enabled and plain HTTP
deployments without failing the pipeline.
2026-06-24 16:46:56 -06:00
Jordan Ramos
15ad2ec4e0 Regenerate TLS certs during deploy if missing after rsync
The certs/ directory is gitignored so rsync --delete wipes it on each
deploy. Add a post-rsync step that generates a self-signed cert if
cert.pem is missing, preventing TLS-enabled servers from crashing on
restart. Applies to both staging and production deploy jobs.
2026-06-24 16:41:01 -06:00