Conversation
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /app/* layout with sidebar and auth gate.
* feat(agent): implement agent core with auth, nftables, Podman, metrics, and audit log
Full Lynx agent implementation:
- Ed25519 command authorization: signature + nonce dedup + timestamp ±30s + permission level
- Audit log with SHA-256 hash chaining for tamper detection
- nftables: atomic full-ruleset apply via stdin, inter-org blocking, checksum tracking
- Podman: rootless tenant users (lynx-tenant-{id}) with isolated subuid/subgid ranges
- WebSocket metrics: CPU (2-sample /proc/stat), RAM (/proc/meminfo), disk (statvfs), streamed every 2s
- Heartbeat watchdog: 300s timeout → lockdown mode (rejects all commands)
- PostgreSQL via sqlx with migrations (audit_log + used_nonces tables)
- Bearer token auth with constant-time comparison via `subtle`
feat(dashboard): production compose with 3 isolated networks and external Podman secrets
Replaces single-network compose with:
- lynx-dashboard-db: backend + postgres only
- lynx-dashboard-cache: backend + redis only
- lynx-dashboard-app: frontend + backend (frontend sole external port: 19443)
- All secrets as external Podman secrets (pg-root, pg-pass, redis-pass, api-token, kek, pepper)
- DATABASE_URL_FILE / REDIS_URL_FILE pattern for secret injection
- PostgreSQL init script creates isolated lynx_dashboard_app user
feat(dashboard): install script with secret generation, health-ordered startup, nftables, WireGuard, TLS
- Detects existing install → abort / update / reinstall (requires exact confirmation phrase)
- Generates 8 Podman secrets via pipe (no shell history, subshell isolation, memory overwrite)
- Starts services in dependency order with health-gate: postgres → redis → backend → frontend
- nftables ruleset: table inet lynx-dashboard, ports 22 + 19443 + 51820 UDP
- Self-signed P-256 cert (90 days) + systemd timer for rotation every 80 days
- WireGuard dashboard keypair + PSK stored as Podman secret; PSK shown once in final output
* feat(agent): add agent install script with WireGuard, systemd, Podman DB, and nftables setup
Complete agent VPS install:
- Creates lynx-agent system user with CAP_NET_ADMIN/CAP_SYS_ADMIN/CAP_SETUID/CAP_SETGID
- Allocates subuid/subgid ranges (1000000+65536) for rootless tenant Podman
- Generates 4 Podman secrets via subshell pipe (no shell history trace)
- Starts PostgreSQL container on lynx-agent-db network (port 127.0.0.1:5434)
- Installs binary as systemd service with LoadCredential (secrets in tmpfs)
- WireGuard peer config built from admin-provided dashboard pubkey + PSK
- nftables: lynx-agent table, only WG inbound + SSH, agent API only from WG iface
- Existing install detection with reinstall phrase confirmation
feat(dashboard): agent management API with WG peer management and command relay
- GET/POST /agents — list and register agents (allocates WG IP .2–.254)
- GET/DELETE /agents/{id} — individual agent ops + WG peer removal
- POST /agents/{id}/heartbeat — relay heartbeat to agent via WireGuard, update status
- POST /agents/{id}/cmd — sign command with Ed25519, relay to agent API
- agents + agent_events migration (migration 004)
- crypto/cmd.rs: sign_command builds JWS-style payload (base64url JSON + Ed25519 sig)
- reqwest client for agent HTTP relay over WireGuard
- AppError extended: NotFound, BadRequest, BadGateway, AgentUnavailable
- From<sqlx::Error> impl so ? works in handlers
* feat(ui): add /app/agents page with Server Component, skeleton loading, and i18n
- AgentList: Server Component, fetches GET /agents with 30s revalidation
- Status badges: online (default) / lockdown (destructive) / offline (secondary)
- Empty state matches overview page pattern
- AgentListSkeleton: 3-card skeleton for Suspense boundary
- i18n keys in en.json + es.json (app.agents namespace)
- Sidebar already linked to /app/agents
* feat: implement audit log sync (agent → dashboard) with per-agent sync token auth
Agent side:
- sync/mod.rs: Tokio task runs every 60s, batches unsynced audit_log entries
- Tracks sync cursor in sync_state table (migration 002)
- Config: DASHBOARD_URL + SYNC_TOKEN (optional — sync disabled if absent)
- reqwest client for HTTP POST to dashboard over WireGuard
Dashboard side:
- Migration 005: central audit_log table + sync_token_hash column on agents
- POST /agents/{id}/audit-sync: no JWT auth — uses per-agent sync token
- Sync token generated at registration (SHA-256 hash stored, plain returned once)
- Hash chain preserved: entry_hash/previous_hash stored verbatim from agent
- ON CONFLICT DO NOTHING: idempotent re-sync
- Constant-time token comparison via subtle crate
* feat: add organizations backend + UI
Backend (migration 006):
- organizations table: id (UUID v7), name, slug (unique), owner_id
- organization_members: role-based (owner/admin/member/viewer)
- projects table: belongs to org + agent, unique slug per org
- GET/POST /organizations, GET/DELETE /organizations/{id}
- List scoped to authenticated user's memberships
- Slug validation: lowercase alphanumeric + hyphens only
- Conflict handling: returns 409 on duplicate slug
UI:
- /app/organizations: Server Component, 60s revalidation
- Card grid with slug, member count, org ID
- OrgListSkeleton for Suspense boundary
- i18n: en + es (app.organizations namespace)
* feat(agent): add nftables divergence detection with dashboard alerting
- Background task runs every 60s: compares live nft checksum vs last apply() result
- Mismatch → WARN log + POST /agents/{id}/events with nftables_divergence event
- Auth: same per-agent sync token used by audit-sync
- AppState gains nft_checksum: Arc<Mutex<Option<String>>> for shared last-known-good state
- set_nft_checksum() called after each apply() in nftables apply handler (future)
Dashboard side:
- POST /agents/{id}/events endpoint — agent-callable (sync token auth)
- Validates event type against allowed list, inserts into agent_events
- Same pattern as audit-sync: no user JWT needed
* feat(dashboard/ui): add settings page with session management and key rotation
Sessions list with per-session revoke (DELETE /admin/sessions/{id}).
JWT key rotation button (POST /admin/rotate) clears cookies and redirects
to login — all sessions invalidated on rotation.
i18n: en + es under app.settings namespace.
* feat(agent/dashboard): container management commands + auto-update system
Agent:
- container.deploy (podman compose up via tenant user)
- container.start/stop/remove/restart (podman CLI via runuser)
- container.update (CPU/memory quota, vertical scaling)
- update.self: download binary from GitHub Releases, verify Ed25519 sig,
atomic rename, graceful exit for systemd restart
Dashboard admin:
- GET /admin/update-check: polls GitHub API for latest release
- POST /admin/trigger-update: signs update.self command to online agents
- GET /admin/update-log: recent update history
- migrations/008_update_log.sql: tracks per-agent update outcomes
* feat(dashboard): agent registration dialog, overview stats, events feed
UI:
- Register Agent dialog (POST /agents) shows agent ID, WG IP, sync token
once; token hidden with reveal toggle
- Overview page: online agent count, org count, recent events feed
- i18n en + es for new keys
Backend:
- GET /agents/events: recent agent_events across all agents (limit param)
* feat(dashboard/agent): WireGuard PSK rotation
Dashboard admin rotate with scope wireguard_psks or all:
- Generates new PSK per online agent via wg genpsk
- Updates dashboard WG peer with new PSK
- Sends signed wg.rotate_psk command to each agent
Agent wg.rotate_psk handler:
- Applies new PSK to wg-lynx-agent peer
- Persists via wg-quick save
* feat(ui): org creation dialog, i18n keys for org/agent registration
- Create Organization dialog with auto-derived slug
- Register Agent and Create Org buttons on respective pages
- i18n en+es: organizations.create/slugConflict/createError
* feat(dashboard/agent): internal PKI — CA issues Ed25519 certs to agents
Dashboard:
- crypto/pki: CA Ed25519 keypair, issue_cert, verify_cert
- CA keypair loaded from CA_PRIVATE_KEY/CA_PUBLIC_KEY env (ephemeral in dev)
- Agent registration returns signed cert + CA public key (once)
- cert stored in agents table (migration 009_agent_certs.sql)
- admin rotate certificates scope re-issues certs for all agents
Agent:
- cert.rs: verify dashboard CA cert (CA_PUBLIC_KEY env)
- Cert verification available for command auth second layer
* feat(dashboard): white-label branding from PostgreSQL
Single-row white_label table stores company_name, logo_url, and
three hex palette colors. Public GET /branding serves defaults on
empty DB. Auth-protected PUT /admin/branding (via admin route_layer)
validates hex format before UPSERT.
UI injects --brand-primary/secondary/accent CSS vars on <html> at
layout level (revalidated every 60s). Sidebar header renders
company_name or logo. Settings page adds BrandingForm (Server Action
PUT) with color swatches + inputs.
* feat(orgs): member management — invite, list, remove
Backend: GET/POST /organizations/{id}/members, DELETE
/organizations/{id}/members/{user_id}. Role-gated (owner/admin only
for mutations). Prevents removing org owner. Adds AppError::Forbidden
(403) variant.
UI: org cards now link to detail page. Detail page lists members with
role badges and per-member remove buttons (owner row protected). Invite
dialog: username + role selector. Server actions revalidate page on
mutation.
* feat(orgs): project listing and vertical scaling UI
Backend: GET/GET /organizations/{id}/projects[/{proj_id}] for project
browsing. PUT /organizations/{id}/projects/{proj_id}/resources signs a
container.update command (tenant_id=org_id, cpus, memory_mb) and
relays it to the target agent via WireGuard. Viewer role blocked from
scaling mutations.
UI: org detail page now lists projects as clickable links. Project
detail page exposes ResourceForm — container name + CPU/memory inputs
that call the backend action and toast on result.
* feat(nftables): divergence detection and restore/accept flow
Agent: nftables.apply now returns the rendered string, which is stored
in AppState.nft_last_ruleset alongside the checksum. New handlers:
nftables.restore (re-applies last ruleset via apply_raw) and
nftables.accept (snapshots current live checksum as new expected).
Dashboard: GET /agents/{id}/nftables-status returns the latest
unresolved divergence event (resolved = followed by a restored or
accepted event). POST /agents/{id}/nftables-resolve signs the
appropriate command and records the resolution event.
UI: AgentList fetches nftables status per online agent in parallel.
Diverged agents show an orange alert with Restore/Accept buttons
(NftablesAlert client component, resolveNftables server action).
* feat(orgs): add project creation
POST /organizations/{id}/projects — role-gated (viewer blocked), slug
validated, agent existence checked, 409 on slug conflict.
UI: org detail page shows CreateProjectDialog alongside project list.
Dialog has name + auto-derived slug + agent selector (online agents
first). slugConflict toast on 409.
* feat(containers): deploy and lifecycle management UI
Backend: POST /containers (deploy), GET /containers (list via
container.list), POST /containers/{name}/{action} (start|stop|restart|
remove). All relay signed commands to the project's agent. relay_project_command helper reduces duplication across all container handlers.
UI: project detail page now shows running container list with per-
container action buttons (state-dependent: start for stopped, stop+
restart for running, remove always). DeployForm accepts name, image,
ports, env, cpus, memory_mb.
* feat(ui): persistent footer and dynamic login branding
Sidebar gains a small "Made with love by Jaroc · lynx" footer at the
bottom — always visible in the app area per CLAUDE.md requirement.
Login page fetches company_name from /branding at render time and
interpolates into "Sign in to {company}" — supports white-label
without hard-coding "Lynx". i18n string updated in both locales.
* feat(settings): update check and trigger UI
UpdateSection client component: polls GET /admin/update-check, shows
current vs latest version with up-to-date/update-available badges, and
offers "Trigger update on all agents" button (POST /admin/trigger-update)
when behind latest. Server actions added to settings actions.ts.
* feat(scale): implement cross-agent horizontal scaling via WireGuard data-plane tunnels
Per-project WireGuard tunnel (distinct from management plane) between
Agent-A and Agent-B. Dashboard generates keypairs and PSK, stores tunnel
record, commands both agents to configure the interface, then deploys
N replicas on Agent-B.
- Migration 011: data_plane_tunnels table with status check constraint
- handlers: list/create/teardown tunnel endpoints with role guard
- router: wire horizontal scale routes (GET/POST/DELETE)
- agent: wg.data_plane.setup + wg.data_plane.teardown command handlers
derive interface name from tunnel_id prefix (wg-lynx-dp-{8hex})
- UI: HorizontalScaleSection with add-tunnel dialog and inline teardown
- i18n: en + es keys for horizontal scaling
* feat(audit): add per-agent audit log viewer
GET /agents/:id/audit-log returns paginated entries (limit/offset) from
the central audit_log table. Entry hash truncated to 16 chars — full
chain verification is server-side only; UI shows fingerprint for spot
checks.
- Backend: list_audit_log handler + route /{id}/audit-log
- UI: server-rendered table page with pagination (offset param)
- Agent card: link to audit log per agent
- i18n: en + es keys
* feat(domain): add custom domain configuration with Let's Encrypt and HSTS
Singleton domain_config table tracks domain, cert type, expiry, HSTS
state, and port 19443 status. Setup is async (returns 202 immediately)
so certbot wait does not block the HTTP response.
- Migration 012: domain_config singleton row with status check constraint
- GET/POST /domain, POST /domain/verify, /domain/hsts, /domain/close-port
- nginx compose YAML + conf generated in code (no external templates)
- certbot webroot challenge via lynx-nginx container
- Port 19443 closed via nft rule (irreversible remotely by design)
- HSTS only toggleable when status = active
- UI: DomainSection in settings — status badge, setup form, verify DNS,
HSTS toggle, port-close with confirmation dialog
- i18n: en + es
* feat(migration): implement dashboard-to-dashboard migration flow
Zero-downtime dashboard relocation from VPS-A to VPS-B. Uses a one-time
migration token (SHA-256 stored, never in DB plaintext) to gate the
data transfer over HTTPS. pg_dump → VPS-B restore → agent notification
→ per-agent confirmation → VPS-A shutdown.
- Migration 013: migration_state singleton table with role + progress tracking
- GET/POST /migration, /migration/prepare, /migration/start, /migration/abort,
/migration/confirm-shutdown (auth-protected)
- POST /migration/receive, /migration/agent-confirm (token-gated, no user auth)
- Agent: dashboard.migrate command → calls /migration/agent-confirm on VPS-B
using agent's sync token
- UI: MigrationSection in settings with dual-pane source/target flow,
progress tracking, abort control, shutdown confirmation
- i18n: en + es
* feat(auth): add password change and profile endpoints
POST /auth/change-password: verifies current password, hashes new one,
then bulk-deletes all sessions for the user and logs password_changed
reason per CLAUDE.md session invalidation rules. Revokes current access
token in Redis.
GET /auth/me: returns id + username for the authenticated user.
- Profile section in settings shows username + change-password form
- Password change redirects to login (all sessions invalidated)
- i18n: en + es
* feat(settings): add key rotation history to security section
* feat(pki): push cert.update to online agents on rotation; add CI workflows
- Add cert.update dispatch arm to agent — verifies new cert against CA
public key, persists to /etc/lynx/cert.json for durability across restarts
- PermissionLevel derives PartialOrd/Ord so permission comparisons compile
- rotate_agent_certs now pushes cert.update to online agents immediately
after DB update, so live agents adopt the new cert without restart
- Add rotate_expiring_certs() — called automatically during trigger_update
for any agent cert expiring within 14 days (CLAUDE.md: automatic cert
renewal during updates when near expiry)
- CI: dashboard-server workflow (fmt, audit, check/clippy with pg service)
- CI: dashboard-ui workflow (bun install + tsc --noEmit)
- CI: agent workflow (fmt, audit, check/clippy; integration tests left as
commented stub pending self-hosted runner per CLAUDE.md)
- cargo fmt applied across all packages (no logic changes)
* fix(dashboard): use base64url unpadded encoding for signed commands
Dashboard encoded command payload/signature with padded Base64Url;
agent decoded with Base64UrlUnpadded — padding mismatch caused all
commands to be rejected with 'payload: invalid base64url'.
Verified end-to-end on VM: cert.update command now reaches agent,
signature verified, cert persisted to /etc/lynx/cert.json.
* feat(dashboard): add background heartbeat scheduler
Polls all non-lockdown agents every 30s via WireGuard, updating
status and last_heartbeat in DB. Uses tokio interval with
MissedTickBehavior::Skip to avoid thundering-herd on startup.
First tick fires immediately so agent status is current without
requiring a manual heartbeat call.
* fix(dashboard): shorten WireGuard interface name to wg-lynx-dash
Linux IFNAMSIZ=16; 'wg-lynx-dashboard' (18 chars) exceeds the limit.
Renamed to 'wg-lynx-dash' (12 chars) in both wg.rs and admin/handlers.
Verified wg.rotate_psk command reaches agent, updates PSK on wg-lynx-agent,
and tunnel remains alive after rotation.
* fix(dashboard): correct Redis key pattern in JWT session flush
rotate_jwt_sessions was flushing 'jti:*' but access tokens are
stored under 'access:{jti}'. Old tokens remained valid after
rotation. Fixed pattern to 'access:*'.
Verified: after jwt_keys rotation, old tokens return 401; fresh
login issues working tokens.
* feat(dashboard): add ip_pool allocator, logs/reset-admin-password CLI, SSRF-safe downloader
- Migration 018: ip_pool table with SELECT FOR UPDATE allocator replaces
linear scan of agents.wg_ip; release_ip() on agent delete
- Dashboard backend CLI: `logs` subcommand (Podman container logs) and
`reset-admin-password --username` (SSH-only, sets force_password_change)
- Agent CLI: `logs [--follow] [--errors] [--since]` via journalctl
- update.rs: resolve DNS once, validate all IPs against RFC1918/loopback/
link-local before connecting (TOCTOU-safe); pinned reqwest resolver
- update.rs: now declares `mod update` in main.rs (was missing)
- Workspace: add clap, url, ipnetwork sqlx feature
* feat(dashboard): security alerts, headers, startup health guard, intercepted alerts
- Add security_alerts table (migration 019) + alerts.rs module
- Fire alert on login rate limit hit (rate_limit_hit kind)
- Fire alert on session intercepted (IP/UA mismatch in middleware)
- GET /admin/alerts + POST /admin/alerts/:id/acknowledge endpoints
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers
to all responses via tower-http SetResponseHeaderLayer
- update.rs: spawn_startup_health_guard() — polls /health every 2s for
30s after boot; if unhealthy restores .prev, writes /etc/lynx/CRITICAL
- reqwest: add rustls-tls feature to dashboard server
* feat(agent): command rate limiter (100/min) with rejected_rate_limit audit log
- AppState: cmd_rate window counter + cmd_rejected_count/window for alert threshold
- execute_command: reject when window exceeds 100 commands/min; log audit entry
with result=rejected_rate_limit; warn when count >= 3 in current minute
* feat(domain): route nginx/certbot/nftables ops through local agent
All domain management operations now go through the local agent
via signed Ed25519 commands instead of direct OS calls from the backend:
- nginx.deploy: deploy nginx container with image digest
- nginx.update_config: reload nginx after cert/HSTS changes
- certbot.obtain: run certbot webroot challenge on the agent VPS
- nftables.close_setup_port: close port 19443 after domain goes live
Added migration 020 adding is_local_agent BOOLEAN to agents table.
nginx.rs in domain handlers is now a pure config-generator with no
OS side effects. Fixed nginx image to use digest instead of mutable tag.
* refactor(agent): split handlers.rs into handlers/ module
Replaces the monolithic handlers.rs with a handlers/ directory organized
by responsibility, following the project's folder organization rule.
Also adds conflict.rs (incompatible software detection) and nginx.rs
(nginx watchdog) that were created but not yet committed.
* feat: add missing files from prior implementation sessions
- agent: .env.example, migration 003 for nginx_configs table
- dashboard UI: public assets (favicon, icons, flags for i18n selector)
- dashboard UI: admin alerts action + AlertsPanel component
- dashboard UI: Zod schemas organized by feature domain
* feat: implement X.509 mTLS, heartbeat scheduler, PKI, and frontend improvements
Backend (dashboard server):
- X.509 CA generation and agent cert issuance (pki.rs additions)
- mTLS client for agent HTTP calls (agents/client.rs)
- X509 fields added to Config (loaded from Podman secrets or ephemeral)
- Heartbeat scheduler polls agents every 30s, dispatches update.self
- Agent registration response includes TLS cert/key/ca DER (base64)
- WireGuard PSK rotation refactored to use mTLS client
- Cert rotation in admin handlers uses mTLS client
- Background heartbeat task spawned in main.rs
- Redis key pattern fix in JWT session flush (rotation.rs)
- WireGuard interface name shortened to wg-lynx-dash
Agent:
- TLS cert/key/ca loaded from env-configured files (config.rs)
- mTLS TLS acceptor built from loaded certs (main.rs)
- serve_tls() with manual hyper accept loop + axum body bridge
- Cargo.toml: hyper + hyper-util + tower added
- WireGuard PSK and WireGuard interface changes
- Setup agent script updates
Frontend:
- Login / register forms with Zod validation and Server Actions
- Dashboard pages: agents, organizations, projects, settings
- Sidebar navigation, alert components
- Branding, domain, migration, rotation, update UI sections
- i18n messages (en/es)
- biome.json, next.config.ts updates
- proxy.ts auth middleware
- Removed default Next.js placeholder SVGs
* feat(scheduler): full 90-day rotation — JWT flush + WireGuard PSKs + certs
Scheduled rotation now covers all three planes:
- JWT session flush (forces re-login for all users)
- WireGuard PSK rotation (coordinated, no tunnel downtime)
- mTLS cert rotation (certs expiring within 30 days)
Made rotate_jwt_sessions and rotate_wireguard_psks pub for scheduler use.
Rotation log scope changed from 'certificates' to 'all' for scheduled runs.
Also: is_local_agent flag added to RegisterAgentRequest so the install
script can mark the local dashboard agent at registration time.
* feat(domain): custom cert upload with PEM validation (Cloudflare + own cert)
POST /domain/cert/upload accepts cloudflare or custom cert type.
Backend validates before sending to agent:
- PEM parseable
- not_before <= now <= not_after (expiry check)
- SAN or CN matches configured domain
- cert/key pair match (custom certs only, via x509-parser pubkey comparison)
- max 64 KB per field
Agent gains nginx.install_cert command: writes cert+key to
/etc/lynx/nginx/certs/{domain}/ and reloads nginx.
nginx_conf_with_cert() generates nginx config with arbitrary cert paths.
x509-parser added as explicit workspace dep (was already transitive).
* fix(domain): set_hsts uses correct cert path for cloudflare/custom cert type
* feat(ws): add persistent WebSocket channel between agent and dashboard
Agent initiates a WS connection to dashboard on startup using its sync
token. Heartbeats and audit log entries now flow over the persistent WS
instead of HTTP polling. Dashboard can push signed commands via WS with
a 30-second response timeout, falling back to HTTP POST if no WS
connection is active.
Dashboard side:
- agents/ws_hub.rs: WS handler at /agents/{id}/ws, auth via sync_token
hash, stores live connections in AppState.agent_ws_conns
- push_command() sends a command envelope with a correlation UUID and
waits on a oneshot for the agent's command_response frame
- AppState gains agent_ws_conns: Arc<RwLock<HashMap<Uuid, AgentWsConn>>>
- send_command handler tries WS first, falls back to HTTP
Agent side:
- ws_client.rs: connects to ws://{dashboard}/agents/{id}/ws, sends
heartbeats every 30s, receives command frames, reconnects with
exponential backoff (5s → 300s)
- system.rs refactored: command_dispatch + run_verified_command shared
between HTTP handler and WS client — no logic duplication
- Lockdown check applies identically on both paths
* feat(ws): skip HTTP heartbeat poll for WS-connected agents
Heartbeat scheduler now checks AppState.agent_ws_conns before HTTP
polling. Agents with an active WS connection send proactive heartbeats
so no HTTP poll is needed. update.self is still dispatched (via WS)
when the scheduler detects a version mismatch.
* feat(agent+dashboard): nftables 3-chain architecture, real-time metrics WS pipeline
Agent:
- Refactor nftables module to lynx-base/lynx-global/lynx-local 3-chain model
- handle_nftables_apply now supports chain-specific updates {chain, rules} alongside
full apply {wireguard_port} for bootstrap; stores wg_port in AppState
- Divergence detector: auto-restore from last known-good ruleset on any chain mismatch;
apply emergency ruleset if restore fails; per-chain detection for severity logging
- Store post-apply checksum from live kernel state (nft -j list table) to ensure
divergence checker uses matching format; remove checksum_of() that hashed input text
- Stream system metrics (CPU/RAM/disk) via WS every 5 seconds alongside heartbeats
Dashboard backend:
- Add per-agent broadcast channels (broadcast::Sender<Arc<String>>) in AppState for
metric fan-out from agent WS to frontend browser WS clients
- ws_hub: create/drop broadcast channel on agent connect/disconnect; fan-out metrics
messages to all subscribed frontend clients
- New GET /agents/{id}/metrics/ws — browser WebSocket endpoint (JWT auth via cookie)
- Auth middleware: fall back to access_token cookie for WebSocket upgrade requests
since browsers cannot send custom headers on WS connections
- nftables rule management: migration 021, CRUD handlers, push_global/push_local,
rules_to_nft_chain() renderer; global_rule_sync tracking per-agent sync state
- Agent reboot handler; NftRulesPanel + AgentDetailActions frontend components
- Certificate upload section in domain settings (Cloudflare Origin + custom certs)
Dashboard frontend:
- Agent detail page: status/WG info, live metrics panel, nftables rule panels, actions
- useAgentMetrics hook: auto-reconnecting WS subscription with exponential backoff
- MetricsPanel: CPU/RAM/disk with progress bars and color-coded severity
- Next.js rewrites: proxy /api/* to backend (enables browser→backend WS via same origin)
- i18n: metrics keys in en.json + es.json
* feat(events): real-time agent events pipeline to browser WS
- Add global events broadcast channel (broadcast::Sender<Arc<String>>) to AppState
- ws_hub: record connected/disconnected events in agent_events + broadcast to browser
- heartbeat.rs: detect online→offline transition, fire heartbeat_lost event + alert
- events.rs: broadcast_event() helper; receive_event broadcasts after DB insert
- New GET /agents/events/ws endpoint (JWT auth) — streams all agent events to browser
- sign_command_system() in crypto/cmd.rs (nil UUID sentinel for scheduler-triggered cmds)
- rule_line() in nftables/mod.rs for system-level chain body generation
- push_pending_global_sync compilation fixed (SignedCommand→Value, correct function refs)
- Frontend useAgentEvents hook with exponential backoff reconnect
- NotificationBell component in Sidebar: badge count + popover for alert events
- i18n: en/es notification strings (heartbeat_lost, lockdown, divergence, conflicting_software)
* feat(dashboard): add admin user and role management
- Add require_admin middleware (checks *:* permission after require_auth)
- Admin router split: require_admin for admin-only routes, require_auth for own-session routes
- New admin handlers: list/delete users, CRUD roles, manage role permissions and user roles
- /auth/me now returns is_admin flag
- Frontend: Admin page at /app/admin (user table + role management with inline CRUD)
- Sidebar shows Admin nav link only for users with *:* permission
- Guard: non-admins redirected from /app/admin at server-render time
- Last-admin guards on delete user, delete role, remove role permission, remove own admin role
* feat(dashboard): add theme system and user preferences
- Migration 022: user_preferences table (theme + locale per user)
- Backend: GET/POST /auth/me/preferences (require_auth via protected router)
- ThemeProvider (next-themes, dark/light/system, SSR-safe with suppressHydrationWarning)
- ThemeToggle dropdown in sidebar header persists to DB + theme_preference cookie
- Locale layout reads theme_preference cookie as defaultTheme to prevent flash
* feat(dashboard): add locale switcher and i18n sign-out in sidebar
- LocaleSwitcher dropdown with flag icons, navigates to same path in new locale
- Persists locale preference to DB via updateLocaleAction
- Sign out button now uses i18n (t("signOut")) instead of hardcoded English
* feat(dashboard): single-session mode and theme-on-login
- Add single_session column to users table (migration 023)
- Login handler returns theme preference so client sets cookie immediately
- Login action sets theme_preference cookie from login response
- POST /auth/me/single-session endpoint to toggle per-user
- GET /auth/me now returns single_session and is_admin fields
- user_preferences migration (022) applied; get/update preferences handlers complete
- SingleSessionToggle client component in settings profile section
- i18n keys for single-session toggle (en + es)
* test(dashboard): add integration test suite for auth, admin, rotation, and security
Adds tests/auth.rs (25 tests), tests/admin.rs (10 tests), tests/rotation.rs
(5 tests), tests/security.rs (13 tests), and tests/redis_fail.rs (5 tests).
Key infrastructure decisions:
- Login helpers return (token, ip) so protected-route calls include the same
x-real-ip that minted the JWT — fixes IP hash mismatch in require_auth
- helpers::seed_test_admin resets force_password_change=false on testadmin
after each binary run, preventing force-password-change-all from poisoning
subsequent test binaries
- Minimal fake Redis server that responds +OK to CLIENT SETINFO setup
commands (redis-rs 0.27 always sends these) then returns errors for all
subsequent commands, allowing ConnectionManager::new() to succeed while
making every auth command fail → 503
- rustls ring CryptoProvider installed once per test process via
install_default() (ignores AlreadySet) to prevent race on parallel tests
- UUID suffix extended from 8 to 16 chars to eliminate timestamp collisions
when tests run at the same millisecond across parallel test binaries
- needs_scheduled_rotation counts both 'scheduled' and 'update' entries as
resetting the 90-day clock; inline unit tests verify the guard logic
* test(dashboard/server): add unit tests for crypto, validation, and auth logic
- crypto/{hash,jwt,kek,password}.rs: inline unit tests covering roundtrips,
tamper detection, constant-time equality, and edge cases
- auth/validate.rs: tests for username/email/password validation rules
- auth/session.rs: rotate_refresh now returns bool (true = swapped, false =
concurrent request already consumed the token) — 0 rows affected = 401
- auth/handlers.rs: handle rotate_refresh returning false with Unauthorized
- main.rs: moved module declarations to lib.rs; main uses the public library
crate so integration tests can import build_router and AppState directly
* test(dashboard/ui): add Vitest unit tests and Playwright E2E setup
- vitest.config.ts: jsdom environment, globals, setup file, aliased @
- src/__tests__/setup.ts: global test setup
- src/__tests__/(auth)/schemas.test.ts: login/register schema validation
- src/__tests__/(dashboard)/app/organizations/schemas.test.ts: org schemas
- src/__tests__/(dashboard)/app/settings/schemas.test.ts: settings schemas
- playwright.config.ts: E2E config targeting http://localhost:19443
- src/e2e/auth.test.ts: login, register, and logout E2E flows
* ci: add CI workflows for agent, dashboard-server, and dashboard-ui
- dashboard-server: full integration test job with PostgreSQL 18 and Redis 8 service containers; runs migrations and cargo test
- dashboard-ui: Vitest unit test job and Playwright E2E scaffold
- agent: cargo-audit vulnerability scan job
- setup-agent.sh: ANSI colors, section headers, NTP check, incompatible software detection/removal, NAT detection for PersistentKeepalive
- setup-dashboard.sh: same shell script improvements plus pg-tde key generation and Podman secret handling
- agent auth module: 131-line unit test suite for signed command verification (Ed25519, nonce, timestamp, permission checks)
* refactor(auth): split handlers.rs into handlers/ submodule; add WS origin validation
auth/handlers split by responsibility:
- register.rs: register + bootstrap_admin
- login.rs: login (rate-limit, anti-enum, single-session)
- session.rs: logout + refresh (token rotation)
- me.rs: GET /auth/me
- password.rs: POST /auth/change-password
- preferences.rs: GET/POST /auth/me/preferences, POST /auth/me/single-session
- mod.rs: re-exports + shared helpers (build_jwt_keys, extract_ip, extract_ua)
WebSocket origin validation (CSWSH prevention, security.md §12.4):
- events_ws: validate_ws_origin checks Origin header on WS upgrade
- metrics_ws: reuses validate_ws_origin from events_ws
- Absent Origin → allow (non-browser clients, integration tests)
- Domain configured → only https://{domain} allowed
- No domain → any https:// allowed (direct IP:19443 access)
* fix(security): audit log hash chain verification + offline agent pending_sync
audit_log hash chain (security.md §12.10):
- store_audit_entries now fetches last known entry_hash for the agent before
inserting any batch; verifies each entry's previous_hash matches in order
- On mismatch: rejects entire batch, fires audit_integrity_failure alert,
inserts agent_events row, broadcasts to frontend — no partial inserts
- AuditSyncEntry derives Clone to allow in-order sorting of batch
global_rule_sync pending for offline agents (agent.md):
- push_global_rules now inserts global_rule_sync rows with synced_at=NULL
for every offline agent after pushing to online agents
- push_pending_global_sync on WS reconnect finds these rows and pushes
the full current global ruleset — no rules lost during agent downtime
* fix(ui): cursor-pointer + disabled:cursor-not-allowed on Button; robots metadata object format
Button base variant: add cursor-pointer (interactive feedback) and
disabled:cursor-not-allowed (overrides pointer-events-none gap in some
browsers where the parent still shows default cursor).
layout.tsx: robots changed from string "noindex, nofollow" to object
{ index: false, follow: false } per Next.js Metadata type.
* feat(agent): split metrics by frequency; fix WireGuard config path
Metrics frequency (agent.md spec):
- CPU/RAM/disk: every 5 seconds (was 2s uniform)
- Container stats via podman stats: every 10 seconds (was missing entirely)
- ws_client.rs: separate container_ticker at 10s; sends container_metrics frame
- handlers/metrics.rs: separate system/container loops with correct intervals
- metrics/mod.rs: add ContainerMetrics + ContainerStat types; sample_containers()
runs podman stats --no-stream --format json and parses CPU/memory
WireGuard config path (agent.md spec):
- Source of truth: /etc/lynx/wireguard/lynx-wg.conf (owned by lynx-agent, 600)
- Symlink: /etc/wireguard/wg-lynx-agent.conf -> /etc/lynx/wireguard/lynx-wg.conf
for wg-quick compatibility (systemctl enable wg-quick@wg-lynx-agent)
* feat(dashboard): close remaining CLAUDE.md gaps
- alerts::fire now accepts &AppState and broadcasts security_alert events
via events_tx to all subscribed frontend WebSocket sessions
- 90-day scheduler rotates PostgreSQL app-user password and Redis password
(both via Podman secret --replace); manual rotate_keys "all" scope does
the same
- LoginRequest carries redirect_to (relative paths only; external URLs
silently discarded) returned in login response
- extract_ip prefers IPv4 over IPv6 from x-forwarded-for list per spec
* fix(lint): resolve all clippy -D warnings + fmt in agent and server
- Auto-fix and manual fix of 44 dashboard-server clippy errors:
useless anyhow conversions, redundant closures, needless borrows,
redundant pattern matching, empty doc comment line
- Auto-fix and manual fix of 27 agent clippy errors:
useless conversions, collapsible if/match, redundant closure,
PathBuf→Path, dead_code on future-use helpers
- cargo fmt applied to both crates — all diffs resolved
* test(dashboard): fix all 17 Playwright E2E tests
- Add custom Zod error messages to register schema matching i18n strings
- Add [locale]/page.tsx to handle redirect from /en → /en/login
- Fix submit-button test: delay POST route instead of invalid RSC mock
- Add PLAYWRIGHT_NO_SERVER env var support in playwright.config.ts
* fix(dashboard): install rustls ring provider at startup
Required for TLS code paths — prevents panic when rustls tries to
auto-select a crypto provider and ring is not the default.
* ci: add toolchain: stable to all dtolnay/rust-toolchain usages
The action requires a 'toolchain' input. Missing input caused
all Rust CI jobs to fail immediately after startup_failure was
fixed by adding third-party actions to the allowlist.
* fix(ci): remove sqlx-mysql CVE and fix compose formatting
- Set sqlx default-features=false in workspace dep to exclude mysql/sqlite
drivers. Adds macros feature explicitly. Eliminates RUSTSEC-2023-0071
(Marvin Attack in rsa crate pulled in by sqlx-mysql).
- Add audit.toml ignoring RUSTSEC-2023-0071 for residual Cargo.lock entry:
sqlx-mysql remains as a resolved optional dep but is not compiled
(verified via cargo tree --workspace).
- Run cargo fmt on lynx-compose package to pass CI fmt check.
* fix(ci): ignore RUSTSEC-2023-0071 in cargo audit
sqlx-mysql is an unresolvable optional dep in Cargo.lock (sqlx-macros-core
pulls it for compile-time query support) but is not compiled into any binary.
No fixed upgrade available for rsa 0.9.10 — advisory explicitly ignored.
* fix(security): validate IDs as UUIDs before URL interpolation in Server Actions
Adds validateId() helper that rejects non-UUID strings, applied to all
agentId/orgId/projId/tunnelId/ruleId/sessionId/userId parameters before
they are interpolated into fetch URLs. Eliminates CodeQL SSRF alerts on
user-controlled URL paths.
* ci(dashboard-ui): pin all action SHAs; quote CI env var as string
* fix(ui): add validateName() and inline container URL builds to eliminate SSRF alerts
Adds validateName() to api.ts for container name validation.
Removes base()/post() helpers from containers.ts; inlines fetch calls
with explicit validateId/validateName so CodeQL can trace all URL segments.
* test(e2e): waitForURL on root redirect test to handle RSC redirect timing
* fix(ui): use .bind() for local nft actions to avoid passing arrow fns to Client Component
* fix(ui): pass placeholder values to migrationAgentsProgress to satisfy next-intl interpolation
* ci: add explicit permissions block to all workflows (CodeQL hardening)
* chore: remove tracked junk files and update gitignore
Remove .fuse_hidden FUSE temp files and .playwright-mcp session artifacts
accidentally committed. Add patterns to .gitignore to prevent recurrence:
.fuse_hidden*, .playwright-mcp/, playwright-report/, test-results/.
* fix(gitignore): correct playwright-mcp ignore pattern
* refactor: update UI components and dependencies across the dashboard application
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#7)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(dashboard): remove development mode, production only
Delete docker-compose.dev.yml, remove dead /api/* rewrite from next.config.ts
(Next.js rewrites don't proxy WebSocket; nginx handles routing in production),
drop dev/test:watch/test:e2e:ui scripts from package.json, simplify
playwright.config.ts to always use bun run start, clean up .env.example.
* feat(nftables): add output chain support and seed global protection rules
- Add port_end and direction columns to nftables_rules; extend kind CHECK
with 8 new types: drop_invalid_state, tcp_flag_null, tcp_flag_xmas,
tcp_flag_ack_new, icmp_ping_limit, allow_icmp_errors, allow_ndp,
block_output_port
- Seed 7 input protection rules and 21 output port-block rules in lynx-global
(SMTP, Telnet, FTP, NetBIOS, SMB, IRC, RPC, TFTP, X11, SOCKS, LDAP, mDNS,
BitTorrent) so all fresh installs start with hardened defaults
- Add rules_to_nft_output_chain() and port_spec() to nftables compiler;
push_both_chains() sends input + output signed commands per agent
- Agent: add nft_global_output_body and nft_local_output_body to AppState;
render lynx-output chain from both; persist to nftables_state DB
- Agent migration 005: seed lynx-global-output and lynx-local-output rows
* style(ui): apply biome formatting across dashboard ui
* fix: replace gen_random_uuid() with uuidv7() — all tables must use UUID v7
gen_random_uuid() generates v4 UUIDs. The project rule is UUID v7 everywhere.
PostgreSQL 18 provides uuidv7() built-in.
- 019_security_alerts, 003_nginx_configs: fix DEFAULT in CREATE TABLE
- 015_roles_permissions, 022_nftables_protection: fix uuidv7() in seeded VALUES
- New migrations 023 and 006: ALTER TABLE to fix DEFAULT on already-created tables
in existing databases
* feat(ui): switch font from Geist to Poppins
* chore(github): add repo metadata files
Add CODEOWNERS, issue template, CONTRIBUTING, README, and SECURITY policy.
* ci: add URL audit script and job to CI pipelines
Ensure all outbound download URLs in update modules are from allowed
GitHub domains. Script scans a fixed set of files; unsupported URLs
require explicit suppression comment or ALLOWED update.
* ci: add release workflows for agent and dashboard
Build musl static binaries for x86_64 and arm64, sign each artifact
with Ed25519 (RELEASE_SIGN_KEY secret), and publish to GitHub Releases.
Dashboard workflow also bundles the Next.js standalone binary and
static assets tarball, each signed separately.
* chore(deps): scan full cargo workspace in dependabot daily
Point cargo ecosystem at workspace root /lynx instead of a single
member crate. Switch to daily schedule and use the new rust label.
* feat(db): add arch column to agents table
Stores the agent architecture (x86_64 / arm64) reported via heartbeat
so the dashboard can build the correct GitHub Release download URL.
* feat(agent): report arch in heartbeat, hardcode release key, add fallback updater
- Heartbeat now includes arch field (x86_64 / arm64)
- Release verify key hardcoded in binary; removed env-var lookup
- SSRF guard: validate_github_url added to update::perform_update
- Track last_dashboard_contact timestamp in state
- Fallback updater: if dashboard absent >6h, poll GitHub directly
and self-update without waiting for a dashboard command
* feat(dashboard): track agent arch and use in arch-aware update dispatch
- ws_hub: persist arch from heartbeat into agents table
- heartbeat + scheduler: read arch from DB when building download URL
- Scheduler now runs release check immediately at startup instead of
waiting for the first hourly tick
* feat(dashboard): implement full binary swap pipeline for releases
- update.rs: download+verify all artifacts before touching any files,
swap frontend binary and extract assets tarball, then swap backend
and exit for Podman restart. Uses Podman socket to stop/start the
frontend container during swap.
- docker-compose: replace build-from-source with prebuilt alpine
containers that mount binaries from host paths. healthcheck uses
wget (present in alpine) instead of curl.
- setup-dashboard.sh: detect arch, fetch latest release tag, download
and verify (Ed25519) backend + frontend binary + assets at install
time before starting services.
* fix: strengthen security hardening across agent and dashboard, including improved input validation, secure file permissions, SSRF protection, constant-time authentication checks, and hardened WebSocket origins.
* refactor: enhance system security with constant-time cryptographic checks, input validation, and updated contribution documentation.
* fix(security): prevent SQL injection in DB password rotation
ALTER USER does not support parameterized queries. Previous code used
string interpolation with single-quote escaping that could be bypassed.
Replaced with dollar-quoting ($$hex$$) — passwords are hex-encoded
[0-9a-f] so the dollar sign can never appear inside them.
Affects both the agent (lynx_agent_app) and dashboard (lynx_dashboard_app)
rotation handlers.
* fix(agent): eliminate shell injection in rootless Podman execution
runuser -l -c "podman args" passes the command through a shell, making
any user-controlled argument a potential injection vector. Replaced with
runuser -u user -- podman arg1 arg2 which hands args directly to execve.
Also explicitly sets HOME and XDG_RUNTIME_DIR since -u does not load the
login environment, which rootless Podman requires.
* fix(agent): add SSRF protection with DNS pinning for binary downloads
Resolves the DNS TOCTOU vulnerability where a hostname could resolve to
a different IP between the validation check and the actual connection,
allowing DNS rebinding to reach internal services.
Fix: resolve hostname once, validate all returned IPs against RFC1918/
loopback/link-local/unspecified ranges, then pin the reqwest client to
the validated IP via .resolve() so no second DNS lookup occurs.
Applies to both the primary update path (update/mod.rs) and the fallback
GitHub API poller (update/fallback.rs). Adds the url crate for URL parsing.
* fix(agent): harden WireGuard handler — interface validation, file permissions, zeroize secrets
* fix(agent): prevent path traversal in nginx cert path construction
* fix(security): heartbeat ACK requires Ed25519 signed command — bearer token alone insufficient
* fix(security): validate audit log hash chain on HTTP sync path
* fix(security): WS origin validation, vps:read permission checks, WG IP error propagation
* fix(auth): per-username rate limit, SHA256 ct_eq for setup token, constant-time IP/UA comparison
* fix(auth): enforce nbf and iat claims in JWT validation — reject future-issued tokens
* fix(security): strict domain and branding input validation — block path traversal vectors
* fix(security): validate IP/CIDR format and port range in nftables rule handlers
* fix(security): add HSTS header to all HTTPS responses
* fix(db): extend cert_type constraint to include cloudflare and custom cert types
* fix(scripts): replace eval PKG_UPDATE with direct case statement — eliminate eval injection
* feat(site): add GitHub Pages landing page
Dark amber-themed static landing with hero, architecture diagram,
feature cards, install guide, and stack badges. Poppins + JetBrains
Mono fonts. .nojekyll disables Jekyll processing.
* fix(ci): close security audit gaps across all components
- Add bun audit (--audit-level high) to dashboard-ui workflow — was
required by project docs, package.json had the script, CI never ran it
- Add cargo audit to lynx-compose workflow — only Rust component without
CVE scanning
- Pin upload-artifact, download-artifact, and action-gh-release to commit
SHAs in release workflows — mutable tags are a supply chain risk
- Verify musl bun binary with SHASUMS256.txt before unzip in
release-dashboard workflow — previously downloaded with no integrity check
- Drop security-events: write from all CI workflows — permission was
declared but SARIF was never uploaded (dead permission, violates least
privilege)
* fix(agent): extend nftables_state chain constraint before seeding output rows
Migration 005 inserted 'lynx-global-output' and 'lynx-local-output'
but the CHECK constraint from migration 004 only permitted 'lynx-global'
and 'lynx-local', causing migration 5 to fail on every fresh install.
Fix: ALTER TABLE to drop and re-add the constraint with all four valid
chain values before the INSERT.
Also applies cargo fmt across agent and dashboard-server (whitespace
and line-length reformats only, no logic changes).
* fix(db): renumber conflicting dashboard migrations and fix IPv6 link-local mask
Migrations 022_nftables_protection and 023_fix_uuid_defaults were added
after dashboard@1.0.1 shipped (which already contains 022_user_preferences
and 023_single_session). sqlx rejected the duplicate version numbers.
Renumbered to 026 and 027 — first available slots after the new 024
(agents_arch) and 025 (domain_cert_types) migrations.
Also fixes a clippy::bad_bit_mask in agent SSRF guard: the fe80::/10
link-local check used mask 0xff00, which zeroes the lower byte and can
never equal 0xfe80. Correct mask for a /10 prefix is 0xffc0.
* fix(server): refactor update params to struct; bypass rate limit in CI tests
perform_dashboard_update had 9 arguments — refactored to
DashboardUpdateParams struct to satisfy clippy::too_many_arguments.
Integration tests share a Redis instance and run in parallel, causing
the per-username login rate limit (10/15min) to be exhausted when
multiple test binaries all log in as testadmin concurrently.
Added LYNX_SKIP_RATE_LIMIT env-var bypass in rate_limit::check() and
set it in the CI test step — production deployments never set this var.
* fix(tests): serialize dashboard-server tests and flush testadmin rate-limit key
The env-var bypass (LYNX_SKIP_RATE_LIMIT) broke redis_fail tests that
rely on Redis being the first failure point to produce 503. Reverted.
Root cause: parallel test binaries all logging in as testadmin exceed the
10/15min per-username limit. Fix:
- Add --test-threads=1 to CI cargo test — serialises tests within each
binary, so at most ~4 concurrent testadmin logins across binaries (well
under the limit of 10).
- Flush rl:login:u:testadmin from Redis in test_state() before each test
so the counter resets between tests running in the same binary.
* fix(ui): remove duplicate migration label props from settings page
* ci(lint-shell): always run shellcheck on PRs to main, not just on .sh changes
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.2...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /app/* layout with sidebar and auth gate.
* feat(agent): implement agent core with auth, nftables, Podman, metrics, and audit log
Full Lynx agent implementation:
- Ed25519 command authorization: signature + nonce dedup + timestamp ±30s + permission level
- Audit log with SHA-256 hash chaining for tamper detection
- nftables: atomic full-ruleset apply via stdin, inter-org blocking, checksum tracking
- Podman: rootless tenant users (lynx-tenant-{id}) with isolated subuid/subgid ranges
- WebSocket metrics: CPU (2-sample /proc/stat), RAM (/proc/meminfo), disk (statvfs), streamed every 2s
- Heartbeat watchdog: 300s timeout → lockdown mode (rejects all commands)
- PostgreSQL via sqlx with migrations (audit_log + used_nonces tables)
- Bearer token auth with constant-time comparison via `subtle`
feat(dashboard): production compose with 3 isolated networks and external Podman secrets
Replaces single-network compose with:
- lynx-dashboard-db: backend + postgres only
- lynx-dashboard-cache: backend + redis only
- lynx-dashboard-app: frontend + backend (frontend sole external port: 19443)
- All secrets as external Podman secrets (pg-root, pg-pass, redis-pass, api-token, kek, pepper)
- DATABASE_URL_FILE / REDIS_URL_FILE pattern for secret injection
- PostgreSQL init script creates isolated lynx_dashboard_app user
feat(dashboard): install script with secret generation, health-ordered startup, nftables, WireGuard, TLS
- Detects existing install → abort / update / reinstall (requires exact confirmation phrase)
- Generates 8 Podman secrets via pipe (no shell history, subshell isolation, memory overwrite)
- Starts services in dependency order with health-gate: postgres → redis → backend → frontend
- nftables ruleset: table inet lynx-dashboard, ports 22 + 19443 + 51820 UDP
- Self-signed P-256 cert (90 days) + systemd timer for rotation every 80 days
- WireGuard dashboard keypair + PSK stored as Podman secret; PSK shown once in final output
* feat(agent): add agent install script with WireGuard, systemd, Podman DB, and nftables setup
Complete agent VPS install:
- Creates lynx-agent system user with CAP_NET_ADMIN/CAP_SYS_ADMIN/CAP_SETUID/CAP_SETGID
- Allocates subuid/subgid ranges (1000000+65536) for rootless tenant Podman
- Generates 4 Podman secrets via subshell pipe (no shell history trace)
- Starts PostgreSQL container on lynx-agent-db network (port 127.0.0.1:5434)
- Installs binary as systemd service with LoadCredential (secrets in tmpfs)
- WireGuard peer config built from admin-provided dashboard pubkey + PSK
- nftables: lynx-agent table, only WG inbound + SSH, agent API only from WG iface
- Existing install detection with reinstall phrase confirmation
feat(dashboard): agent management API with WG peer management and command relay
- GET/POST /agents — list and register agents (allocates WG IP .2–.254)
- GET/DELETE /agents/{id} — individual agent ops + WG peer removal
- POST /agents/{id}/heartbeat — relay heartbeat to agent via WireGuard, update status
- POST /agents/{id}/cmd — sign command with Ed25519, relay to agent API
- agents + agent_events migration (migration 004)
- crypto/cmd.rs: sign_command builds JWS-style payload (base64url JSON + Ed25519 sig)
- reqwest client for agent HTTP relay over WireGuard
- AppError extended: NotFound, BadRequest, BadGateway, AgentUnavailable
- From<sqlx::Error> impl so ? works in handlers
* feat(ui): add /app/agents page with Server Component, skeleton loading, and i18n
- AgentList: Server Component, fetches GET /agents with 30s revalidation
- Status badges: online (default) / lockdown (destructive) / offline (secondary)
- Empty state matches overview page pattern
- AgentListSkeleton: 3-card skeleton for Suspense boundary
- i18n keys in en.json + es.json (app.agents namespace)
- Sidebar already linked to /app/agents
* feat: implement audit log sync (agent → dashboard) with per-agent sync token auth
Agent side:
- sync/mod.rs: Tokio task runs every 60s, batches unsynced audit_log entries
- Tracks sync cursor in sync_state table (migration 002)
- Config: DASHBOARD_URL + SYNC_TOKEN (optional — sync disabled if absent)
- reqwest client for HTTP POST to dashboard over WireGuard
Dashboard side:
- Migration 005: central audit_log table + sync_token_hash column on agents
- POST /agents/{id}/audit-sync: no JWT auth — uses per-agent sync token
- Sync token generated at registration (SHA-256 hash stored, plain returned once)
- Hash chain preserved: entry_hash/previous_hash stored verbatim from agent
- ON CONFLICT DO NOTHING: idempotent re-sync
- Constant-time token comparison via subtle crate
* feat: add organizations backend + UI
Backend (migration 006):
- organizations table: id (UUID v7), name, slug (unique), owner_id
- organization_members: role-based (owner/admin/member/viewer)
- projects table: belongs to org + agent, unique slug per org
- GET/POST /organizations, GET/DELETE /organizations/{id}
- List scoped to authenticated user's memberships
- Slug validation: lowercase alphanumeric + hyphens only
- Conflict handling: returns 409 on duplicate slug
UI:
- /app/organizations: Server Component, 60s revalidation
- Card grid with slug, member count, org ID
- OrgListSkeleton for Suspense boundary
- i18n: en + es (app.organizations namespace)
* feat(agent): add nftables divergence detection with dashboard alerting
- Background task runs every 60s: compares live nft checksum vs last apply() result
- Mismatch → WARN log + POST /agents/{id}/events with nftables_divergence event
- Auth: same per-agent sync token used by audit-sync
- AppState gains nft_checksum: Arc<Mutex<Option<String>>> for shared last-known-good state
- set_nft_checksum() called after each apply() in nftables apply handler (future)
Dashboard side:
- POST /agents/{id}/events endpoint — agent-callable (sync token auth)
- Validates event type against allowed list, inserts into agent_events
- Same pattern as audit-sync: no user JWT needed
* feat(dashboard/ui): add settings page with session management and key rotation
Sessions list with per-session revoke (DELETE /admin/sessions/{id}).
JWT key rotation button (POST /admin/rotate) clears cookies and redirects
to login — all sessions invalidated on rotation.
i18n: en + es under app.settings namespace.
* feat(agent/dashboard): container management commands + auto-update system
Agent:
- container.deploy (podman compose up via tenant user)
- container.start/stop/remove/restart (podman CLI via runuser)
- container.update (CPU/memory quota, vertical scaling)
- update.self: download binary from GitHub Releases, verify Ed25519 sig,
atomic rename, graceful exit for systemd restart
Dashboard admin:
- GET /admin/update-check: polls GitHub API for latest release
- POST /admin/trigger-update: signs update.self command to online agents
- GET /admin/update-log: recent update history
- migrations/008_update_log.sql: tracks per-agent update outcomes
* feat(dashboard): agent registration dialog, overview stats, events feed
UI:
- Register Agent dialog (POST /agents) shows agent ID, WG IP, sync token
once; token hidden with reveal toggle
- Overview page: online agent count, org count, recent events feed
- i18n en + es for new keys
Backend:
- GET /agents/events: recent agent_events across all agents (limit param)
* feat(dashboard/agent): WireGuard PSK rotation
Dashboard admin rotate with scope wireguard_psks or all:
- Generates new PSK per online agent via wg genpsk
- Updates dashboard WG peer with new PSK
- Sends signed wg.rotate_psk command to each agent
Agent wg.rotate_psk handler:
- Applies new PSK to wg-lynx-agent peer
- Persists via wg-quick save
* feat(ui): org creation dialog, i18n keys for org/agent registration
- Create Organization dialog with auto-derived slug
- Register Agent and Create Org buttons on respective pages
- i18n en+es: organizations.create/slugConflict/createError
* feat(dashboard/agent): internal PKI — CA issues Ed25519 certs to agents
Dashboard:
- crypto/pki: CA Ed25519 keypair, issue_cert, verify_cert
- CA keypair loaded from CA_PRIVATE_KEY/CA_PUBLIC_KEY env (ephemeral in dev)
- Agent registration returns signed cert + CA public key (once)
- cert stored in agents table (migration 009_agent_certs.sql)
- admin rotate certificates scope re-issues certs for all agents
Agent:
- cert.rs: verify dashboard CA cert (CA_PUBLIC_KEY env)
- Cert verification available for command auth second layer
* feat(dashboard): white-label branding from PostgreSQL
Single-row white_label table stores company_name, logo_url, and
three hex palette colors. Public GET /branding serves defaults on
empty DB. Auth-protected PUT /admin/branding (via admin route_layer)
validates hex format before UPSERT.
UI injects --brand-primary/secondary/accent CSS vars on <html> at
layout level (revalidated every 60s). Sidebar header renders
company_name or logo. Settings page adds BrandingForm (Server Action
PUT) with color swatches + inputs.
* feat(orgs): member management — invite, list, remove
Backend: GET/POST /organizations/{id}/members, DELETE
/organizations/{id}/members/{user_id}. Role-gated (owner/admin only
for mutations). Prevents removing org owner. Adds AppError::Forbidden
(403) variant.
UI: org cards now link to detail page. Detail page lists members with
role badges and per-member remove buttons (owner row protected). Invite
dialog: username + role selector. Server actions revalidate page on
mutation.
* feat(orgs): project listing and vertical scaling UI
Backend: GET/GET /organizations/{id}/projects[/{proj_id}] for project
browsing. PUT /organizations/{id}/projects/{proj_id}/resources signs a
container.update command (tenant_id=org_id, cpus, memory_mb) and
relays it to the target agent via WireGuard. Viewer role blocked from
scaling mutations.
UI: org detail page now lists projects as clickable links. Project
detail page exposes ResourceForm — container name + CPU/memory inputs
that call the backend action and toast on result.
* feat(nftables): divergence detection and restore/accept flow
Agent: nftables.apply now returns the rendered string, which is stored
in AppState.nft_last_ruleset alongside the checksum. New handlers:
nftables.restore (re-applies last ruleset via apply_raw) and
nftables.accept (snapshots current live checksum as new expected).
Dashboard: GET /agents/{id}/nftables-status returns the latest
unresolved divergence event (resolved = followed by a restored or
accepted event). POST /agents/{id}/nftables-resolve signs the
appropriate command and records the resolution event.
UI: AgentList fetches nftables status per online agent in parallel.
Diverged agents show an orange alert with Restore/Accept buttons
(NftablesAlert client component, resolveNftables server action).
* feat(orgs): add project creation
POST /organizations/{id}/projects — role-gated (viewer blocked), slug
validated, agent existence checked, 409 on slug conflict.
UI: org detail page shows CreateProjectDialog alongside project list.
Dialog has name + auto-derived slug + agent selector (online agents
first). slugConflict toast on 409.
* feat(containers): deploy and lifecycle management UI
Backend: POST /containers (deploy), GET /containers (list via
container.list), POST /containers/{name}/{action} (start|stop|restart|
remove). All relay signed commands to the project's agent. relay_project_command helper reduces duplication across all container handlers.
UI: project detail page now shows running container list with per-
container action buttons (state-dependent: start for stopped, stop+
restart for running, remove always). DeployForm accepts name, image,
ports, env, cpus, memory_mb.
* feat(ui): persistent footer and dynamic login branding
Sidebar gains a small "Made with love by Jaroc · lynx" footer at the
bottom — always visible in the app area per CLAUDE.md requirement.
Login page fetches company_name from /branding at render time and
interpolates into "Sign in to {company}" — supports white-label
without hard-coding "Lynx". i18n string updated in both locales.
* feat(settings): update check and trigger UI
UpdateSection client component: polls GET /admin/update-check, shows
current vs latest version with up-to-date/update-available badges, and
offers "Trigger update on all agents" button (POST /admin/trigger-update)
when behind latest. Server actions added to settings actions.ts.
* feat(scale): implement cross-agent horizontal scaling via WireGuard data-plane tunnels
Per-project WireGuard tunnel (distinct from management plane) between
Agent-A and Agent-B. Dashboard generates keypairs and PSK, stores tunnel
record, commands both agents to configure the interface, then deploys
N replicas on Agent-B.
- Migration 011: data_plane_tunnels table with status check constraint
- handlers: list/create/teardown tunnel endpoints with role guard
- router: wire horizontal scale routes (GET/POST/DELETE)
- agent: wg.data_plane.setup + wg.data_plane.teardown command handlers
derive interface name from tunnel_id prefix (wg-lynx-dp-{8hex})
- UI: HorizontalScaleSection with add-tunnel dialog and inline teardown
- i18n: en + es keys for horizontal scaling
* feat(audit): add per-agent audit log viewer
GET /agents/:id/audit-log returns paginated entries (limit/offset) from
the central audit_log table. Entry hash truncated to 16 chars — full
chain verification is server-side only; UI shows fingerprint for spot
checks.
- Backend: list_audit_log handler + route /{id}/audit-log
- UI: server-rendered table page with pagination (offset param)
- Agent card: link to audit log per agent
- i18n: en + es keys
* feat(domain): add custom domain configuration with Let's Encrypt and HSTS
Singleton domain_config table tracks domain, cert type, expiry, HSTS
state, and port 19443 status. Setup is async (returns 202 immediately)
so certbot wait does not block the HTTP response.
- Migration 012: domain_config singleton row with status check constraint
- GET/POST /domain, POST /domain/verify, /domain/hsts, /domain/close-port
- nginx compose YAML + conf generated in code (no external templates)
- certbot webroot challenge via lynx-nginx container
- Port 19443 closed via nft rule (irreversible remotely by design)
- HSTS only toggleable when status = active
- UI: DomainSection in settings — status badge, setup form, verify DNS,
HSTS toggle, port-close with confirmation dialog
- i18n: en + es
* feat(migration): implement dashboard-to-dashboard migration flow
Zero-downtime dashboard relocation from VPS-A to VPS-B. Uses a one-time
migration token (SHA-256 stored, never in DB plaintext) to gate the
data transfer over HTTPS. pg_dump → VPS-B restore → agent notification
→ per-agent confirmation → VPS-A shutdown.
- Migration 013: migration_state singleton table with role + progress tracking
- GET/POST /migration, /migration/prepare, /migration/start, /migration/abort,
/migration/confirm-shutdown (auth-protected)
- POST /migration/receive, /migration/agent-confirm (token-gated, no user auth)
- Agent: dashboard.migrate command → calls /migration/agent-confirm on VPS-B
using agent's sync token
- UI: MigrationSection in settings with dual-pane source/target flow,
progress tracking, abort control, shutdown confirmation
- i18n: en + es
* feat(auth): add password change and profile endpoints
POST /auth/change-password: verifies current password, hashes new one,
then bulk-deletes all sessions for the user and logs password_changed
reason per CLAUDE.md session invalidation rules. Revokes current access
token in Redis.
GET /auth/me: returns id + username for the authenticated user.
- Profile section in settings shows username + change-password form
- Password change redirects to login (all sessions invalidated)
- i18n: en + es
* feat(settings): add key rotation history to security section
* feat(pki): push cert.update to online agents on rotation; add CI workflows
- Add cert.update dispatch arm to agent — verifies new cert against CA
public key, persists to /etc/lynx/cert.json for durability across restarts
- PermissionLevel derives PartialOrd/Ord so permission comparisons compile
- rotate_agent_certs now pushes cert.update to online agents immediately
after DB update, so live agents adopt the new cert without restart
- Add rotate_expiring_certs() — called automatically during trigger_update
for any agent cert expiring within 14 days (CLAUDE.md: automatic cert
renewal during updates when near expiry)
- CI: dashboard-server workflow (fmt, audit, check/clippy with pg service)
- CI: dashboard-ui workflow (bun install + tsc --noEmit)
- CI: agent workflow (fmt, audit, check/clippy; integration tests left as
commented stub pending self-hosted runner per CLAUDE.md)
- cargo fmt applied across all packages (no logic changes)
* fix(dashboard): use base64url unpadded encoding for signed commands
Dashboard encoded command payload/signature with padded Base64Url;
agent decoded with Base64UrlUnpadded — padding mismatch caused all
commands to be rejected with 'payload: invalid base64url'.
Verified end-to-end on VM: cert.update command now reaches agent,
signature verified, cert persisted to /etc/lynx/cert.json.
* feat(dashboard): add background heartbeat scheduler
Polls all non-lockdown agents every 30s via WireGuard, updating
status and last_heartbeat in DB. Uses tokio interval with
MissedTickBehavior::Skip to avoid thundering-herd on startup.
First tick fires immediately so agent status is current without
requiring a manual heartbeat call.
* fix(dashboard): shorten WireGuard interface name to wg-lynx-dash
Linux IFNAMSIZ=16; 'wg-lynx-dashboard' (18 chars) exceeds the limit.
Renamed to 'wg-lynx-dash' (12 chars) in both wg.rs and admin/handlers.
Verified wg.rotate_psk command reaches agent, updates PSK on wg-lynx-agent,
and tunnel remains alive after rotation.
* fix(dashboard): correct Redis key pattern in JWT session flush
rotate_jwt_sessions was flushing 'jti:*' but access tokens are
stored under 'access:{jti}'. Old tokens remained valid after
rotation. Fixed pattern to 'access:*'.
Verified: after jwt_keys rotation, old tokens return 401; fresh
login issues working tokens.
* feat(dashboard): add ip_pool allocator, logs/reset-admin-password CLI, SSRF-safe downloader
- Migration 018: ip_pool table with SELECT FOR UPDATE allocator replaces
linear scan of agents.wg_ip; release_ip() on agent delete
- Dashboard backend CLI: `logs` subcommand (Podman container logs) and
`reset-admin-password --username` (SSH-only, sets force_password_change)
- Agent CLI: `logs [--follow] [--errors] [--since]` via journalctl
- update.rs: resolve DNS once, validate all IPs against RFC1918/loopback/
link-local before connecting (TOCTOU-safe); pinned reqwest resolver
- update.rs: now declares `mod update` in main.rs (was missing)
- Workspace: add clap, url, ipnetwork sqlx feature
* feat(dashboard): security alerts, headers, startup health guard, intercepted alerts
- Add security_alerts table (migration 019) + alerts.rs module
- Fire alert on login rate limit hit (rate_limit_hit kind)
- Fire alert on session intercepted (IP/UA mismatch in middleware)
- GET /admin/alerts + POST /admin/alerts/:id/acknowledge endpoints
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers
to all responses via tower-http SetResponseHeaderLayer
- update.rs: spawn_startup_health_guard() — polls /health every 2s for
30s after boot; if unhealthy restores .prev, writes /etc/lynx/CRITICAL
- reqwest: add rustls-tls feature to dashboard server
* feat(agent): command rate limiter (100/min) with rejected_rate_limit audit log
- AppState: cmd_rate window counter + cmd_rejected_count/window for alert threshold
- execute_command: reject when window exceeds 100 commands/min; log audit entry
with result=rejected_rate_limit; warn when count >= 3 in current minute
* feat(domain): route nginx/certbot/nftables ops through local agent
All domain management operations now go through the local agent
via signed Ed25519 commands instead of direct OS calls from the backend:
- nginx.deploy: deploy nginx container with image digest
- nginx.update_config: reload nginx after cert/HSTS changes
- certbot.obtain: run certbot webroot challenge on the agent VPS
- nftables.close_setup_port: close port 19443 after domain goes live
Added migration 020 adding is_local_agent BOOLEAN to agents table.
nginx.rs in domain handlers is now a pure config-generator with no
OS side effects. Fixed nginx image to use digest instead of mutable tag.
* refactor(agent): split handlers.rs into handlers/ module
Replaces the monolithic handlers.rs with a handlers/ directory organized
by responsibility, following the project's folder organization rule.
Also adds conflict.rs (incompatible software detection) and nginx.rs
(nginx watchdog) that were created but not yet committed.
* feat: add missing files from prior implementation sessions
- agent: .env.example, migration 003 for nginx_configs table
- dashboard UI: public assets (favicon, icons, flags for i18n selector)
- dashboard UI: admin alerts action + AlertsPanel component
- dashboard UI: Zod schemas organized by feature domain
* feat: implement X.509 mTLS, heartbeat scheduler, PKI, and frontend improvements
Backend (dashboard server):
- X.509 CA generation and agent cert issuance (pki.rs additions)
- mTLS client for agent HTTP calls (agents/client.rs)
- X509 fields added to Config (loaded from Podman secrets or ephemeral)
- Heartbeat scheduler polls agents every 30s, dispatches update.self
- Agent registration response includes TLS cert/key/ca DER (base64)
- WireGuard PSK rotation refactored to use mTLS client
- Cert rotation in admin handlers uses mTLS client
- Background heartbeat task spawned in main.rs
- Redis key pattern fix in JWT session flush (rotation.rs)
- WireGuard interface name shortened to wg-lynx-dash
Agent:
- TLS cert/key/ca loaded from env-configured files (config.rs)
- mTLS TLS acceptor built from loaded certs (main.rs)
- serve_tls() with manual hyper accept loop + axum body bridge
- Cargo.toml: hyper + hyper-util + tower added
- WireGuard PSK and WireGuard interface changes
- Setup agent script updates
Frontend:
- Login / register forms with Zod validation and Server Actions
- Dashboard pages: agents, organizations, projects, settings
- Sidebar navigation, alert components
- Branding, domain, migration, rotation, update UI sections
- i18n messages (en/es)
- biome.json, next.config.ts updates
- proxy.ts auth middleware
- Removed default Next.js placeholder SVGs
* feat(scheduler): full 90-day rotation — JWT flush + WireGuard PSKs + certs
Scheduled rotation now covers all three planes:
- JWT session flush (forces re-login for all users)
- WireGuard PSK rotation (coordinated, no tunnel downtime)
- mTLS cert rotation (certs expiring within 30 days)
Made rotate_jwt_sessions and rotate_wireguard_psks pub for scheduler use.
Rotation log scope changed from 'certificates' to 'all' for scheduled runs.
Also: is_local_agent flag added to RegisterAgentRequest so the install
script can mark the local dashboard agent at registration time.
* feat(domain): custom cert upload with PEM validation (Cloudflare + own cert)
POST /domain/cert/upload accepts cloudflare or custom cert type.
Backend validates before sending to agent:
- PEM parseable
- not_before <= now <= not_after (expiry check)
- SAN or CN matches configured domain
- cert/key pair match (custom certs only, via x509-parser pubkey comparison)
- max 64 KB per field
Agent gains nginx.install_cert command: writes cert+key to
/etc/lynx/nginx/certs/{domain}/ and reloads nginx.
nginx_conf_with_cert() generates nginx config with arbitrary cert paths.
x509-parser added as explicit workspace dep (was already transitive).
* fix(domain): set_hsts uses correct cert path for cloudflare/custom cert type
* feat(ws): add persistent WebSocket channel between agent and dashboard
Agent initiates a WS connection to dashboard on startup using its sync
token. Heartbeats and audit log entries now flow over the persistent WS
instead of HTTP polling. Dashboard can push signed commands via WS with
a 30-second response timeout, falling back to HTTP POST if no WS
connection is active.
Dashboard side:
- agents/ws_hub.rs: WS handler at /agents/{id}/ws, auth via sync_token
hash, stores live connections in AppState.agent_ws_conns
- push_command() sends a command envelope with a correlation UUID and
waits on a oneshot for the agent's command_response frame
- AppState gains agent_ws_conns: Arc<RwLock<HashMap<Uuid, AgentWsConn>>>
- send_command handler tries WS first, falls back to HTTP
Agent side:
- ws_client.rs: connects to ws://{dashboard}/agents/{id}/ws, sends
heartbeats every 30s, receives command frames, reconnects with
exponential backoff (5s → 300s)
- system.rs refactored: command_dispatch + run_verified_command shared
between HTTP handler and WS client — no logic duplication
- Lockdown check applies identically on both paths
* feat(ws): skip HTTP heartbeat poll for WS-connected agents
Heartbeat scheduler now checks AppState.agent_ws_conns before HTTP
polling. Agents with an active WS connection send proactive heartbeats
so no HTTP poll is needed. update.self is still dispatched (via WS)
when the scheduler detects a version mismatch.
* feat(agent+dashboard): nftables 3-chain architecture, real-time metrics WS pipeline
Agent:
- Refactor nftables module to lynx-base/lynx-global/lynx-local 3-chain model
- handle_nftables_apply now supports chain-specific updates {chain, rules} alongside
full apply {wireguard_port} for bootstrap; stores wg_port in AppState
- Divergence detector: auto-restore from last known-good ruleset on any chain mismatch;
apply emergency ruleset if restore fails; per-chain detection for severity logging
- Store post-apply checksum from live kernel state (nft -j list table) to ensure
divergence checker uses matching format; remove checksum_of() that hashed input text
- Stream system metrics (CPU/RAM/disk) via WS every 5 seconds alongside heartbeats
Dashboard backend:
- Add per-agent broadcast channels (broadcast::Sender<Arc<String>>) in AppState for
metric fan-out from agent WS to frontend browser WS clients
- ws_hub: create/drop broadcast channel on agent connect/disconnect; fan-out metrics
messages to all subscribed frontend clients
- New GET /agents/{id}/metrics/ws — browser WebSocket endpoint (JWT auth via cookie)
- Auth middleware: fall back to access_token cookie for WebSocket upgrade requests
since browsers cannot send custom headers on WS connections
- nftables rule management: migration 021, CRUD handlers, push_global/push_local,
rules_to_nft_chain() renderer; global_rule_sync tracking per-agent sync state
- Agent reboot handler; NftRulesPanel + AgentDetailActions frontend components
- Certificate upload section in domain settings (Cloudflare Origin + custom certs)
Dashboard frontend:
- Agent detail page: status/WG info, live metrics panel, nftables rule panels, actions
- useAgentMetrics hook: auto-reconnecting WS subscription with exponential backoff
- MetricsPanel: CPU/RAM/disk with progress bars and color-coded severity
- Next.js rewrites: proxy /api/* to backend (enables browser→backend WS via same origin)
- i18n: metrics keys in en.json + es.json
* feat(events): real-time agent events pipeline to browser WS
- Add global events broadcast channel (broadcast::Sender<Arc<String>>) to AppState
- ws_hub: record connected/disconnected events in agent_events + broadcast to browser
- heartbeat.rs: detect online→offline transition, fire heartbeat_lost event + alert
- events.rs: broadcast_event() helper; receive_event broadcasts after DB insert
- New GET /agents/events/ws endpoint (JWT auth) — streams all agent events to browser
- sign_command_system() in crypto/cmd.rs (nil UUID sentinel for scheduler-triggered cmds)
- rule_line() in nftables/mod.rs for system-level chain body generation
- push_pending_global_sync compilation fixed (SignedCommand→Value, correct function refs)
- Frontend useAgentEvents hook with exponential backoff reconnect
- NotificationBell component in Sidebar: badge count + popover for alert events
- i18n: en/es notification strings (heartbeat_lost, lockdown, divergence, conflicting_software)
* feat(dashboard): add admin user and role management
- Add require_admin middleware (checks *:* permission after require_auth)
- Admin router split: require_admin for admin-only routes, require_auth for own-session routes
- New admin handlers: list/delete users, CRUD roles, manage role permissions and user roles
- /auth/me now returns is_admin flag
- Frontend: Admin page at /app/admin (user table + role management with inline CRUD)
- Sidebar shows Admin nav link only for users with *:* permission
- Guard: non-admins redirected from /app/admin at server-render time
- Last-admin guards on delete user, delete role, remove role permission, remove own admin role
* feat(dashboard): add theme system and user preferences
- Migration 022: user_preferences table (theme + locale per user)
- Backend: GET/POST /auth/me/preferences (require_auth via protected router)
- ThemeProvider (next-themes, dark/light/system, SSR-safe with suppressHydrationWarning)
- ThemeToggle dropdown in sidebar header persists to DB + theme_preference cookie
- Locale layout reads theme_preference cookie as defaultTheme to prevent flash
* feat(dashboard): add locale switcher and i18n sign-out in sidebar
- LocaleSwitcher dropdown with flag icons, navigates to same path in new locale
- Persists locale preference to DB via updateLocaleAction
- Sign out button now uses i18n (t("signOut")) instead of hardcoded English
* feat(dashboard): single-session mode and theme-on-login
- Add single_session column to users table (migration 023)
- Login handler returns theme preference so client sets cookie immediately
- Login action sets theme_preference cookie from login response
- POST /auth/me/single-session endpoint to toggle per-user
- GET /auth/me now returns single_session and is_admin fields
- user_preferences migration (022) applied; get/update preferences handlers complete
- SingleSessionToggle client component in settings profile section
- i18n keys for single-session toggle (en + es)
* test(dashboard): add integration test suite for auth, admin, rotation, and security
Adds tests/auth.rs (25 tests), tests/admin.rs (10 tests), tests/rotation.rs
(5 tests), tests/security.rs (13 tests), and tests/redis_fail.rs (5 tests).
Key infrastructure decisions:
- Login helpers return (token, ip) so protected-route calls include the same
x-real-ip that minted the JWT — fixes IP hash mismatch in require_auth
- helpers::seed_test_admin resets force_password_change=false on testadmin
after each binary run, preventing force-password-change-all from poisoning
subsequent test binaries
- Minimal fake Redis server that responds +OK to CLIENT SETINFO setup
commands (redis-rs 0.27 always sends these) then returns errors for all
subsequent commands, allowing ConnectionManager::new() to succeed while
making every auth command fail → 503
- rustls ring CryptoProvider installed once per test process via
install_default() (ignores AlreadySet) to prevent race on parallel tests
- UUID suffix extended from 8 to 16 chars to eliminate timestamp collisions
when tests run at the same millisecond across parallel test binaries
- needs_scheduled_rotation counts both 'scheduled' and 'update' entries as
resetting the 90-day clock; inline unit tests verify the guard logic
* test(dashboard/server): add unit tests for crypto, validation, and auth logic
- crypto/{hash,jwt,kek,password}.rs: inline unit tests covering roundtrips,
tamper detection, constant-time equality, and edge cases
- auth/validate.rs: tests for username/email/password validation rules
- auth/session.rs: rotate_refresh now returns bool (true = swapped, false =
concurrent request already consumed the token) — 0 rows affected = 401
- auth/handlers.rs: handle rotate_refresh returning false with Unauthorized
- main.rs: moved module declarations to lib.rs; main uses the public library
crate so integration tests can import build_router and AppState directly
* test(dashboard/ui): add Vitest unit tests and Playwright E2E setup
- vitest.config.ts: jsdom environment, globals, setup file, aliased @
- src/__tests__/setup.ts: global test setup
- src/__tests__/(auth)/schemas.test.ts: login/register schema validation
- src/__tests__/(dashboard)/app/organizations/schemas.test.ts: org schemas
- src/__tests__/(dashboard)/app/settings/schemas.test.ts: settings schemas
- playwright.config.ts: E2E config targeting http://localhost:19443
- src/e2e/auth.test.ts: login, register, and logout E2E flows
* ci: add CI workflows for agent, dashboard-server, and dashboard-ui
- dashboard-server: full integration test job with PostgreSQL 18 and Redis 8 service containers; runs migrations and cargo test
- dashboard-ui: Vitest unit test job and Playwright E2E scaffold
- agent: cargo-audit vulnerability scan job
- setup-agent.sh: ANSI colors, section headers, NTP check, incompatible software detection/removal, NAT detection for PersistentKeepalive
- setup-dashboard.sh: same shell script improvements plus pg-tde key generation and Podman secret handling
- agent auth module: 131-line unit test suite for signed command verification (Ed25519, nonce, timestamp, permission checks)
* refactor(auth): split handlers.rs into handlers/ submodule; add WS origin validation
auth/handlers split by responsibility:
- register.rs: register + bootstrap_admin
- login.rs: login (rate-limit, anti-enum, single-session)
- session.rs: logout + refresh (token rotation)
- me.rs: GET /auth/me
- password.rs: POST /auth/change-password
- preferences.rs: GET/POST /auth/me/preferences, POST /auth/me/single-session
- mod.rs: re-exports + shared helpers (build_jwt_keys, extract_ip, extract_ua)
WebSocket origin validation (CSWSH prevention, security.md §12.4):
- events_ws: validate_ws_origin checks Origin header on WS upgrade
- metrics_ws: reuses validate_ws_origin from events_ws
- Absent Origin → allow (non-browser clients, integration tests)
- Domain configured → only https://{domain} allowed
- No domain → any https:// allowed (direct IP:19443 access)
* fix(security): audit log hash chain verification + offline agent pending_sync
audit_log hash chain (security.md §12.10):
- store_audit_entries now fetches last known entry_hash for the agent before
inserting any batch; verifies each entry's previous_hash matches in order
- On mismatch: rejects entire batch, fires audit_integrity_failure alert,
inserts agent_events row, broadcasts to frontend — no partial inserts
- AuditSyncEntry derives Clone to allow in-order sorting of batch
global_rule_sync pending for offline agents (agent.md):
- push_global_rules now inserts global_rule_sync rows with synced_at=NULL
for every offline agent after pushing to online agents
- push_pending_global_sync on WS reconnect finds these rows and pushes
the full current global ruleset — no rules lost during agent downtime
* fix(ui): cursor-pointer + disabled:cursor-not-allowed on Button; robots metadata object format
Button base variant: add cursor-pointer (interactive feedback) and
disabled:cursor-not-allowed (overrides pointer-events-none gap in some
browsers where the parent still shows default cursor).
layout.tsx: robots changed from string "noindex, nofollow" to object
{ index: false, follow: false } per Next.js Metadata type.
* feat(agent): split metrics by frequency; fix WireGuard config path
Metrics frequency (agent.md spec):
- CPU/RAM/disk: every 5 seconds (was 2s uniform)
- Container stats via podman stats: every 10 seconds (was missing entirely)
- ws_client.rs: separate container_ticker at 10s; sends container_metrics frame
- handlers/metrics.rs: separate system/container loops with correct intervals
- metrics/mod.rs: add ContainerMetrics + ContainerStat types; sample_containers()
runs podman stats --no-stream --format json and parses CPU/memory
WireGuard config path (agent.md spec):
- Source of truth: /etc/lynx/wireguard/lynx-wg.conf (owned by lynx-agent, 600)
- Symlink: /etc/wireguard/wg-lynx-agent.conf -> /etc/lynx/wireguard/lynx-wg.conf
for wg-quick compatibility (systemctl enable wg-quick@wg-lynx-agent)
* feat(dashboard): close remaining CLAUDE.md gaps
- alerts::fire now accepts &AppState and broadcasts security_alert events
via events_tx to all subscribed frontend WebSocket sessions
- 90-day scheduler rotates PostgreSQL app-user password and Redis password
(both via Podman secret --replace); manual rotate_keys "all" scope does
the same
- LoginRequest carries redirect_to (relative paths only; external URLs
silently discarded) returned in login response
- extract_ip prefers IPv4 over IPv6 from x-forwarded-for list per spec
* fix(lint): resolve all clippy -D warnings + fmt in agent and server
- Auto-fix and manual fix of 44 dashboard-server clippy errors:
useless anyhow conversions, redundant closures, needless borrows,
redundant pattern matching, empty doc comment line
- Auto-fix and manual fix of 27 agent clippy errors:
useless conversions, collapsible if/match, redundant closure,
PathBuf→Path, dead_code on future-use helpers
- cargo fmt applied to both crates — all diffs resolved
* test(dashboard): fix all 17 Playwright E2E tests
- Add custom Zod error messages to register schema matching i18n strings
- Add [locale]/page.tsx to handle redirect from /en → /en/login
- Fix submit-button test: delay POST route instead of invalid RSC mock
- Add PLAYWRIGHT_NO_SERVER env var support in playwright.config.ts
* fix(dashboard): install rustls ring provider at startup
Required for TLS code paths — prevents panic when rustls tries to
auto-select a crypto provider and ring is not the default.
* ci: add toolchain: stable to all dtolnay/rust-toolchain usages
The action requires a 'toolchain' input. Missing input caused
all Rust CI jobs to fail immediately after startup_failure was
fixed by adding third-party actions to the allowlist.
* fix(ci): remove sqlx-mysql CVE and fix compose formatting
- Set sqlx default-features=false in workspace dep to exclude mysql/sqlite
drivers. Adds macros feature explicitly. Eliminates RUSTSEC-2023-0071
(Marvin Attack in rsa crate pulled in by sqlx-mysql).
- Add audit.toml ignoring RUSTSEC-2023-0071 for residual Cargo.lock entry:
sqlx-mysql remains as a resolved optional dep but is not compiled
(verified via cargo tree --workspace).
- Run cargo fmt on lynx-compose package to pass CI fmt check.
* fix(ci): ignore RUSTSEC-2023-0071 in cargo audit
sqlx-mysql is an unresolvable optional dep in Cargo.lock (sqlx-macros-core
pulls it for compile-time query support) but is not compiled into any binary.
No fixed upgrade available for rsa 0.9.10 — advisory explicitly ignored.
* fix(security): validate IDs as UUIDs before URL interpolation in Server Actions
Adds validateId() helper that rejects non-UUID strings, applied to all
agentId/orgId/projId/tunnelId/ruleId/sessionId/userId parameters before
they are interpolated into fetch URLs. Eliminates CodeQL SSRF alerts on
user-controlled URL paths.
* ci(dashboard-ui): pin all action SHAs; quote CI env var as string
* fix(ui): add validateName() and inline container URL builds to eliminate SSRF alerts
Adds validateName() to api.ts for container name validation.
Removes base()/post() helpers from containers.ts; inlines fetch calls
with explicit validateId/validateName so CodeQL can trace all URL segments.
* test(e2e): waitForURL on root redirect test to handle RSC redirect timing
* fix(ui): use .bind() for local nft actions to avoid passing arrow fns to Client Component
* fix(ui): pass placeholder values to migrationAgentsProgress to satisfy next-intl interpolation
* ci: add explicit permissions block to all workflows (CodeQL hardening)
* chore: remove tracked junk files and update gitignore
Remove .fuse_hidden FUSE temp files and .playwright-mcp session artifacts
accidentally committed. Add patterns to .gitignore to prevent recurrence:
.fuse_hidden*, .playwright-mcp/, playwright-report/, test-results/.
* fix(gitignore): correct playwright-mcp ignore pattern
* refactor: update UI components and dependencies across the dashboard application
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#7)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(dashboard): remove development mode, production only
Delete docker-compose.dev.yml, remove dead /api/* rewrite from next.config.ts
(Next.js rewrites don't proxy WebSocket; nginx handles routing in production),
drop dev/test:watch/test:e2e:ui scripts from package.json, simplify
playwright.config.ts to always use bun run start, clean up .env.example.
* feat(nftables): add output chain support and seed global protection rules
- Add port_end and direction columns to nftables_rules; extend kind CHECK
with 8 new types: drop_invalid_state, tcp_flag_null, tcp_flag_xmas,
tcp_flag_ack_new, icmp_ping_limit, allow_icmp_errors, allow_ndp,
block_output_port
- Seed 7 input protection rules and 21 output port-block rules in lynx-global
(SMTP, Telnet, FTP, NetBIOS, SMB, IRC, RPC, TFTP, X11, SOCKS, LDAP, mDNS,
BitTorrent) so all fresh installs start with hardened defaults
- Add rules_to_nft_output_chain() and port_spec() to nftables compiler;
push_both_chains() sends input + output signed commands per agent
- Agent: add nft_global_output_body and nft_local_output_body to AppState;
render lynx-output chain from both; persist to nftables_state DB
- Agent migration 005: seed lynx-global-output and lynx-local-output rows
* style(ui): apply biome formatting across dashboard ui
* fix: replace gen_random_uuid() with uuidv7() — all tables must use UUID v7
gen_random_uuid() generates v4 UUIDs. The project rule is UUID v7 everywhere.
PostgreSQL 18 provides uuidv7() built-in.
- 019_security_alerts, 003_nginx_configs: fix DEFAULT in CREATE TABLE
- 015_roles_permissions, 022_nftables_protection: fix uuidv7() in seeded VALUES
- New migrations 023 and 006: ALTER TABLE to fix DEFAULT on already-created tables
in existing databases
* feat(ui): switch font from Geist to Poppins
* chore(github): add repo metadata files
Add CODEOWNERS, issue template, CONTRIBUTING, README, and SECURITY policy.
* ci: add URL audit script and job to CI pipelines
Ensure all outbound download URLs in update modules are from allowed
GitHub domains. Script scans a fixed set of files; unsupported URLs
require explicit suppression comment or ALLOWED update.
* ci: add release workflows for agent and dashboard
Build musl static binaries for x86_64 and arm64, sign each artifact
with Ed25519 (RELEASE_SIGN_KEY secret), and publish to GitHub Releases.
Dashboard workflow also bundles the Next.js standalone binary and
static assets tarball, each signed separately.
* chore(deps): scan full cargo workspace in dependabot daily
Point cargo ecosystem at workspace root /lynx instead of a single
member crate. Switch to daily schedule and use the new rust label.
* feat(db): add arch column to agents table
Stores the agent architecture (x86_64 / arm64) reported via heartbeat
so the dashboard can build the correct GitHub Release download URL.
* feat(agent): report arch in heartbeat, hardcode release key, add fallback updater
- Heartbeat now includes arch field (x86_64 / arm64)
- Release verify key hardcoded in binary; removed env-var lookup
- SSRF guard: validate_github_url added to update::perform_update
- Track last_dashboard_contact timestamp in state
- Fallback updater: if dashboard absent >6h, poll GitHub directly
and self-update without waiting for a dashboard command
* feat(dashboard): track agent arch and use in arch-aware update dispatch
- ws_hub: persist arch from heartbeat into agents table
- heartbeat + scheduler: read arch from DB when building download URL
- Scheduler now runs release check immediately at startup instead of
waiting for the first hourly tick
* feat(dashboard): implement full binary swap pipeline for releases
- update.rs: download+verify all artifacts before touching any files,
swap frontend binary and extract assets tarball, then swap backend
and exit for Podman restart. Uses Podman socket to stop/start the
frontend container during swap.
- docker-compose: replace build-from-source with prebuilt alpine
containers that mount binaries from host paths. healthcheck uses
wget (present in alpine) instead of curl.
- setup-dashboard.sh: detect arch, fetch latest release tag, download
and verify (Ed25519) backend + frontend binary + assets at install
time before starting services.
* fix: strengthen security hardening across agent and dashboard, including improved input validation, secure file permissions, SSRF protection, constant-time authentication checks, and hardened WebSocket origins.
* refactor: enhance system security with constant-time cryptographic checks, input validation, and updated contribution documentation.
* fix(security): prevent SQL injection in DB password rotation
ALTER USER does not support parameterized queries. Previous code used
string interpolation with single-quote escaping that could be bypassed.
Replaced with dollar-quoting ($$hex$$) — passwords are hex-encoded
[0-9a-f] so the dollar sign can never appear inside them.
Affects both the agent (lynx_agent_app) and dashboard (lynx_dashboard_app)
rotation handlers.
* fix(agent): eliminate shell injection in rootless Podman execution
runuser -l -c "podman args" passes the command through a shell, making
any user-controlled argument a potential injection vector. Replaced with
runuser -u user -- podman arg1 arg2 which hands args directly to execve.
Also explicitly sets HOME and XDG_RUNTIME_DIR since -u does not load the
login environment, which rootless Podman requires.
* fix(agent): add SSRF protection with DNS pinning for binary downloads
Resolves the DNS TOCTOU vulnerability where a hostname could resolve to
a different IP between the validation check and the actual connection,
allowing DNS rebinding to reach internal services.
Fix: resolve hostname once, validate all returned IPs against RFC1918/
loopback/link-local/unspecified ranges, then pin the reqwest client to
the validated IP via .resolve() so no second DNS lookup occurs.
Applies to both the primary update path (update/mod.rs) and the fallback
GitHub API poller (update/fallback.rs). Adds the url crate for URL parsing.
* fix(agent): harden WireGuard handler — interface validation, file permissions, zeroize secrets
* fix(agent): prevent path traversal in nginx cert path construction
* fix(security): heartbeat ACK requires Ed25519 signed command — bearer token alone insufficient
* fix(security): validate audit log hash chain on HTTP sync path
* fix(security): WS origin validation, vps:read permission checks, WG IP error propagation
* fix(auth): per-username rate limit, SHA256 ct_eq for setup token, constant-time IP/UA comparison
* fix(auth): enforce nbf and iat claims in JWT validation — reject future-issued tokens
* fix(security): strict domain and branding input validation — block path traversal vectors
* fix(security): validate IP/CIDR format and port range in nftables rule handlers
* fix(security): add HSTS header to all HTTPS responses
* fix(db): extend cert_type constraint to include cloudflare and custom cert types
* fix(scripts): replace eval PKG_UPDATE with direct case statement — eliminate eval injection
* feat(site): add GitHub Pages landing page
Dark amber-themed static landing with hero, architecture diagram,
feature cards, install guide, and stack badges. Poppins + JetBrains
Mono fonts. .nojekyll disables Jekyll processing.
* fix(ci): close security audit gaps across all components
- Add bun audit (--audit-level high) to dashboard-ui workflow — was
required by project docs, package.json had the script, CI never ran it
- Add cargo audit to lynx-compose workflow — only Rust component without
CVE scanning
- Pin upload-artifact, download-artifact, and action-gh-release to commit
SHAs in release workflows — mutable tags are a supply chain risk
- Verify musl bun binary with SHASUMS256.txt before unzip in
release-dashboard workflow — previously downloaded with no integrity check
- Drop security-events: write from all CI workflows — permission was
declared but SARIF was never uploaded (dead permission, violates least
privilege)
* fix(agent): extend nftables_state chain constraint before seeding output rows
Migration 005 inserted 'lynx-global-output' and 'lynx-local-output'
but the CHECK constraint from migration 004 only permitted 'lynx-global'
and 'lynx-local', causing migration 5 to fail on every fresh install.
Fix: ALTER TABLE to drop and re-add the constraint with all four valid
chain values before the INSERT.
Also applies cargo fmt across agent and dashboard-server (whitespace
and line-length reformats only, no logic changes).
* fix(db): renumber conflicting dashboard migrations and fix IPv6 link-local mask
Migrations 022_nftables_protection and 023_fix_uuid_defaults were added
after dashboard@1.0.1 shipped (which already contains 022_user_preferences
and 023_single_session). sqlx rejected the duplicate version numbers.
Renumbered to 026 and 027 — first available slots after the new 024
(agents_arch) and 025 (domain_cert_types) migrations.
Also fixes a clippy::bad_bit_mask in agent SSRF guard: the fe80::/10
link-local check used mask 0xff00, which zeroes the lower byte and can
never equal 0xfe80. Correct mask for a /10 prefix is 0xffc0.
* fix(server): refactor update params to struct; bypass rate limit in CI tests
perform_dashboard_update had 9 arguments — refactored to
DashboardUpdateParams struct to satisfy clippy::too_many_arguments.
Integration tests share a Redis instance and run in parallel, causing
the per-username login rate limit (10/15min) to be exhausted when
multiple test binaries all log in as testadmin concurrently.
Added LYNX_SKIP_RATE_LIMIT env-var bypass in rate_limit::check() and
set it in the CI test step — production deployments never set this var.
* fix(tests): serialize dashboard-server tests and flush testadmin rate-limit key
The env-var bypass (LYNX_SKIP_RATE_LIMIT) broke redis_fail tests that
rely on Redis being the first failure point to produce 503. Reverted.
Root cause: parallel test binaries all logging in as testadmin exceed the
10/15min per-username limit. Fix:
- Add --test-threads=1 to CI cargo test — serialises tests within each
binary, so at most ~4 concurrent testadmin logins across binaries (well
under the limit of 10).
- Flush rl:login:u:testadmin from Redis in test_state() before each test
so the counter resets between tests running in the same binary.
* fix(ui): remove duplicate migration label props from settings page
* ci(lint-shell): always run shellcheck on PRs to main, not just on .sh changes
* chore(deps): bump actions/download-artifact from 4.3.0 to 8.0.1
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/d3f86a106a0bac45b974a628896c90dbdf5c8093...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)
---
updated-dependencies:
- dependency-name: actions/download-artifact
dependency-version: 8.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /app/* layout with sidebar and auth gate.
* feat(agent): implement agent core with auth, nftables, Podman, metrics, and audit log
Full Lynx agent implementation:
- Ed25519 command authorization: signature + nonce dedup + timestamp ±30s + permission level
- Audit log with SHA-256 hash chaining for tamper detection
- nftables: atomic full-ruleset apply via stdin, inter-org blocking, checksum tracking
- Podman: rootless tenant users (lynx-tenant-{id}) with isolated subuid/subgid ranges
- WebSocket metrics: CPU (2-sample /proc/stat), RAM (/proc/meminfo), disk (statvfs), streamed every 2s
- Heartbeat watchdog: 300s timeout → lockdown mode (rejects all commands)
- PostgreSQL via sqlx with migrations (audit_log + used_nonces tables)
- Bearer token auth with constant-time comparison via `subtle`
feat(dashboard): production compose with 3 isolated networks and external Podman secrets
Replaces single-network compose with:
- lynx-dashboard-db: backend + postgres only
- lynx-dashboard-cache: backend + redis only
- lynx-dashboard-app: frontend + backend (frontend sole external port: 19443)
- All secrets as external Podman secrets (pg-root, pg-pass, redis-pass, api-token, kek, pepper)
- DATABASE_URL_FILE / REDIS_URL_FILE pattern for secret injection
- PostgreSQL init script creates isolated lynx_dashboard_app user
feat(dashboard): install script with secret generation, health-ordered startup, nftables, WireGuard, TLS
- Detects existing install → abort / update / reinstall (requires exact confirmation phrase)
- Generates 8 Podman secrets via pipe (no shell history, subshell isolation, memory overwrite)
- Starts services in dependency order with health-gate: postgres → redis → backend → frontend
- nftables ruleset: table inet lynx-dashboard, ports 22 + 19443 + 51820 UDP
- Self-signed P-256 cert (90 days) + systemd timer for rotation every 80 days
- WireGuard dashboard keypair + PSK stored as Podman secret; PSK shown once in final output
* feat(agent): add agent install script with WireGuard, systemd, Podman DB, and nftables setup
Complete agent VPS install:
- Creates lynx-agent system user with CAP_NET_ADMIN/CAP_SYS_ADMIN/CAP_SETUID/CAP_SETGID
- Allocates subuid/subgid ranges (1000000+65536) for rootless tenant Podman
- Generates 4 Podman secrets via subshell pipe (no shell history trace)
- Starts PostgreSQL container on lynx-agent-db network (port 127.0.0.1:5434)
- Installs binary as systemd service with LoadCredential (secrets in tmpfs)
- WireGuard peer config built from admin-provided dashboard pubkey + PSK
- nftables: lynx-agent table, only WG inbound + SSH, agent API only from WG iface
- Existing install detection with reinstall phrase confirmation
feat(dashboard): agent management API with WG peer management and command relay
- GET/POST /agents — list and register agents (allocates WG IP .2–.254)
- GET/DELETE /agents/{id} — individual agent ops + WG peer removal
- POST /agents/{id}/heartbeat — relay heartbeat to agent via WireGuard, update status
- POST /agents/{id}/cmd — sign command with Ed25519, relay to agent API
- agents + agent_events migration (migration 004)
- crypto/cmd.rs: sign_command builds JWS-style payload (base64url JSON + Ed25519 sig)
- reqwest client for agent HTTP relay over WireGuard
- AppError extended: NotFound, BadRequest, BadGateway, AgentUnavailable
- From<sqlx::Error> impl so ? works in handlers
* feat(ui): add /app/agents page with Server Component, skeleton loading, and i18n
- AgentList: Server Component, fetches GET /agents with 30s revalidation
- Status badges: online (default) / lockdown (destructive) / offline (secondary)
- Empty state matches overview page pattern
- AgentListSkeleton: 3-card skeleton for Suspense boundary
- i18n keys in en.json + es.json (app.agents namespace)
- Sidebar already linked to /app/agents
* feat: implement audit log sync (agent → dashboard) with per-agent sync token auth
Agent side:
- sync/mod.rs: Tokio task runs every 60s, batches unsynced audit_log entries
- Tracks sync cursor in sync_state table (migration 002)
- Config: DASHBOARD_URL + SYNC_TOKEN (optional — sync disabled if absent)
- reqwest client for HTTP POST to dashboard over WireGuard
Dashboard side:
- Migration 005: central audit_log table + sync_token_hash column on agents
- POST /agents/{id}/audit-sync: no JWT auth — uses per-agent sync token
- Sync token generated at registration (SHA-256 hash stored, plain returned once)
- Hash chain preserved: entry_hash/previous_hash stored verbatim from agent
- ON CONFLICT DO NOTHING: idempotent re-sync
- Constant-time token comparison via subtle crate
* feat: add organizations backend + UI
Backend (migration 006):
- organizations table: id (UUID v7), name, slug (unique), owner_id
- organization_members: role-based (owner/admin/member/viewer)
- projects table: belongs to org + agent, unique slug per org
- GET/POST /organizations, GET/DELETE /organizations/{id}
- List scoped to authenticated user's memberships
- Slug validation: lowercase alphanumeric + hyphens only
- Conflict handling: returns 409 on duplicate slug
UI:
- /app/organizations: Server Component, 60s revalidation
- Card grid with slug, member count, org ID
- OrgListSkeleton for Suspense boundary
- i18n: en + es (app.organizations namespace)
* feat(agent): add nftables divergence detection with dashboard alerting
- Background task runs every 60s: compares live nft checksum vs last apply() result
- Mismatch → WARN log + POST /agents/{id}/events with nftables_divergence event
- Auth: same per-agent sync token used by audit-sync
- AppState gains nft_checksum: Arc<Mutex<Option<String>>> for shared last-known-good state
- set_nft_checksum() called after each apply() in nftables apply handler (future)
Dashboard side:
- POST /agents/{id}/events endpoint — agent-callable (sync token auth)
- Validates event type against allowed list, inserts into agent_events
- Same pattern as audit-sync: no user JWT needed
* feat(dashboard/ui): add settings page with session management and key rotation
Sessions list with per-session revoke (DELETE /admin/sessions/{id}).
JWT key rotation button (POST /admin/rotate) clears cookies and redirects
to login — all sessions invalidated on rotation.
i18n: en + es under app.settings namespace.
* feat(agent/dashboard): container management commands + auto-update system
Agent:
- container.deploy (podman compose up via tenant user)
- container.start/stop/remove/restart (podman CLI via runuser)
- container.update (CPU/memory quota, vertical scaling)
- update.self: download binary from GitHub Releases, verify Ed25519 sig,
atomic rename, graceful exit for systemd restart
Dashboard admin:
- GET /admin/update-check: polls GitHub API for latest release
- POST /admin/trigger-update: signs update.self command to online agents
- GET /admin/update-log: recent update history
- migrations/008_update_log.sql: tracks per-agent update outcomes
* feat(dashboard): agent registration dialog, overview stats, events feed
UI:
- Register Agent dialog (POST /agents) shows agent ID, WG IP, sync token
once; token hidden with reveal toggle
- Overview page: online agent count, org count, recent events feed
- i18n en + es for new keys
Backend:
- GET /agents/events: recent agent_events across all agents (limit param)
* feat(dashboard/agent): WireGuard PSK rotation
Dashboard admin rotate with scope wireguard_psks or all:
- Generates new PSK per online agent via wg genpsk
- Updates dashboard WG peer with new PSK
- Sends signed wg.rotate_psk command to each agent
Agent wg.rotate_psk handler:
- Applies new PSK to wg-lynx-agent peer
- Persists via wg-quick save
* feat(ui): org creation dialog, i18n keys for org/agent registration
- Create Organization dialog with auto-derived slug
- Register Agent and Create Org buttons on respective pages
- i18n en+es: organizations.create/slugConflict/createError
* feat(dashboard/agent): internal PKI — CA issues Ed25519 certs to agents
Dashboard:
- crypto/pki: CA Ed25519 keypair, issue_cert, verify_cert
- CA keypair loaded from CA_PRIVATE_KEY/CA_PUBLIC_KEY env (ephemeral in dev)
- Agent registration returns signed cert + CA public key (once)
- cert stored in agents table (migration 009_agent_certs.sql)
- admin rotate certificates scope re-issues certs for all agents
Agent:
- cert.rs: verify dashboard CA cert (CA_PUBLIC_KEY env)
- Cert verification available for command auth second layer
* feat(dashboard): white-label branding from PostgreSQL
Single-row white_label table stores company_name, logo_url, and
three hex palette colors. Public GET /branding serves defaults on
empty DB. Auth-protected PUT /admin/branding (via admin route_layer)
validates hex format before UPSERT.
UI injects --brand-primary/secondary/accent CSS vars on <html> at
layout level (revalidated every 60s). Sidebar header renders
company_name or logo. Settings page adds BrandingForm (Server Action
PUT) with color swatches + inputs.
* feat(orgs): member management — invite, list, remove
Backend: GET/POST /organizations/{id}/members, DELETE
/organizations/{id}/members/{user_id}. Role-gated (owner/admin only
for mutations). Prevents removing org owner. Adds AppError::Forbidden
(403) variant.
UI: org cards now link to detail page. Detail page lists members with
role badges and per-member remove buttons (owner row protected). Invite
dialog: username + role selector. Server actions revalidate page on
mutation.
* feat(orgs): project listing and vertical scaling UI
Backend: GET/GET /organizations/{id}/projects[/{proj_id}] for project
browsing. PUT /organizations/{id}/projects/{proj_id}/resources signs a
container.update command (tenant_id=org_id, cpus, memory_mb) and
relays it to the target agent via WireGuard. Viewer role blocked from
scaling mutations.
UI: org detail page now lists projects as clickable links. Project
detail page exposes ResourceForm — container name + CPU/memory inputs
that call the backend action and toast on result.
* feat(nftables): divergence detection and restore/accept flow
Agent: nftables.apply now returns the rendered string, which is stored
in AppState.nft_last_ruleset alongside the checksum. New handlers:
nftables.restore (re-applies last ruleset via apply_raw) and
nftables.accept (snapshots current live checksum as new expected).
Dashboard: GET /agents/{id}/nftables-status returns the latest
unresolved divergence event (resolved = followed by a restored or
accepted event). POST /agents/{id}/nftables-resolve signs the
appropriate command and records the resolution event.
UI: AgentList fetches nftables status per online agent in parallel.
Diverged agents show an orange alert with Restore/Accept buttons
(NftablesAlert client component, resolveNftables server action).
* feat(orgs): add project creation
POST /organizations/{id}/projects — role-gated (viewer blocked), slug
validated, agent existence checked, 409 on slug conflict.
UI: org detail page shows CreateProjectDialog alongside project list.
Dialog has name + auto-derived slug + agent selector (online agents
first). slugConflict toast on 409.
* feat(containers): deploy and lifecycle management UI
Backend: POST /containers (deploy), GET /containers (list via
container.list), POST /containers/{name}/{action} (start|stop|restart|
remove). All relay signed commands to the project's agent. relay_project_command helper reduces duplication across all container handlers.
UI: project detail page now shows running container list with per-
container action buttons (state-dependent: start for stopped, stop+
restart for running, remove always). DeployForm accepts name, image,
ports, env, cpus, memory_mb.
* feat(ui): persistent footer and dynamic login branding
Sidebar gains a small "Made with love by Jaroc · lynx" footer at the
bottom — always visible in the app area per CLAUDE.md requirement.
Login page fetches company_name from /branding at render time and
interpolates into "Sign in to {company}" — supports white-label
without hard-coding "Lynx". i18n string updated in both locales.
* feat(settings): update check and trigger UI
UpdateSection client component: polls GET /admin/update-check, shows
current vs latest version with up-to-date/update-available badges, and
offers "Trigger update on all agents" button (POST /admin/trigger-update)
when behind latest. Server actions added to settings actions.ts.
* feat(scale): implement cross-agent horizontal scaling via WireGuard data-plane tunnels
Per-project WireGuard tunnel (distinct from management plane) between
Agent-A and Agent-B. Dashboard generates keypairs and PSK, stores tunnel
record, commands both agents to configure the interface, then deploys
N replicas on Agent-B.
- Migration 011: data_plane_tunnels table with status check constraint
- handlers: list/create/teardown tunnel endpoints with role guard
- router: wire horizontal scale routes (GET/POST/DELETE)
- agent: wg.data_plane.setup + wg.data_plane.teardown command handlers
derive interface name from tunnel_id prefix (wg-lynx-dp-{8hex})
- UI: HorizontalScaleSection with add-tunnel dialog and inline teardown
- i18n: en + es keys for horizontal scaling
* feat(audit): add per-agent audit log viewer
GET /agents/:id/audit-log returns paginated entries (limit/offset) from
the central audit_log table. Entry hash truncated to 16 chars — full
chain verification is server-side only; UI shows fingerprint for spot
checks.
- Backend: list_audit_log handler + route /{id}/audit-log
- UI: server-rendered table page with pagination (offset param)
- Agent card: link to audit log per agent
- i18n: en + es keys
* feat(domain): add custom domain configuration with Let's Encrypt and HSTS
Singleton domain_config table tracks domain, cert type, expiry, HSTS
state, and port 19443 status. Setup is async (returns 202 immediately)
so certbot wait does not block the HTTP response.
- Migration 012: domain_config singleton row with status check constraint
- GET/POST /domain, POST /domain/verify, /domain/hsts, /domain/close-port
- nginx compose YAML + conf generated in code (no external templates)
- certbot webroot challenge via lynx-nginx container
- Port 19443 closed via nft rule (irreversible remotely by design)
- HSTS only toggleable when status = active
- UI: DomainSection in settings — status badge, setup form, verify DNS,
HSTS toggle, port-close with confirmation dialog
- i18n: en + es
* feat(migration): implement dashboard-to-dashboard migration flow
Zero-downtime dashboard relocation from VPS-A to VPS-B. Uses a one-time
migration token (SHA-256 stored, never in DB plaintext) to gate the
data transfer over HTTPS. pg_dump → VPS-B restore → agent notification
→ per-agent confirmation → VPS-A shutdown.
- Migration 013: migration_state singleton table with role + progress tracking
- GET/POST /migration, /migration/prepare, /migration/start, /migration/abort,
/migration/confirm-shutdown (auth-protected)
- POST /migration/receive, /migration/agent-confirm (token-gated, no user auth)
- Agent: dashboard.migrate command → calls /migration/agent-confirm on VPS-B
using agent's sync token
- UI: MigrationSection in settings with dual-pane source/target flow,
progress tracking, abort control, shutdown confirmation
- i18n: en + es
* feat(auth): add password change and profile endpoints
POST /auth/change-password: verifies current password, hashes new one,
then bulk-deletes all sessions for the user and logs password_changed
reason per CLAUDE.md session invalidation rules. Revokes current access
token in Redis.
GET /auth/me: returns id + username for the authenticated user.
- Profile section in settings shows username + change-password form
- Password change redirects to login (all sessions invalidated)
- i18n: en + es
* feat(settings): add key rotation history to security section
* feat(pki): push cert.update to online agents on rotation; add CI workflows
- Add cert.update dispatch arm to agent — verifies new cert against CA
public key, persists to /etc/lynx/cert.json for durability across restarts
- PermissionLevel derives PartialOrd/Ord so permission comparisons compile
- rotate_agent_certs now pushes cert.update to online agents immediately
after DB update, so live agents adopt the new cert without restart
- Add rotate_expiring_certs() — called automatically during trigger_update
for any agent cert expiring within 14 days (CLAUDE.md: automatic cert
renewal during updates when near expiry)
- CI: dashboard-server workflow (fmt, audit, check/clippy with pg service)
- CI: dashboard-ui workflow (bun install + tsc --noEmit)
- CI: agent workflow (fmt, audit, check/clippy; integration tests left as
commented stub pending self-hosted runner per CLAUDE.md)
- cargo fmt applied across all packages (no logic changes)
* fix(dashboard): use base64url unpadded encoding for signed commands
Dashboard encoded command payload/signature with padded Base64Url;
agent decoded with Base64UrlUnpadded — padding mismatch caused all
commands to be rejected with 'payload: invalid base64url'.
Verified end-to-end on VM: cert.update command now reaches agent,
signature verified, cert persisted to /etc/lynx/cert.json.
* feat(dashboard): add background heartbeat scheduler
Polls all non-lockdown agents every 30s via WireGuard, updating
status and last_heartbeat in DB. Uses tokio interval with
MissedTickBehavior::Skip to avoid thundering-herd on startup.
First tick fires immediately so agent status is current without
requiring a manual heartbeat call.
* fix(dashboard): shorten WireGuard interface name to wg-lynx-dash
Linux IFNAMSIZ=16; 'wg-lynx-dashboard' (18 chars) exceeds the limit.
Renamed to 'wg-lynx-dash' (12 chars) in both wg.rs and admin/handlers.
Verified wg.rotate_psk command reaches agent, updates PSK on wg-lynx-agent,
and tunnel remains alive after rotation.
* fix(dashboard): correct Redis key pattern in JWT session flush
rotate_jwt_sessions was flushing 'jti:*' but access tokens are
stored under 'access:{jti}'. Old tokens remained valid after
rotation. Fixed pattern to 'access:*'.
Verified: after jwt_keys rotation, old tokens return 401; fresh
login issues working tokens.
* feat(dashboard): add ip_pool allocator, logs/reset-admin-password CLI, SSRF-safe downloader
- Migration 018: ip_pool table with SELECT FOR UPDATE allocator replaces
linear scan of agents.wg_ip; release_ip() on agent delete
- Dashboard backend CLI: `logs` subcommand (Podman container logs) and
`reset-admin-password --username` (SSH-only, sets force_password_change)
- Agent CLI: `logs [--follow] [--errors] [--since]` via journalctl
- update.rs: resolve DNS once, validate all IPs against RFC1918/loopback/
link-local before connecting (TOCTOU-safe); pinned reqwest resolver
- update.rs: now declares `mod update` in main.rs (was missing)
- Workspace: add clap, url, ipnetwork sqlx feature
* feat(dashboard): security alerts, headers, startup health guard, intercepted alerts
- Add security_alerts table (migration 019) + alerts.rs module
- Fire alert on login rate limit hit (rate_limit_hit kind)
- Fire alert on session intercepted (IP/UA mismatch in middleware)
- GET /admin/alerts + POST /admin/alerts/:id/acknowledge endpoints
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers
to all responses via tower-http SetResponseHeaderLayer
- update.rs: spawn_startup_health_guard() — polls /health every 2s for
30s after boot; if unhealthy restores .prev, writes /etc/lynx/CRITICAL
- reqwest: add rustls-tls feature to dashboard server
* feat(agent): command rate limiter (100/min) with rejected_rate_limit audit log
- AppState: cmd_rate window counter + cmd_rejected_count/window for alert threshold
- execute_command: reject when window exceeds 100 commands/min; log audit entry
with result=rejected_rate_limit; warn when count >= 3 in current minute
* feat(domain): route nginx/certbot/nftables ops through local agent
All domain management operations now go through the local agent
via signed Ed25519 commands instead of direct OS calls from the backend:
- nginx.deploy: deploy nginx container with image digest
- nginx.update_config: reload nginx after cert/HSTS changes
- certbot.obtain: run certbot webroot challenge on the agent VPS
- nftables.close_setup_port: close port 19443 after domain goes live
Added migration 020 adding is_local_agent BOOLEAN to agents table.
nginx.rs in domain handlers is now a pure config-generator with no
OS side effects. Fixed nginx image to use digest instead of mutable tag.
* refactor(agent): split handlers.rs into handlers/ module
Replaces the monolithic handlers.rs with a handlers/ directory organized
by responsibility, following the project's folder organization rule.
Also adds conflict.rs (incompatible software detection) and nginx.rs
(nginx watchdog) that were created but not yet committed.
* feat: add missing files from prior implementation sessions
- agent: .env.example, migration 003 for nginx_configs table
- dashboard UI: public assets (favicon, icons, flags for i18n selector)
- dashboard UI: admin alerts action + AlertsPanel component
- dashboard UI: Zod schemas organized by feature domain
* feat: implement X.509 mTLS, heartbeat scheduler, PKI, and frontend improvements
Backend (dashboard server):
- X.509 CA generation and agent cert issuance (pki.rs additions)
- mTLS client for agent HTTP calls (agents/client.rs)
- X509 fields added to Config (loaded from Podman secrets or ephemeral)
- Heartbeat scheduler polls agents every 30s, dispatches update.self
- Agent registration response includes TLS cert/key/ca DER (base64)
- WireGuard PSK rotation refactored to use mTLS client
- Cert rotation in admin handlers uses mTLS client
- Background heartbeat task spawned in main.rs
- Redis key pattern fix in JWT session flush (rotation.rs)
- WireGuard interface name shortened to wg-lynx-dash
Agent:
- TLS cert/key/ca loaded from env-configured files (config.rs)
- mTLS TLS acceptor built from loaded certs (main.rs)
- serve_tls() with manual hyper accept loop + axum body bridge
- Cargo.toml: hyper + hyper-util + tower added
- WireGuard PSK and WireGuard interface changes
- Setup agent script updates
Frontend:
- Login / register forms with Zod validation and Server Actions
- Dashboard pages: agents, organizations, projects, settings
- Sidebar navigation, alert components
- Branding, domain, migration, rotation, update UI sections
- i18n messages (en/es)
- biome.json, next.config.ts updates
- proxy.ts auth middleware
- Removed default Next.js placeholder SVGs
* feat(scheduler): full 90-day rotation — JWT flush + WireGuard PSKs + certs
Scheduled rotation now covers all three planes:
- JWT session flush (forces re-login for all users)
- WireGuard PSK rotation (coordinated, no tunnel downtime)
- mTLS cert rotation (certs expiring within 30 days)
Made rotate_jwt_sessions and rotate_wireguard_psks pub for scheduler use.
Rotation log scope changed from 'certificates' to 'all' for scheduled runs.
Also: is_local_agent flag added to RegisterAgentRequest so the install
script can mark the local dashboard agent at registration time.
* feat(domain): custom cert upload with PEM validation (Cloudflare + own cert)
POST /domain/cert/upload accepts cloudflare or custom cert type.
Backend validates before sending to agent:
- PEM parseable
- not_before <= now <= not_after (expiry check)
- SAN or CN matches configured domain
- cert/key pair match (custom certs only, via x509-parser pubkey comparison)
- max 64 KB per field
Agent gains nginx.install_cert command: writes cert+key to
/etc/lynx/nginx/certs/{domain}/ and reloads nginx.
nginx_conf_with_cert() generates nginx config with arbitrary cert paths.
x509-parser added as explicit workspace dep (was already transitive).
* fix(domain): set_hsts uses correct cert path for cloudflare/custom cert type
* feat(ws): add persistent WebSocket channel between agent and dashboard
Agent initiates a WS connection to dashboard on startup using its sync
token. Heartbeats and audit log entries now flow over the persistent WS
instead of HTTP polling. Dashboard can push signed commands via WS with
a 30-second response timeout, falling back to HTTP POST if no WS
connection is active.
Dashboard side:
- agents/ws_hub.rs: WS handler at /agents/{id}/ws, auth via sync_token
hash, stores live connections in AppState.agent_ws_conns
- push_command() sends a command envelope with a correlation UUID and
waits on a oneshot for the agent's command_response frame
- AppState gains agent_ws_conns: Arc<RwLock<HashMap<Uuid, AgentWsConn>>>
- send_command handler tries WS first, falls back to HTTP
Agent side:
- ws_client.rs: connects to ws://{dashboard}/agents/{id}/ws, sends
heartbeats every 30s, receives command frames, reconnects with
exponential backoff (5s → 300s)
- system.rs refactored: command_dispatch + run_verified_command shared
between HTTP handler and WS client — no logic duplication
- Lockdown check applies identically on both paths
* feat(ws): skip HTTP heartbeat poll for WS-connected agents
Heartbeat scheduler now checks AppState.agent_ws_conns before HTTP
polling. Agents with an active WS connection send proactive heartbeats
so no HTTP poll is needed. update.self is still dispatched (via WS)
when the scheduler detects a version mismatch.
* feat(agent+dashboard): nftables 3-chain architecture, real-time metrics WS pipeline
Agent:
- Refactor nftables module to lynx-base/lynx-global/lynx-local 3-chain model
- handle_nftables_apply now supports chain-specific updates {chain, rules} alongside
full apply {wireguard_port} for bootstrap; stores wg_port in AppState
- Divergence detector: auto-restore from last known-good ruleset on any chain mismatch;
apply emergency ruleset if restore fails; per-chain detection for severity logging
- Store post-apply checksum from live kernel state (nft -j list table) to ensure
divergence checker uses matching format; remove checksum_of() that hashed input text
- Stream system metrics (CPU/RAM/disk) via WS every 5 seconds alongside heartbeats
Dashboard backend:
- Add per-agent broadcast channels (broadcast::Sender<Arc<String>>) in AppState for
metric fan-out from agent WS to frontend browser WS clients
- ws_hub: create/drop broadcast channel on agent connect/disconnect; fan-out metrics
messages to all subscribed frontend clients
- New GET /agents/{id}/metrics/ws — browser WebSocket endpoint (JWT auth via cookie)
- Auth middleware: fall back to access_token cookie for WebSocket upgrade requests
since browsers cannot send custom headers on WS connections
- nftables rule management: migration 021, CRUD handlers, push_global/push_local,
rules_to_nft_chain() renderer; global_rule_sync tracking per-agent sync state
- Agent reboot handler; NftRulesPanel + AgentDetailActions frontend components
- Certificate upload section in domain settings (Cloudflare Origin + custom certs)
Dashboard frontend:
- Agent detail page: status/WG info, live metrics panel, nftables rule panels, actions
- useAgentMetrics hook: auto-reconnecting WS subscription with exponential backoff
- MetricsPanel: CPU/RAM/disk with progress bars and color-coded severity
- Next.js rewrites: proxy /api/* to backend (enables browser→backend WS via same origin)
- i18n: metrics keys in en.json + es.json
* feat(events): real-time agent events pipeline to browser WS
- Add global events broadcast channel (broadcast::Sender<Arc<String>>) to AppState
- ws_hub: record connected/disconnected events in agent_events + broadcast to browser
- heartbeat.rs: detect online→offline transition, fire heartbeat_lost event + alert
- events.rs: broadcast_event() helper; receive_event broadcasts after DB insert
- New GET /agents/events/ws endpoint (JWT auth) — streams all agent events to browser
- sign_command_system() in crypto/cmd.rs (nil UUID sentinel for scheduler-triggered cmds)
- rule_line() in nftables/mod.rs for system-level chain body generation
- push_pending_global_sync compilation fixed (SignedCommand→Value, correct function refs)
- Frontend useAgentEvents hook with exponential backoff reconnect
- NotificationBell component in Sidebar: badge count + popover for alert events
- i18n: en/es notification strings (heartbeat_lost, lockdown, divergence, conflicting_software)
* feat(dashboard): add admin user and role management
- Add require_admin middleware (checks *:* permission after require_auth)
- Admin router split: require_admin for admin-only routes, require_auth for own-session routes
- New admin handlers: list/delete users, CRUD roles, manage role permissions and user roles
- /auth/me now returns is_admin flag
- Frontend: Admin page at /app/admin (user table + role management with inline CRUD)
- Sidebar shows Admin nav link only for users with *:* permission
- Guard: non-admins redirected from /app/admin at server-render time
- Last-admin guards on delete user, delete role, remove role permission, remove own admin role
* feat(dashboard): add theme system and user preferences
- Migration 022: user_preferences table (theme + locale per user)
- Backend: GET/POST /auth/me/preferences (require_auth via protected router)
- ThemeProvider (next-themes, dark/light/system, SSR-safe with suppressHydrationWarning)
- ThemeToggle dropdown in sidebar header persists to DB + theme_preference cookie
- Locale layout reads theme_preference cookie as defaultTheme to prevent flash
* feat(dashboard): add locale switcher and i18n sign-out in sidebar
- LocaleSwitcher dropdown with flag icons, navigates to same path in new locale
- Persists locale preference to DB via updateLocaleAction
- Sign out button now uses i18n (t("signOut")) instead of hardcoded English
* feat(dashboard): single-session mode and theme-on-login
- Add single_session column to users table (migration 023)
- Login handler returns theme preference so client sets cookie immediately
- Login action sets theme_preference cookie from login response
- POST /auth/me/single-session endpoint to toggle per-user
- GET /auth/me now returns single_session and is_admin fields
- user_preferences migration (022) applied; get/update preferences handlers complete
- SingleSessionToggle client component in settings profile section
- i18n keys for single-session toggle (en + es)
* test(dashboard): add integration test suite for auth, admin, rotation, and security
Adds tests/auth.rs (25 tests), tests/admin.rs (10 tests), tests/rotation.rs
(5 tests), tests/security.rs (13 tests), and tests/redis_fail.rs (5 tests).
Key infrastructure decisions:
- Login helpers return (token, ip) so protected-route calls include the same
x-real-ip that minted the JWT — fixes IP hash mismatch in require_auth
- helpers::seed_test_admin resets force_password_change=false on testadmin
after each binary run, preventing force-password-change-all from poisoning
subsequent test binaries
- Minimal fake Redis server that responds +OK to CLIENT SETINFO setup
commands (redis-rs 0.27 always sends these) then returns errors for all
subsequent commands, allowing ConnectionManager::new() to succeed while
making every auth command fail → 503
- rustls ring CryptoProvider installed once per test process via
install_default() (ignores AlreadySet) to prevent race on parallel tests
- UUID suffix extended from 8 to 16 chars to eliminate timestamp collisions
when tests run at the same millisecond across parallel test binaries
- needs_scheduled_rotation counts both 'scheduled' and 'update' entries as
resetting the 90-day clock; inline unit tests verify the guard logic
* test(dashboard/server): add unit tests for crypto, validation, and auth logic
- crypto/{hash,jwt,kek,password}.rs: inline unit tests covering roundtrips,
tamper detection, constant-time equality, and edge cases
- auth/validate.rs: tests for username/email/password validation rules
- auth/session.rs: rotate_refresh now returns bool (true = swapped, false =
concurrent request already consumed the token) — 0 rows affected = 401
- auth/handlers.rs: handle rotate_refresh returning false with Unauthorized
- main.rs: moved module declarations to lib.rs; main uses the public library
crate so integration tests can import build_router and AppState directly
* test(dashboard/ui): add Vitest unit tests and Playwright E2E setup
- vitest.config.ts: jsdom environment, globals, setup file, aliased @
- src/__tests__/setup.ts: global test setup
- src/__tests__/(auth)/schemas.test.ts: login/register schema validation
- src/__tests__/(dashboard)/app/organizations/schemas.test.ts: org schemas
- src/__tests__/(dashboard)/app/settings/schemas.test.ts: settings schemas
- playwright.config.ts: E2E config targeting http://localhost:19443
- src/e2e/auth.test.ts: login, register, and logout E2E flows
* ci: add CI workflows for agent, dashboard-server, and dashboard-ui
- dashboard-server: full integration test job with PostgreSQL 18 and Redis 8 service containers; runs migrations and cargo test
- dashboard-ui: Vitest unit test job and Playwright E2E scaffold
- agent: cargo-audit vulnerability scan job
- setup-agent.sh: ANSI colors, section headers, NTP check, incompatible software detection/removal, NAT detection for PersistentKeepalive
- setup-dashboard.sh: same shell script improvements plus pg-tde key generation and Podman secret handling
- agent auth module: 131-line unit test suite for signed command verification (Ed25519, nonce, timestamp, permission checks)
* refactor(auth): split handlers.rs into handlers/ submodule; add WS origin validation
auth/handlers split by responsibility:
- register.rs: register + bootstrap_admin
- login.rs: login (rate-limit, anti-enum, single-session)
- session.rs: logout + refresh (token rotation)
- me.rs: GET /auth/me
- password.rs: POST /auth/change-password
- preferences.rs: GET/POST /auth/me/preferences, POST /auth/me/single-session
- mod.rs: re-exports + shared helpers (build_jwt_keys, extract_ip, extract_ua)
WebSocket origin validation (CSWSH prevention, security.md §12.4):
- events_ws: validate_ws_origin checks Origin header on WS upgrade
- metrics_ws: reuses validate_ws_origin from events_ws
- Absent Origin → allow (non-browser clients, integration tests)
- Domain configured → only https://{domain} allowed
- No domain → any https:// allowed (direct IP:19443 access)
* fix(security): audit log hash chain verification + offline agent pending_sync
audit_log hash chain (security.md §12.10):
- store_audit_entries now fetches last known entry_hash for the agent before
inserting any batch; verifies each entry's previous_hash matches in order
- On mismatch: rejects entire batch, fires audit_integrity_failure alert,
inserts agent_events row, broadcasts to frontend — no partial inserts
- AuditSyncEntry derives Clone to allow in-order sorting of batch
global_rule_sync pending for offline agents (agent.md):
- push_global_rules now inserts global_rule_sync rows with synced_at=NULL
for every offline agent after pushing to online agents
- push_pending_global_sync on WS reconnect finds these rows and pushes
the full current global ruleset — no rules lost during agent downtime
* fix(ui): cursor-pointer + disabled:cursor-not-allowed on Button; robots metadata object format
Button base variant: add cursor-pointer (interactive feedback) and
disabled:cursor-not-allowed (overrides pointer-events-none gap in some
browsers where the parent still shows default cursor).
layout.tsx: robots changed from string "noindex, nofollow" to object
{ index: false, follow: false } per Next.js Metadata type.
* feat(agent): split metrics by frequency; fix WireGuard config path
Metrics frequency (agent.md spec):
- CPU/RAM/disk: every 5 seconds (was 2s uniform)
- Container stats via podman stats: every 10 seconds (was missing entirely)
- ws_client.rs: separate container_ticker at 10s; sends container_metrics frame
- handlers/metrics.rs: separate system/container loops with correct intervals
- metrics/mod.rs: add ContainerMetrics + ContainerStat types; sample_containers()
runs podman stats --no-stream --format json and parses CPU/memory
WireGuard config path (agent.md spec):
- Source of truth: /etc/lynx/wireguard/lynx-wg.conf (owned by lynx-agent, 600)
- Symlink: /etc/wireguard/wg-lynx-agent.conf -> /etc/lynx/wireguard/lynx-wg.conf
for wg-quick compatibility (systemctl enable wg-quick@wg-lynx-agent)
* feat(dashboard): close remaining CLAUDE.md gaps
- alerts::fire now accepts &AppState and broadcasts security_alert events
via events_tx to all subscribed frontend WebSocket sessions
- 90-day scheduler rotates PostgreSQL app-user password and Redis password
(both via Podman secret --replace); manual rotate_keys "all" scope does
the same
- LoginRequest carries redirect_to (relative paths only; external URLs
silently discarded) returned in login response
- extract_ip prefers IPv4 over IPv6 from x-forwarded-for list per spec
* fix(lint): resolve all clippy -D warnings + fmt in agent and server
- Auto-fix and manual fix of 44 dashboard-server clippy errors:
useless anyhow conversions, redundant closures, needless borrows,
redundant pattern matching, empty doc comment line
- Auto-fix and manual fix of 27 agent clippy errors:
useless conversions, collapsible if/match, redundant closure,
PathBuf→Path, dead_code on future-use helpers
- cargo fmt applied to both crates — all diffs resolved
* test(dashboard): fix all 17 Playwright E2E tests
- Add custom Zod error messages to register schema matching i18n strings
- Add [locale]/page.tsx to handle redirect from /en → /en/login
- Fix submit-button test: delay POST route instead of invalid RSC mock
- Add PLAYWRIGHT_NO_SERVER env var support in playwright.config.ts
* fix(dashboard): install rustls ring provider at startup
Required for TLS code paths — prevents panic when rustls tries to
auto-select a crypto provider and ring is not the default.
* ci: add toolchain: stable to all dtolnay/rust-toolchain usages
The action requires a 'toolchain' input. Missing input caused
all Rust CI jobs to fail immediately after startup_failure was
fixed by adding third-party actions to the allowlist.
* fix(ci): remove sqlx-mysql CVE and fix compose formatting
- Set sqlx default-features=false in workspace dep to exclude mysql/sqlite
drivers. Adds macros feature explicitly. Eliminates RUSTSEC-2023-0071
(Marvin Attack in rsa crate pulled in by sqlx-mysql).
- Add audit.toml ignoring RUSTSEC-2023-0071 for residual Cargo.lock entry:
sqlx-mysql remains as a resolved optional dep but is not compiled
(verified via cargo tree --workspace).
- Run cargo fmt on lynx-compose package to pass CI fmt check.
* fix(ci): ignore RUSTSEC-2023-0071 in cargo audit
sqlx-mysql is an unresolvable optional dep in Cargo.lock (sqlx-macros-core
pulls it for compile-time query support) but is not compiled into any binary.
No fixed upgrade available for rsa 0.9.10 — advisory explicitly ignored.
* fix(security): validate IDs as UUIDs before URL interpolation in Server Actions
Adds validateId() helper that rejects non-UUID strings, applied to all
agentId/orgId/projId/tunnelId/ruleId/sessionId/userId parameters before
they are interpolated into fetch URLs. Eliminates CodeQL SSRF alerts on
user-controlled URL paths.
* ci(dashboard-ui): pin all action SHAs; quote CI env var as string
* fix(ui): add validateName() and inline container URL builds to eliminate SSRF alerts
Adds validateName() to api.ts for container name validation.
Removes base()/post() helpers from containers.ts; inlines fetch calls
with explicit validateId/validateName so CodeQL can trace all URL segments.
* test(e2e): waitForURL on root redirect test to handle RSC redirect timing
* fix(ui): use .bind() for local nft actions to avoid passing arrow fns to Client Component
* fix(ui): pass placeholder values to migrationAgentsProgress to satisfy next-intl interpolation
* ci: add explicit permissions block to all workflows (CodeQL hardening)
* chore: remove tracked junk files and update gitignore
Remove .fuse_hidden FUSE temp files and .playwright-mcp session artifacts
accidentally committed. Add patterns to .gitignore to prevent recurrence:
.fuse_hidden*, .playwright-mcp/, playwright-report/, test-results/.
* fix(gitignore): correct playwright-mcp ignore pattern
* refactor: update UI components and dependencies across the dashboard application
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#7)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(dashboard): remove development mode, production only
Delete docker-compose.dev.yml, remove dead /api/* rewrite from next.config.ts
(Next.js rewrites don't proxy WebSocket; nginx handles routing in production),
drop dev/test:watch/test:e2e:ui scripts from package.json, simplify
playwright.config.ts to always use bun run start, clean up .env.example.
* feat(nftables): add output chain support and seed global protection rules
- Add port_end and direction columns to nftables_rules; extend kind CHECK
with 8 new types: drop_invalid_state, tcp_flag_null, tcp_flag_xmas,
tcp_flag_ack_new, icmp_ping_limit, allow_icmp_errors, allow_ndp,
block_output_port
- Seed 7 input protection rules and 21 output port-block rules in lynx-global
(SMTP, Telnet, FTP, NetBIOS, SMB, IRC, RPC, TFTP, X11, SOCKS, LDAP, mDNS,
BitTorrent) so all fresh installs start with hardened defaults
- Add rules_to_nft_output_chain() and port_spec() to nftables compiler;
push_both_chains() sends input + output signed commands per agent
- Agent: add nft_global_output_body and nft_local_output_body to AppState;
render lynx-output chain from both; persist to nftables_state DB
- Agent migration 005: seed lynx-global-output and lynx-local-output rows
* style(ui): apply biome formatting across dashboard ui
* fix: replace gen_random_uuid() with uuidv7() — all tables must use UUID v7
gen_random_uuid() generates v4 UUIDs. The project rule is UUID v7 everywhere.
PostgreSQL 18 provides uuidv7() built-in.
- 019_security_alerts, 003_nginx_configs: fix DEFAULT in CREATE TABLE
- 015_roles_permissions, 022_nftables_protection: fix uuidv7() in seeded VALUES
- New migrations 023 and 006: ALTER TABLE to fix DEFAULT on already-created tables
in existing databases
* feat(ui): switch font from Geist to Poppins
* chore(github): add repo metadata files
Add CODEOWNERS, issue template, CONTRIBUTING, README, and SECURITY policy.
* ci: add URL audit script and job to CI pipelines
Ensure all outbound download URLs in update modules are from allowed
GitHub domains. Script scans a fixed set of files; unsupported URLs
require explicit suppression comment or ALLOWED update.
* ci: add release workflows for agent and dashboard
Build musl static binaries for x86_64 and arm64, sign each artifact
with Ed25519 (RELEASE_SIGN_KEY secret), and publish to GitHub Releases.
Dashboard workflow also bundles the Next.js standalone binary and
static assets tarball, each signed separately.
* chore(deps): scan full cargo workspace in dependabot daily
Point cargo ecosystem at workspace root /lynx instead of a single
member crate. Switch to daily schedule and use the new rust label.
* feat(db): add arch column to agents table
Stores the agent architecture (x86_64 / arm64) reported via heartbeat
so the dashboard can build the correct GitHub Release download URL.
* feat(agent): report arch in heartbeat, hardcode release key, add fallback updater
- Heartbeat now includes arch field (x86_64 / arm64)
- Release verify key hardcoded in binary; removed env-var lookup
- SSRF guard: validate_github_url added to update::perform_update
- Track last_dashboard_contact timestamp in state
- Fallback updater: if dashboard absent >6h, poll GitHub directly
and self-update without waiting for a dashboard command
* feat(dashboard): track agent arch and use in arch-aware update dispatch
- ws_hub: persist arch from heartbeat into agents table
- heartbeat + scheduler: read arch from DB when building download URL
- Scheduler now runs release check immediately at startup instead of
waiting for the first hourly tick
* feat(dashboard): implement full binary swap pipeline for releases
- update.rs: download+verify all artifacts before touching any files,
swap frontend binary and extract assets tarball, then swap backend
and exit for Podman restart. Uses Podman socket to stop/start the
frontend container during swap.
- docker-compose: replace build-from-source with prebuilt alpine
containers that mount binaries from host paths. healthcheck uses
wget (present in alpine) instead of curl.
- setup-dashboard.sh: detect arch, fetch latest release tag, download
and verify (Ed25519) backend + frontend binary + assets at install
time before starting services.
* fix: strengthen security hardening across agent and dashboard, including improved input validation, secure file permissions, SSRF protection, constant-time authentication checks, and hardened WebSocket origins.
* refactor: enhance system security with constant-time cryptographic checks, input validation, and updated contribution documentation.
* fix(security): prevent SQL injection in DB password rotation
ALTER USER does not support parameterized queries. Previous code used
string interpolation with single-quote escaping that could be bypassed.
Replaced with dollar-quoting ($$hex$$) — passwords are hex-encoded
[0-9a-f] so the dollar sign can never appear inside them.
Affects both the agent (lynx_agent_app) and dashboard (lynx_dashboard_app)
rotation handlers.
* fix(agent): eliminate shell injection in rootless Podman execution
runuser -l -c "podman args" passes the command through a shell, making
any user-controlled argument a potential injection vector. Replaced with
runuser -u user -- podman arg1 arg2 which hands args directly to execve.
Also explicitly sets HOME and XDG_RUNTIME_DIR since -u does not load the
login environment, which rootless Podman requires.
* fix(agent): add SSRF protection with DNS pinning for binary downloads
Resolves the DNS TOCTOU vulnerability where a hostname could resolve to
a different IP between the validation check and the actual connection,
allowing DNS rebinding to reach internal services.
Fix: resolve hostname once, validate all returned IPs against RFC1918/
loopback/link-local/unspecified ranges, then pin the reqwest client to
the validated IP via .resolve() so no second DNS lookup occurs.
Applies to both the primary update path (update/mod.rs) and the fallback
GitHub API poller (update/fallback.rs). Adds the url crate for URL parsing.
* fix(agent): harden WireGuard handler — interface validation, file permissions, zeroize secrets
* fix(agent): prevent path traversal in nginx cert path construction
* fix(security): heartbeat ACK requires Ed25519 signed command — bearer token alone insufficient
* fix(security): validate audit log hash chain on HTTP sync path
* fix(security): WS origin validation, vps:read permission checks, WG IP error propagation
* fix(auth): per-username rate limit, SHA256 ct_eq for setup token, constant-time IP/UA comparison
* fix(auth): enforce nbf and iat claims in JWT validation — reject future-issued tokens
* fix(security): strict domain and branding input validation — block path traversal vectors
* fix(security): validate IP/CIDR format and port range in nftables rule handlers
* fix(security): add HSTS header to all HTTPS responses
* fix(db): extend cert_type constraint to include cloudflare and custom cert types
* fix(scripts): replace eval PKG_UPDATE with direct case statement — eliminate eval injection
* feat(site): add GitHub Pages landing page
Dark amber-themed static landing with hero, architecture diagram,
feature cards, install guide, and stack badges. Poppins + JetBrains
Mono fonts. .nojekyll disables Jekyll processing.
* fix(ci): close security audit gaps across all components
- Add bun audit (--audit-level high) to dashboard-ui workflow — was
required by project docs, package.json had the script, CI never ran it
- Add cargo audit to lynx-compose workflow — only Rust component without
CVE scanning
- Pin upload-artifact, download-artifact, and action-gh-release to commit
SHAs in release workflows — mutable tags are a supply chain risk
- Verify musl bun binary with SHASUMS256.txt before unzip in
release-dashboard workflow — previously downloaded with no integrity check
- Drop security-events: write from all CI workflows — permission was
declared but SARIF was never uploaded (dead permission, violates least
privilege)
* fix(agent): extend nftables_state chain constraint before seeding output rows
Migration 005 inserted 'lynx-global-output' and 'lynx-local-output'
but the CHECK constraint from migration 004 only permitted 'lynx-global'
and 'lynx-local', causing migration 5 to fail on every fresh install.
Fix: ALTER TABLE to drop and re-add the constraint with all four valid
chain values before the INSERT.
Also applies cargo fmt across agent and dashboard-server (whitespace
and line-length reformats only, no logic changes).
* fix(db): renumber conflicting dashboard migrations and fix IPv6 link-local mask
Migrations 022_nftables_protection and 023_fix_uuid_defaults were added
after dashboard@1.0.1 shipped (which already contains 022_user_preferences
and 023_single_session). sqlx rejected the duplicate version numbers.
Renumbered to 026 and 027 — first available slots after the new 024
(agents_arch) and 025 (domain_cert_types) migrations.
Also fixes a clippy::bad_bit_mask in agent SSRF guard: the fe80::/10
link-local check used mask 0xff00, which zeroes the lower byte and can
never equal 0xfe80. Correct mask for a /10 prefix is 0xffc0.
* fix(server): refactor update params to struct; bypass rate limit in CI tests
perform_dashboard_update had 9 arguments — refactored to
DashboardUpdateParams struct to satisfy clippy::too_many_arguments.
Integration tests share a Redis instance and run in parallel, causing
the per-username login rate limit (10/15min) to be exhausted when
multiple test binaries all log in as testadmin concurrently.
Added LYNX_SKIP_RATE_LIMIT env-var bypass in rate_limit::check() and
set it in the CI test step — production deployments never set this var.
* fix(tests): serialize dashboard-server tests and flush testadmin rate-limit key
The env-var bypass (LYNX_SKIP_RATE_LIMIT) broke redis_fail tests that
rely on Redis being the first failure point to produce 503. Reverted.
Root cause: parallel test binaries all logging in as testadmin exceed the
10/15min per-username limit. Fix:
- Add --test-threads=1 to CI cargo test — serialises tests within each
binary, so at most ~4 concurrent testadmin logins across binaries (well
under the limit of 10).
- Flush rl:login:u:testadmin from Redis in test_state() before each test
so the counter resets between tests running in the same binary.
* fix(ui): remove duplicate migration label props from settings page
* ci(lint-shell): always run shellcheck on PRs to main, not just on .sh changes
* chore(deps): bump sha2 from 0.10.9 to 0.11.0 in /lynx
Bumps [sha2](https://github.com/RustCrypto/hashes) from 0.10.9 to 0.11.0.
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)
---
updated-dependencies:
- dependency-name: sha2
dependency-version: 0.11.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /app/* layout with sidebar and auth gate.
* feat(agent): implement agent core with auth, nftables, Podman, metrics, and audit log
Full Lynx agent implementation:
- Ed25519 command authorization: signature + nonce dedup + timestamp ±30s + permission level
- Audit log with SHA-256 hash chaining for tamper detection
- nftables: atomic full-ruleset apply via stdin, inter-org blocking, checksum tracking
- Podman: rootless tenant users (lynx-tenant-{id}) with isolated subuid/subgid ranges
- WebSocket metrics: CPU (2-sample /proc/stat), RAM (/proc/meminfo), disk (statvfs), streamed every 2s
- Heartbeat watchdog: 300s timeout → lockdown mode (rejects all commands)
- PostgreSQL via sqlx with migrations (audit_log + used_nonces tables)
- Bearer token auth with constant-time comparison via `subtle`
feat(dashboard): production compose with 3 isolated networks and external Podman secrets
Replaces single-network compose with:
- lynx-dashboard-db: backend + postgres only
- lynx-dashboard-cache: backend + redis only
- lynx-dashboard-app: frontend + backend (frontend sole external port: 19443)
- All secrets as external Podman secrets (pg-root, pg-pass, redis-pass, api-token, kek, pepper)
- DATABASE_URL_FILE / REDIS_URL_FILE pattern for secret injection
- PostgreSQL init script creates isolated lynx_dashboard_app user
feat(dashboard): install script with secret generation, health-ordered startup, nftables, WireGuard, TLS
- Detects existing install → abort / update / reinstall (requires exact confirmation phrase)
- Generates 8 Podman secrets via pipe (no shell history, subshell isolation, memory overwrite)
- Starts services in dependency order with health-gate: postgres → redis → backend → frontend
- nftables ruleset: table inet lynx-dashboard, ports 22 + 19443 + 51820 UDP
- Self-signed P-256 cert (90 days) + systemd timer for rotation every 80 days
- WireGuard dashboard keypair + PSK stored as Podman secret; PSK shown once in final output
* feat(agent): add agent install script with WireGuard, systemd, Podman DB, and nftables setup
Complete agent VPS install:
- Creates lynx-agent system user with CAP_NET_ADMIN/CAP_SYS_ADMIN/CAP_SETUID/CAP_SETGID
- Allocates subuid/subgid ranges (1000000+65536) for rootless tenant Podman
- Generates 4 Podman secrets via subshell pipe (no shell history trace)
- Starts PostgreSQL container on lynx-agent-db network (port 127.0.0.1:5434)
- Installs binary as systemd service with LoadCredential (secrets in tmpfs)
- WireGuard peer config built from admin-provided dashboard pubkey + PSK
- nftables: lynx-agent table, only WG inbound + SSH, agent API only from WG iface
- Existing install detection with reinstall phrase confirmation
feat(dashboard): agent management API with WG peer management and command relay
- GET/POST /agents — list and register agents (allocates WG IP .2–.254)
- GET/DELETE /agents/{id} — individual agent ops + WG peer removal
- POST /agents/{id}/heartbeat — relay heartbeat to agent via WireGuard, update status
- POST /agents/{id}/cmd — sign command with Ed25519, relay to agent API
- agents + agent_events migration (migration 004)
- crypto/cmd.rs: sign_command builds JWS-style payload (base64url JSON + Ed25519 sig)
- reqwest client for agent HTTP relay over WireGuard
- AppError extended: NotFound, BadRequest, BadGateway, AgentUnavailable
- From<sqlx::Error> impl so ? works in handlers
* feat(ui): add /app/agents page with Server Component, skeleton loading, and i18n
- AgentList: Server Component, fetches GET /agents with 30s revalidation
- Status badges: online (default) / lockdown (destructive) / offline (secondary)
- Empty state matches overview page pattern
- AgentListSkeleton: 3-card skeleton for Suspense boundary
- i18n keys in en.json + es.json (app.agents namespace)
- Sidebar already linked to /app/agents
* feat: implement audit log sync (agent → dashboard) with per-agent sync token auth
Agent side:
- sync/mod.rs: Tokio task runs every 60s, batches unsynced audit_log entries
- Tracks sync cursor in sync_state table (migration 002)
- Config: DASHBOARD_URL + SYNC_TOKEN (optional — sync disabled if absent)
- reqwest client for HTTP POST to dashboard over WireGuard
Dashboard side:
- Migration 005: central audit_log table + sync_token_hash column on agents
- POST /agents/{id}/audit-sync: no JWT auth — uses per-agent sync token
- Sync token generated at registration (SHA-256 hash stored, plain returned once)
- Hash chain preserved: entry_hash/previous_hash stored verbatim from agent
- ON CONFLICT DO NOTHING: idempotent re-sync
- Constant-time token comparison via subtle crate
* feat: add organizations backend + UI
Backend (migration 006):
- organizations table: id (UUID v7), name, slug (unique), owner_id
- organization_members: role-based (owner/admin/member/viewer)
- projects table: belongs to org + agent, unique slug per org
- GET/POST /organizations, GET/DELETE /organizations/{id}
- List scoped to authenticated user's memberships
- Slug validation: lowercase alphanumeric + hyphens only
- Conflict handling: returns 409 on duplicate slug
UI:
- /app/organizations: Server Component, 60s revalidation
- Card grid with slug, member count, org ID
- OrgListSkeleton for Suspense boundary
- i18n: en + es (app.organizations namespace)
* feat(agent): add nftables divergence detection with dashboard alerting
- Background task runs every 60s: compares live nft checksum vs last apply() result
- Mismatch → WARN log + POST /agents/{id}/events with nftables_divergence event
- Auth: same per-agent sync token used by audit-sync
- AppState gains nft_checksum: Arc<Mutex<Option<String>>> for shared last-known-good state
- set_nft_checksum() called after each apply() in nftables apply handler (future)
Dashboard side:
- POST /agents/{id}/events endpoint — agent-callable (sync token auth)
- Validates event type against allowed list, inserts into agent_events
- Same pattern as audit-sync: no user JWT needed
* feat(dashboard/ui): add settings page with session management and key rotation
Sessions list with per-session revoke (DELETE /admin/sessions/{id}).
JWT key rotation button (POST /admin/rotate) clears cookies and redirects
to login — all sessions invalidated on rotation.
i18n: en + es under app.settings namespace.
* feat(agent/dashboard): container management commands + auto-update system
Agent:
- container.deploy (podman compose up via tenant user)
- container.start/stop/remove/restart (podman CLI via runuser)
- container.update (CPU/memory quota, vertical scaling)
- update.self: download binary from GitHub Releases, verify Ed25519 sig,
atomic rename, graceful exit for systemd restart
Dashboard admin:
- GET /admin/update-check: polls GitHub API for latest release
- POST /admin/trigger-update: signs update.self command to online agents
- GET /admin/update-log: recent update history
- migrations/008_update_log.sql: tracks per-agent update outcomes
* feat(dashboard): agent registration dialog, overview stats, events feed
UI:
- Register Agent dialog (POST /agents) shows agent ID, WG IP, sync token
once; token hidden with reveal toggle
- Overview page: online agent count, org count, recent events feed
- i18n en + es for new keys
Backend:
- GET /agents/events: recent agent_events across all agents (limit param)
* feat(dashboard/agent): WireGuard PSK rotation
Dashboard admin rotate with scope wireguard_psks or all:
- Generates new PSK per online agent via wg genpsk
- Updates dashboard WG peer with new PSK
- Sends signed wg.rotate_psk command to each agent
Agent wg.rotate_psk handler:
- Applies new PSK to wg-lynx-agent peer
- Persists via wg-quick save
* feat(ui): org creation dialog, i18n keys for org/agent registration
- Create Organization dialog with auto-derived slug
- Register Agent and Create Org buttons on respective pages
- i18n en+es: organizations.create/slugConflict/createError
* feat(dashboard/agent): internal PKI — CA issues Ed25519 certs to agents
Dashboard:
- crypto/pki: CA Ed25519 keypair, issue_cert, verify_cert
- CA keypair loaded from CA_PRIVATE_KEY/CA_PUBLIC_KEY env (ephemeral in dev)
- Agent registration returns signed cert + CA public key (once)
- cert stored in agents table (migration 009_agent_certs.sql)
- admin rotate certificates scope re-issues certs for all agents
Agent:
- cert.rs: verify dashboard CA cert (CA_PUBLIC_KEY env)
- Cert verification available for command auth second layer
* feat(dashboard): white-label branding from PostgreSQL
Single-row white_label table stores company_name, logo_url, and
three hex palette colors. Public GET /branding serves defaults on
empty DB. Auth-protected PUT /admin/branding (via admin route_layer)
validates hex format before UPSERT.
UI injects --brand-primary/secondary/accent CSS vars on <html> at
layout level (revalidated every 60s). Sidebar header renders
company_name or logo. Settings page adds BrandingForm (Server Action
PUT) with color swatches + inputs.
* feat(orgs): member management — invite, list, remove
Backend: GET/POST /organizations/{id}/members, DELETE
/organizations/{id}/members/{user_id}. Role-gated (owner/admin only
for mutations). Prevents removing org owner. Adds AppError::Forbidden
(403) variant.
UI: org cards now link to detail page. Detail page lists members with
role badges and per-member remove buttons (owner row protected). Invite
dialog: username + role selector. Server actions revalidate page on
mutation.
* feat(orgs): project listing and vertical scaling UI
Backend: GET/GET /organizations/{id}/projects[/{proj_id}] for project
browsing. PUT /organizations/{id}/projects/{proj_id}/resources signs a
container.update command (tenant_id=org_id, cpus, memory_mb) and
relays it to the target agent via WireGuard. Viewer role blocked from
scaling mutations.
UI: org detail page now lists projects as clickable links. Project
detail page exposes ResourceForm — container name + CPU/memory inputs
that call the backend action and toast on result.
* feat(nftables): divergence detection and restore/accept flow
Agent: nftables.apply now returns the rendered string, which is stored
in AppState.nft_last_ruleset alongside the checksum. New handlers:
nftables.restore (re-applies last ruleset via apply_raw) and
nftables.accept (snapshots current live checksum as new expected).
Dashboard: GET /agents/{id}/nftables-status returns the latest
unresolved divergence event (resolved = followed by a restored or
accepted event). POST /agents/{id}/nftables-resolve signs the
appropriate command and records the resolution event.
UI: AgentList fetches nftables status per online agent in parallel.
Diverged agents show an orange alert with Restore/Accept buttons
(NftablesAlert client component, resolveNftables server action).
* feat(orgs): add project creation
POST /organizations/{id}/projects — role-gated (viewer blocked), slug
validated, agent existence checked, 409 on slug conflict.
UI: org detail page shows CreateProjectDialog alongside project list.
Dialog has name + auto-derived slug + agent selector (online agents
first). slugConflict toast on 409.
* feat(containers): deploy and lifecycle management UI
Backend: POST /containers (deploy), GET /containers (list via
container.list), POST /containers/{name}/{action} (start|stop|restart|
remove). All relay signed commands to the project's agent. relay_project_command helper reduces duplication across all container handlers.
UI: project detail page now shows running container list with per-
container action buttons (state-dependent: start for stopped, stop+
restart for running, remove always). DeployForm accepts name, image,
ports, env, cpus, memory_mb.
* feat(ui): persistent footer and dynamic login branding
Sidebar gains a small "Made with love by Jaroc · lynx" footer at the
bottom — always visible in the app area per CLAUDE.md requirement.
Login page fetches company_name from /branding at render time and
interpolates into "Sign in to {company}" — supports white-label
without hard-coding "Lynx". i18n string updated in both locales.
* feat(settings): update check and trigger UI
UpdateSection client component: polls GET /admin/update-check, shows
current vs latest version with up-to-date/update-available badges, and
offers "Trigger update on all agents" button (POST /admin/trigger-update)
when behind latest. Server actions added to settings actions.ts.
* feat(scale): implement cross-agent horizontal scaling via WireGuard data-plane tunnels
Per-project WireGuard tunnel (distinct from management plane) between
Agent-A and Agent-B. Dashboard generates keypairs and PSK, stores tunnel
record, commands both agents to configure the interface, then deploys
N replicas on Agent-B.
- Migration 011: data_plane_tunnels table with status check constraint
- handlers: list/create/teardown tunnel endpoints with role guard
- router: wire horizontal scale routes (GET/POST/DELETE)
- agent: wg.data_plane.setup + wg.data_plane.teardown command handlers
derive interface name from tunnel_id prefix (wg-lynx-dp-{8hex})
- UI: HorizontalScaleSection with add-tunnel dialog and inline teardown
- i18n: en + es keys for horizontal scaling
* feat(audit): add per-agent audit log viewer
GET /agents/:id/audit-log returns paginated entries (limit/offset) from
the central audit_log table. Entry hash truncated to 16 chars — full
chain verification is server-side only; UI shows fingerprint for spot
checks.
- Backend: list_audit_log handler + route /{id}/audit-log
- UI: server-rendered table page with pagination (offset param)
- Agent card: link to audit log per agent
- i18n: en + es keys
* feat(domain): add custom domain configuration with Let's Encrypt and HSTS
Singleton domain_config table tracks domain, cert type, expiry, HSTS
state, and port 19443 status. Setup is async (returns 202 immediately)
so certbot wait does not block the HTTP response.
- Migration 012: domain_config singleton row with status check constraint
- GET/POST /domain, POST /domain/verify, /domain/hsts, /domain/close-port
- nginx compose YAML + conf generated in code (no external templates)
- certbot webroot challenge via lynx-nginx container
- Port 19443 closed via nft rule (irreversible remotely by design)
- HSTS only toggleable when status = active
- UI: DomainSection in settings — status badge, setup form, verify DNS,
HSTS toggle, port-close with confirmation dialog
- i18n: en + es
* feat(migration): implement dashboard-to-dashboard migration flow
Zero-downtime dashboard relocation from VPS-A to VPS-B. Uses a one-time
migration token (SHA-256 stored, never in DB plaintext) to gate the
data transfer over HTTPS. pg_dump → VPS-B restore → agent notification
→ per-agent confirmation → VPS-A shutdown.
- Migration 013: migration_state singleton table with role + progress tracking
- GET/POST /migration, /migration/prepare, /migration/start, /migration/abort,
/migration/confirm-shutdown (auth-protected)
- POST /migration/receive, /migration/agent-confirm (token-gated, no user auth)
- Agent: dashboard.migrate command → calls /migration/agent-confirm on VPS-B
using agent's sync token
- UI: MigrationSection in settings with dual-pane source/target flow,
progress tracking, abort control, shutdown confirmation
- i18n: en + es
* feat(auth): add password change and profile endpoints
POST /auth/change-password: verifies current password, hashes new one,
then bulk-deletes all sessions for the user and logs password_changed
reason per CLAUDE.md session invalidation rules. Revokes current access
token in Redis.
GET /auth/me: returns id + username for the authenticated user.
- Profile section in settings shows username + change-password form
- Password change redirects to login (all sessions invalidated)
- i18n: en + es
* feat(settings): add key rotation history to security section
* feat(pki): push cert.update to online agents on rotation; add CI workflows
- Add cert.update dispatch arm to agent — verifies new cert against CA
public key, persists to /etc/lynx/cert.json for durability across restarts
- PermissionLevel derives PartialOrd/Ord so permission comparisons compile
- rotate_agent_certs now pushes cert.update to online agents immediately
after DB update, so live agents adopt the new cert without restart
- Add rotate_expiring_certs() — called automatically during trigger_update
for any agent cert expiring within 14 days (CLAUDE.md: automatic cert
renewal during updates when near expiry)
- CI: dashboard-server workflow (fmt, audit, check/clippy with pg service)
- CI: dashboard-ui workflow (bun install + tsc --noEmit)
- CI: agent workflow (fmt, audit, check/clippy; integration tests left as
commented stub pending self-hosted runner per CLAUDE.md)
- cargo fmt applied across all packages (no logic changes)
* fix(dashboard): use base64url unpadded encoding for signed commands
Dashboard encoded command payload/signature with padded Base64Url;
agent decoded with Base64UrlUnpadded — padding mismatch caused all
commands to be rejected with 'payload: invalid base64url'.
Verified end-to-end on VM: cert.update command now reaches agent,
signature verified, cert persisted to /etc/lynx/cert.json.
* feat(dashboard): add background heartbeat scheduler
Polls all non-lockdown agents every 30s via WireGuard, updating
status and last_heartbeat in DB. Uses tokio interval with
MissedTickBehavior::Skip to avoid thundering-herd on startup.
First tick fires immediately so agent status is current without
requiring a manual heartbeat call.
* fix(dashboard): shorten WireGuard interface name to wg-lynx-dash
Linux IFNAMSIZ=16; 'wg-lynx-dashboard' (18 chars) exceeds the limit.
Renamed to 'wg-lynx-dash' (12 chars) in both wg.rs and admin/handlers.
Verified wg.rotate_psk command reaches agent, updates PSK on wg-lynx-agent,
and tunnel remains alive after rotation.
* fix(dashboard): correct Redis key pattern in JWT session flush
rotate_jwt_sessions was flushing 'jti:*' but access tokens are
stored under 'access:{jti}'. Old tokens remained valid after
rotation. Fixed pattern to 'access:*'.
Verified: after jwt_keys rotation, old tokens return 401; fresh
login issues working tokens.
* feat(dashboard): add ip_pool allocator, logs/reset-admin-password CLI, SSRF-safe downloader
- Migration 018: ip_pool table with SELECT FOR UPDATE allocator replaces
linear scan of agents.wg_ip; release_ip() on agent delete
- Dashboard backend CLI: `logs` subcommand (Podman container logs) and
`reset-admin-password --username` (SSH-only, sets force_password_change)
- Agent CLI: `logs [--follow] [--errors] [--since]` via journalctl
- update.rs: resolve DNS once, validate all IPs against RFC1918/loopback/
link-local before connecting (TOCTOU-safe); pinned reqwest resolver
- update.rs: now declares `mod update` in main.rs (was missing)
- Workspace: add clap, url, ipnetwork sqlx feature
* feat(dashboard): security alerts, headers, startup health guard, intercepted alerts
- Add security_alerts table (migration 019) + alerts.rs module
- Fire alert on login rate limit hit (rate_limit_hit kind)
- Fire alert on session intercepted (IP/UA mismatch in middleware)
- GET /admin/alerts + POST /admin/alerts/:id/acknowledge endpoints
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers
to all responses via tower-http SetResponseHeaderLayer
- update.rs: spawn_startup_health_guard() — polls /health every 2s for
30s after boot; if unhealthy restores .prev, writes /etc/lynx/CRITICAL
- reqwest: add rustls-tls feature to dashboard server
* feat(agent): command rate limiter (100/min) with rejected_rate_limit audit log
- AppState: cmd_rate window counter + cmd_rejected_count/window for alert threshold
- execute_command: reject when window exceeds 100 commands/min; log audit entry
with result=rejected_rate_limit; warn when count >= 3 in current minute
* feat(domain): route nginx/certbot/nftables ops through local agent
All domain management operations now go through the local agent
via signed Ed25519 commands instead of direct OS calls from the backend:
- nginx.deploy: deploy nginx container with image digest
- nginx.update_config: reload nginx after cert/HSTS changes
- certbot.obtain: run certbot webroot challenge on the agent VPS
- nftables.close_setup_port: close port 19443 after domain goes live
Added migration 020 adding is_local_agent BOOLEAN to agents table.
nginx.rs in domain handlers is now a pure config-generator with no
OS side effects. Fixed nginx image to use digest instead of mutable tag.
* refactor(agent): split handlers.rs into handlers/ module
Replaces the monolithic handlers.rs with a handlers/ directory organized
by responsibility, following the project's folder organization rule.
Also adds conflict.rs (incompatible software detection) and nginx.rs
(nginx watchdog) that were created but not yet committed.
* feat: add missing files from prior implementation sessions
- agent: .env.example, migration 003 for nginx_configs table
- dashboard UI: public assets (favicon, icons, flags for i18n selector)
- dashboard UI: admin alerts action + AlertsPanel component
- dashboard UI: Zod schemas organized by feature domain
* feat: implement X.509 mTLS, heartbeat scheduler, PKI, and frontend improvements
Backend (dashboard server):
- X.509 CA generation and agent cert issuance (pki.rs additions)
- mTLS client for agent HTTP calls (agents/client.rs)
- X509 fields added to Config (loaded from Podman secrets or ephemeral)
- Heartbeat scheduler polls agents every 30s, dispatches update.self
- Agent registration response includes TLS cert/key/ca DER (base64)
- WireGuard PSK rotation refactored to use mTLS client
- Cert rotation in admin handlers uses mTLS client
- Background heartbeat task spawned in main.rs
- Redis key pattern fix in JWT session flush (rotation.rs)
- WireGuard interface name shortened to wg-lynx-dash
Agent:
- TLS cert/key/ca loaded from env-configured files (config.rs)
- mTLS TLS acceptor built from loaded certs (main.rs)
- serve_tls() with manual hyper accept loop + axum body bridge
- Cargo.toml: hyper + hyper-util + tower added
- WireGuard PSK and WireGuard interface changes
- Setup agent script updates
Frontend:
- Login / register forms with Zod validation and Server Actions
- Dashboard pages: agents, organizations, projects, settings
- Sidebar navigation, alert components
- Branding, domain, migration, rotation, update UI sections
- i18n messages (en/es)
- biome.json, next.config.ts updates
- proxy.ts auth middleware
- Removed default Next.js placeholder SVGs
* feat(scheduler): full 90-day rotation — JWT flush + WireGuard PSKs + certs
Scheduled rotation now covers all three planes:
- JWT session flush (forces re-login for all users)
- WireGuard PSK rotation (coordinated, no tunnel downtime)
- mTLS cert rotation (certs expiring within 30 days)
Made rotate_jwt_sessions and rotate_wireguard_psks pub for scheduler use.
Rotation log scope changed from 'certificates' to 'all' for scheduled runs.
Also: is_local_agent flag added to RegisterAgentRequest so the install
script can mark the local dashboard agent at registration time.
* feat(domain): custom cert upload with PEM validation (Cloudflare + own cert)
POST /domain/cert/upload accepts cloudflare or custom cert type.
Backend validates before sending to agent:
- PEM parseable
- not_before <= now <= not_after (expiry check)
- SAN or CN matches configured domain
- cert/key pair match (custom certs only, via x509-parser pubkey comparison)
- max 64 KB per field
Agent gains nginx.install_cert command: writes cert+key to
/etc/lynx/nginx/certs/{domain}/ and reloads nginx.
nginx_conf_with_cert() generates nginx config with arbitrary cert paths.
x509-parser added as explicit workspace dep (was already transitive).
* fix(domain): set_hsts uses correct cert path for cloudflare/custom cert type
* feat(ws): add persistent WebSocket channel between agent and dashboard
Agent initiates a WS connection to dashboard on startup using its sync
token. Heartbeats and audit log entries now flow over the persistent WS
instead of HTTP polling. Dashboard can push signed commands via WS with
a 30-second response timeout, falling back to HTTP POST if no WS
connection is active.
Dashboard side:
- agents/ws_hub.rs: WS handler at /agents/{id}/ws, auth via sync_token
hash, stores live connections in AppState.agent_ws_conns
- push_command() sends a command envelope with a correlation UUID and
waits on a oneshot for the agent's command_response frame
- AppState gains agent_ws_conns: Arc<RwLock<HashMap<Uuid, AgentWsConn>>>
- send_command handler tries WS first, falls back to HTTP
Agent side:
- ws_client.rs: connects to ws://{dashboard}/agents/{id}/ws, sends
heartbeats every 30s, receives command frames, reconnects with
exponential backoff (5s → 300s)
- system.rs refactored: command_dispatch + run_verified_command shared
between HTTP handler and WS client — no logic duplication
- Lockdown check applies identically on both paths
* feat(ws): skip HTTP heartbeat poll for WS-connected agents
Heartbeat scheduler now checks AppState.agent_ws_conns before HTTP
polling. Agents with an active WS connection send proactive heartbeats
so no HTTP poll is needed. update.self is still dispatched (via WS)
when the scheduler detects a version mismatch.
* feat(agent+dashboard): nftables 3-chain architecture, real-time metrics WS pipeline
Agent:
- Refactor nftables module to lynx-base/lynx-global/lynx-local 3-chain model
- handle_nftables_apply now supports chain-specific updates {chain, rules} alongside
full apply {wireguard_port} for bootstrap; stores wg_port in AppState
- Divergence detector: auto-restore from last known-good ruleset on any chain mismatch;
apply emergency ruleset if restore fails; per-chain detection for severity logging
- Store post-apply checksum from live kernel state (nft -j list table) to ensure
divergence checker uses matching format; remove checksum_of() that hashed input text
- Stream system metrics (CPU/RAM/disk) via WS every 5 seconds alongside heartbeats
Dashboard backend:
- Add per-agent broadcast channels (broadcast::Sender<Arc<String>>) in AppState for
metric fan-out from agent WS to frontend browser WS clients
- ws_hub: create/drop broadcast channel on agent connect/disconnect; fan-out metrics
messages to all subscribed frontend clients
- New GET /agents/{id}/metrics/ws — browser WebSocket endpoint (JWT auth via cookie)
- Auth middleware: fall back to access_token cookie for WebSocket upgrade requests
since browsers cannot send custom headers on WS connections
- nftables rule management: migration 021, CRUD handlers, push_global/push_local,
rules_to_nft_chain() renderer; global_rule_sync tracking per-agent sync state
- Agent reboot handler; NftRulesPanel + AgentDetailActions frontend components
- Certificate upload section in domain settings (Cloudflare Origin + custom certs)
Dashboard frontend:
- Agent detail page: status/WG info, live metrics panel, nftables rule panels, actions
- useAgentMetrics hook: auto-reconnecting WS subscription with exponential backoff
- MetricsPanel: CPU/RAM/disk with progress bars and color-coded severity
- Next.js rewrites: proxy /api/* to backend (enables browser→backend WS via same origin)
- i18n: metrics keys in en.json + es.json
* feat(events): real-time agent events pipeline to browser WS
- Add global events broadcast channel (broadcast::Sender<Arc<String>>) to AppState
- ws_hub: record connected/disconnected events in agent_events + broadcast to browser
- heartbeat.rs: detect online→offline transition, fire heartbeat_lost event + alert
- events.rs: broadcast_event() helper; receive_event broadcasts after DB insert
- New GET /agents/events/ws endpoint (JWT auth) — streams all agent events to browser
- sign_command_system() in crypto/cmd.rs (nil UUID sentinel for scheduler-triggered cmds)
- rule_line() in nftables/mod.rs for system-level chain body generation
- push_pending_global_sync compilation fixed (SignedCommand→Value, correct function refs)
- Frontend useAgentEvents hook with exponential backoff reconnect
- NotificationBell component in Sidebar: badge count + popover for alert events
- i18n: en/es notification strings (heartbeat_lost, lockdown, divergence, conflicting_software)
* feat(dashboard): add admin user and role management
- Add require_admin middleware (checks *:* permission after require_auth)
- Admin router split: require_admin for admin-only routes, require_auth for own-session routes
- New admin handlers: list/delete users, CRUD roles, manage role permissions and user roles
- /auth/me now returns is_admin flag
- Frontend: Admin page at /app/admin (user table + role management with inline CRUD)
- Sidebar shows Admin nav link only for users with *:* permission
- Guard: non-admins redirected from /app/admin at server-render time
- Last-admin guards on delete user, delete role, remove role permission, remove own admin role
* feat(dashboard): add theme system and user preferences
- Migration 022: user_preferences table (theme + locale per user)
- Backend: GET/POST /auth/me/preferences (require_auth via protected router)
- ThemeProvider (next-themes, dark/light/system, SSR-safe with suppressHydrationWarning)
- ThemeToggle dropdown in sidebar header persists to DB + theme_preference cookie
- Locale layout reads theme_preference cookie as defaultTheme to prevent flash
* feat(dashboard): add locale switcher and i18n sign-out in sidebar
- LocaleSwitcher dropdown with flag icons, navigates to same path in new locale
- Persists locale preference to DB via updateLocaleAction
- Sign out button now uses i18n (t("signOut")) instead of hardcoded English
* feat(dashboard): single-session mode and theme-on-login
- Add single_session column to users table (migration 023)
- Login handler returns theme preference so client sets cookie immediately
- Login action sets theme_preference cookie from login response
- POST /auth/me/single-session endpoint to toggle per-user
- GET /auth/me now returns single_session and is_admin fields
- user_preferences migration (022) applied; get/update preferences handlers complete
- SingleSessionToggle client component in settings profile section
- i18n keys for single-session toggle (en + es)
* test(dashboard): add integration test suite for auth, admin, rotation, and security
Adds tests/auth.rs (25 tests), tests/admin.rs (10 tests), tests/rotation.rs
(5 tests), tests/security.rs (13 tests), and tests/redis_fail.rs (5 tests).
Key infrastructure decisions:
- Login helpers return (token, ip) so protected-route calls include the same
x-real-ip that minted the JWT — fixes IP hash mismatch in require_auth
- helpers::seed_test_admin resets force_password_change=false on testadmin
after each binary run, preventing force-password-change-all from poisoning
subsequent test binaries
- Minimal fake Redis server that responds +OK to CLIENT SETINFO setup
commands (redis-rs 0.27 always sends these) then returns errors for all
subsequent commands, allowing ConnectionManager::new() to succeed while
making every auth command fail → 503
- rustls ring CryptoProvider installed once per test process via
install_default() (ignores AlreadySet) to prevent race on parallel tests
- UUID suffix extended from 8 to 16 chars to eliminate timestamp collisions
when tests run at the same millisecond across parallel test binaries
- needs_scheduled_rotation counts both 'scheduled' and 'update' entries as
resetting the 90-day clock; inline unit tests verify the guard logic
* test(dashboard/server): add unit tests for crypto, validation, and auth logic
- crypto/{hash,jwt,kek,password}.rs: inline unit tests covering roundtrips,
tamper detection, constant-time equality, and edge cases
- auth/validate.rs: tests for username/email/password validation rules
- auth/session.rs: rotate_refresh now returns bool (true = swapped, false =
concurrent request already consumed the token) — 0 rows affected = 401
- auth/handlers.rs: handle rotate_refresh returning false with Unauthorized
- main.rs: moved module declarations to lib.rs; main uses the public library
crate so integration tests can import build_router and AppState directly
* test(dashboard/ui): add Vitest unit tests and Playwright E2E setup
- vitest.config.ts: jsdom environment, globals, setup file, aliased @
- src/__tests__/setup.ts: global test setup
- src/__tests__/(auth)/schemas.test.ts: login/register schema validation
- src/__tests__/(dashboard)/app/organizations/schemas.test.ts: org schemas
- src/__tests__/(dashboard)/app/settings/schemas.test.ts: settings schemas
- playwright.config.ts: E2E config targeting http://localhost:19443
- src/e2e/auth.test.ts: login, register, and logout E2E flows
* ci: add CI workflows for agent, dashboard-server, and dashboard-ui
- dashboard-server: full integration test job with PostgreSQL 18 and Redis 8 service containers; runs migrations and cargo test
- dashboard-ui: Vitest unit test job and Playwright E2E scaffold
- agent: cargo-audit vulnerability scan job
- setup-agent.sh: ANSI colors, section headers, NTP check, incompatible software detection/removal, NAT detection for PersistentKeepalive
- setup-dashboard.sh: same shell script improvements plus pg-tde key generation and Podman secret handling
- agent auth module: 131-line unit test suite for signed command verification (Ed25519, nonce, timestamp, permission checks)
* refactor(auth): split handlers.rs into handlers/ submodule; add WS origin validation
auth/handlers split by responsibility:
- register.rs: register + bootstrap_admin
- login.rs: login (rate-limit, anti-enum, single-session)
- session.rs: logout + refresh (token rotation)
- me.rs: GET /auth/me
- password.rs: POST /auth/change-password
- preferences.rs: GET/POST /auth/me/preferences, POST /auth/me/single-session
- mod.rs: re-exports + shared helpers (build_jwt_keys, extract_ip, extract_ua)
WebSocket origin validation (CSWSH prevention, security.md §12.4):
- events_ws: validate_ws_origin checks Origin header on WS upgrade
- metrics_ws: reuses validate_ws_origin from events_ws
- Absent Origin → allow (non-browser clients, integration tests)
- Domain configured → only https://{domain} allowed
- No domain → any https:// allowed (direct IP:19443 access)
* fix(security): audit log hash chain verification + offline agent pending_sync
audit_log hash chain (security.md §12.10):
- store_audit_entries now fetches last known entry_hash for the agent before
inserting any batch; verifies each entry's previous_hash matches in order
- On mismatch: rejects entire batch, fires audit_integrity_failure alert,
inserts agent_events row, broadcasts to frontend — no partial inserts
- AuditSyncEntry derives Clone to allow in-order sorting of batch
global_rule_sync pending for offline agents (agent.md):
- push_global_rules now inserts global_rule_sync rows with synced_at=NULL
for every offline agent after pushing to online agents
- push_pending_global_sync on WS reconnect finds these rows and pushes
the full current global ruleset — no rules lost during agent downtime
* fix(ui): cursor-pointer + disabled:cursor-not-allowed on Button; robots metadata object format
Button base variant: add cursor-pointer (interactive feedback) and
disabled:cursor-not-allowed (overrides pointer-events-none gap in some
browsers where the parent still shows default cursor).
layout.tsx: robots changed from string "noindex, nofollow" to object
{ index: false, follow: false } per Next.js Metadata type.
* feat(agent): split metrics by frequency; fix WireGuard config path
Metrics frequency (agent.md spec):
- CPU/RAM/disk: every 5 seconds (was 2s uniform)
- Container stats via podman stats: every 10 seconds (was missing entirely)
- ws_client.rs: separate container_ticker at 10s; sends container_metrics frame
- handlers/metrics.rs: separate system/container loops with correct intervals
- metrics/mod.rs: add ContainerMetrics + ContainerStat types; sample_containers()
runs podman stats --no-stream --format json and parses CPU/memory
WireGuard config path (agent.md spec):
- Source of truth: /etc/lynx/wireguard/lynx-wg.conf (owned by lynx-agent, 600)
- Symlink: /etc/wireguard/wg-lynx-agent.conf -> /etc/lynx/wireguard/lynx-wg.conf
for wg-quick compatibility (systemctl enable wg-quick@wg-lynx-agent)
* feat(dashboard): close remaining CLAUDE.md gaps
- alerts::fire now accepts &AppState and broadcasts security_alert events
via events_tx to all subscribed frontend WebSocket sessions
- 90-day scheduler rotates PostgreSQL app-user password and Redis password
(both via Podman secret --replace); manual rotate_keys "all" scope does
the same
- LoginRequest carries redirect_to (relative paths only; external URLs
silently discarded) returned in login response
- extract_ip prefers IPv4 over IPv6 from x-forwarded-for list per spec
* fix(lint): resolve all clippy -D warnings + fmt in agent and server
- Auto-fix and manual fix of 44 dashboard-server clippy errors:
useless anyhow conversions, redundant closures, needless borrows,
redundant pattern matching, empty doc comment line
- Auto-fix and manual fix of 27 agent clippy errors:
useless conversions, collapsible if/match, redundant closure,
PathBuf→Path, dead_code on future-use helpers
- cargo fmt applied to both crates — all diffs resolved
* test(dashboard): fix all 17 Playwright E2E tests
- Add custom Zod error messages to register schema matching i18n strings
- Add [locale]/page.tsx to handle redirect from /en → /en/login
- Fix submit-button test: delay POST route instead of invalid RSC mock
- Add PLAYWRIGHT_NO_SERVER env var support in playwright.config.ts
* fix(dashboard): install rustls ring provider at startup
Required for TLS code paths — prevents panic when rustls tries to
auto-select a crypto provider and ring is not the default.
* ci: add toolchain: stable to all dtolnay/rust-toolchain usages
The action requires a 'toolchain' input. Missing input caused
all Rust CI jobs to fail immediately after startup_failure was
fixed by adding third-party actions to the allowlist.
* fix(ci): remove sqlx-mysql CVE and fix compose formatting
- Set sqlx default-features=false in workspace dep to exclude mysql/sqlite
drivers. Adds macros feature explicitly. Eliminates RUSTSEC-2023-0071
(Marvin Attack in rsa crate pulled in by sqlx-mysql).
- Add audit.toml ignoring RUSTSEC-2023-0071 for residual Cargo.lock entry:
sqlx-mysql remains as a resolved optional dep but is not compiled
(verified via cargo tree --workspace).
- Run cargo fmt on lynx-compose package to pass CI fmt check.
* fix(ci): ignore RUSTSEC-2023-0071 in cargo audit
sqlx-mysql is an unresolvable optional dep in Cargo.lock (sqlx-macros-core
pulls it for compile-time query support) but is not compiled into any binary.
No fixed upgrade available for rsa 0.9.10 — advisory explicitly ignored.
* fix(security): validate IDs as UUIDs before URL interpolation in Server Actions
Adds validateId() helper that rejects non-UUID strings, applied to all
agentId/orgId/projId/tunnelId/ruleId/sessionId/userId parameters before
they are interpolated into fetch URLs. Eliminates CodeQL SSRF alerts on
user-controlled URL paths.
* ci(dashboard-ui): pin all action SHAs; quote CI env var as string
* fix(ui): add validateName() and inline container URL builds to eliminate SSRF alerts
Adds validateName() to api.ts for container name validation.
Removes base()/post() helpers from containers.ts; inlines fetch calls
with explicit validateId/validateName so CodeQL can trace all URL segments.
* test(e2e): waitForURL on root redirect test to handle RSC redirect timing
* fix(ui): use .bind() for local nft actions to avoid passing arrow fns to Client Component
* fix(ui): pass placeholder values to migrationAgentsProgress to satisfy next-intl interpolation
* ci: add explicit permissions block to all workflows (CodeQL hardening)
* chore: remove tracked junk files and update gitignore
Remove .fuse_hidden FUSE temp files and .playwright-mcp session artifacts
accidentally committed. Add patterns to .gitignore to prevent recurrence:
.fuse_hidden*, .playwright-mcp/, playwright-report/, test-results/.
* fix(gitignore): correct playwright-mcp ignore pattern
* refactor: update UI components and dependencies across the dashboard application
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#7)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(dashboard): remove development mode, production only
Delete docker-compose.dev.yml, remove dead /api/* rewrite from next.config.ts
(Next.js rewrites don't proxy WebSocket; nginx handles routing in production),
drop dev/test:watch/test:e2e:ui scripts from package.json, simplify
playwright.config.ts to always use bun run start, clean up .env.example.
* feat(nftables): add output chain support and seed global protection rules
- Add port_end and direction columns to nftables_rules; extend kind CHECK
with 8 new types: drop_invalid_state, tcp_flag_null, tcp_flag_xmas,
tcp_flag_ack_new, icmp_ping_limit, allow_icmp_errors, allow_ndp,
block_output_port
- Seed 7 input protection rules and 21 output port-block rules in lynx-global
(SMTP, Telnet, FTP, NetBIOS, SMB, IRC, RPC, TFTP, X11, SOCKS, LDAP, mDNS,
BitTorrent) so all fresh installs start with hardened defaults
- Add rules_to_nft_output_chain() and port_spec() to nftables compiler;
push_both_chains() sends input + output signed commands per agent
- Agent: add nft_global_output_body and nft_local_output_body to AppState;
render lynx-output chain from both; persist to nftables_state DB
- Agent migration 005: seed lynx-global-output and lynx-local-output rows
* style(ui): apply biome formatting across dashboard ui
* fix: replace gen_random_uuid() with uuidv7() — all tables must use UUID v7
gen_random_uuid() generates v4 UUIDs. The project rule is UUID v7 everywhere.
PostgreSQL 18 provides uuidv7() built-in.
- 019_security_alerts, 003_nginx_configs: fix DEFAULT in CREATE TABLE
- 015_roles_permissions, 022_nftables_protection: fix uuidv7() in seeded VALUES
- New migrations 023 and 006: ALTER TABLE to fix DEFAULT on already-created tables
in existing databases
* feat(ui): switch font from Geist to Poppins
* chore(github): add repo metadata files
Add CODEOWNERS, issue template, CONTRIBUTING, README, and SECURITY policy.
* ci: add URL audit script and job to CI pipelines
Ensure all outbound download URLs in update modules are from allowed
GitHub domains. Script scans a fixed set of files; unsupported URLs
require explicit suppression comment or ALLOWED update.
* ci: add release workflows for agent and dashboard
Build musl static binaries for x86_64 and arm64, sign each artifact
with Ed25519 (RELEASE_SIGN_KEY secret), and publish to GitHub Releases.
Dashboard workflow also bundles the Next.js standalone binary and
static assets tarball, each signed separately.
* chore(deps): scan full cargo workspace in dependabot daily
Point cargo ecosystem at workspace root /lynx instead of a single
member crate. Switch to daily schedule and use the new rust label.
* feat(db): add arch column to agents table
Stores the agent architecture (x86_64 / arm64) reported via heartbeat
so the dashboard can build the correct GitHub Release download URL.
* feat(agent): report arch in heartbeat, hardcode release key, add fallback updater
- Heartbeat now includes arch field (x86_64 / arm64)
- Release verify key hardcoded in binary; removed env-var lookup
- SSRF guard: validate_github_url added to update::perform_update
- Track last_dashboard_contact timestamp in state
- Fallback updater: if dashboard absent >6h, poll GitHub directly
and self-update without waiting for a dashboard command
* feat(dashboard): track agent arch and use in arch-aware update dispatch
- ws_hub: persist arch from heartbeat into agents table
- heartbeat + scheduler: read arch from DB when building download URL
- Scheduler now runs release check immediately at startup instead of
waiting for the first hourly tick
* feat(dashboard): implement full binary swap pipeline for releases
- update.rs: download+verify all artifacts before touching any files,
swap frontend binary and extract assets tarball, then swap backend
and exit for Podman restart. Uses Podman socket to stop/start the
frontend container during swap.
- docker-compose: replace build-from-source with prebuilt alpine
containers that mount binaries from host paths. healthcheck uses
wget (present in alpine) instead of curl.
- setup-dashboard.sh: detect arch, fetch latest release tag, download
and verify (Ed25519) backend + frontend binary + assets at install
time before starting services.
* fix: strengthen security hardening across agent and dashboard, including improved input validation, secure file permissions, SSRF protection, constant-time authentication checks, and hardened WebSocket origins.
* refactor: enhance system security with constant-time cryptographic checks, input validation, and updated contribution documentation.
* fix(security): prevent SQL injection in DB password rotation
ALTER USER does not support parameterized queries. Previous code used
string interpolation with single-quote escaping that could be bypassed.
Replaced with dollar-quoting ($$hex$$) — passwords are hex-encoded
[0-9a-f] so the dollar sign can never appear inside them.
Affects both the agent (lynx_agent_app) and dashboard (lynx_dashboard_app)
rotation handlers.
* fix(agent): eliminate shell injection in rootless Podman execution
runuser -l -c "podman args" passes the command through a shell, making
any user-controlled argument a potential injection vector. Replaced with
runuser -u user -- podman arg1 arg2 which hands args directly to execve.
Also explicitly sets HOME and XDG_RUNTIME_DIR since -u does not load the
login environment, which rootless Podman requires.
* fix(agent): add SSRF protection with DNS pinning for binary downloads
Resolves the DNS TOCTOU vulnerability where a hostname could resolve to
a different IP between the validation check and the actual connection,
allowing DNS rebinding to reach internal services.
Fix: resolve hostname once, validate all returned IPs against RFC1918/
loopback/link-local/unspecified ranges, then pin the reqwest client to
the validated IP via .resolve() so no second DNS lookup occurs.
Applies to both the primary update path (update/mod.rs) and the fallback
GitHub API poller (update/fallback.rs). Adds the url crate for URL parsing.
* fix(agent): harden WireGuard handler — interface validation, file permissions, zeroize secrets
* fix(agent): prevent path traversal in nginx cert path construction
* fix(security): heartbeat ACK requires Ed25519 signed command — bearer token alone insufficient
* fix(security): validate audit log hash chain on HTTP sync path
* fix(security): WS origin validation, vps:read permission checks, WG IP error propagation
* fix(auth): per-username rate limit, SHA256 ct_eq for setup token, constant-time IP/UA comparison
* fix(auth): enforce nbf and iat claims in JWT validation — reject future-issued tokens
* fix(security): strict domain and branding input validation — block path traversal vectors
* fix(security): validate IP/CIDR format and port range in nftables rule handlers
* fix(security): add HSTS header to all HTTPS responses
* fix(db): extend cert_type constraint to include cloudflare and custom cert types
* fix(scripts): replace eval PKG_UPDATE with direct case statement — eliminate eval injection
* feat(site): add GitHub Pages landing page
Dark amber-themed static landing with hero, architecture diagram,
feature cards, install guide, and stack badges. Poppins + JetBrains
Mono fonts. .nojekyll disables Jekyll processing.
* fix(ci): close security audit gaps across all components
- Add bun audit (--audit-level high) to dashboard-ui workflow — was
required by project docs, package.json had the script, CI never ran it
- Add cargo audit to lynx-compose workflow — only Rust component without
CVE scanning
- Pin upload-artifact, download-artifact, and action-gh-release to commit
SHAs in release workflows — mutable tags are a supply chain risk
- Verify musl bun binary with SHASUMS256.txt before unzip in
release-dashboard workflow — previously downloaded with no integrity check
- Drop security-events: write from all CI workflows — permission was
declared but SARIF was never uploaded (dead permission, violates least
privilege)
* fix(agent): extend nftables_state chain constraint before seeding output rows
Migration 005 inserted 'lynx-global-output' and 'lynx-local-output'
but the CHECK constraint from migration 004 only permitted 'lynx-global'
and 'lynx-local', causing migration 5 to fail on every fresh install.
Fix: ALTER TABLE to drop and re-add the constraint with all four valid
chain values before the INSERT.
Also applies cargo fmt across agent and dashboard-server (whitespace
and line-length reformats only, no logic changes).
* fix(db): renumber conflicting dashboard migrations and fix IPv6 link-local mask
Migrations 022_nftables_protection and 023_fix_uuid_defaults were added
after dashboard@1.0.1 shipped (which already contains 022_user_preferences
and 023_single_session). sqlx rejected the duplicate version numbers.
Renumbered to 026 and 027 — first available slots after the new 024
(agents_arch) and 025 (domain_cert_types) migrations.
Also fixes a clippy::bad_bit_mask in agent SSRF guard: the fe80::/10
link-local check used mask 0xff00, which zeroes the lower byte and can
never equal 0xfe80. Correct mask for a /10 prefix is 0xffc0.
* fix(server): refactor update params to struct; bypass rate limit in CI tests
perform_dashboard_update had 9 arguments — refactored to
DashboardUpdateParams struct to satisfy clippy::too_many_arguments.
Integration tests share a Redis instance and run in parallel, causing
the per-username login rate limit (10/15min) to be exhausted when
multiple test binaries all log in as testadmin concurrently.
Added LYNX_SKIP_RATE_LIMIT env-var bypass in rate_limit::check() and
set it in the CI test step — production deployments never set this var.
* fix(tests): serialize dashboard-server tests and flush testadmin rate-limit key
The env-var bypass (LYNX_SKIP_RATE_LIMIT) broke redis_fail tests that
rely on Redis being the first failure point to produce 503. Reverted.
Root cause: parallel test binaries all logging in as testadmin exceed the
10/15min per-username limit. Fix:
- Add --test-threads=1 to CI cargo test — serialises tests within each
binary, so at most ~4 concurrent testadmin logins across binaries (well
under the limit of 10).
- Flush rl:login:u:testadmin from Redis in test_state() before each test
so the counter resets between tests running in the same binary.
* fix(ui): remove duplicate migration label props from settings page
* ci(lint-shell): always run shellcheck on PRs to main, not just on .sh changes
* chore(deps): bump x509-parser from 0.16.0 to 0.18.1 in /lynx
Bumps [x509-parser](https://github.com/rusticata/x509-parser) from 0.16.0 to 0.18.1.
- [Changelog](https://github.com/rusticata/x509-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rusticata/x509-parser/commits)
---
updated-dependencies:
- dependency-name: x509-parser
dependency-version: 0.18.1
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /app/* layout with sidebar and auth gate.
* feat(agent): implement agent core with auth, nftables, Podman, metrics, and audit log
Full Lynx agent implementation:
- Ed25519 command authorization: signature + nonce dedup + timestamp ±30s + permission level
- Audit log with SHA-256 hash chaining for tamper detection
- nftables: atomic full-ruleset apply via stdin, inter-org blocking, checksum tracking
- Podman: rootless tenant users (lynx-tenant-{id}) with isolated subuid/subgid ranges
- WebSocket metrics: CPU (2-sample /proc/stat), RAM (/proc/meminfo), disk (statvfs), streamed every 2s
- Heartbeat watchdog: 300s timeout → lockdown mode (rejects all commands)
- PostgreSQL via sqlx with migrations (audit_log + used_nonces tables)
- Bearer token auth with constant-time comparison via `subtle`
feat(dashboard): production compose with 3 isolated networks and external Podman secrets
Replaces single-network compose with:
- lynx-dashboard-db: backend + postgres only
- lynx-dashboard-cache: backend + redis only
- lynx-dashboard-app: frontend + backend (frontend sole external port: 19443)
- All secrets as external Podman secrets (pg-root, pg-pass, redis-pass, api-token, kek, pepper)
- DATABASE_URL_FILE / REDIS_URL_FILE pattern for secret injection
- PostgreSQL init script creates isolated lynx_dashboard_app user
feat(dashboard): install script with secret generation, health-ordered startup, nftables, WireGuard, TLS
- Detects existing install → abort / update / reinstall (requires exact confirmation phrase)
- Generates 8 Podman secrets via pipe (no shell history, subshell isolation, memory overwrite)
- Starts services in dependency order with health-gate: postgres → redis → backend → frontend
- nftables ruleset: table inet lynx-dashboard, ports 22 + 19443 + 51820 UDP
- Self-signed P-256 cert (90 days) + systemd timer for rotation every 80 days
- WireGuard dashboard keypair + PSK stored as Podman secret; PSK shown once in final output
* feat(agent): add agent install script with WireGuard, systemd, Podman DB, and nftables setup
Complete agent VPS install:
- Creates lynx-agent system user with CAP_NET_ADMIN/CAP_SYS_ADMIN/CAP_SETUID/CAP_SETGID
- Allocates subuid/subgid ranges (1000000+65536) for rootless tenant Podman
- Generates 4 Podman secrets via subshell pipe (no shell history trace)
- Starts PostgreSQL container on lynx-agent-db network (port 127.0.0.1:5434)
- Installs binary as systemd service with LoadCredential (secrets in tmpfs)
- WireGuard peer config built from admin-provided dashboard pubkey + PSK
- nftables: lynx-agent table, only WG inbound + SSH, agent API only from WG iface
- Existing install detection with reinstall phrase confirmation
feat(dashboard): agent management API with WG peer management and command relay
- GET/POST /agents — list and register agents (allocates WG IP .2–.254)
- GET/DELETE /agents/{id} — individual agent ops + WG peer removal
- POST /agents/{id}/heartbeat — relay heartbeat to agent via WireGuard, update status
- POST /agents/{id}/cmd — sign command with Ed25519, relay to agent API
- agents + agent_events migration (migration 004)
- crypto/cmd.rs: sign_command builds JWS-style payload (base64url JSON + Ed25519 sig)
- reqwest client for agent HTTP relay over WireGuard
- AppError extended: NotFound, BadRequest, BadGateway, AgentUnavailable
- From<sqlx::Error> impl so ? works in handlers
* feat(ui): add /app/agents page with Server Component, skeleton loading, and i18n
- AgentList: Server Component, fetches GET /agents with 30s revalidation
- Status badges: online (default) / lockdown (destructive) / offline (secondary)
- Empty state matches overview page pattern
- AgentListSkeleton: 3-card skeleton for Suspense boundary
- i18n keys in en.json + es.json (app.agents namespace)
- Sidebar already linked to /app/agents
* feat: implement audit log sync (agent → dashboard) with per-agent sync token auth
Agent side:
- sync/mod.rs: Tokio task runs every 60s, batches unsynced audit_log entries
- Tracks sync cursor in sync_state table (migration 002)
- Config: DASHBOARD_URL + SYNC_TOKEN (optional — sync disabled if absent)
- reqwest client for HTTP POST to dashboard over WireGuard
Dashboard side:
- Migration 005: central audit_log table + sync_token_hash column on agents
- POST /agents/{id}/audit-sync: no JWT auth — uses per-agent sync token
- Sync token generated at registration (SHA-256 hash stored, plain returned once)
- Hash chain preserved: entry_hash/previous_hash stored verbatim from agent
- ON CONFLICT DO NOTHING: idempotent re-sync
- Constant-time token comparison via subtle crate
* feat: add organizations backend + UI
Backend (migration 006):
- organizations table: id (UUID v7), name, slug (unique), owner_id
- organization_members: role-based (owner/admin/member/viewer)
- projects table: belongs to org + agent, unique slug per org
- GET/POST /organizations, GET/DELETE /organizations/{id}
- List scoped to authenticated user's memberships
- Slug validation: lowercase alphanumeric + hyphens only
- Conflict handling: returns 409 on duplicate slug
UI:
- /app/organizations: Server Component, 60s revalidation
- Card grid with slug, member count, org ID
- OrgListSkeleton for Suspense boundary
- i18n: en + es (app.organizations namespace)
* feat(agent): add nftables divergence detection with dashboard alerting
- Background task runs every 60s: compares live nft checksum vs last apply() result
- Mismatch → WARN log + POST /agents/{id}/events with nftables_divergence event
- Auth: same per-agent sync token used by audit-sync
- AppState gains nft_checksum: Arc<Mutex<Option<String>>> for shared last-known-good state
- set_nft_checksum() called after each apply() in nftables apply handler (future)
Dashboard side:
- POST /agents/{id}/events endpoint — agent-callable (sync token auth)
- Validates event type against allowed list, inserts into agent_events
- Same pattern as audit-sync: no user JWT needed
* feat(dashboard/ui): add settings page with session management and key rotation
Sessions list with per-session revoke (DELETE /admin/sessions/{id}).
JWT key rotation button (POST /admin/rotate) clears cookies and redirects
to login — all sessions invalidated on rotation.
i18n: en + es under app.settings namespace.
* feat(agent/dashboard): container management commands + auto-update system
Agent:
- container.deploy (podman compose up via tenant user)
- container.start/stop/remove/restart (podman CLI via runuser)
- container.update (CPU/memory quota, vertical scaling)
- update.self: download binary from GitHub Releases, verify Ed25519 sig,
atomic rename, graceful exit for systemd restart
Dashboard admin:
- GET /admin/update-check: polls GitHub API for latest release
- POST /admin/trigger-update: signs update.self command to online agents
- GET /admin/update-log: recent update history
- migrations/008_update_log.sql: tracks per-agent update outcomes
* feat(dashboard): agent registration dialog, overview stats, events feed
UI:
- Register Agent dialog (POST /agents) shows agent ID, WG IP, sync token
once; token hidden with reveal toggle
- Overview page: online agent count, org count, recent events feed
- i18n en + es for new keys
Backend:
- GET /agents/events: recent agent_events across all agents (limit param)
* feat(dashboard/agent): WireGuard PSK rotation
Dashboard admin rotate with scope wireguard_psks or all:
- Generates new PSK per online agent via wg genpsk
- Updates dashboard WG peer with new PSK
- Sends signed wg.rotate_psk command to each agent
Agent wg.rotate_psk handler:
- Applies new PSK to wg-lynx-agent peer
- Persists via wg-quick save
* feat(ui): org creation dialog, i18n keys for org/agent registration
- Create Organization dialog with auto-derived slug
- Register Agent and Create Org buttons on respective pages
- i18n en+es: organizations.create/slugConflict/createError
* feat(dashboard/agent): internal PKI — CA issues Ed25519 certs to agents
Dashboard:
- crypto/pki: CA Ed25519 keypair, issue_cert, verify_cert
- CA keypair loaded from CA_PRIVATE_KEY/CA_PUBLIC_KEY env (ephemeral in dev)
- Agent registration returns signed cert + CA public key (once)
- cert stored in agents table (migration 009_agent_certs.sql)
- admin rotate certificates scope re-issues certs for all agents
Agent:
- cert.rs: verify dashboard CA cert (CA_PUBLIC_KEY env)
- Cert verification available for command auth second layer
* feat(dashboard): white-label branding from PostgreSQL
Single-row white_label table stores company_name, logo_url, and
three hex palette colors. Public GET /branding serves defaults on
empty DB. Auth-protected PUT /admin/branding (via admin route_layer)
validates hex format before UPSERT.
UI injects --brand-primary/secondary/accent CSS vars on <html> at
layout level (revalidated every 60s). Sidebar header renders
company_name or logo. Settings page adds BrandingForm (Server Action
PUT) with color swatches + inputs.
* feat(orgs): member management — invite, list, remove
Backend: GET/POST /organizations/{id}/members, DELETE
/organizations/{id}/members/{user_id}. Role-gated (owner/admin only
for mutations). Prevents removing org owner. Adds AppError::Forbidden
(403) variant.
UI: org cards now link to detail page. Detail page lists members with
role badges and per-member remove buttons (owner row protected). Invite
dialog: username + role selector. Server actions revalidate page on
mutation.
* feat(orgs): project listing and vertical scaling UI
Backend: GET/GET /organizations/{id}/projects[/{proj_id}] for project
browsing. PUT /organizations/{id}/projects/{proj_id}/resources signs a
container.update command (tenant_id=org_id, cpus, memory_mb) and
relays it to the target agent via WireGuard. Viewer role blocked from
scaling mutations.
UI: org detail page now lists projects as clickable links. Project
detail page exposes ResourceForm — container name + CPU/memory inputs
that call the backend action and toast on result.
* feat(nftables): divergence detection and restore/accept flow
Agent: nftables.apply now returns the rendered string, which is stored
in AppState.nft_last_ruleset alongside the checksum. New handlers:
nftables.restore (re-applies last ruleset via apply_raw) and
nftables.accept (snapshots current live checksum as new expected).
Dashboard: GET /agents/{id}/nftables-status returns the latest
unresolved divergence event (resolved = followed by a restored or
accepted event). POST /agents/{id}/nftables-resolve signs the
appropriate command and records the resolution event.
UI: AgentList fetches nftables status per online agent in parallel.
Diverged agents show an orange alert with Restore/Accept buttons
(NftablesAlert client component, resolveNftables server action).
* feat(orgs): add project creation
POST /organizations/{id}/projects — role-gated (viewer blocked), slug
validated, agent existence checked, 409 on slug conflict.
UI: org detail page shows CreateProjectDialog alongside project list.
Dialog has name + auto-derived slug + agent selector (online agents
first). slugConflict toast on 409.
* feat(containers): deploy and lifecycle management UI
Backend: POST /containers (deploy), GET /containers (list via
container.list), POST /containers/{name}/{action} (start|stop|restart|
remove). All relay signed commands to the project's agent. relay_project_command helper reduces duplication across all container handlers.
UI: project detail page now shows running container list with per-
container action buttons (state-dependent: start for stopped, stop+
restart for running, remove always). DeployForm accepts name, image,
ports, env, cpus, memory_mb.
* feat(ui): persistent footer and dynamic login branding
Sidebar gains a small "Made with love by Jaroc · lynx" footer at the
bottom — always visible in the app area per CLAUDE.md requirement.
Login page fetches company_name from /branding at render time and
interpolates into "Sign in to {company}" — supports white-label
without hard-coding "Lynx". i18n string updated in both locales.
* feat(settings): update check and trigger UI
UpdateSection client component: polls GET /admin/update-check, shows
current vs latest version with up-to-date/update-available badges, and
offers "Trigger update on all agents" button (POST /admin/trigger-update)
when behind latest. Server actions added to settings actions.ts.
* feat(scale): implement cross-agent horizontal scaling via WireGuard data-plane tunnels
Per-project WireGuard tunnel (distinct from management plane) between
Agent-A and Agent-B. Dashboard generates keypairs and PSK, stores tunnel
record, commands both agents to configure the interface, then deploys
N replicas on Agent-B.
- Migration 011: data_plane_tunnels table with status check constraint
- handlers: list/create/teardown tunnel endpoints with role guard
- router: wire horizontal scale routes (GET/POST/DELETE)
- agent: wg.data_plane.setup + wg.data_plane.teardown command handlers
derive interface name from tunnel_id prefix (wg-lynx-dp-{8hex})
- UI: HorizontalScaleSection with add-tunnel dialog and inline teardown
- i18n: en + es keys for horizontal scaling
* feat(audit): add per-agent audit log viewer
GET /agents/:id/audit-log returns paginated entries (limit/offset) from
the central audit_log table. Entry hash truncated to 16 chars — full
chain verification is server-side only; UI shows fingerprint for spot
checks.
- Backend: list_audit_log handler + route /{id}/audit-log
- UI: server-rendered table page with pagination (offset param)
- Agent card: link to audit log per agent
- i18n: en + es keys
* feat(domain): add custom domain configuration with Let's Encrypt and HSTS
Singleton domain_config table tracks domain, cert type, expiry, HSTS
state, and port 19443 status. Setup is async (returns 202 immediately)
so certbot wait does not block the HTTP response.
- Migration 012: domain_config singleton row with status check constraint
- GET/POST /domain, POST /domain/verify, /domain/hsts, /domain/close-port
- nginx compose YAML + conf generated in code (no external templates)
- certbot webroot challenge via lynx-nginx container
- Port 19443 closed via nft rule (irreversible remotely by design)
- HSTS only toggleable when status = active
- UI: DomainSection in settings — status badge, setup form, verify DNS,
HSTS toggle, port-close with confirmation dialog
- i18n: en + es
* feat(migration): implement dashboard-to-dashboard migration flow
Zero-downtime dashboard relocation from VPS-A to VPS-B. Uses a one-time
migration token (SHA-256 stored, never in DB plaintext) to gate the
data transfer over HTTPS. pg_dump → VPS-B restore → agent notification
→ per-agent confirmation → VPS-A shutdown.
- Migration 013: migration_state singleton table with role + progress tracking
- GET/POST /migration, /migration/prepare, /migration/start, /migration/abort,
/migration/confirm-shutdown (auth-protected)
- POST /migration/receive, /migration/agent-confirm (token-gated, no user auth)
- Agent: dashboard.migrate command → calls /migration/agent-confirm on VPS-B
using agent's sync token
- UI: MigrationSection in settings with dual-pane source/target flow,
progress tracking, abort control, shutdown confirmation
- i18n: en + es
* feat(auth): add password change and profile endpoints
POST /auth/change-password: verifies current password, hashes new one,
then bulk-deletes all sessions for the user and logs password_changed
reason per CLAUDE.md session invalidation rules. Revokes current access
token in Redis.
GET /auth/me: returns id + username for the authenticated user.
- Profile section in settings shows username + change-password form
- Password change redirects to login (all sessions invalidated)
- i18n: en + es
* feat(settings): add key rotation history to security section
* feat(pki): push cert.update to online agents on rotation; add CI workflows
- Add cert.update dispatch arm to agent — verifies new cert against CA
public key, persists to /etc/lynx/cert.json for durability across restarts
- PermissionLevel derives PartialOrd/Ord so permission comparisons compile
- rotate_agent_certs now pushes cert.update to online agents immediately
after DB update, so live agents adopt the new cert without restart
- Add rotate_expiring_certs() — called automatically during trigger_update
for any agent cert expiring within 14 days (CLAUDE.md: automatic cert
renewal during updates when near expiry)
- CI: dashboard-server workflow (fmt, audit, check/clippy with pg service)
- CI: dashboard-ui workflow (bun install + tsc --noEmit)
- CI: agent workflow (fmt, audit, check/clippy; integration tests left as
commented stub pending self-hosted runner per CLAUDE.md)
- cargo fmt applied across all packages (no logic changes)
* fix(dashboard): use base64url unpadded encoding for signed commands
Dashboard encoded command payload/signature with padded Base64Url;
agent decoded with Base64UrlUnpadded — padding mismatch caused all
commands to be rejected with 'payload: invalid base64url'.
Verified end-to-end on VM: cert.update command now reaches agent,
signature verified, cert persisted to /etc/lynx/cert.json.
* feat(dashboard): add background heartbeat scheduler
Polls all non-lockdown agents every 30s via WireGuard, updating
status and last_heartbeat in DB. Uses tokio interval with
MissedTickBehavior::Skip to avoid thundering-herd on startup.
First tick fires immediately so agent status is current without
requiring a manual heartbeat call.
* fix(dashboard): shorten WireGuard interface name to wg-lynx-dash
Linux IFNAMSIZ=16; 'wg-lynx-dashboard' (18 chars) exceeds the limit.
Renamed to 'wg-lynx-dash' (12 chars) in both wg.rs and admin/handlers.
Verified wg.rotate_psk command reaches agent, updates PSK on wg-lynx-agent,
and tunnel remains alive after rotation.
* fix(dashboard): correct Redis key pattern in JWT session flush
rotate_jwt_sessions was flushing 'jti:*' but access tokens are
stored under 'access:{jti}'. Old tokens remained valid after
rotation. Fixed pattern to 'access:*'.
Verified: after jwt_keys rotation, old tokens return 401; fresh
login issues working tokens.
* feat(dashboard): add ip_pool allocator, logs/reset-admin-password CLI, SSRF-safe downloader
- Migration 018: ip_pool table with SELECT FOR UPDATE allocator replaces
linear scan of agents.wg_ip; release_ip() on agent delete
- Dashboard backend CLI: `logs` subcommand (Podman container logs) and
`reset-admin-password --username` (SSH-only, sets force_password_change)
- Agent CLI: `logs [--follow] [--errors] [--since]` via journalctl
- update.rs: resolve DNS once, validate all IPs against RFC1918/loopback/
link-local before connecting (TOCTOU-safe); pinned reqwest resolver
- update.rs: now declares `mod update` in main.rs (was missing)
- Workspace: add clap, url, ipnetwork sqlx feature
* feat(dashboard): security alerts, headers, startup health guard, intercepted alerts
- Add security_alerts table (migration 019) + alerts.rs module
- Fire alert on login rate limit hit (rate_limit_hit kind)
- Fire alert on session intercepted (IP/UA mismatch in middleware)
- GET /admin/alerts + POST /admin/alerts/:id/acknowledge endpoints
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers
to all responses via tower-http SetResponseHeaderLayer
- update.rs: spawn_startup_health_guard() — polls /health every 2s for
30s after boot; if unhealthy restores .prev, writes /etc/lynx/CRITICAL
- reqwest: add rustls-tls feature to dashboard server
* feat(agent): command rate limiter (100/min) with rejected_rate_limit audit log
- AppState: cmd_rate window counter + cmd_rejected_count/window for alert threshold
- execute_command: reject when window exceeds 100 commands/min; log audit entry
with result=rejected_rate_limit; warn when count >= 3 in current minute
* feat(domain): route nginx/certbot/nftables ops through local agent
All domain management operations now go through the local agent
via signed Ed25519 commands instead of direct OS calls from the backend:
- nginx.deploy: deploy nginx container with image digest
- nginx.update_config: reload nginx after cert/HSTS changes
- certbot.obtain: run certbot webroot challenge on the agent VPS
- nftables.close_setup_port: close port 19443 after domain goes live
Added migration 020 adding is_local_agent BOOLEAN to agents table.
nginx.rs in domain handlers is now a pure config-generator with no
OS side effects. Fixed nginx image to use digest instead of mutable tag.
* refactor(agent): split handlers.rs into handlers/ module
Replaces the monolithic handlers.rs with a handlers/ directory organized
by responsibility, following the project's folder organization rule.
Also adds conflict.rs (incompatible software detection) and nginx.rs
(nginx watchdog) that were created but not yet committed.
* feat: add missing files from prior implementation sessions
- agent: .env.example, migration 003 for nginx_configs table
- dashboard UI: public assets (favicon, icons, flags for i18n selector)
- dashboard UI: admin alerts action + AlertsPanel component
- dashboard UI: Zod schemas organized by feature domain
* feat: implement X.509 mTLS, heartbeat scheduler, PKI, and frontend improvements
Backend (dashboard server):
- X.509 CA generation and agent cert issuance (pki.rs additions)
- mTLS client for agent HTTP calls (agents/client.rs)
- X509 fields added to Config (loaded from Podman secrets or ephemeral)
- Heartbeat scheduler polls agents every 30s, dispatches update.self
- Agent registration response includes TLS cert/key/ca DER (base64)
- WireGuard PSK rotation refactored to use mTLS client
- Cert rotation in admin handlers uses mTLS client
- Background heartbeat task spawned in main.rs
- Redis key pattern fix in JWT session flush (rotation.rs)
- WireGuard interface name shortened to wg-lynx-dash
Agent:
- TLS cert/key/ca loaded from env-configured files (config.rs)
- mTLS TLS acceptor built from loaded certs (main.rs)
- serve_tls() with manual hyper accept loop + axum body bridge
- Cargo.toml: hyper + hyper-util + tower added
- WireGuard PSK and WireGuard interface changes
- Setup agent script updates
Frontend:
- Login / register forms with Zod validation and Server Actions
- Dashboard pages: agents, organizations, projects, settings
- Sidebar navigation, alert components
- Branding, domain, migration, rotation, update UI sections
- i18n messages (en/es)
- biome.json, next.config.ts updates
- proxy.ts auth middleware
- Removed default Next.js placeholder SVGs
* feat(scheduler): full 90-day rotation — JWT flush + WireGuard PSKs + certs
Scheduled rotation now covers all three planes:
- JWT session flush (forces re-login for all users)
- WireGuard PSK rotation (coordinated, no tunnel downtime)
- mTLS cert rotation (certs expiring within 30 days)
Made rotate_jwt_sessions and rotate_wireguard_psks pub for scheduler use.
Rotation log scope changed from 'certificates' to 'all' for scheduled runs.
Also: is_local_agent flag added to RegisterAgentRequest so the install
script can mark the local dashboard agent at registration time.
* feat(domain): custom cert upload with PEM validation (Cloudflare + own cert)
POST /domain/cert/upload accepts cloudflare or custom cert type.
Backend validates before sending to agent:
- PEM parseable
- not_before <= now <= not_after (expiry check)
- SAN or CN matches configured domain
- cert/key pair match (custom certs only, via x509-parser pubkey comparison)
- max 64 KB per field
Agent gains nginx.install_cert command: writes cert+key to
/etc/lynx/nginx/certs/{domain}/ and reloads nginx.
nginx_conf_with_cert() generates nginx config with arbitrary cert paths.
x509-parser added as explicit workspace dep (was already transitive).
* fix(domain): set_hsts uses correct cert path for cloudflare/custom cert type
* feat(ws): add persistent WebSocket channel between agent and dashboard
Agent initiates a WS connection to dashboard on startup using its sync
token. Heartbeats and audit log entries now flow over the persistent WS
instead of HTTP polling. Dashboard can push signed commands via WS with
a 30-second response timeout, falling back to HTTP POST if no WS
connection is active.
Dashboard side:
- agents/ws_hub.rs: WS handler at /agents/{id}/ws, auth via sync_token
hash, stores live connections in AppState.agent_ws_conns
- push_command() sends a command envelope with a correlation UUID and
waits on a oneshot for the agent's command_response frame
- AppState gains agent_ws_conns: Arc<RwLock<HashMap<Uuid, AgentWsConn>>>
- send_command handler tries WS first, falls back to HTTP
Agent side:
- ws_client.rs: connects to ws://{dashboard}/agents/{id}/ws, sends
heartbeats every 30s, receives command frames, reconnects with
exponential backoff (5s → 300s)
- system.rs refactored: command_dispatch + run_verified_command shared
between HTTP handler and WS client — no logic duplication
- Lockdown check applies identically on both paths
* feat(ws): skip HTTP heartbeat poll for WS-connected agents
Heartbeat scheduler now checks AppState.agent_ws_conns before HTTP
polling. Agents with an active WS connection send proactive heartbeats
so no HTTP poll is needed. update.self is still dispatched (via WS)
when the scheduler detects a version mismatch.
* feat(agent+dashboard): nftables 3-chain architecture, real-time metrics WS pipeline
Agent:
- Refactor nftables module to lynx-base/lynx-global/lynx-local 3-chain model
- handle_nftables_apply now supports chain-specific updates {chain, rules} alongside
full apply {wireguard_port} for bootstrap; stores wg_port in AppState
- Divergence detector: auto-restore from last known-good ruleset on any chain mismatch;
apply emergency ruleset if restore fails; per-chain detection for severity logging
- Store post-apply checksum from live kernel state (nft -j list table) to ensure
divergence checker uses matching format; remove checksum_of() that hashed input text
- Stream system metrics (CPU/RAM/disk) via WS every 5 seconds alongside heartbeats
Dashboard backend:
- Add per-agent broadcast channels (broadcast::Sender<Arc<String>>) in AppState for
metric fan-out from agent WS to frontend browser WS clients
- ws_hub: create/drop broadcast channel on agent connect/disconnect; fan-out metrics
messages to all subscribed frontend clients
- New GET /agents/{id}/metrics/ws — browser WebSocket endpoint (JWT auth via cookie)
- Auth middleware: fall back to access_token cookie for WebSocket upgrade requests
since browsers cannot send custom headers on WS connections
- nftables rule management: migration 021, CRUD handlers, push_global/push_local,
rules_to_nft_chain() renderer; global_rule_sync tracking per-agent sync state
- Agent reboot handler; NftRulesPanel + AgentDetailActions frontend components
- Certificate upload section in domain settings (Cloudflare Origin + custom certs)
Dashboard frontend:
- Agent detail page: status/WG info, live metrics panel, nftables rule panels, actions
- useAgentMetrics hook: auto-reconnecting WS subscription with exponential backoff
- MetricsPanel: CPU/RAM/disk with progress bars and color-coded severity
- Next.js rewrites: proxy /api/* to backend (enables browser→backend WS via same origin)
- i18n: metrics keys in en.json + es.json
* feat(events): real-time agent events pipeline to browser WS
- Add global events broadcast channel (broadcast::Sender<Arc<String>>) to AppState
- ws_hub: record connected/disconnected events in agent_events + broadcast to browser
- heartbeat.rs: detect online→offline transition, fire heartbeat_lost event + alert
- events.rs: broadcast_event() helper; receive_event broadcasts after DB insert
- New GET /agents/events/ws endpoint (JWT auth) — streams all agent events to browser
- sign_command_system() in crypto/cmd.rs (nil UUID sentinel for scheduler-triggered cmds)
- rule_line() in nftables/mod.rs for system-level chain body generation
- push_pending_global_sync compilation fixed (SignedCommand→Value, correct function refs)
- Frontend useAgentEvents hook with exponential backoff reconnect
- NotificationBell component in Sidebar: badge count + popover for alert events
- i18n: en/es notification strings (heartbeat_lost, lockdown, divergence, conflicting_software)
* feat(dashboard): add admin user and role management
- Add require_admin middleware (checks *:* permission after require_auth)
- Admin router split: require_admin for admin-only routes, require_auth for own-session routes
- New admin handlers: list/delete users, CRUD roles, manage role permissions and user roles
- /auth/me now returns is_admin flag
- Frontend: Admin page at /app/admin (user table + role management with inline CRUD)
- Sidebar shows Admin nav link only for users with *:* permission
- Guard: non-admins redirected from /app/admin at server-render time
- Last-admin guards on delete user, delete role, remove role permission, remove own admin role
* feat(dashboard): add theme system and user preferences
- Migration 022: user_preferences table (theme + locale per user)
- Backend: GET/POST /auth/me/preferences (require_auth via protected router)
- ThemeProvider (next-themes, dark/light/system, SSR-safe with suppressHydrationWarning)
- ThemeToggle dropdown in sidebar header persists to DB + theme_preference cookie
- Locale layout reads theme_preference cookie as defaultTheme to prevent flash
* feat(dashboard): add locale switcher and i18n sign-out in sidebar
- LocaleSwitcher dropdown with flag icons, navigates to same path in new locale
- Persists locale preference to DB via updateLocaleAction
- Sign out button now uses i18n (t("signOut")) instead of hardcoded English
* feat(dashboard): single-session mode and theme-on-login
- Add single_session column to users table (migration 023)
- Login handler returns theme preference so client sets cookie immediately
- Login action sets theme_preference cookie from login response
- POST /auth/me/single-session endpoint to toggle per-user
- GET /auth/me now returns single_session and is_admin fields
- user_preferences migration (022) applied; get/update preferences handlers complete
- SingleSessionToggle client component in settings profile section
- i18n keys for single-session toggle (en + es)
* test(dashboard): add integration test suite for auth, admin, rotation, and security
Adds tests/auth.rs (25 tests), tests/admin.rs (10 tests), tests/rotation.rs
(5 tests), tests/security.rs (13 tests), and tests/redis_fail.rs (5 tests).
Key infrastructure decisions:
- Login helpers return (token, ip) so protected-route calls include the same
x-real-ip that minted the JWT — fixes IP hash mismatch in require_auth
- helpers::seed_test_admin resets force_password_change=false on testadmin
after each binary run, preventing force-password-change-all from poisoning
subsequent test binaries
- Minimal fake Redis server that responds +OK to CLIENT SETINFO setup
commands (redis-rs 0.27 always sends these) then returns errors for all
subsequent commands, allowing ConnectionManager::new() to succeed while
making every auth command fail → 503
- rustls ring CryptoProvider installed once per test process via
install_default() (ignores AlreadySet) to prevent race on parallel tests
- UUID suffix extended from 8 to 16 chars to eliminate timestamp collisions
when tests run at the same millisecond across parallel test binaries
- needs_scheduled_rotation counts both 'scheduled' and 'update' entries as
resetting the 90-day clock; inline unit tests verify the guard logic
* test(dashboard/server): add unit tests for crypto, validation, and auth logic
- crypto/{hash,jwt,kek,password}.rs: inline unit tests covering roundtrips,
tamper detection, constant-time equality, and edge cases
- auth/validate.rs: tests for username/email/password validation rules
- auth/session.rs: rotate_refresh now returns bool (true = swapped, false =
concurrent request already consumed the token) — 0 rows affected = 401
- auth/handlers.rs: handle rotate_refresh returning false with Unauthorized
- main.rs: moved module declarations to lib.rs; main uses the public library
crate so integration tests can import build_router and AppState directly
* test(dashboard/ui): add Vitest unit tests and Playwright E2E setup
- vitest.config.ts: jsdom environment, globals, setup file, aliased @
- src/__tests__/setup.ts: global test setup
- src/__tests__/(auth)/schemas.test.ts: login/register schema validation
- src/__tests__/(dashboard)/app/organizations/schemas.test.ts: org schemas
- src/__tests__/(dashboard)/app/settings/schemas.test.ts: settings schemas
- playwright.config.ts: E2E config targeting http://localhost:19443
- src/e2e/auth.test.ts: login, register, and logout E2E flows
* ci: add CI workflows for agent, dashboard-server, and dashboard-ui
- dashboard-server: full integration test job with PostgreSQL 18 and Redis 8 service containers; runs migrations and cargo test
- dashboard-ui: Vitest unit test job and Playwright E2E scaffold
- agent: cargo-audit vulnerability scan job
- setup-agent.sh: ANSI colors, section headers, NTP check, incompatible software detection/removal, NAT detection for PersistentKeepalive
- setup-dashboard.sh: same shell script improvements plus pg-tde key generation and Podman secret handling
- agent auth module: 131-line unit test suite for signed command verification (Ed25519, nonce, timestamp, permission checks)
* refactor(auth): split handlers.rs into handlers/ submodule; add WS origin validation
auth/handlers split by responsibility:
- register.rs: register + bootstrap_admin
- login.rs: login (rate-limit, anti-enum, single-session)
- session.rs: logout + refresh (token rotation)
- me.rs: GET /auth/me
- password.rs: POST /auth/change-password
- preferences.rs: GET/POST /auth/me/preferences, POST /auth/me/single-session
- mod.rs: re-exports + shared helpers (build_jwt_keys, extract_ip, extract_ua)
WebSocket origin validation (CSWSH prevention, security.md §12.4):
- events_ws: validate_ws_origin checks Origin header on WS upgrade
- metrics_ws: reuses validate_ws_origin from events_ws
- Absent Origin → allow (non-browser clients, integration tests)
- Domain configured → only https://{domain} allowed
- No domain → any https:// allowed (direct IP:19443 access)
* fix(security): audit log hash chain verification + offline agent pending_sync
audit_log hash chain (security.md §12.10):
- store_audit_entries now fetches last known entry_hash for the agent before
inserting any batch; verifies each entry's previous_hash matches in order
- On mismatch: rejects entire batch, fires audit_integrity_failure alert,
inserts agent_events row, broadcasts to frontend — no partial inserts
- AuditSyncEntry derives Clone to allow in-order sorting of batch
global_rule_sync pending for offline agents (agent.md):
- push_global_rules now inserts global_rule_sync rows with synced_at=NULL
for every offline agent after pushing to online agents
- push_pending_global_sync on WS reconnect finds these rows and pushes
the full current global ruleset — no rules lost during agent downtime
* fix(ui): cursor-pointer + disabled:cursor-not-allowed on Button; robots metadata object format
Button base variant: add cursor-pointer (interactive feedback) and
disabled:cursor-not-allowed (overrides pointer-events-none gap in some
browsers where the parent still shows default cursor).
layout.tsx: robots changed from string "noindex, nofollow" to object
{ index: false, follow: false } per Next.js Metadata type.
* feat(agent): split metrics by frequency; fix WireGuard config path
Metrics frequency (agent.md spec):
- CPU/RAM/disk: every 5 seconds (was 2s uniform)
- Container stats via podman stats: every 10 seconds (was missing entirely)
- ws_client.rs: separate container_ticker at 10s; sends container_metrics frame
- handlers/metrics.rs: separate system/container loops with correct intervals
- metrics/mod.rs: add ContainerMetrics + ContainerStat types; sample_containers()
runs podman stats --no-stream --format json and parses CPU/memory
WireGuard config path (agent.md spec):
- Source of truth: /etc/lynx/wireguard/lynx-wg.conf (owned by lynx-agent, 600)
- Symlink: /etc/wireguard/wg-lynx-agent.conf -> /etc/lynx/wireguard/lynx-wg.conf
for wg-quick compatibility (systemctl enable wg-quick@wg-lynx-agent)
* feat(dashboard): close remaining CLAUDE.md gaps
- alerts::fire now accepts &AppState and broadcasts security_alert events
via events_tx to all subscribed frontend WebSocket sessions
- 90-day scheduler rotates PostgreSQL app-user password and Redis password
(both via Podman secret --replace); manual rotate_keys "all" scope does
the same
- LoginRequest carries redirect_to (relative paths only; external URLs
silently discarded) returned in login response
- extract_ip prefers IPv4 over IPv6 from x-forwarded-for list per spec
* fix(lint): resolve all clippy -D warnings + fmt in agent and server
- Auto-fix and manual fix of 44 dashboard-server clippy errors:
useless anyhow conversions, redundant closures, needless borrows,
redundant pattern matching, empty doc comment line
- Auto-fix and manual fix of 27 agent clippy errors:
useless conversions, collapsible if/match, redundant closure,
PathBuf→Path, dead_code on future-use helpers
- cargo fmt applied to both crates — all diffs resolved
* test(dashboard): fix all 17 Playwright E2E tests
- Add custom Zod error messages to register schema matching i18n strings
- Add [locale]/page.tsx to handle redirect from /en → /en/login
- Fix submit-button test: delay POST route instead of invalid RSC mock
- Add PLAYWRIGHT_NO_SERVER env var support in playwright.config.ts
* fix(dashboard): install rustls ring provider at startup
Required for TLS code paths — prevents panic when rustls tries to
auto-select a crypto provider and ring is not the default.
* ci: add toolchain: stable to all dtolnay/rust-toolchain usages
The action requires a 'toolchain' input. Missing input caused
all Rust CI jobs to fail immediately after startup_failure was
fixed by adding third-party actions to the allowlist.
* fix(ci): remove sqlx-mysql CVE and fix compose formatting
- Set sqlx default-features=false in workspace dep to exclude mysql/sqlite
drivers. Adds macros feature explicitly. Eliminates RUSTSEC-2023-0071
(Marvin Attack in rsa crate pulled in by sqlx-mysql).
- Add audit.toml ignoring RUSTSEC-2023-0071 for residual Cargo.lock entry:
sqlx-mysql remains as a resolved optional dep but is not compiled
(verified via cargo tree --workspace).
- Run cargo fmt on lynx-compose package to pass CI fmt check.
* fix(ci): ignore RUSTSEC-2023-0071 in cargo audit
sqlx-mysql is an unresolvable optional dep in Cargo.lock (sqlx-macros-core
pulls it for compile-time query support) but is not compiled into any binary.
No fixed upgrade available for rsa 0.9.10 — advisory explicitly ignored.
* fix(security): validate IDs as UUIDs before URL interpolation in Server Actions
Adds validateId() helper that rejects non-UUID strings, applied to all
agentId/orgId/projId/tunnelId/ruleId/sessionId/userId parameters before
they are interpolated into fetch URLs. Eliminates CodeQL SSRF alerts on
user-controlled URL paths.
* ci(dashboard-ui): pin all action SHAs; quote CI env var as string
* fix(ui): add validateName() and inline container URL builds to eliminate SSRF alerts
Adds validateName() to api.ts for container name validation.
Removes base()/post() helpers from containers.ts; inlines fetch calls
with explicit validateId/validateName so CodeQL can trace all URL segments.
* test(e2e): waitForURL on root redirect test to handle RSC redirect timing
* fix(ui): use .bind() for local nft actions to avoid passing arrow fns to Client Component
* fix(ui): pass placeholder values to migrationAgentsProgress to satisfy next-intl interpolation
* ci: add explicit permissions block to all workflows (CodeQL hardening)
* chore: remove tracked junk files and update gitignore
Remove .fuse_hidden FUSE temp files and .playwright-mcp session artifacts
accidentally committed. Add patterns to .gitignore to prevent recurrence:
.fuse_hidden*, .playwright-mcp/, playwright-report/, test-results/.
* fix(gitignore): correct playwright-mcp ignore pattern
* refactor: update UI components and dependencies across the dashboard application
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#7)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(dashboard): remove development mode, production only
Delete docker-compose.dev.yml, remove dead /api/* rewrite from next.config.ts
(Next.js rewrites don't proxy WebSocket; nginx handles routing in production),
drop dev/test:watch/test:e2e:ui scripts from package.json, simplify
playwright.config.ts to always use bun run start, clean up .env.example.
* feat(nftables): add output chain support and seed global protection rules
- Add port_end and direction columns to nftables_rules; extend kind CHECK
with 8 new types: drop_invalid_state, tcp_flag_null, tcp_flag_xmas,
tcp_flag_ack_new, icmp_ping_limit, allow_icmp_errors, allow_ndp,
block_output_port
- Seed 7 input protection rules and 21 output port-block rules in lynx-global
(SMTP, Telnet, FTP, NetBIOS, SMB, IRC, RPC, TFTP, X11, SOCKS, LDAP, mDNS,
BitTorrent) so all fresh installs start with hardened defaults
- Add rules_to_nft_output_chain() and port_spec() to nftables compiler;
push_both_chains() sends input + output signed commands per agent
- Agent: add nft_global_output_body and nft_local_output_body to AppState;
render lynx-output chain from both; persist to nftables_state DB
- Agent migration 005: seed lynx-global-output and lynx-local-output rows
* style(ui): apply biome formatting across dashboard ui
* fix: replace gen_random_uuid() with uuidv7() — all tables must use UUID v7
gen_random_uuid() generates v4 UUIDs. The project rule is UUID v7 everywhere.
PostgreSQL 18 provides uuidv7() built-in.
- 019_security_alerts, 003_nginx_configs: fix DEFAULT in CREATE TABLE
- 015_roles_permissions, 022_nftables_protection: fix uuidv7() in seeded VALUES
- New migrations 023 and 006: ALTER TABLE to fix DEFAULT on already-created tables
in existing databases
* feat(ui): switch font from Geist to Poppins
* chore(github): add repo metadata files
Add CODEOWNERS, issue template, CONTRIBUTING, README, and SECURITY policy.
* ci: add URL audit script and job to CI pipelines
Ensure all outbound download URLs in update modules are from allowed
GitHub domains. Script scans a fixed set of files; unsupported URLs
require explicit suppression comment or ALLOWED update.
* ci: add release workflows for agent and dashboard
Build musl static binaries for x86_64 and arm64, sign each artifact
with Ed25519 (RELEASE_SIGN_KEY secret), and publish to GitHub Releases.
Dashboard workflow also bundles the Next.js standalone binary and
static assets tarball, each signed separately.
* chore(deps): scan full cargo workspace in dependabot daily
Point cargo ecosystem at workspace root /lynx instead of a single
member crate. Switch to daily schedule and use the new rust label.
* feat(db): add arch column to agents table
Stores the agent architecture (x86_64 / arm64) reported via heartbeat
so the dashboard can build the correct GitHub Release download URL.
* feat(agent): report arch in heartbeat, hardcode release key, add fallback updater
- Heartbeat now includes arch field (x86_64 / arm64)
- Release verify key hardcoded in binary; removed env-var lookup
- SSRF guard: validate_github_url added to update::perform_update
- Track last_dashboard_contact timestamp in state
- Fallback updater: if dashboard absent >6h, poll GitHub directly
and self-update without waiting for a dashboard command
* feat(dashboard): track agent arch and use in arch-aware update dispatch
- ws_hub: persist arch from heartbeat into agents table
- heartbeat + scheduler: read arch from DB when building download URL
- Scheduler now runs release check immediately at startup instead of
waiting for the first hourly tick
* feat(dashboard): implement full binary swap pipeline for releases
- update.rs: download+verify all artifacts before touching any files,
swap frontend binary and extract assets tarball, then swap backend
and exit for Podman restart. Uses Podman socket to stop/start the
frontend container during swap.
- docker-compose: replace build-from-source with prebuilt alpine
containers that mount binaries from host paths. healthcheck uses
wget (present in alpine) instead of curl.
- setup-dashboard.sh: detect arch, fetch latest release tag, download
and verify (Ed25519) backend + frontend binary + assets at install
time before starting services.
* fix: strengthen security hardening across agent and dashboard, including improved input validation, secure file permissions, SSRF protection, constant-time authentication checks, and hardened WebSocket origins.
* refactor: enhance system security with constant-time cryptographic checks, input validation, and updated contribution documentation.
* fix(security): prevent SQL injection in DB password rotation
ALTER USER does not support parameterized queries. Previous code used
string interpolation with single-quote escaping that could be bypassed.
Replaced with dollar-quoting ($$hex$$) — passwords are hex-encoded
[0-9a-f] so the dollar sign can never appear inside them.
Affects both the agent (lynx_agent_app) and dashboard (lynx_dashboard_app)
rotation handlers.
* fix(agent): eliminate shell injection in rootless Podman execution
runuser -l -c "podman args" passes the command through a shell, making
any user-controlled argument a potential injection vector. Replaced with
runuser -u user -- podman arg1 arg2 which hands args directly to execve.
Also explicitly sets HOME and XDG_RUNTIME_DIR since -u does not load the
login environment, which rootless Podman requires.
* fix(agent): add SSRF protection with DNS pinning for binary downloads
Resolves the DNS TOCTOU vulnerability where a hostname could resolve to
a different IP between the validation check and the actual connection,
allowing DNS rebinding to reach internal services.
Fix: resolve hostname once, validate all returned IPs against RFC1918/
loopback/link-local/unspecified ranges, then pin the reqwest client to
the validated IP via .resolve() so no second DNS lookup occurs.
Applies to both the primary update path (update/mod.rs) and the fallback
GitHub API poller (update/fallback.rs). Adds the url crate for URL parsing.
* fix(agent): harden WireGuard handler — interface validation, file permissions, zeroize secrets
* fix(agent): prevent path traversal in nginx cert path construction
* fix(security): heartbeat ACK requires Ed25519 signed command — bearer token alone insufficient
* fix(security): validate audit log hash chain on HTTP sync path
* fix(security): WS origin validation, vps:read permission checks, WG IP error propagation
* fix(auth): per-username rate limit, SHA256 ct_eq for setup token, constant-time IP/UA comparison
* fix(auth): enforce nbf and iat claims in JWT validation — reject future-issued tokens
* fix(security): strict domain and branding input validation — block path traversal vectors
* fix(security): validate IP/CIDR format and port range in nftables rule handlers
* fix(security): add HSTS header to all HTTPS responses
* fix(db): extend cert_type constraint to include cloudflare and custom cert types
* fix(scripts): replace eval PKG_UPDATE with direct case statement — eliminate eval injection
* feat(site): add GitHub Pages landing page
Dark amber-themed static landing with hero, architecture diagram,
feature cards, install guide, and stack badges. Poppins + JetBrains
Mono fonts. .nojekyll disables Jekyll processing.
* fix(ci): close security audit gaps across all components
- Add bun audit (--audit-level high) to dashboard-ui workflow — was
required by project docs, package.json had the script, CI never ran it
- Add cargo audit to lynx-compose workflow — only Rust component without
CVE scanning
- Pin upload-artifact, download-artifact, and action-gh-release to commit
SHAs in release workflows — mutable tags are a supply chain risk
- Verify musl bun binary with SHASUMS256.txt before unzip in
release-dashboard workflow — previously downloaded with no integrity check
- Drop security-events: write from all CI workflows — permission was
declared but SARIF was never uploaded (dead permission, violates least
privilege)
* fix(agent): extend nftables_state chain constraint before seeding output rows
Migration 005 inserted 'lynx-global-output' and 'lynx-local-output'
but the CHECK constraint from migration 004 only permitted 'lynx-global'
and 'lynx-local', causing migration 5 to fail on every fresh install.
Fix: ALTER TABLE to drop and re-add the constraint with all four valid
chain values before the INSERT.
Also applies cargo fmt across agent and dashboard-server (whitespace
and line-length reformats only, no logic changes).
* fix(db): renumber conflicting dashboard migrations and fix IPv6 link-local mask
Migrations 022_nftables_protection and 023_fix_uuid_defaults were added
after dashboard@1.0.1 shipped (which already contains 022_user_preferences
and 023_single_session). sqlx rejected the duplicate version numbers.
Renumbered to 026 and 027 — first available slots after the new 024
(agents_arch) and 025 (domain_cert_types) migrations.
Also fixes a clippy::bad_bit_mask in agent SSRF guard: the fe80::/10
link-local check used mask 0xff00, which zeroes the lower byte and can
never equal 0xfe80. Correct mask for a /10 prefix is 0xffc0.
* fix(server): refactor update params to struct; bypass rate limit in CI tests
perform_dashboard_update had 9 arguments — refactored to
DashboardUpdateParams struct to satisfy clippy::too_many_arguments.
Integration tests share a Redis instance and run in parallel, causing
the per-username login rate limit (10/15min) to be exhausted when
multiple test binaries all log in as testadmin concurrently.
Added LYNX_SKIP_RATE_LIMIT env-var bypass in rate_limit::check() and
set it in the CI test step — production deployments never set this var.
* fix(tests): serialize dashboard-server tests and flush testadmin rate-limit key
The env-var bypass (LYNX_SKIP_RATE_LIMIT) broke redis_fail tests that
rely on Redis being the first failure point to produce 503. Reverted.
Root cause: parallel test binaries all logging in as testadmin exceed the
10/15min per-username limit. Fix:
- Add --test-threads=1 to CI cargo test — serialises tests within each
binary, so at most ~4 concurrent testadmin logins across binaries (well
under the limit of 10).
- Flush rl:login:u:testadmin from Redis in test_state() before each test
so the counter resets between tests running in the same binary.
* fix(ui): remove duplicate migration label props from settings page
* ci(lint-shell): always run shellcheck on PRs to main, not just on .sh changes
* chore(deps): bump tower-http from 0.6.10 to 0.6.11 in /lynx
Bumps [tower-http](https://github.com/tower-rs/tower-http) from 0.6.10 to 0.6.11.
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.10...tower-http-0.6.11)
---
updated-dependencies:
- dependency-name: tower-http
dependency-version: 0.6.11
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /app/* layout with sidebar and auth gate.
* feat(agent): implement agent core with auth, nftables, Podman, metrics, and audit log
Full Lynx agent implementation:
- Ed25519 command authorization: signature + nonce dedup + timestamp ±30s + permission level
- Audit log with SHA-256 hash chaining for tamper detection
- nftables: atomic full-ruleset apply via stdin, inter-org blocking, checksum tracking
- Podman: rootless tenant users (lynx-tenant-{id}) with isolated subuid/subgid ranges
- WebSocket metrics: CPU (2-sample /proc/stat), RAM (/proc/meminfo), disk (statvfs), streamed every 2s
- Heartbeat watchdog: 300s timeout → lockdown mode (rejects all commands)
- PostgreSQL via sqlx with migrations (audit_log + used_nonces tables)
- Bearer token auth with constant-time comparison via `subtle`
feat(dashboard): production compose with 3 isolated networks and external Podman secrets
Replaces single-network compose with:
- lynx-dashboard-db: backend + postgres only
- lynx-dashboard-cache: backend + redis only
- lynx-dashboard-app: frontend + backend (frontend sole external port: 19443)
- All secrets as external Podman secrets (pg-root, pg-pass, redis-pass, api-token, kek, pepper)
- DATABASE_URL_FILE / REDIS_URL_FILE pattern for secret injection
- PostgreSQL init script creates isolated lynx_dashboard_app user
feat(dashboard): install script with secret generation, health-ordered startup, nftables, WireGuard, TLS
- Detects existing install → abort / update / reinstall (requires exact confirmation phrase)
- Generates 8 Podman secrets via pipe (no shell history, subshell isolation, memory overwrite)
- Starts services in dependency order with health-gate: postgres → redis → backend → frontend
- nftables ruleset: table inet lynx-dashboard, ports 22 + 19443 + 51820 UDP
- Self-signed P-256 cert (90 days) + systemd timer for rotation every 80 days
- WireGuard dashboard keypair + PSK stored as Podman secret; PSK shown once in final output
* feat(agent): add agent install script with WireGuard, systemd, Podman DB, and nftables setup
Complete agent VPS install:
- Creates lynx-agent system user with CAP_NET_ADMIN/CAP_SYS_ADMIN/CAP_SETUID/CAP_SETGID
- Allocates subuid/subgid ranges (1000000+65536) for rootless tenant Podman
- Generates 4 Podman secrets via subshell pipe (no shell history trace)
- Starts PostgreSQL container on lynx-agent-db network (port 127.0.0.1:5434)
- Installs binary as systemd service with LoadCredential (secrets in tmpfs)
- WireGuard peer config built from admin-provided dashboard pubkey + PSK
- nftables: lynx-agent table, only WG inbound + SSH, agent API only from WG iface
- Existing install detection with reinstall phrase confirmation
feat(dashboard): agent management API with WG peer management and command relay
- GET/POST /agents — list and register agents (allocates WG IP .2–.254)
- GET/DELETE /agents/{id} — individual agent ops + WG peer removal
- POST /agents/{id}/heartbeat — relay heartbeat to agent via WireGuard, update status
- POST /agents/{id}/cmd — sign command with Ed25519, relay to agent API
- agents + agent_events migration (migration 004)
- crypto/cmd.rs: sign_command builds JWS-style payload (base64url JSON + Ed25519 sig)
- reqwest client for agent HTTP relay over WireGuard
- AppError extended: NotFound, BadRequest, BadGateway, AgentUnavailable
- From<sqlx::Error> impl so ? works in handlers
* feat(ui): add /app/agents page with Server Component, skeleton loading, and i18n
- AgentList: Server Component, fetches GET /agents with 30s revalidation
- Status badges: online (default) / lockdown (destructive) / offline (secondary)
- Empty state matches overview page pattern
- AgentListSkeleton: 3-card skeleton for Suspense boundary
- i18n keys in en.json + es.json (app.agents namespace)
- Sidebar already linked to /app/agents
* feat: implement audit log sync (agent → dashboard) with per-agent sync token auth
Agent side:
- sync/mod.rs: Tokio task runs every 60s, batches unsynced audit_log entries
- Tracks sync cursor in sync_state table (migration 002)
- Config: DASHBOARD_URL + SYNC_TOKEN (optional — sync disabled if absent)
- reqwest client for HTTP POST to dashboard over WireGuard
Dashboard side:
- Migration 005: central audit_log table + sync_token_hash column on agents
- POST /agents/{id}/audit-sync: no JWT auth — uses per-agent sync token
- Sync token generated at registration (SHA-256 hash stored, plain returned once)
- Hash chain preserved: entry_hash/previous_hash stored verbatim from agent
- ON CONFLICT DO NOTHING: idempotent re-sync
- Constant-time token comparison via subtle crate
* feat: add organizations backend + UI
Backend (migration 006):
- organizations table: id (UUID v7), name, slug (unique), owner_id
- organization_members: role-based (owner/admin/member/viewer)
- projects table: belongs to org + agent, unique slug per org
- GET/POST /organizations, GET/DELETE /organizations/{id}
- List scoped to authenticated user's memberships
- Slug validation: lowercase alphanumeric + hyphens only
- Conflict handling: returns 409 on duplicate slug
UI:
- /app/organizations: Server Component, 60s revalidation
- Card grid with slug, member count, org ID
- OrgListSkeleton for Suspense boundary
- i18n: en + es (app.organizations namespace)
* feat(agent): add nftables divergence detection with dashboard alerting
- Background task runs every 60s: compares live nft checksum vs last apply() result
- Mismatch → WARN log + POST /agents/{id}/events with nftables_divergence event
- Auth: same per-agent sync token used by audit-sync
- AppState gains nft_checksum: Arc<Mutex<Option<String>>> for shared last-known-good state
- set_nft_checksum() called after each apply() in nftables apply handler (future)
Dashboard side:
- POST /agents/{id}/events endpoint — agent-callable (sync token auth)
- Validates event type against allowed list, inserts into agent_events
- Same pattern as audit-sync: no user JWT needed
* feat(dashboard/ui): add settings page with session management and key rotation
Sessions list with per-session revoke (DELETE /admin/sessions/{id}).
JWT key rotation button (POST /admin/rotate) clears cookies and redirects
to login — all sessions invalidated on rotation.
i18n: en + es under app.settings namespace.
* feat(agent/dashboard): container management commands + auto-update system
Agent:
- container.deploy (podman compose up via tenant user)
- container.start/stop/remove/restart (podman CLI via runuser)
- container.update (CPU/memory quota, vertical scaling)
- update.self: download binary from GitHub Releases, verify Ed25519 sig,
atomic rename, graceful exit for systemd restart
Dashboard admin:
- GET /admin/update-check: polls GitHub API for latest release
- POST /admin/trigger-update: signs update.self command to online agents
- GET /admin/update-log: recent update history
- migrations/008_update_log.sql: tracks per-agent update outcomes
* feat(dashboard): agent registration dialog, overview stats, events feed
UI:
- Register Agent dialog (POST /agents) shows agent ID, WG IP, sync token
once; token hidden with reveal toggle
- Overview page: online agent count, org count, recent events feed
- i18n en + es for new keys
Backend:
- GET /agents/events: recent agent_events across all agents (limit param)
* feat(dashboard/agent): WireGuard PSK rotation
Dashboard admin rotate with scope wireguard_psks or all:
- Generates new PSK per online agent via wg genpsk
- Updates dashboard WG peer with new PSK
- Sends signed wg.rotate_psk command to each agent
Agent wg.rotate_psk handler:
- Applies new PSK to wg-lynx-agent peer
- Persists via wg-quick save
* feat(ui): org creation dialog, i18n keys for org/agent registration
- Create Organization dialog with auto-derived slug
- Register Agent and Create Org buttons on respective pages
- i18n en+es: organizations.create/slugConflict/createError
* feat(dashboard/agent): internal PKI — CA issues Ed25519 certs to agents
Dashboard:
- crypto/pki: CA Ed25519 keypair, issue_cert, verify_cert
- CA keypair loaded from CA_PRIVATE_KEY/CA_PUBLIC_KEY env (ephemeral in dev)
- Agent registration returns signed cert + CA public key (once)
- cert stored in agents table (migration 009_agent_certs.sql)
- admin rotate certificates scope re-issues certs for all agents
Agent:
- cert.rs: verify dashboard CA cert (CA_PUBLIC_KEY env)
- Cert verification available for command auth second layer
* feat(dashboard): white-label branding from PostgreSQL
Single-row white_label table stores company_name, logo_url, and
three hex palette colors. Public GET /branding serves defaults on
empty DB. Auth-protected PUT /admin/branding (via admin route_layer)
validates hex format before UPSERT.
UI injects --brand-primary/secondary/accent CSS vars on <html> at
layout level (revalidated every 60s). Sidebar header renders
company_name or logo. Settings page adds BrandingForm (Server Action
PUT) with color swatches + inputs.
* feat(orgs): member management — invite, list, remove
Backend: GET/POST /organizations/{id}/members, DELETE
/organizations/{id}/members/{user_id}. Role-gated (owner/admin only
for mutations). Prevents removing org owner. Adds AppError::Forbidden
(403) variant.
UI: org cards now link to detail page. Detail page lists members with
role badges and per-member remove buttons (owner row protected). Invite
dialog: username + role selector. Server actions revalidate page on
mutation.
* feat(orgs): project listing and vertical scaling UI
Backend: GET/GET /organizations/{id}/projects[/{proj_id}] for project
browsing. PUT /organizations/{id}/projects/{proj_id}/resources signs a
container.update command (tenant_id=org_id, cpus, memory_mb) and
relays it to the target agent via WireGuard. Viewer role blocked from
scaling mutations.
UI: org detail page now lists projects as clickable links. Project
detail page exposes ResourceForm — container name + CPU/memory inputs
that call the backend action and toast on result.
* feat(nftables): divergence detection and restore/accept flow
Agent: nftables.apply now returns the rendered string, which is stored
in AppState.nft_last_ruleset alongside the checksum. New handlers:
nftables.restore (re-applies last ruleset via apply_raw) and
nftables.accept (snapshots current live checksum as new expected).
Dashboard: GET /agents/{id}/nftables-status returns the latest
unresolved divergence event (resolved = followed by a restored or
accepted event). POST /agents/{id}/nftables-resolve signs the
appropriate command and records the resolution event.
UI: AgentList fetches nftables status per online agent in parallel.
Diverged agents show an orange alert with Restore/Accept buttons
(NftablesAlert client component, resolveNftables server action).
* feat(orgs): add project creation
POST /organizations/{id}/projects — role-gated (viewer blocked), slug
validated, agent existence checked, 409 on slug conflict.
UI: org detail page shows CreateProjectDialog alongside project list.
Dialog has name + auto-derived slug + agent selector (online agents
first). slugConflict toast on 409.
* feat(containers): deploy and lifecycle management UI
Backend: POST /containers (deploy), GET /containers (list via
container.list), POST /containers/{name}/{action} (start|stop|restart|
remove). All relay signed commands to the project's agent. relay_project_command helper reduces duplication across all container handlers.
UI: project detail page now shows running container list with per-
container action buttons (state-dependent: start for stopped, stop+
restart for running, remove always). DeployForm accepts name, image,
ports, env, cpus, memory_mb.
* feat(ui): persistent footer and dynamic login branding
Sidebar gains a small "Made with love by Jaroc · lynx" footer at the
bottom — always visible in the app area per CLAUDE.md requirement.
Login page fetches company_name from /branding at render time and
interpolates into "Sign in to {company}" — supports white-label
without hard-coding "Lynx". i18n string updated in both locales.
* feat(settings): update check and trigger UI
UpdateSection client component: polls GET /admin/update-check, shows
current vs latest version with up-to-date/update-available badges, and
offers "Trigger update on all agents" button (POST /admin/trigger-update)
when behind latest. Server actions added to settings actions.ts.
* feat(scale): implement cross-agent horizontal scaling via WireGuard data-plane tunnels
Per-project WireGuard tunnel (distinct from management plane) between
Agent-A and Agent-B. Dashboard generates keypairs and PSK, stores tunnel
record, commands both agents to configure the interface, then deploys
N replicas on Agent-B.
- Migration 011: data_plane_tunnels table with status check constraint
- handlers: list/create/teardown tunnel endpoints with role guard
- router: wire horizontal scale routes (GET/POST/DELETE)
- agent: wg.data_plane.setup + wg.data_plane.teardown command handlers
derive interface name from tunnel_id prefix (wg-lynx-dp-{8hex})
- UI: HorizontalScaleSection with add-tunnel dialog and inline teardown
- i18n: en + es keys for horizontal scaling
* feat(audit): add per-agent audit log viewer
GET /agents/:id/audit-log returns paginated entries (limit/offset) from
the central audit_log table. Entry hash truncated to 16 chars — full
chain verification is server-side only; UI shows fingerprint for spot
checks.
- Backend: list_audit_log handler + route /{id}/audit-log
- UI: server-rendered table page with pagination (offset param)
- Agent card: link to audit log per agent
- i18n: en + es keys
* feat(domain): add custom domain configuration with Let's Encrypt and HSTS
Singleton domain_config table tracks domain, cert type, expiry, HSTS
state, and port 19443 status. Setup is async (returns 202 immediately)
so certbot wait does not block the HTTP response.
- Migration 012: domain_config singleton row with status check constraint
- GET/POST /domain, POST /domain/verify, /domain/hsts, /domain/close-port
- nginx compose YAML + conf generated in code (no external templates)
- certbot webroot challenge via lynx-nginx container
- Port 19443 closed via nft rule (irreversible remotely by design)
- HSTS only toggleable when status = active
- UI: DomainSection in settings — status badge, setup form, verify DNS,
HSTS toggle, port-close with confirmation dialog
- i18n: en + es
* feat(migration): implement dashboard-to-dashboard migration flow
Zero-downtime dashboard relocation from VPS-A to VPS-B. Uses a one-time
migration token (SHA-256 stored, never in DB plaintext) to gate the
data transfer over HTTPS. pg_dump → VPS-B restore → agent notification
→ per-agent confirmation → VPS-A shutdown.
- Migration 013: migration_state singleton table with role + progress tracking
- GET/POST /migration, /migration/prepare, /migration/start, /migration/abort,
/migration/confirm-shutdown (auth-protected)
- POST /migration/receive, /migration/agent-confirm (token-gated, no user auth)
- Agent: dashboard.migrate command → calls /migration/agent-confirm on VPS-B
using agent's sync token
- UI: MigrationSection in settings with dual-pane source/target flow,
progress tracking, abort control, shutdown confirmation
- i18n: en + es
* feat(auth): add password change and profile endpoints
POST /auth/change-password: verifies current password, hashes new one,
then bulk-deletes all sessions for the user and logs password_changed
reason per CLAUDE.md session invalidation rules. Revokes current access
token in Redis.
GET /auth/me: returns id + username for the authenticated user.
- Profile section in settings shows username + change-password form
- Password change redirects to login (all sessions invalidated)
- i18n: en + es
* feat(settings): add key rotation history to security section
* feat(pki): push cert.update to online agents on rotation; add CI workflows
- Add cert.update dispatch arm to agent — verifies new cert against CA
public key, persists to /etc/lynx/cert.json for durability across restarts
- PermissionLevel derives PartialOrd/Ord so permission comparisons compile
- rotate_agent_certs now pushes cert.update to online agents immediately
after DB update, so live agents adopt the new cert without restart
- Add rotate_expiring_certs() — called automatically during trigger_update
for any agent cert expiring within 14 days (CLAUDE.md: automatic cert
renewal during updates when near expiry)
- CI: dashboard-server workflow (fmt, audit, check/clippy with pg service)
- CI: dashboard-ui workflow (bun install + tsc --noEmit)
- CI: agent workflow (fmt, audit, check/clippy; integration tests left as
commented stub pending self-hosted runner per CLAUDE.md)
- cargo fmt applied across all packages (no logic changes)
* fix(dashboard): use base64url unpadded encoding for signed commands
Dashboard encoded command payload/signature with padded Base64Url;
agent decoded with Base64UrlUnpadded — padding mismatch caused all
commands to be rejected with 'payload: invalid base64url'.
Verified end-to-end on VM: cert.update command now reaches agent,
signature verified, cert persisted to /etc/lynx/cert.json.
* feat(dashboard): add background heartbeat scheduler
Polls all non-lockdown agents every 30s via WireGuard, updating
status and last_heartbeat in DB. Uses tokio interval with
MissedTickBehavior::Skip to avoid thundering-herd on startup.
First tick fires immediately so agent status is current without
requiring a manual heartbeat call.
* fix(dashboard): shorten WireGuard interface name to wg-lynx-dash
Linux IFNAMSIZ=16; 'wg-lynx-dashboard' (18 chars) exceeds the limit.
Renamed to 'wg-lynx-dash' (12 chars) in both wg.rs and admin/handlers.
Verified wg.rotate_psk command reaches agent, updates PSK on wg-lynx-agent,
and tunnel remains alive after rotation.
* fix(dashboard): correct Redis key pattern in JWT session flush
rotate_jwt_sessions was flushing 'jti:*' but access tokens are
stored under 'access:{jti}'. Old tokens remained valid after
rotation. Fixed pattern to 'access:*'.
Verified: after jwt_keys rotation, old tokens return 401; fresh
login issues working tokens.
* feat(dashboard): add ip_pool allocator, logs/reset-admin-password CLI, SSRF-safe downloader
- Migration 018: ip_pool table with SELECT FOR UPDATE allocator replaces
linear scan of agents.wg_ip; release_ip() on agent delete
- Dashboard backend CLI: `logs` subcommand (Podman container logs) and
`reset-admin-password --username` (SSH-only, sets force_password_change)
- Agent CLI: `logs [--follow] [--errors] [--since]` via journalctl
- update.rs: resolve DNS once, validate all IPs against RFC1918/loopback/
link-local before connecting (TOCTOU-safe); pinned reqwest resolver
- update.rs: now declares `mod update` in main.rs (was missing)
- Workspace: add clap, url, ipnetwork sqlx feature
* feat(dashboard): security alerts, headers, startup health guard, intercepted alerts
- Add security_alerts table (migration 019) + alerts.rs module
- Fire alert on login rate limit hit (rate_limit_hit kind)
- Fire alert on session intercepted (IP/UA mismatch in middleware)
- GET /admin/alerts + POST /admin/alerts/:id/acknowledge endpoints
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers
to all responses via tower-http SetResponseHeaderLayer
- update.rs: spawn_startup_health_guard() — polls /health every 2s for
30s after boot; if unhealthy restores .prev, writes /etc/lynx/CRITICAL
- reqwest: add rustls-tls feature to dashboard server
* feat(agent): command rate limiter (100/min) with rejected_rate_limit audit log
- AppState: cmd_rate window counter + cmd_rejected_count/window for alert threshold
- execute_command: reject when window exceeds 100 commands/min; log audit entry
with result=rejected_rate_limit; warn when count >= 3 in current minute
* feat(domain): route nginx/certbot/nftables ops through local agent
All domain management operations now go through the local agent
via signed Ed25519 commands instead of direct OS calls from the backend:
- nginx.deploy: deploy nginx container with image digest
- nginx.update_config: reload nginx after cert/HSTS changes
- certbot.obtain: run certbot webroot challenge on the agent VPS
- nftables.close_setup_port: close port 19443 after domain goes live
Added migration 020 adding is_local_agent BOOLEAN to agents table.
nginx.rs in domain handlers is now a pure config-generator with no
OS side effects. Fixed nginx image to use digest instead of mutable tag.
* refactor(agent): split handlers.rs into handlers/ module
Replaces the monolithic handlers.rs with a handlers/ directory organized
by responsibility, following the project's folder organization rule.
Also adds conflict.rs (incompatible software detection) and nginx.rs
(nginx watchdog) that were created but not yet committed.
* feat: add missing files from prior implementation sessions
- agent: .env.example, migration 003 for nginx_configs table
- dashboard UI: public assets (favicon, icons, flags for i18n selector)
- dashboard UI: admin alerts action + AlertsPanel component
- dashboard UI: Zod schemas organized by feature domain
* feat: implement X.509 mTLS, heartbeat scheduler, PKI, and frontend improvements
Backend (dashboard server):
- X.509 CA generation and agent cert issuance (pki.rs additions)
- mTLS client for agent HTTP calls (agents/client.rs)
- X509 fields added to Config (loaded from Podman secrets or ephemeral)
- Heartbeat scheduler polls agents every 30s, dispatches update.self
- Agent registration response includes TLS cert/key/ca DER (base64)
- WireGuard PSK rotation refactored to use mTLS client
- Cert rotation in admin handlers uses mTLS client
- Background heartbeat task spawned in main.rs
- Redis key pattern fix in JWT session flush (rotation.rs)
- WireGuard interface name shortened to wg-lynx-dash
Agent:
- TLS cert/key/ca loaded from env-configured files (config.rs)
- mTLS TLS acceptor built from loaded certs (main.rs)
- serve_tls() with manual hyper accept loop + axum body bridge
- Cargo.toml: hyper + hyper-util + tower added
- WireGuard PSK and WireGuard interface changes
- Setup agent script updates
Frontend:
- Login / register forms with Zod validation and Server Actions
- Dashboard pages: agents, organizations, projects, settings
- Sidebar navigation, alert components
- Branding, domain, migration, rotation, update UI sections
- i18n messages (en/es)
- biome.json, next.config.ts updates
- proxy.ts auth middleware
- Removed default Next.js placeholder SVGs
* feat(scheduler): full 90-day rotation — JWT flush + WireGuard PSKs + certs
Scheduled rotation now covers all three planes:
- JWT session flush (forces re-login for all users)
- WireGuard PSK rotation (coordinated, no tunnel downtime)
- mTLS cert rotation (certs expiring within 30 days)
Made rotate_jwt_sessions and rotate_wireguard_psks pub for scheduler use.
Rotation log scope changed from 'certificates' to 'all' for scheduled runs.
Also: is_local_agent flag added to RegisterAgentRequest so the install
script can mark the local dashboard agent at registration time.
* feat(domain): custom cert upload with PEM validation (Cloudflare + own cert)
POST /domain/cert/upload accepts cloudflare or custom cert type.
Backend validates before sending to agent:
- PEM parseable
- not_before <= now <= not_after (expiry check)
- SAN or CN matches configured domain
- cert/key pair match (custom certs only, via x509-parser pubkey comparison)
- max 64 KB per field
Agent gains nginx.install_cert command: writes cert+key to
/etc/lynx/nginx/certs/{domain}/ and reloads nginx.
nginx_conf_with_cert() generates nginx config with arbitrary cert paths.
x509-parser added as explicit workspace dep (was already transitive).
* fix(domain): set_hsts uses correct cert path for cloudflare/custom cert type
* feat(ws): add persistent WebSocket channel between agent and dashboard
Agent initiates a WS connection to dashboard on startup using its sync
token. Heartbeats and audit log entries now flow over the persistent WS
instead of HTTP polling. Dashboard can push signed commands via WS with
a 30-second response timeout, falling back to HTTP POST if no WS
connection is active.
Dashboard side:
- agents/ws_hub.rs: WS handler at /agents/{id}/ws, auth via sync_token
hash, stores live connections in AppState.agent_ws_conns
- push_command() sends a command envelope with a correlation UUID and
waits on a oneshot for the agent's command_response frame
- AppState gains agent_ws_conns: Arc<RwLock<HashMap<Uuid, AgentWsConn>>>
- send_command handler tries WS first, falls back to HTTP
Agent side:
- ws_client.rs: connects to ws://{dashboard}/agents/{id}/ws, sends
heartbeats every 30s, receives command frames, reconnects with
exponential backoff (5s → 300s)
- system.rs refactored: command_dispatch + run_verified_command shared
between HTTP handler and WS client — no logic duplication
- Lockdown check applies identically on both paths
* feat(ws): skip HTTP heartbeat poll for WS-connected agents
Heartbeat scheduler now checks AppState.agent_ws_conns before HTTP
polling. Agents with an active WS connection send proactive heartbeats
so no HTTP poll is needed. update.self is still dispatched (via WS)
when the scheduler detects a version mismatch.
* feat(agent+dashboard): nftables 3-chain architecture, real-time metrics WS pipeline
Agent:
- Refactor nftables module to lynx-base/lynx-global/lynx-local 3-chain model
- handle_nftables_apply now supports chain-specific updates {chain, rules} alongside
full apply {wireguard_port} for bootstrap; stores wg_port in AppState
- Divergence detector: auto-restore from last known-good ruleset on any chain mismatch;
apply emergency ruleset if restore fails; per-chain detection for severity logging
- Store post-apply checksum from live kernel state (nft -j list table) to ensure
divergence checker uses matching format; remove checksum_of() that hashed input text
- Stream system metrics (CPU/RAM/disk) via WS every 5 seconds alongside heartbeats
Dashboard backend:
- Add per-agent broadcast channels (broadcast::Sender<Arc<String>>) in AppState for
metric fan-out from agent WS to frontend browser WS clients
- ws_hub: create/drop broadcast channel on agent connect/disconnect; fan-out metrics
messages to all subscribed frontend clients
- New GET /agents/{id}/metrics/ws — browser WebSocket endpoint (JWT auth via cookie)
- Auth middleware: fall back to access_token cookie for WebSocket upgrade requests
since browsers cannot send custom headers on WS connections
- nftables rule management: migration 021, CRUD handlers, push_global/push_local,
rules_to_nft_chain() renderer; global_rule_sync tracking per-agent sync state
- Agent reboot handler; NftRulesPanel + AgentDetailActions frontend components
- Certificate upload section in domain settings (Cloudflare Origin + custom certs)
Dashboard frontend:
- Agent detail page: status/WG info, live metrics panel, nftables rule panels, actions
- useAgentMetrics hook: auto-reconnecting WS subscription with exponential backoff
- MetricsPanel: CPU/RAM/disk with progress bars and color-coded severity
- Next.js rewrites: proxy /api/* to backend (enables browser→backend WS via same origin)
- i18n: metrics keys in en.json + es.json
* feat(events): real-time agent events pipeline to browser WS
- Add global events broadcast channel (broadcast::Sender<Arc<String>>) to AppState
- ws_hub: record connected/disconnected events in agent_events + broadcast to browser
- heartbeat.rs: detect online→offline transition, fire heartbeat_lost event + alert
- events.rs: broadcast_event() helper; receive_event broadcasts after DB insert
- New GET /agents/events/ws endpoint (JWT auth) — streams all agent events to browser
- sign_command_system() in crypto/cmd.rs (nil UUID sentinel for scheduler-triggered cmds)
- rule_line() in nftables/mod.rs for system-level chain body generation
- push_pending_global_sync compilation fixed (SignedCommand→Value, correct function refs)
- Frontend useAgentEvents hook with exponential backoff reconnect
- NotificationBell component in Sidebar: badge count + popover for alert events
- i18n: en/es notification strings (heartbeat_lost, lockdown, divergence, conflicting_software)
* feat(dashboard): add admin user and role management
- Add require_admin middleware (checks *:* permission after require_auth)
- Admin router split: require_admin for admin-only routes, require_auth for own-session routes
- New admin handlers: list/delete users, CRUD roles, manage role permissions and user roles
- /auth/me now returns is_admin flag
- Frontend: Admin page at /app/admin (user table + role management with inline CRUD)
- Sidebar shows Admin nav link only for users with *:* permission
- Guard: non-admins redirected from /app/admin at server-render time
- Last-admin guards on delete user, delete role, remove role permission, remove own admin role
* feat(dashboard): add theme system and user preferences
- Migration 022: user_preferences table (theme + locale per user)
- Backend: GET/POST /auth/me/preferences (require_auth via protected router)
- ThemeProvider (next-themes, dark/light/system, SSR-safe with suppressHydrationWarning)
- ThemeToggle dropdown in sidebar header persists to DB + theme_preference cookie
- Locale layout reads theme_preference cookie as defaultTheme to prevent flash
* feat(dashboard): add locale switcher and i18n sign-out in sidebar
- LocaleSwitcher dropdown with flag icons, navigates to same path in new locale
- Persists locale preference to DB via updateLocaleAction
- Sign out button now uses i18n (t("signOut")) instead of hardcoded English
* feat(dashboard): single-session mode and theme-on-login
- Add single_session column to users table (migration 023)
- Login handler returns theme preference so client sets cookie immediately
- Login action sets theme_preference cookie from login response
- POST /auth/me/single-session endpoint to toggle per-user
- GET /auth/me now returns single_session and is_admin fields
- user_preferences migration (022) applied; get/update preferences handlers complete
- SingleSessionToggle client component in settings profile section
- i18n keys for single-session toggle (en + es)
* test(dashboard): add integration test suite for auth, admin, rotation, and security
Adds tests/auth.rs (25 tests), tests/admin.rs (10 tests), tests/rotation.rs
(5 tests), tests/security.rs (13 tests), and tests/redis_fail.rs (5 tests).
Key infrastructure decisions:
- Login helpers return (token, ip) so protected-route calls include the same
x-real-ip that minted the JWT — fixes IP hash mismatch in require_auth
- helpers::seed_test_admin resets force_password_change=false on testadmin
after each binary run, preventing force-password-change-all from poisoning
subsequent test binaries
- Minimal fake Redis server that responds +OK to CLIENT SETINFO setup
commands (redis-rs 0.27 always sends these) then returns errors for all
subsequent commands, allowing ConnectionManager::new() to succeed while
making every auth command fail → 503
- rustls ring CryptoProvider installed once per test process via
install_default() (ignores AlreadySet) to prevent race on parallel tests
- UUID suffix extended from 8 to 16 chars to eliminate timestamp collisions
when tests run at the same millisecond across parallel test binaries
- needs_scheduled_rotation counts both 'scheduled' and 'update' entries as
resetting the 90-day clock; inline unit tests verify the guard logic
* test(dashboard/server): add unit tests for crypto, validation, and auth logic
- crypto/{hash,jwt,kek,password}.rs: inline unit tests covering roundtrips,
tamper detection, constant-time equality, and edge cases
- auth/validate.rs: tests for username/email/password validation rules
- auth/session.rs: rotate_refresh now returns bool (true = swapped, false =
concurrent request already consumed the token) — 0 rows affected = 401
- auth/handlers.rs: handle rotate_refresh returning false with Unauthorized
- main.rs: moved module declarations to lib.rs; main uses the public library
crate so integration tests can import build_router and AppState directly
* test(dashboard/ui): add Vitest unit tests and Playwright E2E setup
- vitest.config.ts: jsdom environment, globals, setup file, aliased @
- src/__tests__/setup.ts: global test setup
- src/__tests__/(auth)/schemas.test.ts: login/register schema validation
- src/__tests__/(dashboard)/app/organizations/schemas.test.ts: org schemas
- src/__tests__/(dashboard)/app/settings/schemas.test.ts: settings schemas
- playwright.config.ts: E2E config targeting http://localhost:19443
- src/e2e/auth.test.ts: login, register, and logout E2E flows
* ci: add CI workflows for agent, dashboard-server, and dashboard-ui
- dashboard-server: full integration test job with PostgreSQL 18 and Redis 8 service containers; runs migrations and cargo test
- dashboard-ui: Vitest unit test job and Playwright E2E scaffold
- agent: cargo-audit vulnerability scan job
- setup-agent.sh: ANSI colors, section headers, NTP check, incompatible software detection/removal, NAT detection for PersistentKeepalive
- setup-dashboard.sh: same shell script improvements plus pg-tde key generation and Podman secret handling
- agent auth module: 131-line unit test suite for signed command verification (Ed25519, nonce, timestamp, permission checks)
* refactor(auth): split handlers.rs into handlers/ submodule; add WS origin validation
auth/handlers split by responsibility:
- register.rs: register + bootstrap_admin
- login.rs: login (rate-limit, anti-enum, single-session)
- session.rs: logout + refresh (token rotation)
- me.rs: GET /auth/me
- password.rs: POST /auth/change-password
- preferences.rs: GET/POST /auth/me/preferences, POST /auth/me/single-session
- mod.rs: re-exports + shared helpers (build_jwt_keys, extract_ip, extract_ua)
WebSocket origin validation (CSWSH prevention, security.md §12.4):
- events_ws: validate_ws_origin checks Origin header on WS upgrade
- metrics_ws: reuses validate_ws_origin from events_ws
- Absent Origin → allow (non-browser clients, integration tests)
- Domain configured → only https://{domain} allowed
- No domain → any https:// allowed (direct IP:19443 access)
* fix(security): audit log hash chain verification + offline agent pending_sync
audit_log hash chain (security.md §12.10):
- store_audit_entries now fetches last known entry_hash for the agent before
inserting any batch; verifies each entry's previous_hash matches in order
- On mismatch: rejects entire batch, fires audit_integrity_failure alert,
inserts agent_events row, broadcasts to frontend — no partial inserts
- AuditSyncEntry derives Clone to allow in-order sorting of batch
global_rule_sync pending for offline agents (agent.md):
- push_global_rules now inserts global_rule_sync rows with synced_at=NULL
for every offline agent after pushing to online agents
- push_pending_global_sync on WS reconnect finds these rows and pushes
the full current global ruleset — no rules lost during agent downtime
* fix(ui): cursor-pointer + disabled:cursor-not-allowed on Button; robots metadata object format
Button base variant: add cursor-pointer (interactive feedback) and
disabled:cursor-not-allowed (overrides pointer-events-none gap in some
browsers where the parent still shows default cursor).
layout.tsx: robots changed from string "noindex, nofollow" to object
{ index: false, follow: false } per Next.js Metadata type.
* feat(agent): split metrics by frequency; fix WireGuard config path
Metrics frequency (agent.md spec):
- CPU/RAM/disk: every 5 seconds (was 2s uniform)
- Container stats via podman stats: every 10 seconds (was missing entirely)
- ws_client.rs: separate container_ticker at 10s; sends container_metrics frame
- handlers/metrics.rs: separate system/container loops with correct intervals
- metrics/mod.rs: add ContainerMetrics + ContainerStat types; sample_containers()
runs podman stats --no-stream --format json and parses CPU/memory
WireGuard config path (agent.md spec):
- Source of truth: /etc/lynx/wireguard/lynx-wg.conf (owned by lynx-agent, 600)
- Symlink: /etc/wireguard/wg-lynx-agent.conf -> /etc/lynx/wireguard/lynx-wg.conf
for wg-quick compatibility (systemctl enable wg-quick@wg-lynx-agent)
* feat(dashboard): close remaining CLAUDE.md gaps
- alerts::fire now accepts &AppState and broadcasts security_alert events
via events_tx to all subscribed frontend WebSocket sessions
- 90-day scheduler rotates PostgreSQL app-user password and Redis password
(both via Podman secret --replace); manual rotate_keys "all" scope does
the same
- LoginRequest carries redirect_to (relative paths only; external URLs
silently discarded) returned in login response
- extract_ip prefers IPv4 over IPv6 from x-forwarded-for list per spec
* fix(lint): resolve all clippy -D warnings + fmt in agent and server
- Auto-fix and manual fix of 44 dashboard-server clippy errors:
useless anyhow conversions, redundant closures, needless borrows,
redundant pattern matching, empty doc comment line
- Auto-fix and manual fix of 27 agent clippy errors:
useless conversions, collapsible if/match, redundant closure,
PathBuf→Path, dead_code on future-use helpers
- cargo fmt applied to both crates — all diffs resolved
* test(dashboard): fix all 17 Playwright E2E tests
- Add custom Zod error messages to register schema matching i18n strings
- Add [locale]/page.tsx to handle redirect from /en → /en/login
- Fix submit-button test: delay POST route instead of invalid RSC mock
- Add PLAYWRIGHT_NO_SERVER env var support in playwright.config.ts
* fix(dashboard): install rustls ring provider at startup
Required for TLS code paths — prevents panic when rustls tries to
auto-select a crypto provider and ring is not the default.
* ci: add toolchain: stable to all dtolnay/rust-toolchain usages
The action requires a 'toolchain' input. Missing input caused
all Rust CI jobs to fail immediately after startup_failure was
fixed by adding third-party actions to the allowlist.
* fix(ci): remove sqlx-mysql CVE and fix compose formatting
- Set sqlx default-features=false in workspace dep to exclude mysql/sqlite
drivers. Adds macros feature explicitly. Eliminates RUSTSEC-2023-0071
(Marvin Attack in rsa crate pulled in by sqlx-mysql).
- Add audit.toml ignoring RUSTSEC-2023-0071 for residual Cargo.lock entry:
sqlx-mysql remains as a resolved optional dep but is not compiled
(verified via cargo tree --workspace).
- Run cargo fmt on lynx-compose package to pass CI fmt check.
* fix(ci): ignore RUSTSEC-2023-0071 in cargo audit
sqlx-mysql is an unresolvable optional dep in Cargo.lock (sqlx-macros-core
pulls it for compile-time query support) but is not compiled into any binary.
No fixed upgrade available for rsa 0.9.10 — advisory explicitly ignored.
* fix(security): validate IDs as UUIDs before URL interpolation in Server Actions
Adds validateId() helper that rejects non-UUID strings, applied to all
agentId/orgId/projId/tunnelId/ruleId/sessionId/userId parameters before
they are interpolated into fetch URLs. Eliminates CodeQL SSRF alerts on
user-controlled URL paths.
* ci(dashboard-ui): pin all action SHAs; quote CI env var as string
* fix(ui): add validateName() and inline container URL builds to eliminate SSRF alerts
Adds validateName() to api.ts for container name validation.
Removes base()/post() helpers from containers.ts; inlines fetch calls
with explicit validateId/validateName so CodeQL can trace all URL segments.
* test(e2e): waitForURL on root redirect test to handle RSC redirect timing
* fix(ui): use .bind() for local nft actions to avoid passing arrow fns to Client Component
* fix(ui): pass placeholder values to migrationAgentsProgress to satisfy next-intl interpolation
* ci: add explicit permissions block to all workflows (CodeQL hardening)
* chore: remove tracked junk files and update gitignore
Remove .fuse_hidden FUSE temp files and .playwright-mcp session artifacts
accidentally committed. Add patterns to .gitignore to prevent recurrence:
.fuse_hidden*, .playwright-mcp/, playwright-report/, test-results/.
* fix(gitignore): correct playwright-mcp ignore pattern
* refactor: update UI components and dependencies across the dashboard application
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#7)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(dashboard): remove development mode, production only
Delete docker-compose.dev.yml, remove dead /api/* rewrite from next.config.ts
(Next.js rewrites don't proxy WebSocket; nginx handles routing in production),
drop dev/test:watch/test:e2e:ui scripts from package.json, simplify
playwright.config.ts to always use bun run start, clean up .env.example.
* feat(nftables): add output chain support and seed global protection rules
- Add port_end and direction columns to nftables_rules; extend kind CHECK
with 8 new types: drop_invalid_state, tcp_flag_null, tcp_flag_xmas,
tcp_flag_ack_new, icmp_ping_limit, allow_icmp_errors, allow_ndp,
block_output_port
- Seed 7 input protection rules and 21 output port-block rules in lynx-global
(SMTP, Telnet, FTP, NetBIOS, SMB, IRC, RPC, TFTP, X11, SOCKS, LDAP, mDNS,
BitTorrent) so all fresh installs start with hardened defaults
- Add rules_to_nft_output_chain() and port_spec() to nftables compiler;
push_both_chains() sends input + output signed commands per agent
- Agent: add nft_global_output_body and nft_local_output_body to AppState;
render lynx-output chain from both; persist to nftables_state DB
- Agent migration 005: seed lynx-global-output and lynx-local-output rows
* style(ui): apply biome formatting across dashboard ui
* fix: replace gen_random_uuid() with uuidv7() — all tables must use UUID v7
gen_random_uuid() generates v4 UUIDs. The project rule is UUID v7 everywhere.
PostgreSQL 18 provides uuidv7() built-in.
- 019_security_alerts, 003_nginx_configs: fix DEFAULT in CREATE TABLE
- 015_roles_permissions, 022_nftables_protection: fix uuidv7() in seeded VALUES
- New migrations 023 and 006: ALTER TABLE to fix DEFAULT on already-created tables
in existing databases
* feat(ui): switch font from Geist to Poppins
* chore(github): add repo metadata files
Add CODEOWNERS, issue template, CONTRIBUTING, README, and SECURITY policy.
* ci: add URL audit script and job to CI pipelines
Ensure all outbound download URLs in update modules are from allowed
GitHub domains. Script scans a fixed set of files; unsupported URLs
require explicit suppression comment or ALLOWED update.
* ci: add release workflows for agent and dashboard
Build musl static binaries for x86_64 and arm64, sign each artifact
with Ed25519 (RELEASE_SIGN_KEY secret), and publish to GitHub Releases.
Dashboard workflow also bundles the Next.js standalone binary and
static assets tarball, each signed separately.
* chore(deps): scan full cargo workspace in dependabot daily
Point cargo ecosystem at workspace root /lynx instead of a single
member crate. Switch to daily schedule and use the new rust label.
* feat(db): add arch column to agents table
Stores the agent architecture (x86_64 / arm64) reported via heartbeat
so the dashboard can build the correct GitHub Release download URL.
* feat(agent): report arch in heartbeat, hardcode release key, add fallback updater
- Heartbeat now includes arch field (x86_64 / arm64)
- Release verify key hardcoded in binary; removed env-var lookup
- SSRF guard: validate_github_url added to update::perform_update
- Track last_dashboard_contact timestamp in state
- Fallback updater: if dashboard absent >6h, poll GitHub directly
and self-update without waiting for a dashboard command
* feat(dashboard): track agent arch and use in arch-aware update dispatch
- ws_hub: persist arch from heartbeat into agents table
- heartbeat + scheduler: read arch from DB when building download URL
- Scheduler now runs release check immediately at startup instead of
waiting for the first hourly tick
* feat(dashboard): implement full binary swap pipeline for releases
- update.rs: download+verify all artifacts before touching any files,
swap frontend binary and extract assets tarball, then swap backend
and exit for Podman restart. Uses Podman socket to stop/start the
frontend container during swap.
- docker-compose: replace build-from-source with prebuilt alpine
containers that mount binaries from host paths. healthcheck uses
wget (present in alpine) instead of curl.
- setup-dashboard.sh: detect arch, fetch latest release tag, download
and verify (Ed25519) backend + frontend binary + assets at install
time before starting services.
* fix: strengthen security hardening across agent and dashboard, including improved input validation, secure file permissions, SSRF protection, constant-time authentication checks, and hardened WebSocket origins.
* refactor: enhance system security with constant-time cryptographic checks, input validation, and updated contribution documentation.
* fix(security): prevent SQL injection in DB password rotation
ALTER USER does not support parameterized queries. Previous code used
string interpolation with single-quote escaping that could be bypassed.
Replaced with dollar-quoting ($$hex$$) — passwords are hex-encoded
[0-9a-f] so the dollar sign can never appear inside them.
Affects both the agent (lynx_agent_app) and dashboard (lynx_dashboard_app)
rotation handlers.
* fix(agent): eliminate shell injection in rootless Podman execution
runuser -l -c "podman args" passes the command through a shell, making
any user-controlled argument a potential injection vector. Replaced with
runuser -u user -- podman arg1 arg2 which hands args directly to execve.
Also explicitly sets HOME and XDG_RUNTIME_DIR since -u does not load the
login environment, which rootless Podman requires.
* fix(agent): add SSRF protection with DNS pinning for binary downloads
Resolves the DNS TOCTOU vulnerability where a hostname could resolve to
a different IP between the validation check and the actual connection,
allowing DNS rebinding to reach internal services.
Fix: resolve hostname once, validate all returned IPs against RFC1918/
loopback/link-local/unspecified ranges, then pin the reqwest client to
the validated IP via .resolve() so no second DNS lookup occurs.
Applies to both the primary update path (update/mod.rs) and the fallback
GitHub API poller (update/fallback.rs). Adds the url crate for URL parsing.
* fix(agent): harden WireGuard handler — interface validation, file permissions, zeroize secrets
* fix(agent): prevent path traversal in nginx cert path construction
* fix(security): heartbeat ACK requires Ed25519 signed command — bearer token alone insufficient
* fix(security): validate audit log hash chain on HTTP sync path
* fix(security): WS origin validation, vps:read permission checks, WG IP error propagation
* fix(auth): per-username rate limit, SHA256 ct_eq for setup token, constant-time IP/UA comparison
* fix(auth): enforce nbf and iat claims in JWT validation — reject future-issued tokens
* fix(security): strict domain and branding input validation — block path traversal vectors
* fix(security): validate IP/CIDR format and port range in nftables rule handlers
* fix(security): add HSTS header to all HTTPS responses
* fix(db): extend cert_type constraint to include cloudflare and custom cert types
* fix(scripts): replace eval PKG_UPDATE with direct case statement — eliminate eval injection
* feat(site): add GitHub Pages landing page
Dark amber-themed static landing with hero, architecture diagram,
feature cards, install guide, and stack badges. Poppins + JetBrains
Mono fonts. .nojekyll disables Jekyll processing.
* fix(ci): close security audit gaps across all components
- Add bun audit (--audit-level high) to dashboard-ui workflow — was
required by project docs, package.json had the script, CI never ran it
- Add cargo audit to lynx-compose workflow — only Rust component without
CVE scanning
- Pin upload-artifact, download-artifact, and action-gh-release to commit
SHAs in release workflows — mutable tags are a supply chain risk
- Verify musl bun binary with SHASUMS256.txt before unzip in
release-dashboard workflow — previously downloaded with no integrity check
- Drop security-events: write from all CI workflows — permission was
declared but SARIF was never uploaded (dead permission, violates least
privilege)
* fix(agent): extend nftables_state chain constraint before seeding output rows
Migration 005 inserted 'lynx-global-output' and 'lynx-local-output'
but the CHECK constraint from migration 004 only permitted 'lynx-global'
and 'lynx-local', causing migration 5 to fail on every fresh install.
Fix: ALTER TABLE to drop and re-add the constraint with all four valid
chain values before the INSERT.
Also applies cargo fmt across agent and dashboard-server (whitespace
and line-length reformats only, no logic changes).
* fix(db): renumber conflicting dashboard migrations and fix IPv6 link-local mask
Migrations 022_nftables_protection and 023_fix_uuid_defaults were added
after dashboard@1.0.1 shipped (which already contains 022_user_preferences
and 023_single_session). sqlx rejected the duplicate version numbers.
Renumbered to 026 and 027 — first available slots after the new 024
(agents_arch) and 025 (domain_cert_types) migrations.
Also fixes a clippy::bad_bit_mask in agent SSRF guard: the fe80::/10
link-local check used mask 0xff00, which zeroes the lower byte and can
never equal 0xfe80. Correct mask for a /10 prefix is 0xffc0.
* fix(server): refactor update params to struct; bypass rate limit in CI tests
perform_dashboard_update had 9 arguments — refactored to
DashboardUpdateParams struct to satisfy clippy::too_many_arguments.
Integration tests share a Redis instance and run in parallel, causing
the per-username login rate limit (10/15min) to be exhausted when
multiple test binaries all log in as testadmin concurrently.
Added LYNX_SKIP_RATE_LIMIT env-var bypass in rate_limit::check() and
set it in the CI test step — production deployments never set this var.
* fix(tests): serialize dashboard-server tests and flush testadmin rate-limit key
The env-var bypass (LYNX_SKIP_RATE_LIMIT) broke redis_fail tests that
rely on Redis being the first failure point to produce 503. Reverted.
Root cause: parallel test binaries all logging in as testadmin exceed the
10/15min per-username limit. Fix:
- Add --test-threads=1 to CI cargo test — serialises tests within each
binary, so at most ~4 concurrent testadmin logins across binaries (well
under the limit of 10).
- Flush rl:login:u:testadmin from Redis in test_state() before each test
so the counter resets between tests running in the same binary.
* fix(ui): remove duplicate migration label props from settings page
* ci(lint-shell): always run shellcheck on PRs to main, not just on .sh changes
* chore(deps): bump notify from 6.1.1 to 8.2.0 in /lynx
Bumps [notify](https://github.com/notify-rs/notify) from 6.1.1 to 8.2.0.
- [Release notes](https://github.com/notify-rs/notify/releases)
- [Changelog](https://github.com/notify-rs/notify/blob/notify-8.2.0/CHANGELOG.md)
- [Commits](https://github.com/notify-rs/notify/compare/notify-6.1.1...notify-8.2.0)
---
updated-dependencies:
- dependency-name: notify
dependency-version: 8.2.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /app/* layout with sidebar and auth gate.
* feat(agent): implement agent core with auth, nftables, Podman, metrics, and audit log
Full Lynx agent implementation:
- Ed25519 command authorization: signature + nonce dedup + timestamp ±30s + permission level
- Audit log with SHA-256 hash chaining for tamper detection
- nftables: atomic full-ruleset apply via stdin, inter-org blocking, checksum tracking
- Podman: rootless tenant users (lynx-tenant-{id}) with isolated subuid/subgid ranges
- WebSocket metrics: CPU (2-sample /proc/stat), RAM (/proc/meminfo), disk (statvfs), streamed every 2s
- Heartbeat watchdog: 300s timeout → lockdown mode (rejects all commands)
- PostgreSQL via sqlx with migrations (audit_log + used_nonces tables)
- Bearer token auth with constant-time comparison via `subtle`
feat(dashboard): production compose with 3 isolated networks and external Podman secrets
Replaces single-network compose with:
- lynx-dashboard-db: backend + postgres only
- lynx-dashboard-cache: backend + redis only
- lynx-dashboard-app: frontend + backend (frontend sole external port: 19443)
- All secrets as external Podman secrets (pg-root, pg-pass, redis-pass, api-token, kek, pepper)
- DATABASE_URL_FILE / REDIS_URL_FILE pattern for secret injection
- PostgreSQL init script creates isolated lynx_dashboard_app user
feat(dashboard): install script with secret generation, health-ordered startup, nftables, WireGuard, TLS
- Detects existing install → abort / update / reinstall (requires exact confirmation phrase)
- Generates 8 Podman secrets via pipe (no shell history, subshell isolation, memory overwrite)
- Starts services in dependency order with health-gate: postgres → redis → backend → frontend
- nftables ruleset: table inet lynx-dashboard, ports 22 + 19443 + 51820 UDP
- Self-signed P-256 cert (90 days) + systemd timer for rotation every 80 days
- WireGuard dashboard keypair + PSK stored as Podman secret; PSK shown once in final output
* feat(agent): add agent install script with WireGuard, systemd, Podman DB, and nftables setup
Complete agent VPS install:
- Creates lynx-agent system user with CAP_NET_ADMIN/CAP_SYS_ADMIN/CAP_SETUID/CAP_SETGID
- Allocates subuid/subgid ranges (1000000+65536) for rootless tenant Podman
- Generates 4 Podman secrets via subshell pipe (no shell history trace)
- Starts PostgreSQL container on lynx-agent-db network (port 127.0.0.1:5434)
- Installs binary as systemd service with LoadCredential (secrets in tmpfs)
- WireGuard peer config built from admin-provided dashboard pubkey + PSK
- nftables: lynx-agent table, only WG inbound + SSH, agent API only from WG iface
- Existing install detection with reinstall phrase confirmation
feat(dashboard): agent management API with WG peer management and command relay
- GET/POST /agents — list and register agents (allocates WG IP .2–.254)
- GET/DELETE /agents/{id} — individual agent ops + WG peer removal
- POST /agents/{id}/heartbeat — relay heartbeat to agent via WireGuard, update status
- POST /agents/{id}/cmd — sign command with Ed25519, relay to agent API
- agents + agent_events migration (migration 004)
- crypto/cmd.rs: sign_command builds JWS-style payload (base64url JSON + Ed25519 sig)
- reqwest client for agent HTTP relay over WireGuard
- AppError extended: NotFound, BadRequest, BadGateway, AgentUnavailable
- From<sqlx::Error> impl so ? works in handlers
* feat(ui): add /app/agents page with Server Component, skeleton loading, and i18n
- AgentList: Server Component, fetches GET /agents with 30s revalidation
- Status badges: online (default) / lockdown (destructive) / offline (secondary)
- Empty state matches overview page pattern
- AgentListSkeleton: 3-card skeleton for Suspense boundary
- i18n keys in en.json + es.json (app.agents namespace)
- Sidebar already linked to /app/agents
* feat: implement audit log sync (agent → dashboard) with per-agent sync token auth
Agent side:
- sync/mod.rs: Tokio task runs every 60s, batches unsynced audit_log entries
- Tracks sync cursor in sync_state table (migration 002)
- Config: DASHBOARD_URL + SYNC_TOKEN (optional — sync disabled if absent)
- reqwest client for HTTP POST to dashboard over WireGuard
Dashboard side:
- Migration 005: central audit_log table + sync_token_hash column on agents
- POST /agents/{id}/audit-sync: no JWT auth — uses per-agent sync token
- Sync token generated at registration (SHA-256 hash stored, plain returned once)
- Hash chain preserved: entry_hash/previous_hash stored verbatim from agent
- ON CONFLICT DO NOTHING: idempotent re-sync
- Constant-time token comparison via subtle crate
* feat: add organizations backend + UI
Backend (migration 006):
- organizations table: id (UUID v7), name, slug (unique), owner_id
- organization_members: role-based (owner/admin/member/viewer)
- projects table: belongs to org + agent, unique slug per org
- GET/POST /organizations, GET/DELETE /organizations/{id}
- List scoped to authenticated user's memberships
- Slug validation: lowercase alphanumeric + hyphens only
- Conflict handling: returns 409 on duplicate slug
UI:
- /app/organizations: Server Component, 60s revalidation
- Card grid with slug, member count, org ID
- OrgListSkeleton for Suspense boundary
- i18n: en + es (app.organizations namespace)
* feat(agent): add nftables divergence detection with dashboard alerting
- Background task runs every 60s: compares live nft checksum vs last apply() result
- Mismatch → WARN log + POST /agents/{id}/events with nftables_divergence event
- Auth: same per-agent sync token used by audit-sync
- AppState gains nft_checksum: Arc<Mutex<Option<String>>> for shared last-known-good state
- set_nft_checksum() called after each apply() in nftables apply handler (future)
Dashboard side:
- POST /agents/{id}/events endpoint — agent-callable (sync token auth)
- Validates event type against allowed list, inserts into agent_events
- Same pattern as audit-sync: no user JWT needed
* feat(dashboard/ui): add settings page with session management and key rotation
Sessions list with per-session revoke (DELETE /admin/sessions/{id}).
JWT key rotation button (POST /admin/rotate) clears cookies and redirects
to login — all sessions invalidated on rotation.
i18n: en + es under app.settings namespace.
* feat(agent/dashboard): container management commands + auto-update system
Agent:
- container.deploy (podman compose up via tenant user)
- container.start/stop/remove/restart (podman CLI via runuser)
- container.update (CPU/memory quota, vertical scaling)
- update.self: download binary from GitHub Releases, verify Ed25519 sig,
atomic rename, graceful exit for systemd restart
Dashboard admin:
- GET /admin/update-check: polls GitHub API for latest release
- POST /admin/trigger-update: signs update.self command to online agents
- GET /admin/update-log: recent update history
- migrations/008_update_log.sql: tracks per-agent update outcomes
* feat(dashboard): agent registration dialog, overview stats, events feed
UI:
- Register Agent dialog (POST /agents) shows agent ID, WG IP, sync token
once; token hidden with reveal toggle
- Overview page: online agent count, org count, recent events feed
- i18n en + es for new keys
Backend:
- GET /agents/events: recent agent_events across all agents (limit param)
* feat(dashboard/agent): WireGuard PSK rotation
Dashboard admin rotate with scope wireguard_psks or all:
- Generates new PSK per online agent via wg genpsk
- Updates dashboard WG peer with new PSK
- Sends signed wg.rotate_psk command to each agent
Agent wg.rotate_psk handler:
- Applies new PSK to wg-lynx-agent peer
- Persists via wg-quick save
* feat(ui): org creation dialog, i18n keys for org/agent registration
- Create Organization dialog with auto-derived slug
- Register Agent and Create Org buttons on respective pages
- i18n en+es: organizations.create/slugConflict/createError
* feat(dashboard/agent): internal PKI — CA issues Ed25519 certs to agents
Dashboard:
- crypto/pki: CA Ed25519 keypair, issue_cert, verify_cert
- CA keypair loaded from CA_PRIVATE_KEY/CA_PUBLIC_KEY env (ephemeral in dev)
- Agent registration returns signed cert + CA public key (once)
- cert stored in agents table (migration 009_agent_certs.sql)
- admin rotate certificates scope re-issues certs for all agents
Agent:
- cert.rs: verify dashboard CA cert (CA_PUBLIC_KEY env)
- Cert verification available for command auth second layer
* feat(dashboard): white-label branding from PostgreSQL
Single-row white_label table stores company_name, logo_url, and
three hex palette colors. Public GET /branding serves defaults on
empty DB. Auth-protected PUT /admin/branding (via admin route_layer)
validates hex format before UPSERT.
UI injects --brand-primary/secondary/accent CSS vars on <html> at
layout level (revalidated every 60s). Sidebar header renders
company_name or logo. Settings page adds BrandingForm (Server Action
PUT) with color swatches + inputs.
* feat(orgs): member management — invite, list, remove
Backend: GET/POST /organizations/{id}/members, DELETE
/organizations/{id}/members/{user_id}. Role-gated (owner/admin only
for mutations). Prevents removing org owner. Adds AppError::Forbidden
(403) variant.
UI: org cards now link to detail page. Detail page lists members with
role badges and per-member remove buttons (owner row protected). Invite
dialog: username + role selector. Server actions revalidate page on
mutation.
* feat(orgs): project listing and vertical scaling UI
Backend: GET/GET /organizations/{id}/projects[/{proj_id}] for project
browsing. PUT /organizations/{id}/projects/{proj_id}/resources signs a
container.update command (tenant_id=org_id, cpus, memory_mb) and
relays it to the target agent via WireGuard. Viewer role blocked from
scaling mutations.
UI: org detail page now lists projects as clickable links. Project
detail page exposes ResourceForm — container name + CPU/memory inputs
that call the backend action and toast on result.
* feat(nftables): divergence detection and restore/accept flow
Agent: nftables.apply now returns the rendered string, which is stored
in AppState.nft_last_ruleset alongside the checksum. New handlers:
nftables.restore (re-applies last ruleset via apply_raw) and
nftables.accept (snapshots current live checksum as new expected).
Dashboard: GET /agents/{id}/nftables-status returns the latest
unresolved divergence event (resolved = followed by a restored or
accepted event). POST /agents/{id}/nftables-resolve signs the
appropriate command and records the resolution event.
UI: AgentList fetches nftables status per online agent in parallel.
Diverged agents show an orange alert with Restore/Accept buttons
(NftablesAlert client component, resolveNftables server action).
* feat(orgs): add project creation
POST /organizations/{id}/projects — role-gated (viewer blocked), slug
validated, agent existence checked, 409 on slug conflict.
UI: org detail page shows CreateProjectDialog alongside project list.
Dialog has name + auto-derived slug + agent selector (online agents
first). slugConflict toast on 409.
* feat(containers): deploy and lifecycle management UI
Backend: POST /containers (deploy), GET /containers (list via
container.list), POST /containers/{name}/{action} (start|stop|restart|
remove). All relay signed commands to the project's agent. relay_project_command helper reduces duplication across all container handlers.
UI: project detail page now shows running container list with per-
container action buttons (state-dependent: start for stopped, stop+
restart for running, remove always). DeployForm accepts name, image,
ports, env, cpus, memory_mb.
* feat(ui): persistent footer and dynamic login branding
Sidebar gains a small "Made with love by Jaroc · lynx" footer at the
bottom — always visible in the app area per CLAUDE.md requirement.
Login page fetches company_name from /branding at render time and
interpolates into "Sign in to {company}" — supports white-label
without hard-coding "Lynx". i18n string updated in both locales.
* feat(settings): update check and trigger UI
UpdateSection client component: polls GET /admin/update-check, shows
current vs latest version with up-to-date/update-available badges, and
offers "Trigger update on all agents" button (POST /admin/trigger-update)
when behind latest. Server actions added to settings actions.ts.
* feat(scale): implement cross-agent horizontal scaling via WireGuard data-plane tunnels
Per-project WireGuard tunnel (distinct from management plane) between
Agent-A and Agent-B. Dashboard generates keypairs and PSK, stores tunnel
record, commands both agents to configure the interface, then deploys
N replicas on Agent-B.
- Migration 011: data_plane_tunnels table with status check constraint
- handlers: list/create/teardown tunnel endpoints with role guard
- router: wire horizontal scale routes (GET/POST/DELETE)
- agent: wg.data_plane.setup + wg.data_plane.teardown command handlers
derive interface name from tunnel_id prefix (wg-lynx-dp-{8hex})
- UI: HorizontalScaleSection with add-tunnel dialog and inline teardown
- i18n: en + es keys for horizontal scaling
* feat(audit): add per-agent audit log viewer
GET /agents/:id/audit-log returns paginated entries (limit/offset) from
the central audit_log table. Entry hash truncated to 16 chars — full
chain verification is server-side only; UI shows fingerprint for spot
checks.
- Backend: list_audit_log handler + route /{id}/audit-log
- UI: server-rendered table page with pagination (offset param)
- Agent card: link to audit log per agent
- i18n: en + es keys
* feat(domain): add custom domain configuration with Let's Encrypt and HSTS
Singleton domain_config table tracks domain, cert type, expiry, HSTS
state, and port 19443 status. Setup is async (returns 202 immediately)
so certbot wait does not block the HTTP response.
- Migration 012: domain_config singleton row with status check constraint
- GET/POST /domain, POST /domain/verify, /domain/hsts, /domain/close-port
- nginx compose YAML + conf generated in code (no external templates)
- certbot webroot challenge via lynx-nginx container
- Port 19443 closed via nft rule (irreversible remotely by design)
- HSTS only toggleable when status = active
- UI: DomainSection in settings — status badge, setup form, verify DNS,
HSTS toggle, port-close with confirmation dialog
- i18n: en + es
* feat(migration): implement dashboard-to-dashboard migration flow
Zero-downtime dashboard relocation from VPS-A to VPS-B. Uses a one-time
migration token (SHA-256 stored, never in DB plaintext) to gate the
data transfer over HTTPS. pg_dump → VPS-B restore → agent notification
→ per-agent confirmation → VPS-A shutdown.
- Migration 013: migration_state singleton table with role + progress tracking
- GET/POST /migration, /migration/prepare, /migration/start, /migration/abort,
/migration/confirm-shutdown (auth-protected)
- POST /migration/receive, /migration/agent-confirm (token-gated, no user auth)
- Agent: dashboard.migrate command → calls /migration/agent-confirm on VPS-B
using agent's sync token
- UI: MigrationSection in settings with dual-pane source/target flow,
progress tracking, abort control, shutdown confirmation
- i18n: en + es
* feat(auth): add password change and profile endpoints
POST /auth/change-password: verifies current password, hashes new one,
then bulk-deletes all sessions for the user and logs password_changed
reason per CLAUDE.md session invalidation rules. Revokes current access
token in Redis.
GET /auth/me: returns id + username for the authenticated user.
- Profile section in settings shows username + change-password form
- Password change redirects to login (all sessions invalidated)
- i18n: en + es
* feat(settings): add key rotation history to security section
* feat(pki): push cert.update to online agents on rotation; add CI workflows
- Add cert.update dispatch arm to agent — verifies new cert against CA
public key, persists to /etc/lynx/cert.json for durability across restarts
- PermissionLevel derives PartialOrd/Ord so permission comparisons compile
- rotate_agent_certs now pushes cert.update to online agents immediately
after DB update, so live agents adopt the new cert without restart
- Add rotate_expiring_certs() — called automatically during trigger_update
for any agent cert expiring within 14 days (CLAUDE.md: automatic cert
renewal during updates when near expiry)
- CI: dashboard-server workflow (fmt, audit, check/clippy with pg service)
- CI: dashboard-ui workflow (bun install + tsc --noEmit)
- CI: agent workflow (fmt, audit, check/clippy; integration tests left as
commented stub pending self-hosted runner per CLAUDE.md)
- cargo fmt applied across all packages (no logic changes)
* fix(dashboard): use base64url unpadded encoding for signed commands
Dashboard encoded command payload/signature with padded Base64Url;
agent decoded with Base64UrlUnpadded — padding mismatch caused all
commands to be rejected with 'payload: invalid base64url'.
Verified end-to-end on VM: cert.update command now reaches agent,
signature verified, cert persisted to /etc/lynx/cert.json.
* feat(dashboard): add background heartbeat scheduler
Polls all non-lockdown agents every 30s via WireGuard, updating
status and last_heartbeat in DB. Uses tokio interval with
MissedTickBehavior::Skip to avoid thundering-herd on startup.
First tick fires immediately so agent status is current without
requiring a manual heartbeat call.
* fix(dashboard): shorten WireGuard interface name to wg-lynx-dash
Linux IFNAMSIZ=16; 'wg-lynx-dashboard' (18 chars) exceeds the limit.
Renamed to 'wg-lynx-dash' (12 chars) in both wg.rs and admin/handlers.
Verified wg.rotate_psk command reaches agent, updates PSK on wg-lynx-agent,
and tunnel remains alive after rotation.
* fix(dashboard): correct Redis key pattern in JWT session flush
rotate_jwt_sessions was flushing 'jti:*' but access tokens are
stored under 'access:{jti}'. Old tokens remained valid after
rotation. Fixed pattern to 'access:*'.
Verified: after jwt_keys rotation, old tokens return 401; fresh
login issues working tokens.
* feat(dashboard): add ip_pool allocator, logs/reset-admin-password CLI, SSRF-safe downloader
- Migration 018: ip_pool table with SELECT FOR UPDATE allocator replaces
linear scan of agents.wg_ip; release_ip() on agent delete
- Dashboard backend CLI: `logs` subcommand (Podman container logs) and
`reset-admin-password --username` (SSH-only, sets force_password_change)
- Agent CLI: `logs [--follow] [--errors] [--since]` via journalctl
- update.rs: resolve DNS once, validate all IPs against RFC1918/loopback/
link-local before connecting (TOCTOU-safe); pinned reqwest resolver
- update.rs: now declares `mod update` in main.rs (was missing)
- Workspace: add clap, url, ipnetwork sqlx feature
* feat(dashboard): security alerts, headers, startup health guard, intercepted alerts
- Add security_alerts table (migration 019) + alerts.rs module
- Fire alert on login rate limit hit (rate_limit_hit kind)
- Fire alert on session intercepted (IP/UA mismatch in middleware)
- GET /admin/alerts + POST /admin/alerts/:id/acknowledge endpoints
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers
to all responses via tower-http SetResponseHeaderLayer
- update.rs: spawn_startup_health_guard() — polls /health every 2s for
30s after boot; if unhealthy restores .prev, writes /etc/lynx/CRITICAL
- reqwest: add rustls-tls feature to dashboard server
* feat(agent): command rate limiter (100/min) with rejected_rate_limit audit log
- AppState: cmd_rate window counter + cmd_rejected_count/window for alert threshold
- execute_command: reject when window exceeds 100 commands/min; log audit entry
with result=rejected_rate_limit; warn when count >= 3 in current minute
* feat(domain): route nginx/certbot/nftables ops through local agent
All domain management operations now go through the local agent
via signed Ed25519 commands instead of direct OS calls from the backend:
- nginx.deploy: deploy nginx container with image digest
- nginx.update_config: reload nginx after cert/HSTS changes
- certbot.obtain: run certbot webroot challenge on the agent VPS
- nftables.close_setup_port: close port 19443 after domain goes live
Added migration 020 adding is_local_agent BOOLEAN to agents table.
nginx.rs in domain handlers is now a pure config-generator with no
OS side effects. Fixed nginx image to use digest instead of mutable tag.
* refactor(agent): split handlers.rs into handlers/ module
Replaces the monolithic handlers.rs with a handlers/ directory organized
by responsibility, following the project's folder organization rule.
Also adds conflict.rs (incompatible software detection) and nginx.rs
(nginx watchdog) that were created but not yet committed.
* feat: add missing files from prior implementation sessions
- agent: .env.example, migration 003 for nginx_configs table
- dashboard UI: public assets (favicon, icons, flags for i18n selector)
- dashboard UI: admin alerts action + AlertsPanel component
- dashboard UI: Zod schemas organized by feature domain
* feat: implement X.509 mTLS, heartbeat scheduler, PKI, and frontend improvements
Backend (dashboard server):
- X.509 CA generation and agent cert issuance (pki.rs additions)
- mTLS client for agent HTTP calls (agents/client.rs)
- X509 fields added to Config (loaded from Podman secrets or ephemeral)
- Heartbeat scheduler polls agents every 30s, dispatches update.self
- Agent registration response includes TLS cert/key/ca DER (base64)
- WireGuard PSK rotation refactored to use mTLS client
- Cert rotation in admin handlers uses mTLS client
- Background heartbeat task spawned in main.rs
- Redis key pattern fix in JWT session flush (rotation.rs)
- WireGuard interface name shortened to wg-lynx-dash
Agent:
- TLS cert/key/ca loaded from env-configured files (config.rs)
- mTLS TLS acceptor built from loaded certs (main.rs)
- serve_tls() with manual hyper accept loop + axum body bridge
- Cargo.toml: hyper + hyper-util + tower added
- WireGuard PSK and WireGuard interface changes
- Setup agent script updates
Frontend:
- Login / register forms with Zod validation and Server Actions
- Dashboard pages: agents, organizations, projects, settings
- Sidebar navigation, alert components
- Branding, domain, migration, rotation, update UI sections
- i18n messages (en/es)
- biome.json, next.config.ts updates
- proxy.ts auth middleware
- Removed default Next.js placeholder SVGs
* feat(scheduler): full 90-day rotation — JWT flush + WireGuard PSKs + certs
Scheduled rotation now covers all three planes:
- JWT session flush (forces re-login for all users)
- WireGuard PSK rotation (coordinated, no tunnel downtime)
- mTLS cert rotation (certs expiring within 30 days)
Made rotate_jwt_sessions and rotate_wireguard_psks pub for scheduler use.
Rotation log scope changed from 'certificates' to 'all' for scheduled runs.
Also: is_local_agent flag added to RegisterAgentRequest so the install
script can mark the local dashboard agent at registration time.
* feat(domain): custom cert upload with PEM validation (Cloudflare + own cert)
POST /domain/cert/upload accepts cloudflare or custom cert type.
Backend validates before sending to agent:
- PEM parseable
- not_before <= now <= not_after (expiry check)
- SAN or CN matches configured domain
- cert/key pair match (custom certs only, via x509-parser pubkey comparison)
- max 64 KB per field
Agent gains nginx.install_cert command: writes cert+key to
/etc/lynx/nginx/certs/{domain}/ and reloads nginx.
nginx_conf_with_cert() generates nginx config with arbitrary cert paths.
x509-parser added as explicit workspace dep (was already transitive).
* fix(domain): set_hsts uses correct cert path for cloudflare/custom cert type
* feat(ws): add persistent WebSocket channel between agent and dashboard
Agent initiates a WS connection to dashboard on startup using its sync
token. Heartbeats and audit log entries now flow over the persistent WS
instead of HTTP polling. Dashboard can push signed commands via WS with
a 30-second response timeout, falling back to HTTP POST if no WS
connection is active.
Dashboard side:
- agents/ws_hub.rs: WS handler at /agents/{id}/ws, auth via sync_token
hash, stores live connections in AppState.agent_ws_conns
- push_command() sends a command envelope with a correlation UUID and
waits on a oneshot for the agent's command_response frame
- AppState gains agent_ws_conns: Arc<RwLock<HashMap<Uuid, AgentWsConn>>>
- send_command handler tries WS first, falls back to HTTP
Agent side:
- ws_client.rs: connects to ws://{dashboard}/agents/{id}/ws, sends
heartbeats every 30s, receives command frames, reconnects with
exponential backoff (5s → 300s)
- system.rs refactored: command_dispatch + run_verified_command shared
between HTTP handler and WS client — no logic duplication
- Lockdown check applies identically on both paths
* feat(ws): skip HTTP heartbeat poll for WS-connected agents
Heartbeat scheduler now checks AppState.agent_ws_conns before HTTP
polling. Agents with an active WS connection send proactive heartbeats
so no HTTP poll is needed. update.self is still dispatched (via WS)
when the scheduler detects a version mismatch.
* feat(agent+dashboard): nftables 3-chain architecture, real-time metrics WS pipeline
Agent:
- Refactor nftables module to lynx-base/lynx-global/lynx-local 3-chain model
- handle_nftables_apply now supports chain-specific updates {chain, rules} alongside
full apply {wireguard_port} for bootstrap; stores wg_port in AppState
- Divergence detector: auto-restore from last known-good ruleset on any chain mismatch;
apply emergency ruleset if restore fails; per-chain detection for severity logging
- Store post-apply checksum from live kernel state (nft -j list table) to ensure
divergence checker uses matching format; remove checksum_of() that hashed input text
- Stream system metrics (CPU/RAM/disk) via WS every 5 seconds alongside heartbeats
Dashboard backend:
- Add per-agent broadcast channels (broadcast::Sender<Arc<String>>) in AppState for
metric fan-out from agent WS to frontend browser WS clients
- ws_hub: create/drop broadcast channel on agent connect/disconnect; fan-out metrics
messages to all subscribed frontend clients
- New GET /agents/{id}/metrics/ws — browser WebSocket endpoint (JWT auth via cookie)
- Auth middleware: fall back to access_token cookie for WebSocket upgrade requests
since browsers cannot send custom headers on WS connections
- nftables rule management: migration 021, CRUD handlers, push_global/push_local,
rules_to_nft_chain() renderer; global_rule_sync tracking per-agent sync state
- Agent reboot handler; NftRulesPanel + AgentDetailActions frontend components
- Certificate upload section in domain settings (Cloudflare Origin + custom certs)
Dashboard frontend:
- Agent detail page: status/WG info, live metrics panel, nftables rule panels, actions
- useAgentMetrics hook: auto-reconnecting WS subscription with exponential backoff
- MetricsPanel: CPU/RAM/disk with progress bars and color-coded severity
- Next.js rewrites: proxy /api/* to backend (enables browser→backend WS via same origin)
- i18n: metrics keys in en.json + es.json
* feat(events): real-time agent events pipeline to browser WS
- Add global events broadcast channel (broadcast::Sender<Arc<String>>) to AppState
- ws_hub: record connected/disconnected events in agent_events + broadcast to browser
- heartbeat.rs: detect online→offline transition, fire heartbeat_lost event + alert
- events.rs: broadcast_event() helper; receive_event broadcasts after DB insert
- New GET /agents/events/ws endpoint (JWT auth) — streams all agent events to browser
- sign_command_system() in crypto/cmd.rs (nil UUID sentinel for scheduler-triggered cmds)
- rule_line() in nftables/mod.rs for system-level chain body generation
- push_pending_global_sync compilation fixed (SignedCommand→Value, correct function refs)
- Frontend useAgentEvents hook with exponential backoff reconnect
- NotificationBell component in Sidebar: badge count + popover for alert events
- i18n: en/es notification strings (heartbeat_lost, lockdown, divergence, conflicting_software)
* feat(dashboard): add admin user and role management
- Add require_admin middleware (checks *:* permission after require_auth)
- Admin router split: require_admin for admin-only routes, require_auth for own-session routes
- New admin handlers: list/delete users, CRUD roles, manage role permissions and user roles
- /auth/me now returns is_admin flag
- Frontend: Admin page at /app/admin (user table + role management with inline CRUD)
- Sidebar shows Admin nav link only for users with *:* permission
- Guard: non-admins redirected from /app/admin at server-render time
- Last-admin guards on delete user, delete role, remove role permission, remove own admin role
* feat(dashboard): add theme system and user preferences
- Migration 022: user_preferences table (theme + locale per user)
- Backend: GET/POST /auth/me/preferences (require_auth via protected router)
- ThemeProvider (next-themes, dark/light/system, SSR-safe with suppressHydrationWarning)
- ThemeToggle dropdown in sidebar header persists to DB + theme_preference cookie
- Locale layout reads theme_preference cookie as defaultTheme to prevent flash
* feat(dashboard): add locale switcher and i18n sign-out in sidebar
- LocaleSwitcher dropdown with flag icons, navigates to same path in new locale
- Persists locale preference to DB via updateLocaleAction
- Sign out button now uses i18n (t("signOut")) instead of hardcoded English
* feat(dashboard): single-session mode and theme-on-login
- Add single_session column to users table (migration 023)
- Login handler returns theme preference so client sets cookie immediately
- Login action sets theme_preference cookie from login response
- POST /auth/me/single-session endpoint to toggle per-user
- GET /auth/me now returns single_session and is_admin fields
- user_preferences migration (022) applied; get/update preferences handlers complete
- SingleSessionToggle client component in settings profile section
- i18n keys for single-session toggle (en + es)
* test(dashboard): add integration test suite for auth, admin, rotation, and security
Adds tests/auth.rs (25 tests), tests/admin.rs (10 tests), tests/rotation.rs
(5 tests), tests/security.rs (13 tests), and tests/redis_fail.rs (5 tests).
Key infrastructure decisions:
- Login helpers return (token, ip) so protected-route calls include the same
x-real-ip that minted the JWT — fixes IP hash mismatch in require_auth
- helpers::seed_test_admin resets force_password_change=false on testadmin
after each binary run, preventing force-password-change-all from poisoning
subsequent test binaries
- Minimal fake Redis server that responds +OK to CLIENT SETINFO setup
commands (redis-rs 0.27 always sends these) then returns errors for all
subsequent commands, allowing ConnectionManager::new() to succeed while
making every auth command fail → 503
- rustls ring CryptoProvider installed once per test process via
install_default() (ignores AlreadySet) to prevent race on parallel tests
- UUID suffix extended from 8 to 16 chars to eliminate timestamp collisions
when tests run at the same millisecond across parallel test binaries
- needs_scheduled_rotation counts both 'scheduled' and 'update' entries as
resetting the 90-day clock; inline unit tests verify the guard logic
* test(dashboard/server): add unit tests for crypto, validation, and auth logic
- crypto/{hash,jwt,kek,password}.rs: inline unit tests covering roundtrips,
tamper detection, constant-time equality, and edge cases
- auth/validate.rs: tests for username/email/password validation rules
- auth/session.rs: rotate_refresh now returns bool (true = swapped, false =
concurrent request already consumed the token) — 0 rows affected = 401
- auth/handlers.rs: handle rotate_refresh returning false with Unauthorized
- main.rs: moved module declarations to lib.rs; main uses the public library
crate so integration tests can import build_router and AppState directly
* test(dashboard/ui): add Vitest unit tests and Playwright E2E setup
- vitest.config.ts: jsdom environment, globals, setup file, aliased @
- src/__tests__/setup.ts: global test setup
- src/__tests__/(auth)/schemas.test.ts: login/register schema validation
- src/__tests__/(dashboard)/app/organizations/schemas.test.ts: org schemas
- src/__tests__/(dashboard)/app/settings/schemas.test.ts: settings schemas
- playwright.config.ts: E2E config targeting http://localhost:19443
- src/e2e/auth.test.ts: login, register, and logout E2E flows
* ci: add CI workflows for agent, dashboard-server, and dashboard-ui
- dashboard-server: full integration test job with PostgreSQL 18 and Redis 8 service containers; runs migrations and cargo test
- dashboard-ui: Vitest unit test job and Playwright E2E scaffold
- agent: cargo-audit vulnerability scan job
- setup-agent.sh: ANSI colors, section headers, NTP check, incompatible software detection/removal, NAT detection for PersistentKeepalive
- setup-dashboard.sh: same shell script improvements plus pg-tde key generation and Podman secret handling
- agent auth module: 131-line unit test suite for signed command verification (Ed25519, nonce, timestamp, permission checks)
* refactor(auth): split handlers.rs into handlers/ submodule; add WS origin validation
auth/handlers split by responsibility:
- register.rs: register + bootstrap_admin
- login.rs: login (rate-limit, anti-enum, single-session)
- session.rs: logout + refresh (token rotation)
- me.rs: GET /auth/me
- password.rs: POST /auth/change-password
- preferences.rs: GET/POST /auth/me/preferences, POST /auth/me/single-session
- mod.rs: re-exports + shared helpers (build_jwt_keys, extract_ip, extract_ua)
WebSocket origin validation (CSWSH prevention, security.md §12.4):
- events_ws: validate_ws_origin checks Origin header on WS upgrade
- metrics_ws: reuses validate_ws_origin from events_ws
- Absent Origin → allow (non-browser clients, integration tests)
- Domain configured → only https://{domain} allowed
- No domain → any https:// allowed (direct IP:19443 access)
* fix(security): audit log hash chain verification + offline agent pending_sync
audit_log hash chain (security.md §12.10):
- store_audit_entries now fetches last known entry_hash for the agent before
inserting any batch; verifies each entry's previous_hash matches in order
- On mismatch: rejects entire batch, fires audit_integrity_failure alert,
inserts agent_events row, broadcasts to frontend — no partial inserts
- AuditSyncEntry derives Clone to allow in-order sorting of batch
global_rule_sync pending for offline agents (agent.md):
- push_global_rules now inserts global_rule_sync rows with synced_at=NULL
for every offline agent after pushing to online agents
- push_pending_global_sync on WS reconnect finds these rows and pushes
the full current global ruleset — no rules lost during agent downtime
* fix(ui): cursor-pointer + disabled:cursor-not-allowed on Button; robots metadata object format
Button base variant: add cursor-pointer (interactive feedback) and
disabled:cursor-not-allowed (overrides pointer-events-none gap in some
browsers where the parent still shows default cursor).
layout.tsx: robots changed from string "noindex, nofollow" to object
{ index: false, follow: false } per Next.js Metadata type.
* feat(agent): split metrics by frequency; fix WireGuard config path
Metrics frequency (agent.md spec):
- CPU/RAM/disk: every 5 seconds (was 2s uniform)
- Container stats via podman stats: every 10 seconds (was missing entirely)
- ws_client.rs: separate container_ticker at 10s; sends container_metrics frame
- handlers/metrics.rs: separate system/container loops with correct intervals
- metrics/mod.rs: add ContainerMetrics + ContainerStat types; sample_containers()
runs podman stats --no-stream --format json and parses CPU/memory
WireGuard config path (agent.md spec):
- Source of truth: /etc/lynx/wireguard/lynx-wg.conf (owned by lynx-agent, 600)
- Symlink: /etc/wireguard/wg-lynx-agent.conf -> /etc/lynx/wireguard/lynx-wg.conf
for wg-quick compatibility (systemctl enable wg-quick@wg-lynx-agent)
* feat(dashboard): close remaining CLAUDE.md gaps
- alerts::fire now accepts &AppState and broadcasts security_alert events
via events_tx to all subscribed frontend WebSocket sessions
- 90-day scheduler rotates PostgreSQL app-user password and Redis password
(both via Podman secret --replace); manual rotate_keys "all" scope does
the same
- LoginRequest carries redirect_to (relative paths only; external URLs
silently discarded) returned in login response
- extract_ip prefers IPv4 over IPv6 from x-forwarded-for list per spec
* fix(lint): resolve all clippy -D warnings + fmt in agent and server
- Auto-fix and manual fix of 44 dashboard-server clippy errors:
useless anyhow conversions, redundant closures, needless borrows,
redundant pattern matching, empty doc comment line
- Auto-fix and manual fix of 27 agent clippy errors:
useless conversions, collapsible if/match, redundant closure,
PathBuf→Path, dead_code on future-use helpers
- cargo fmt applied to both crates — all diffs resolved
* test(dashboard): fix all 17 Playwright E2E tests
- Add custom Zod error messages to register schema matching i18n strings
- Add [locale]/page.tsx to handle redirect from /en → /en/login
- Fix submit-button test: delay POST route instead of invalid RSC mock
- Add PLAYWRIGHT_NO_SERVER env var support in playwright.config.ts
* fix(dashboard): install rustls ring provider at startup
Required for TLS code paths — prevents panic when rustls tries to
auto-select a crypto provider and ring is not the default.
* ci: add toolchain: stable to all dtolnay/rust-toolchain usages
The action requires a 'toolchain' input. Missing input caused
all Rust CI jobs to fail immediately after startup_failure was
fixed by adding third-party actions to the allowlist.
* fix(ci): remove sqlx-mysql CVE and fix compose formatting
- Set sqlx default-features=false in workspace dep to exclude mysql/sqlite
drivers. Adds macros feature explicitly. Eliminates RUSTSEC-2023-0071
(Marvin Attack in rsa crate pulled in by sqlx-mysql).
- Add audit.toml ignoring RUSTSEC-2023-0071 for residual Cargo.lock entry:
sqlx-mysql remains as a resolved optional dep but is not compiled
(verified via cargo tree --workspace).
- Run cargo fmt on lynx-compose package to pass CI fmt check.
* fix(ci): ignore RUSTSEC-2023-0071 in cargo audit
sqlx-mysql is an unresolvable optional dep in Cargo.lock (sqlx-macros-core
pulls it for compile-time query support) but is not compiled into any binary.
No fixed upgrade available for rsa 0.9.10 — advisory explicitly ignored.
* fix(security): validate IDs as UUIDs before URL interpolation in Server Actions
Adds validateId() helper that rejects non-UUID strings, applied to all
agentId/orgId/projId/tunnelId/ruleId/sessionId/userId parameters before
they are interpolated into fetch URLs. Eliminates CodeQL SSRF alerts on
user-controlled URL paths.
* ci(dashboard-ui): pin all action SHAs; quote CI env var as string
* fix(ui): add validateName() and inline container URL builds to eliminate SSRF alerts
Adds validateName() to api.ts for container name validation.
Removes base()/post() helpers from containers.ts; inlines fetch calls
with explicit validateId/validateName so CodeQL can trace all URL segments.
* test(e2e): waitForURL on root redirect test to handle RSC redirect timing
* fix(ui): use .bind() for local nft actions to avoid passing arrow fns to Client Component
* fix(ui): pass placeholder values to migrationAgentsProgress to satisfy next-intl interpolation
* ci: add explicit permissions block to all workflows (CodeQL hardening)
* chore: remove tracked junk files and update gitignore
Remove .fuse_hidden FUSE temp files and .playwright-mcp session artifacts
accidentally committed. Add patterns to .gitignore to prevent recurrence:
.fuse_hidden*, .playwright-mcp/, playwright-report/, test-results/.
* fix(gitignore): correct playwright-mcp ignore pattern
* refactor: update UI components and dependencies across the dashboard application
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#7)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(dashboard): remove development mode, production only
Delete docker-compose.dev.yml, remove dead /api/* rewrite from next.config.ts
(Next.js rewrites don't proxy WebSocket; nginx handles routing in production),
drop dev/test:watch/test:e2e:ui scripts from package.json, simplify
playwright.config.ts to always use bun run start, clean up .env.example.
* feat(nftables): add output chain support and seed global protection rules
- Add port_end and direction columns to nftables_rules; extend kind CHECK
with 8 new types: drop_invalid_state, tcp_flag_null, tcp_flag_xmas,
tcp_flag_ack_new, icmp_ping_limit, allow_icmp_errors, allow_ndp,
block_output_port
- Seed 7 input protection rules and 21 output port-block rules in lynx-global
(SMTP, Telnet, FTP, NetBIOS, SMB, IRC, RPC, TFTP, X11, SOCKS, LDAP, mDNS,
BitTorrent) so all fresh installs start with hardened defaults
- Add rules_to_nft_output_chain() and port_spec() to nftables compiler;
push_both_chains() sends input + output signed commands per agent
- Agent: add nft_global_output_body and nft_local_output_body to AppState;
render lynx-output chain from both; persist to nftables_state DB
- Agent migration 005: seed lynx-global-output and lynx-local-output rows
* style(ui): apply biome formatting across dashboard ui
* fix: replace gen_random_uuid() with uuidv7() — all tables must use UUID v7
gen_random_uuid() generates v4 UUIDs. The project rule is UUID v7 everywhere.
PostgreSQL 18 provides uuidv7() built-in.
- 019_security_alerts, 003_nginx_configs: fix DEFAULT in CREATE TABLE
- 015_roles_permissions, 022_nftables_protection: fix uuidv7() in seeded VALUES
- New migrations 023 and 006: ALTER TABLE to fix DEFAULT on already-created tables
in existing databases
* feat(ui): switch font from Geist to Poppins
* chore(github): add repo metadata files
Add CODEOWNERS, issue template, CONTRIBUTING, README, and SECURITY policy.
* ci: add URL audit script and job to CI pipelines
Ensure all outbound download URLs in update modules are from allowed
GitHub domains. Script scans a fixed set of files; unsupported URLs
require explicit suppression comment or ALLOWED update.
* ci: add release workflows for agent and dashboard
Build musl static binaries for x86_64 and arm64, sign each artifact
with Ed25519 (RELEASE_SIGN_KEY secret), and publish to GitHub Releases.
Dashboard workflow also bundles the Next.js standalone binary and
static assets tarball, each signed separately.
* chore(deps): scan full cargo workspace in dependabot daily
Point cargo ecosystem at workspace root /lynx instead of a single
member crate. Switch to daily schedule and use the new rust label.
* feat(db): add arch column to agents table
Stores the agent architecture (x86_64 / arm64) reported via heartbeat
so the dashboard can build the correct GitHub Release download URL.
* feat(agent): report arch in heartbeat, hardcode release key, add fallback updater
- Heartbeat now includes arch field (x86_64 / arm64)
- Release verify key hardcoded in binary; removed env-var lookup
- SSRF guard: validate_github_url added to update::perform_update
- Track last_dashboard_contact timestamp in state
- Fallback updater: if dashboard absent >6h, poll GitHub directly
and self-update without waiting for a dashboard command
* feat(dashboard): track agent arch and use in arch-aware update dispatch
- ws_hub: persist arch from heartbeat into agents table
- heartbeat + scheduler: read arch from DB when building download URL
- Scheduler now runs release check immediately at startup instead of
waiting for the first hourly tick
* feat(dashboard): implement full binary swap pipeline for releases
- update.rs: download+verify all artifacts before touching any files,
swap frontend binary and extract assets tarball, then swap backend
and exit for Podman restart. Uses Podman socket to stop/start the
frontend container during swap.
- docker-compose: replace build-from-source with prebuilt alpine
containers that mount binaries from host paths. healthcheck uses
wget (present in alpine) instead of curl.
- setup-dashboard.sh: detect arch, fetch latest release tag, download
and verify (Ed25519) backend + frontend binary + assets at install
time before starting services.
* fix: strengthen security hardening across agent and dashboard, including improved input validation, secure file permissions, SSRF protection, constant-time authentication checks, and hardened WebSocket origins.
* refactor: enhance system security with constant-time cryptographic checks, input validation, and updated contribution documentation.
* fix(security): prevent SQL injection in DB password rotation
ALTER USER does not support parameterized queries. Previous code used
string interpolation with single-quote escaping that could be bypassed.
Replaced with dollar-quoting ($$hex$$) — passwords are hex-encoded
[0-9a-f] so the dollar sign can never appear inside them.
Affects both the agent (lynx_agent_app) and dashboard (lynx_dashboard_app)
rotation handlers.
* fix(agent): eliminate shell injection in rootless Podman execution
runuser -l -c "podman args" passes the command through a shell, making
any user-controlled argument a potential injection vector. Replaced with
runuser -u user -- podman arg1 arg2 which hands args directly to execve.
Also explicitly sets HOME and XDG_RUNTIME_DIR since -u does not load the
login environment, which rootless Podman requires.
* fix(agent): add SSRF protection with DNS pinning for binary downloads
Resolves the DNS TOCTOU vulnerability where a hostname could resolve to
a different IP between the validation check and the actual connection,
allowing DNS rebinding to reach internal services.
Fix: resolve hostname once, validate all returned IPs against RFC1918/
loopback/link-local/unspecified ranges, then pin the reqwest client to
the validated IP via .resolve() so no second DNS lookup occurs.
Applies to both the primary update path (update/mod.rs) and the fallback
GitHub API poller (update/fallback.rs). Adds the url crate for URL parsing.
* fix(agent): harden WireGuard handler — interface validation, file permissions, zeroize secrets
* fix(agent): prevent path traversal in nginx cert path construction
* fix(security): heartbeat ACK requires Ed25519 signed command — bearer token alone insufficient
* fix(security): validate audit log hash chain on HTTP sync path
* fix(security): WS origin validation, vps:read permission checks, WG IP error propagation
* fix(auth): per-username rate limit, SHA256 ct_eq for setup token, constant-time IP/UA comparison
* fix(auth): enforce nbf and iat claims in JWT validation — reject future-issued tokens
* fix(security): strict domain and branding input validation — block path traversal vectors
* fix(security): validate IP/CIDR format and port range in nftables rule handlers
* fix(security): add HSTS header to all HTTPS responses
* fix(db): extend cert_type constraint to include cloudflare and custom cert types
* fix(scripts): replace eval PKG_UPDATE with direct case statement — eliminate eval injection
* feat(site): add GitHub Pages landing page
Dark amber-themed static landing with hero, architecture diagram,
feature cards, install guide, and stack badges. Poppins + JetBrains
Mono fonts. .nojekyll disables Jekyll processing.
* fix(ci): close security audit gaps across all components
- Add bun audit (--audit-level high) to dashboard-ui workflow — was
required by project docs, package.json had the script, CI never ran it
- Add cargo audit to lynx-compose workflow — only Rust component without
CVE scanning
- Pin upload-artifact, download-artifact, and action-gh-release to commit
SHAs in release workflows — mutable tags are a supply chain risk
- Verify musl bun binary with SHASUMS256.txt before unzip in
release-dashboard workflow — previously downloaded with no integrity check
- Drop security-events: write from all CI workflows — permission was
declared but SARIF was never uploaded (dead permission, violates least
privilege)
* fix(agent): extend nftables_state chain constraint before seeding output rows
Migration 005 inserted 'lynx-global-output' and 'lynx-local-output'
but the CHECK constraint from migration 004 only permitted 'lynx-global'
and 'lynx-local', causing migration 5 to fail on every fresh install.
Fix: ALTER TABLE to drop and re-add the constraint with all four valid
chain values before the INSERT.
Also applies cargo fmt across agent and dashboard-server (whitespace
and line-length reformats only, no logic changes).
* fix(db): renumber conflicting dashboard migrations and fix IPv6 link-local mask
Migrations 022_nftables_protection and 023_fix_uuid_defaults were added
after dashboard@1.0.1 shipped (which already contains 022_user_preferences
and 023_single_session). sqlx rejected the duplicate version numbers.
Renumbered to 026 and 027 — first available slots after the new 024
(agents_arch) and 025 (domain_cert_types) migrations.
Also fixes a clippy::bad_bit_mask in agent SSRF guard: the fe80::/10
link-local check used mask 0xff00, which zeroes the lower byte and can
never equal 0xfe80. Correct mask for a /10 prefix is 0xffc0.
* fix(server): refactor update params to struct; bypass rate limit in CI tests
perform_dashboard_update had 9 arguments — refactored to
DashboardUpdateParams struct to satisfy clippy::too_many_arguments.
Integration tests share a Redis instance and run in parallel, causing
the per-username login rate limit (10/15min) to be exhausted when
multiple test binaries all log in as testadmin concurrently.
Added LYNX_SKIP_RATE_LIMIT env-var bypass in rate_limit::check() and
set it in the CI test step — production deployments never set this var.
* fix(tests): serialize dashboard-server tests and flush testadmin rate-limit key
The env-var bypass (LYNX_SKIP_RATE_LIMIT) broke redis_fail tests that
rely on Redis being the first failure point to produce 503. Reverted.
Root cause: parallel test binaries all logging in as testadmin exceed the
10/15min per-username limit. Fix:
- Add --test-threads=1 to CI cargo test — serialises tests within each
binary, so at most ~4 concurrent testadmin logins across binaries (well
under the limit of 10).
- Flush rl:login:u:testadmin from Redis in test_state() before each test
so the counter resets between tests running in the same binary.
* fix(ui): remove duplicate migration label props from settings page
* ci(lint-shell): always run shellcheck on PRs to main, not just on .sh changes
* chore(deps): bump nix from 0.29.0 to 0.31.3 in /lynx
Bumps [nix](https://github.com/nix-rust/nix) from 0.29.0 to 0.31.3.
- [Changelog](https://github.com/nix-rust/nix/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nix-rust/nix/compare/v0.29.0...v0.31.3)
---
updated-dependencies:
- dependency-name: nix
dependency-version: 0.31.3
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [redis](https://github.com/redis-rs/redis-rs) from 0.27.6 to 1.2.1. - [Release notes](https://github.com/redis-rs/redis-rs/releases) - [Commits](redis-rs/redis-rs@redis-0.27.6...redis-1.2.1) --- updated-dependencies: - dependency-name: redis dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) from 0.24.0 to 0.29.0. - [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md) - [Commits](snapview/tokio-tungstenite@v0.24.0...v0.29.0) --- updated-dependencies: - dependency-name: tokio-tungstenite dependency-version: 0.29.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [rand](https://github.com/rust-random/rand) from 0.8.6 to 0.10.1. - [Release notes](https://github.com/rust-random/rand/releases) - [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md) - [Commits](rust-random/rand@0.8.6...0.10.1) --- updated-dependencies: - dependency-name: rand dependency-version: 0.10.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [rcgen](https://github.com/rustls/rcgen) from 0.13.2 to 0.14.8. - [Release notes](https://github.com/rustls/rcgen/releases) - [Commits](rustls/rcgen@v0.13.2...v0.14.8) --- updated-dependencies: - dependency-name: rcgen dependency-version: 0.14.8 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump bollard from 0.17.1 to 0.21.0 in /lynx Bumps [bollard](https://github.com/fussybeaver/bollard) from 0.17.1 to 0.21.0. - [Release notes](https://github.com/fussybeaver/bollard/releases) - [Changelog](https://github.com/fussybeaver/bollard/blob/master/RELEASE.md) - [Commits](fussybeaver/bollard@v0.17.1...v0.21.0) --- updated-dependencies: - dependency-name: bollard dependency-version: 0.21.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> * fix(compose): adapt to bollard 0.21.0 API All Options types moved to bollard::query_parameters. Models renamed: Config→ContainerCreateBody, CreateNetworkOptions→NetworkCreateRequest, ConnectNetworkOptions→NetworkConnectRequest, CreateVolumeOptions→VolumeCreateRequest, MountTypeEnum→MountType. BodyType/body_full required for upload/build calls. Fields changed from concrete to Option<T> throughout. * style(compose): apply rustfmt --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Both release workflows now run CI checks before building: - release-agent: prepare (sqlx cache) + ci (fmt/clippy/test/audit) must pass before build - release-dashboard: prepare (sqlx cache) + ci-server (fmt/clippy/test/audit) + ci-ui (typecheck/test/audit) must pass before build Fixes prepare job in release-dashboard: postgres:18 → 18-alpine, consistent credentials, both migration sets run before workspace-wide sqlx cache check.
…changes - rand 0.10: replace removed rand::RngCore/rand::rngs::OsRng with rand::Rng + rand::rng() - rand 0.10: replace thread_rng() with rng(), gen_range() with random_range() (RngExt) - rcgen 0.14: replace private CertificateParams::from_ca_cert_der + 3-arg signed_by with Issuer::from_ca_cert_der + 2-arg signed_by - tungstenite 0.29: Message::Text now takes Utf8Bytes — add .into() on String args - crypto/kek.rs: use aes_gcm::aead::OsRng for generate_nonce (rand_core 0.6 compat)
- nftables: forward chain policy accept (containers need host routing);
allow DNS from RFC1918 subnets for aardvark-dns resolution
- install-dashboard.sh: compose down --volumes on clean reinstall so
postgres_data volume is removed and init SQL reruns with new secrets
- release-dashboard.yml: patch server.js __dirname before bun compile
so frontend binary resolves paths at deployment dir, not CI workspace
- update.rs: write version file on successful update, add VERSION_FILE const
- scheduler.rs: read version from /etc/lynx/bin/dashboard-version at
runtime instead of compile-time env!("CARGO_PKG_VERSION") (always 0.1.0)
- docker-compose.yml: correct postgres data volume mount path
- 01-init.sql: grant USAGE,CREATE on schema public to app user
- Add lynx-dashboard-nginx container (nginx:1.27-alpine) that terminates TLS on 19443 and proxies to frontend on 3000 - Add nginx/default.conf: TLS 1.3, security headers, WebSocket support, error_page 502/503 serves updating.html during auto-updates - Add nginx/updating.html: auto-refresh page shown during backend updates - install-dashboard.sh: generate self-signed ECDSA P-256 cert (90 days, SAN=host IP) and deploy nginx config before starting containers - docker-compose.yml: frontend no longer exposes port 19443 directly; nginx depends_on frontend healthy before starting
- install.sh: delegate to lynx/ scripts via exec (remove broken scripts/ path)
- setup-agent.sh: add GitHub Releases download with Ed25519 verify, persist
agent UUID across reinstalls, fix binary path to /etc/lynx/bin/, add
CAP_SYS_BOOT, add /etc/lynx/bin to ReadWritePaths, add update option
- setup-dashboard.sh: write version file after binary install
- update-dashboard.sh: new — version check, download, sig verify, atomic swap,
health check, .prev backup, frontend stop/start
- update-agent.sh: new — same flow for agent systemd service
- scripts/lint.sh: update script list (remove deleted, add lynx/* scripts)
- lint-shell.yml: add bash -n step, pin shellcheck 0.10.0, drop unused permission
- scripts/{dashboard,agent}/install-*.sh: remove (incomplete, replaced by lynx/)
- setup-api.sh: remove (empty file)
…tup-dashboard.sh - _cleanup_existing(): stop containers first (stop → remove → verify) to prevent secrets/volumes being locked; fail explicitly if containers remain after removal to surface mismatches early instead of silently reusing stale state - Add broad volume pattern sweep (dashboard.*postgres|postgres.*dashboard) to catch compose project-name variations (tmp_, lynx-dashboard_, dashboard_) - Start nginx via direct podman run instead of podman-compose up -d nginx; avoids stale --requires=<container_id> dependency graph errors when IDs change across sequential up -d SERVICE calls - Add podman secret rm before PSK creation (idempotent); prevents "secret name in use" error on reinstall when prior cleanup could not remove a mounted secret docker-compose.yml: rename redis → valkey service/container/image/commands
…esting - UUID v7 fallback: split variant byte into separate var to avoid printf '%x' receiving non-integer (decimal + hex chars concatenated) - PostgreSQL volume mount: /var/lib/postgresql/data → /var/lib/postgresql (Postgres 18 stores in version-specific subdir; /data suffix causes "data found in unused mount" error and container exits immediately) - GRANT USAGE → GRANT USAGE, CREATE on schema public: lynx_agent_app needs CREATE to run sqlx migrations on first startup - podman logs --tail flag: move before container name (Podman syntax) - podman secret create: add >/dev/null to suppress secret ID noise in output - /etc/lynx and /etc/lynx/bin permissions: 700→755 so that lynx-agent user (service user) can traverse directories and exec the binary; credentials/ and wireguard/ remain 700
…nit SQL
- setup-dashboard.sh: add lynx-dashboard-containers.service (oneshot
systemd unit) to start all 5 containers on boot, since podman-restart.service
only handles restart-policy=always, not unless-stopped
- setup-dashboard.sh: copy init SQL to /etc/lynx/db/init/ before running
podman-compose so the postgres bind mount points to a persistent path
that survives reboots (previously used /tmp/... which is wiped on boot)
- setup-dashboard.sh: cleanup removes lynx-dashboard-containers.service on reinstall
- docker-compose.yml: use ${LYNX_DB_INIT_DIR:-./server/db/init} so prod uses
/etc/lynx/db/init while local dev falls back to the repo-relative path
- setup-agent.sh: add lynx-agent-postgres.service (oneshot) to start postgres
container on boot; lynx-agent.service now requires and depends on it;
cleanup removes the new service on reinstall
Global rate limit shared across all source IPs means repeated automated SSH connections (health checks, deployments) can exhaust the burst bucket and lock out all admins simultaneously. Per-IP metering with burst 20 gives each source its own bucket — one noisy IP cannot affect others.
Root cause: table inet lynx-dashboard and table inet lynx-agent both hooked input at priority 0. nftables processes ALL tables — drop in any table kills the packet regardless of accept verdicts in other tables. The Rust agent's lynx-base had no SSH or ICMP rules, so it dropped every packet including SSH, causing total host lockout after reboot. Agent (nftables/mod.rs): - Add flush prefix to render_ruleset() so re-apply never leaves orphaned chains from previous installs coexisting at the same hook priority - Add SSH rate-limit meter and ICMP rules to lynx-base (immutable) - Add dashboard_port: Option<u16> to Ruleset — opens 19443 on dashboard VPS only; remote agents get None - Add container DNS rules (iifname "podman*" dport 53) to lynx-base when dashboard_port is set — rootful Podman on dashboard VPS needs aardvark-dns reachable from the host INPUT chain - Add Podman + WireGuard forward rules to lynx-forward when dashboard_port is set — agents connect to the backend via wg-lynx-dash; without these the FORWARD chain drops those packets - Persist rendered ruleset to /etc/nftables-lynx-agent.conf after every apply/apply_raw/apply_emergency so nftables.service loads correct rules on reboot before lynx-agent.service starts - Extract EMERGENCY_RULESET as module-level const with flush prefix - Add dashboard_port field to Config (reads DASHBOARD_PORT env var) - Add 16 new tests (92 total, all passing) setup-agent.sh: - Replace table inet with lynx-base/lynx-forward/lynx-output chain names consistent with Rust agent (was input/forward/output — both coexisted in the same table at the same hook, with drop winning) - Detect dashboard VPS by presence of /etc/lynx/nginx/default.conf; set DASHBOARD_PORT=19443 in agent config when detected - Add container DNS and WireGuard/Podman forward rules to bootstrap nftables on dashboard VPS so rules are correct during the boot window before lynx-agent.service applies its own ruleset setup-dashboard.sh: - Replace table inet lynx-dashboard with table inet lynx-agent bootstrap (same names as Rust agent) so nftables.service and the binary stay consistent across reboots - Write to /etc/nftables-lynx-agent.conf; migrate old lynx-dashboard include from /etc/nftables.conf - Order lynx-dashboard-containers.service After=lynx-agent.service so containers start only after the agent has applied the correct ruleset (container DNS must be live before containers try to resolve names)
… lynx-forward Netavark's DNAT rewrites published port destinations from the host IP to 10.89.x.x container IPs in PREROUTING. The FORWARD chain then processes the rewritten packet. Without an explicit accept for new connections to 10.89.0.0/16, lynx-forward policy drop kills these packets — making all published ports (including 19443) unreachable from outside the host. Add 'ip daddr 10.89.0.0/16 ct state new accept' to the dashboard-VPS forward rules in render_ruleset(), setup-dashboard.sh bootstrap, and setup-agent.sh bootstrap (IS_DASHBOARD_VPS path). Remote agents don't use rootful Podman so this rule is dashboard-only (gated on dashboard_port.is_some()). Fixes: port 19443 timing out externally after fresh install.
The frontend container mounts /etc/lynx/frontend as :ro to prevent the process from modifying its own binary. Next.js writes prerender cache to .next/cache which fails with EROFS on the read-only mount. Add a named Podman volume (frontend_next_cache) overlaying the cache subdirectory so Next.js can write cache without relaxing the :ro protection on the binary and static asset directory.
rotation.rs: apply rustfmt (trailing commas + alignment). setup-dashboard.sh: replace 7 unused IP variables (SC2034) with a documentation comment; only the 3 subnet variables used in the network-creation loop are kept as actual variables.
render_contains_dashboard_management_ip expected 10.100.0.1 with minimal_ruleset (dashboard_port=None), but management plane rules are only rendered when dashboard_port is set. Update the test to: - set dashboard_port=Some(19443) when verifying IP presence - also assert 10.100.0.0/16 subnet rule is included - add negative case: management plane rules absent on remote agent (no dashboard_port)
…, frontend update recovery - Switch agent and dashboard PostgreSQL to Percona 18.4-2 image (sha256:71cce6ed) which includes pg_tde - Enable pg_tde via POSTGRES_INITDB_ARGS=-c shared_preload_libraries=pg_tde - Init SQL uses new pg_tde 18.x API: pg_tde_add_database_key_provider_file + pg_tde_create_key_using_database_key_provider + pg_tde_set_key_using_database_key_provider + ALTER DATABASE ... SET default_table_access_method=tde_heap - Keyring managed by pg_tde at /etc/lynx/pg-keyring/lynx.keyring (writable bind-mount, UID 26) - All future tables in lynx_agent and lynx_dashboard are transparently encrypted (tde_heap) - Generate lynx-kek credential (KEK for agent envelope encryption) → /etc/lynx/credentials/lynx-kek + LoadCredential - Store WG PSK as /etc/lynx/credentials/lynx-wg-psk (separate from wg.conf) + LoadCredential for tmpfs isolation - Add KEK_FILE to agent.env so Rust agent can load KEK from /run/credentials/ - Remove stale lynx-agent-pg-data volume before starting fresh PostgreSQL container (prevents silently skipped init SQL) - Create /etc/lynx/pg-keyring/ with chown 26:26 before container start - update-dashboard.sh: add frontend health check wait + .prev binary restore if frontend fails to start after update - Add backup warning in dashboard install completion for /etc/lynx/pg-keyring/lynx.keyring and KEK Tested on lynx-3 VM: Percona image pulls, pg_tde initializes, keyring file created, agent migrations run, audit_log/used_nonces/nftables_state all encrypted (pg_tde_is_encrypted=t). Agent active with all 6 credentials present.
- setup-dashboard.sh: chmod 644 on lynx-dashboard-pg-root and lynx-dashboard-pg-pass after creation. Percona pg_tde image runs entrypoint as UID 26 (postgres) from the start — unlike the official postgres image which starts as root. Bind-mounted 600 root:root files are unreadable by UID 26. The /etc/lynx/secrets/ directory (700 root:root) provides host-level protection. - All 4 scripts: replace first-match release fetch with semver-max. GitHub /releases API returns releases in non-deterministic order; first-match could return an older release (1.4.9 instead of 1.4.11). Fix: collect all matching tags, sort by (major, minor, patch), print the highest.
…ths, secrets mount
- scheduler.rs: use semver-max instead of .next() to find the actual
latest release tag — GitHub API does not guarantee sorted order, so
first-match returned stale versions (1.4.9 instead of 1.4.11)
- scheduler.rs: filter prerelease + draft releases in version check
- scheduler.rs: needs_scheduled_rotation returns false on fresh install
(no rotation_log rows) and seeds the 90-day clock; prevents an
immediate startup rotation that changes the PG password before the
backend has finished initialising
- podman.rs: add container_stop/container_start using versioned libpod
API (/v4.0.0/libpod/containers/{name}/stop|start) — unversioned path
/containers/{name}/stop returns 404 on Podman 4.x
- update.rs: swap_frontend uses podman::container_stop/start; remove
duplicate podman_request helper that used the broken unversioned path
- setup-dashboard.sh: mount /etc/lynx/secrets:/etc/lynx/secrets:rw in
backend container so rotation.rs can update the bind-mounted secret
files (host file write was failing with ENOENT — path not in container)
- update sqlx offline cache for new rotation_log seed INSERT
…al fixes Dashboard: - ws_hub: add 90s idle timeout on agent WS recv; prevents dead TCP connections (iptables DROP scenario) from keeping is_connected() true indefinitely - ws_hub: add 30s timeout on socket.send() so idle timer fires even when TCP send buffer is full (WG interface gone scenario) - heartbeat: fire heartbeat_lost after grace period expires for offline agents, not only on online→offline transition Agent: - sync: reset last_synced_at to epoch on 422 (hash chain mismatch) so agent resends from genesis on next cycle instead of spamming the dashboard - wireguard: handle_wg_rotate_psk now also updates PresharedKey in lynx-wg.conf so the correct PSK survives a full reboot (wg-quick reads from conf) - audit: add RejectedRateLimit result variant matching spec (result was "rejected" with error field, now "rejected_rate_limit" directly) - setup-agent: make lynx-wg-psk LoadCredential optional (-prefix) so missing PSK credential doesn't hard-crash the service unit
…ough
- rotation: replace broken HTTP dispatch to remote agents with push_command
(ws_hub), the only channel that works for remote agents (they bind to
127.0.0.1 only). Silently failed before — now WS-connected agents rotate
correctly; offline agents get a WARN and will rotate on reconnect.
- ws_hub: command_response handler passed only the inner 'body' field to
callers, dropping 'ok' and 'error' fields. Callers received {} for any
failed command, making error types invisible and the rate-limit result
undetectable. Now passes msg.data (full response) so ok/error propagate.
- sqlx cache: add offline cache entries for two heartbeat.rs query_scalar!
queries (past_grace + already_fired) added in aab67eb.
…own on clock skew heartbeat_ack is a connection-management command that must succeed even when the agent's clock is out of sync. Without this bypass, clock skew >30s causes all heartbeat_acks to be rejected → lockdown timer expires → agent enters lockdown. Nonce dedup still prevents replay attacks. Fixes §3.5 test: agent must not enter lockdown from clock skew alone.
build_signed_command now defaults to nftables.get_status so timestamp tests properly verify rejection. heartbeat_ack bypass has its own dedicated test.
Remote agents now generate nftables lynx-base rule:
ip saddr {dashboard_ip} udp dport 51820 accept
instead of accepting WireGuard from any IP. Dashboard VPS
keeps unrestricted (agents connect from many public IPs).
Dashboard WG IP extracted from DASHBOARD_URL at ruleset
build time via new extract_url_host() helper.
Also add extract_url_host() public fn, a test for WG source
IP restriction, and update sqlx offline cache with test queries.
Fixes §19.3 test failure.
…uard - perform_update() now backs up current binary to /etc/lynx/bin/lynx-agent.prev before atomic swap — enables .prev recovery if new binary fails to start - spawn_startup_health_guard() polls /health every 2s for 30s on startup: - healthy → delete /etc/lynx/CRITICAL if present (recovery from prior failure) - unhealthy → restore .prev and exit 1 (systemd restarts with old binary) - .prev unavailable → write /etc/lynx/CRITICAL, exit 1 (manual recovery needed) - removes dead tmp_path() helper (now uses fixed /etc/lynx/bin/lynx-agent path)
…lockdowns Introduces LockdownReason enum to track the cause of lockdown: - Heartbeat: cleared by heartbeat_ack (normal reconnection) - PgUnreachable: requires manual restart (admin fix + systemctl restart) - IncompatibleSoftware: requires manual restart (remove software + restart) - NftablesFailure: requires manual restart (fix nft + restart) Previously, heartbeat_ack cleared lockdown regardless of reason — a conflict removal failure or PG crash would be silently cleared by the next heartbeat_ack (within 30s) without any admin intervention.
Jaro-c
added a commit
that referenced
this pull request
Jun 3, 2026
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#14)
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /app/* layout with sidebar and auth gate.
* feat(agent): implement agent core with auth, nftables, Podman, metrics, and audit log
Full Lynx agent implementation:
- Ed25519 command authorization: signature + nonce dedup + timestamp ±30s + permission level
- Audit log with SHA-256 hash chaining for tamper detection
- nftables: atomic full-ruleset apply via stdin, inter-org blocking, checksum tracking
- Podman: rootless tenant users (lynx-tenant-{id}) with isolated subuid/subgid ranges
- WebSocket metrics: CPU (2-sample /proc/stat), RAM (/proc/meminfo), disk (statvfs), streamed every 2s
- Heartbeat watchdog: 300s timeout → lockdown mode (rejects all commands)
- PostgreSQL via sqlx with migrations (audit_log + used_nonces tables)
- Bearer token auth with constant-time comparison via `subtle`
feat(dashboard): production compose with 3 isolated networks and external Podman secrets
Replaces single-network compose with:
- lynx-dashboard-db: backend + postgres only
- lynx-dashboard-cache: backend + redis only
- lynx-dashboard-app: frontend + backend (frontend sole external port: 19443)
- All secrets as external Podman secrets (pg-root, pg-pass, redis-pass, api-token, kek, pepper)
- DATABASE_URL_FILE / REDIS_URL_FILE pattern for secret injection
- PostgreSQL init script creates isolated lynx_dashboard_app user
feat(dashboard): install script with secret generation, health-ordered startup, nftables, WireGuard, TLS
- Detects existing install → abort / update / reinstall (requires exact confirmation phrase)
- Generates 8 Podman secrets via pipe (no shell history, subshell isolation, memory overwrite)
- Starts services in dependency order with health-gate: postgres → redis → backend → frontend
- nftables ruleset: table inet lynx-dashboard, ports 22 + 19443 + 51820 UDP
- Self-signed P-256 cert (90 days) + systemd timer for rotation every 80 days
- WireGuard dashboard keypair + PSK stored as Podman secret; PSK shown once in final output
* feat(agent): add agent install script with WireGuard, systemd, Podman DB, and nftables setup
Complete agent VPS install:
- Creates lynx-agent system user with CAP_NET_ADMIN/CAP_SYS_ADMIN/CAP_SETUID/CAP_SETGID
- Allocates subuid/subgid ranges (1000000+65536) for rootless tenant Podman
- Generates 4 Podman secrets via subshell pipe (no shell history trace)
- Starts PostgreSQL container on lynx-agent-db network (port 127.0.0.1:5434)
- Installs binary as systemd service with LoadCredential (secrets in tmpfs)
- WireGuard peer config built from admin-provided dashboard pubkey + PSK
- nftables: lynx-agent table, only WG inbound + SSH, agent API only from WG iface
- Existing install detection with reinstall phrase confirmation
feat(dashboard): agent management API with WG peer management and command relay
- GET/POST /agents — list and register agents (allocates WG IP .2–.254)
- GET/DELETE /agents/{id} — individual agent ops + WG peer removal
- POST /agents/{id}/heartbeat — relay heartbeat to agent via WireGuard, update status
- POST /agents/{id}/cmd — sign command with Ed25519, relay to agent API
- agents + agent_events migration (migration 004)
- crypto/cmd.rs: sign_command builds JWS-style payload (base64url JSON + Ed25519 sig)
- reqwest client for agent HTTP relay over WireGuard
- AppError extended: NotFound, BadRequest, BadGateway, AgentUnavailable
- From<sqlx::Error> impl so ? works in handlers
* feat(ui): add /app/agents page with Server Component, skeleton loading, and i18n
- AgentList: Server Component, fetches GET /agents with 30s revalidation
- Status badges: online (default) / lockdown (destructive) / offline (secondary)
- Empty state matches overview page pattern
- AgentListSkeleton: 3-card skeleton for Suspense boundary
- i18n keys in en.json + es.json (app.agents namespace)
- Sidebar already linked to /app/agents
* feat: implement audit log sync (agent → dashboard) with per-agent sync token auth
Agent side:
- sync/mod.rs: Tokio task runs every 60s, batches unsynced audit_log entries
- Tracks sync cursor in sync_state table (migration 002)
- Config: DASHBOARD_URL + SYNC_TOKEN (optional — sync disabled if absent)
- reqwest client for HTTP POST to dashboard over WireGuard
Dashboard side:
- Migration 005: central audit_log table + sync_token_hash column on agents
- POST /agents/{id}/audit-sync: no JWT auth — uses per-agent sync token
- Sync token generated at registration (SHA-256 hash stored, plain returned once)
- Hash chain preserved: entry_hash/previous_hash stored verbatim from agent
- ON CONFLICT DO NOTHING: idempotent re-sync
- Constant-time token comparison via subtle crate
* feat: add organizations backend + UI
Backend (migration 006):
- organizations table: id (UUID v7), name, slug (unique), owner_id
- organization_members: role-based (owner/admin/member/viewer)
- projects table: belongs to org + agent, unique slug per org
- GET/POST /organizations, GET/DELETE /organizations/{id}
- List scoped to authenticated user's memberships
- Slug validation: lowercase alphanumeric + hyphens only
- Conflict handling: returns 409 on duplicate slug
UI:
- /app/organizations: Server Component, 60s revalidation
- Card grid with slug, member count, org ID
- OrgListSkeleton for Suspense boundary
- i18n: en + es (app.organizations namespace)
* feat(agent): add nftables divergence detection with dashboard alerting
- Background task runs every 60s: compares live nft checksum vs last apply() result
- Mismatch → WARN log + POST /agents/{id}/events with nftables_divergence event
- Auth: same per-agent sync token used by audit-sync
- AppState gains nft_checksum: Arc<Mutex<Option<String>>> for shared last-known-good state
- set_nft_checksum() called after each apply() in nftables apply handler (future)
Dashboard side:
- POST /agents/{id}/events endpoint — agent-callable (sync token auth)
- Validates event type against allowed list, inserts into agent_events
- Same pattern as audit-sync: no user JWT needed
* feat(dashboard/ui): add settings page with session management and key rotation
Sessions list with per-session revoke (DELETE /admin/sessions/{id}).
JWT key rotation button (POST /admin/rotate) clears cookies and redirects
to login — all sessions invalidated on rotation.
i18n: en + es under app.settings namespace.
* feat(agent/dashboard): container management commands + auto-update system
Agent:
- container.deploy (podman compose up via tenant user)
- container.start/stop/remove/restart (podman CLI via runuser)
- container.update (CPU/memory quota, vertical scaling)
- update.self: download binary from GitHub Releases, verify Ed25519 sig,
atomic rename, graceful exit for systemd restart
Dashboard admin:
- GET /admin/update-check: polls GitHub API for latest release
- POST /admin/trigger-update: signs update.self command to online agents
- GET /admin/update-log: recent update history
- migrations/008_update_log.sql: tracks per-agent update outcomes
* feat(dashboard): agent registration dialog, overview stats, events feed
UI:
- Register Agent dialog (POST /agents) shows agent ID, WG IP, sync token
once; token hidden with reveal toggle
- Overview page: online agent count, org count, recent events feed
- i18n en + es for new keys
Backend:
- GET /agents/events: recent agent_events across all agents (limit param)
* feat(dashboard/agent): WireGuard PSK rotation
Dashboard admin rotate with scope wireguard_psks or all:
- Generates new PSK per online agent via wg genpsk
- Updates dashboard WG peer with new PSK
- Sends signed wg.rotate_psk command to each agent
Agent wg.rotate_psk handler:
- Applies new PSK to wg-lynx-agent peer
- Persists via wg-quick save
* feat(ui): org creation dialog, i18n keys for org/agent registration
- Create Organization dialog with auto-derived slug
- Register Agent and Create Org buttons on respective pages
- i18n en+es: organizations.create/slugConflict/createError
* feat(dashboard/agent): internal PKI — CA issues Ed25519 certs to agents
Dashboard:
- crypto/pki: CA Ed25519 keypair, issue_cert, verify_cert
- CA keypair loaded from CA_PRIVATE_KEY/CA_PUBLIC_KEY env (ephemeral in dev)
- Agent registration returns signed cert + CA public key (once)
- cert stored in agents table (migration 009_agent_certs.sql)
- admin rotate certificates scope re-issues certs for all agents
Agent:
- cert.rs: verify dashboard CA cert (CA_PUBLIC_KEY env)
- Cert verification available for command auth second layer
* feat(dashboard): white-label branding from PostgreSQL
Single-row white_label table stores company_name, logo_url, and
three hex palette colors. Public GET /branding serves defaults on
empty DB. Auth-protected PUT /admin/branding (via admin route_layer)
validates hex format before UPSERT.
UI injects --brand-primary/secondary/accent CSS vars on <html> at
layout level (revalidated every 60s). Sidebar header renders
company_name or logo. Settings page adds BrandingForm (Server Action
PUT) with color swatches + inputs.
* feat(orgs): member management — invite, list, remove
Backend: GET/POST /organizations/{id}/members, DELETE
/organizations/{id}/members/{user_id}. Role-gated (owner/admin only
for mutations). Prevents removing org owner. Adds AppError::Forbidden
(403) variant.
UI: org cards now link to detail page. Detail page lists members with
role badges and per-member remove buttons (owner row protected). Invite
dialog: username + role selector. Server actions revalidate page on
mutation.
* feat(orgs): project listing and vertical scaling UI
Backend: GET/GET /organizations/{id}/projects[/{proj_id}] for project
browsing. PUT /organizations/{id}/projects/{proj_id}/resources signs a
container.update command (tenant_id=org_id, cpus, memory_mb) and
relays it to the target agent via WireGuard. Viewer role blocked from
scaling mutations.
UI: org detail page now lists projects as clickable links. Project
detail page exposes ResourceForm — container name + CPU/memory inputs
that call the backend action and toast on result.
* feat(nftables): divergence detection and restore/accept flow
Agent: nftables.apply now returns the rendered string, which is stored
in AppState.nft_last_ruleset alongside the checksum. New handlers:
nftables.restore (re-applies last ruleset via apply_raw) and
nftables.accept (snapshots current live checksum as new expected).
Dashboard: GET /agents/{id}/nftables-status returns the latest
unresolved divergence event (resolved = followed by a restored or
accepted event). POST /agents/{id}/nftables-resolve signs the
appropriate command and records the resolution event.
UI: AgentList fetches nftables status per online agent in parallel.
Diverged agents show an orange alert with Restore/Accept buttons
(NftablesAlert client component, resolveNftables server action).
* feat(orgs): add project creation
POST /organizations/{id}/projects — role-gated (viewer blocked), slug
validated, agent existence checked, 409 on slug conflict.
UI: org detail page shows CreateProjectDialog alongside project list.
Dialog has name + auto-derived slug + agent selector (online agents
first). slugConflict toast on 409.
* feat(containers): deploy and lifecycle management UI
Backend: POST /containers (deploy), GET /containers (list via
container.list), POST /containers/{name}/{action} (start|stop|restart|
remove). All relay signed commands to the project's agent. relay_project_command helper reduces duplication across all container handlers.
UI: project detail page now shows running container list with per-
container action buttons (state-dependent: start for stopped, stop+
restart for running, remove always). DeployForm accepts name, image,
ports, env, cpus, memory_mb.
* feat(ui): persistent footer and dynamic login branding
Sidebar gains a small "Made with love by Jaroc · lynx" footer at the
bottom — always visible in the app area per CLAUDE.md requirement.
Login page fetches company_name from /branding at render time and
interpolates into "Sign in to {company}" — supports white-label
without hard-coding "Lynx". i18n string updated in both locales.
* feat(settings): update check and trigger UI
UpdateSection client component: polls GET /admin/update-check, shows
current vs latest version with up-to-date/update-available badges, and
offers "Trigger update on all agents" button (POST /admin/trigger-update)
when behind latest. Server actions added to settings actions.ts.
* feat(scale): implement cross-agent horizontal scaling via WireGuard data-plane tunnels
Per-project WireGuard tunnel (distinct from management plane) between
Agent-A and Agent-B. Dashboard generates keypairs and PSK, stores tunnel
record, commands both agents to configure the interface, then deploys
N replicas on Agent-B.
- Migration 011: data_plane_tunnels table with status check constraint
- handlers: list/create/teardown tunnel endpoints with role guard
- router: wire horizontal scale routes (GET/POST/DELETE)
- agent: wg.data_plane.setup + wg.data_plane.teardown command handlers
derive interface name from tunnel_id prefix (wg-lynx-dp-{8hex})
- UI: HorizontalScaleSection with add-tunnel dialog and inline teardown
- i18n: en + es keys for horizontal scaling
* feat(audit): add per-agent audit log viewer
GET /agents/:id/audit-log returns paginated entries (limit/offset) from
the central audit_log table. Entry hash truncated to 16 chars — full
chain verification is server-side only; UI shows fingerprint for spot
checks.
- Backend: list_audit_log handler + route /{id}/audit-log
- UI: server-rendered table page with pagination (offset param)
- Agent card: link to audit log per agent
- i18n: en + es keys
* feat(domain): add custom domain configuration with Let's Encrypt and HSTS
Singleton domain_config table tracks domain, cert type, expiry, HSTS
state, and port 19443 status. Setup is async (returns 202 immediately)
so certbot wait does not block the HTTP response.
- Migration 012: domain_config singleton row with status check constraint
- GET/POST /domain, POST /domain/verify, /domain/hsts, /domain/close-port
- nginx compose YAML + conf generated in code (no external templates)
- certbot webroot challenge via lynx-nginx container
- Port 19443 closed via nft rule (irreversible remotely by design)
- HSTS only toggleable when status = active
- UI: DomainSection in settings — status badge, setup form, verify DNS,
HSTS toggle, port-close with confirmation dialog
- i18n: en + es
* feat(migration): implement dashboard-to-dashboard migration flow
Zero-downtime dashboard relocation from VPS-A to VPS-B. Uses a one-time
migration token (SHA-256 stored, never in DB plaintext) to gate the
data transfer over HTTPS. pg_dump → VPS-B restore → agent notification
→ per-agent confirmation → VPS-A shutdown.
- Migration 013: migration_state singleton table with role + progress tracking
- GET/POST /migration, /migration/prepare, /migration/start, /migration/abort,
/migration/confirm-shutdown (auth-protected)
- POST /migration/receive, /migration/agent-confirm (token-gated, no user auth)
- Agent: dashboard.migrate command → calls /migration/agent-confirm on VPS-B
using agent's sync token
- UI: MigrationSection in settings with dual-pane source/target flow,
progress tracking, abort control, shutdown confirmation
- i18n: en + es
* feat(auth): add password change and profile endpoints
POST /auth/change-password: verifies current password, hashes new one,
then bulk-deletes all sessions for the user and logs password_changed
reason per CLAUDE.md session invalidation rules. Revokes current access
token in Redis.
GET /auth/me: returns id + username for the authenticated user.
- Profile section in settings shows username + change-password form
- Password change redirects to login (all sessions invalidated)
- i18n: en + es
* feat(settings): add key rotation history to security section
* feat(pki): push cert.update to online agents on rotation; add CI workflows
- Add cert.update dispatch arm to agent — verifies new cert against CA
public key, persists to /etc/lynx/cert.json for durability across restarts
- PermissionLevel derives PartialOrd/Ord so permission comparisons compile
- rotate_agent_certs now pushes cert.update to online agents immediately
after DB update, so live agents adopt the new cert without restart
- Add rotate_expiring_certs() — called automatically during trigger_update
for any agent cert expiring within 14 days (CLAUDE.md: automatic cert
renewal during updates when near expiry)
- CI: dashboard-server workflow (fmt, audit, check/clippy with pg service)
- CI: dashboard-ui workflow (bun install + tsc --noEmit)
- CI: agent workflow (fmt, audit, check/clippy; integration tests left as
commented stub pending self-hosted runner per CLAUDE.md)
- cargo fmt applied across all packages (no logic changes)
* fix(dashboard): use base64url unpadded encoding for signed commands
Dashboard encoded command payload/signature with padded Base64Url;
agent decoded with Base64UrlUnpadded — padding mismatch caused all
commands to be rejected with 'payload: invalid base64url'.
Verified end-to-end on VM: cert.update command now reaches agent,
signature verified, cert persisted to /etc/lynx/cert.json.
* feat(dashboard): add background heartbeat scheduler
Polls all non-lockdown agents every 30s via WireGuard, updating
status and last_heartbeat in DB. Uses tokio interval with
MissedTickBehavior::Skip to avoid thundering-herd on startup.
First tick fires immediately so agent status is current without
requiring a manual heartbeat call.
* fix(dashboard): shorten WireGuard interface name to wg-lynx-dash
Linux IFNAMSIZ=16; 'wg-lynx-dashboard' (18 chars) exceeds the limit.
Renamed to 'wg-lynx-dash' (12 chars) in both wg.rs and admin/handlers.
Verified wg.rotate_psk command reaches agent, updates PSK on wg-lynx-agent,
and tunnel remains alive after rotation.
* fix(dashboard): correct Redis key pattern in JWT session flush
rotate_jwt_sessions was flushing 'jti:*' but access tokens are
stored under 'access:{jti}'. Old tokens remained valid after
rotation. Fixed pattern to 'access:*'.
Verified: after jwt_keys rotation, old tokens return 401; fresh
login issues working tokens.
* feat(dashboard): add ip_pool allocator, logs/reset-admin-password CLI, SSRF-safe downloader
- Migration 018: ip_pool table with SELECT FOR UPDATE allocator replaces
linear scan of agents.wg_ip; release_ip() on agent delete
- Dashboard backend CLI: `logs` subcommand (Podman container logs) and
`reset-admin-password --username` (SSH-only, sets force_password_change)
- Agent CLI: `logs [--follow] [--errors] [--since]` via journalctl
- update.rs: resolve DNS once, validate all IPs against RFC1918/loopback/
link-local before connecting (TOCTOU-safe); pinned reqwest resolver
- update.rs: now declares `mod update` in main.rs (was missing)
- Workspace: add clap, url, ipnetwork sqlx feature
* feat(dashboard): security alerts, headers, startup health guard, intercepted alerts
- Add security_alerts table (migration 019) + alerts.rs module
- Fire alert on login rate limit hit (rate_limit_hit kind)
- Fire alert on session intercepted (IP/UA mismatch in middleware)
- GET /admin/alerts + POST /admin/alerts/:id/acknowledge endpoints
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers
to all responses via tower-http SetResponseHeaderLayer
- update.rs: spawn_startup_health_guard() — polls /health every 2s for
30s after boot; if unhealthy restores .prev, writes /etc/lynx/CRITICAL
- reqwest: add rustls-tls feature to dashboard server
* feat(agent): command rate limiter (100/min) with rejected_rate_limit audit log
- AppState: cmd_rate window counter + cmd_rejected_count/window for alert threshold
- execute_command: reject when window exceeds 100 commands/min; log audit entry
with result=rejected_rate_limit; warn when count >= 3 in current minute
* feat(domain): route nginx/certbot/nftables ops through local agent
All domain management operations now go through the local agent
via signed Ed25519 commands instead of direct OS calls from the backend:
- nginx.deploy: deploy nginx container with image digest
- nginx.update_config: reload nginx after cert/HSTS changes
- certbot.obtain: run certbot webroot challenge on the agent VPS
- nftables.close_setup_port: close port 19443 after domain goes live
Added migration 020 adding is_local_agent BOOLEAN to agents table.
nginx.rs in domain handlers is now a pure config-generator with no
OS side effects. Fixed nginx image to use digest instead of mutable tag.
* refactor(agent): split handlers.rs into handlers/ module
Replaces the monolithic handlers.rs with a handlers/ directory organized
by responsibility, following the project's folder organization rule.
Also adds conflict.rs (incompatible software detection) and nginx.rs
(nginx watchdog) that were created but not yet committed.
* feat: add missing files from prior implementation sessions
- agent: .env.example, migration 003 for nginx_configs table
- dashboard UI: public assets (favicon, icons, flags for i18n selector)
- dashboard UI: admin alerts action + AlertsPanel component
- dashboard UI: Zod schemas organized by feature domain
* feat: implement X.509 mTLS, heartbeat scheduler, PKI, and frontend improvements
Backend (dashboard server):
- X.509 CA generation and agent cert issuance (pki.rs additions)
- mTLS client for agent HTTP calls (agents/client.rs)
- X509 fields added to Config (loaded from Podman secrets or ephemeral)
- Heartbeat scheduler polls agents every 30s, dispatches update.self
- Agent registration response includes TLS cert/key/ca DER (base64)
- WireGuard PSK rotation refactored to use mTLS client
- Cert rotation in admin handlers uses mTLS client
- Background heartbeat task spawned in main.rs
- Redis key pattern fix in JWT session flush (rotation.rs)
- WireGuard interface name shortened to wg-lynx-dash
Agent:
- TLS cert/key/ca loaded from env-configured files (config.rs)
- mTLS TLS acceptor built from loaded certs (main.rs)
- serve_tls() with manual hyper accept loop + axum body bridge
- Cargo.toml: hyper + hyper-util + tower added
- WireGuard PSK and WireGuard interface changes
- Setup agent script updates
Frontend:
- Login / register forms with Zod validation and Server Actions
- Dashboard pages: agents, organizations, projects, settings
- Sidebar navigation, alert components
- Branding, domain, migration, rotation, update UI sections
- i18n messages (en/es)
- biome.json, next.config.ts updates
- proxy.ts auth middleware
- Removed default Next.js placeholder SVGs
* feat(scheduler): full 90-day rotation — JWT flush + WireGuard PSKs + certs
Scheduled rotation now covers all three planes:
- JWT session flush (forces re-login for all users)
- WireGuard PSK rotation (coordinated, no tunnel downtime)
- mTLS cert rotation (certs expiring within 30 days)
Made rotate_jwt_sessions and rotate_wireguard_psks pub for scheduler use.
Rotation log scope changed from 'certificates' to 'all' for scheduled runs.
Also: is_local_agent flag added to RegisterAgentRequest so the install
script can mark the local dashboard agent at registration time.
* feat(domain): custom cert upload with PEM validation (Cloudflare + own cert)
POST /domain/cert/upload accepts cloudflare or custom cert type.
Backend validates before sending to agent:
- PEM parseable
- not_before <= now <= not_after (expiry check)
- SAN or CN matches configured domain
- cert/key pair match (custom certs only, via x509-parser pubkey comparison)
- max 64 KB per field
Agent gains nginx.install_cert command: writes cert+key to
/etc/lynx/nginx/certs/{domain}/ and reloads nginx.
nginx_conf_with_cert() generates nginx config with arbitrary cert paths.
x509-parser added as explicit workspace dep (was already transitive).
* fix(domain): set_hsts uses correct cert path for cloudflare/custom cert type
* feat(ws): add persistent WebSocket channel between agent and dashboard
Agent initiates a WS connection to dashboard on startup using its sync
token. Heartbeats and audit log entries now flow over the persistent WS
instead of HTTP polling. Dashboard can push signed commands via WS with
a 30-second response timeout, falling back to HTTP POST if no WS
connection is active.
Dashboard side:
- agents/ws_hub.rs: WS handler at /agents/{id}/ws, auth via sync_token
hash, stores live connections in AppState.agent_ws_conns
- push_command() sends a command envelope with a correlation UUID and
waits on a oneshot for the agent's command_response frame
- AppState gains agent_ws_conns: Arc<RwLock<HashMap<Uuid, AgentWsConn>>>
- send_command handler tries WS first, falls back to HTTP
Agent side:
- ws_client.rs: connects to ws://{dashboard}/agents/{id}/ws, sends
heartbeats every 30s, receives command frames, reconnects with
exponential backoff (5s → 300s)
- system.rs refactored: command_dispatch + run_verified_command shared
between HTTP handler and WS client — no logic duplication
- Lockdown check applies identically on both paths
* feat(ws): skip HTTP heartbeat poll for WS-connected agents
Heartbeat scheduler now checks AppState.agent_ws_conns before HTTP
polling. Agents with an active WS connection send proactive heartbeats
so no HTTP poll is needed. update.self is still dispatched (via WS)
when the scheduler detects a version mismatch.
* feat(agent+dashboard): nftables 3-chain architecture, real-time metrics WS pipeline
Agent:
- Refactor nftables module to lynx-base/lynx-global/lynx-local 3-chain model
- handle_nftables_apply now supports chain-specific updates {chain, rules} alongside
full apply {wireguard_port} for bootstrap; stores wg_port in AppState
- Divergence detector: auto-restore from last known-good ruleset on any chain mismatch;
apply emergency ruleset if restore fails; per-chain detection for severity logging
- Store post-apply checksum from live kernel state (nft -j list table) to ensure
divergence checker uses matching format; remove checksum_of() that hashed input text
- Stream system metrics (CPU/RAM/disk) via WS every 5 seconds alongside heartbeats
Dashboard backend:
- Add per-agent broadcast channels (broadcast::Sender<Arc<String>>) in AppState for
metric fan-out from agent WS to frontend browser WS clients
- ws_hub: create/drop broadcast channel on agent connect/disconnect; fan-out metrics
messages to all subscribed frontend clients
- New GET /agents/{id}/metrics/ws — browser WebSocket endpoint (JWT auth via cookie)
- Auth middleware: fall back to access_token cookie for WebSocket upgrade requests
since browsers cannot send custom headers on WS connections
- nftables rule management: migration 021, CRUD handlers, push_global/push_local,
rules_to_nft_chain() renderer; global_rule_sync tracking per-agent sync state
- Agent reboot handler; NftRulesPanel + AgentDetailActions frontend components
- Certificate upload section in domain settings (Cloudflare Origin + custom certs)
Dashboard frontend:
- Agent detail page: status/WG info, live metrics panel, nftables rule panels, actions
- useAgentMetrics hook: auto-reconnecting WS subscription with exponential backoff
- MetricsPanel: CPU/RAM/disk with progress bars and color-coded severity
- Next.js rewrites: proxy /api/* to backend (enables browser→backend WS via same origin)
- i18n: metrics keys in en.json + es.json
* feat(events): real-time agent events pipeline to browser WS
- Add global events broadcast channel (broadcast::Sender<Arc<String>>) to AppState
- ws_hub: record connected/disconnected events in agent_events + broadcast to browser
- heartbeat.rs: detect online→offline transition, fire heartbeat_lost event + alert
- events.rs: broadcast_event() helper; receive_event broadcasts after DB insert
- New GET /agents/events/ws endpoint (JWT auth) — streams all agent events to browser
- sign_command_system() in crypto/cmd.rs (nil UUID sentinel for scheduler-triggered cmds)
- rule_line() in nftables/mod.rs for system-level chain body generation
- push_pending_global_sync compilation fixed (SignedCommand→Value, correct function refs)
- Frontend useAgentEvents hook with exponential backoff reconnect
- NotificationBell component in Sidebar: badge count + popover for alert events
- i18n: en/es notification strings (heartbeat_lost, lockdown, divergence, conflicting_software)
* feat(dashboard): add admin user and role management
- Add require_admin middleware (checks *:* permission after require_auth)
- Admin router split: require_admin for admin-only routes, require_auth for own-session routes
- New admin handlers: list/delete users, CRUD roles, manage role permissions and user roles
- /auth/me now returns is_admin flag
- Frontend: Admin page at /app/admin (user table + role management with inline CRUD)
- Sidebar shows Admin nav link only for users with *:* permission
- Guard: non-admins redirected from /app/admin at server-render time
- Last-admin guards on delete user, delete role, remove role permission, remove own admin role
* feat(dashboard): add theme system and user preferences
- Migration 022: user_preferences table (theme + locale per user)
- Backend: GET/POST /auth/me/preferences (require_auth via protected router)
- ThemeProvider (next-themes, dark/light/system, SSR-safe with suppressHydrationWarning)
- ThemeToggle dropdown in sidebar header persists to DB + theme_preference cookie
- Locale layout reads theme_preference cookie as defaultTheme to prevent flash
* feat(dashboard): add locale switcher and i18n sign-out in sidebar
- LocaleSwitcher dropdown with flag icons, navigates to same path in new locale
- Persists locale preference to DB via updateLocaleAction
- Sign out button now uses i18n (t("signOut")) instead of hardcoded English
* feat(dashboard): single-session mode and theme-on-login
- Add single_session column to users table (migration 023)
- Login handler returns theme preference so client sets cookie immediately
- Login action sets theme_preference cookie from login response
- POST /auth/me/single-session endpoint to toggle per-user
- GET /auth/me now returns single_session and is_admin fields
- user_preferences migration (022) applied; get/update preferences handlers complete
- SingleSessionToggle client component in settings profile section
- i18n keys for single-session toggle (en + es)
* test(dashboard): add integration test suite for auth, admin, rotation, and security
Adds tests/auth.rs (25 tests), tests/admin.rs (10 tests), tests/rotation.rs
(5 tests), tests/security.rs (13 tests), and tests/redis_fail.rs (5 tests).
Key infrastructure decisions:
- Login helpers return (token, ip) so protected-route calls include the same
x-real-ip that minted the JWT — fixes IP hash mismatch in require_auth
- helpers::seed_test_admin resets force_password_change=false on testadmin
after each binary run, preventing force-password-change-all from poisoning
subsequent test binaries
- Minimal fake Redis server that responds +OK to CLIENT SETINFO setup
commands (redis-rs 0.27 always sends these) then returns errors for all
subsequent commands, allowing ConnectionManager::new() to succeed while
making every auth command fail → 503
- rustls ring CryptoProvider installed once per test process via
install_default() (ignores AlreadySet) to prevent race on parallel tests
- UUID suffix extended from 8 to 16 chars to eliminate timestamp collisions
when tests run at the same millisecond across parallel test binaries
- needs_scheduled_rotation counts both 'scheduled' and 'update' entries as
resetting the 90-day clock; inline unit tests verify the guard logic
* test(dashboard/server): add unit tests for crypto, validation, and auth logic
- crypto/{hash,jwt,kek,password}.rs: inline unit tests covering roundtrips,
tamper detection, constant-time equality, and edge cases
- auth/validate.rs: tests for username/email/password validation rules
- auth/session.rs: rotate_refresh now returns bool (true = swapped, false =
concurrent request already consumed the token) — 0 rows affected = 401
- auth/handlers.rs: handle rotate_refresh returning false with Unauthorized
- main.rs: moved module declarations to lib.rs; main uses the public library
crate so integration tests can import build_router and AppState directly
* test(dashboard/ui): add Vitest unit tests and Playwright E2E setup
- vitest.config.ts: jsdom environment, globals, setup file, aliased @
- src/__tests__/setup.ts: global test setup
- src/__tests__/(auth)/schemas.test.ts: login/register schema validation
- src/__tests__/(dashboard)/app/organizations/schemas.test.ts: org schemas
- src/__tests__/(dashboard)/app/settings/schemas.test.ts: settings schemas
- playwright.config.ts: E2E config targeting http://localhost:19443
- src/e2e/auth.test.ts: login, register, and logout E2E flows
* ci: add CI workflows for agent, dashboard-server, and dashboard-ui
- dashboard-server: full integration test job with PostgreSQL 18 and Redis 8 service containers; runs migrations and cargo test
- dashboard-ui: Vitest unit test job and Playwright E2E scaffold
- agent: cargo-audit vulnerability scan job
- setup-agent.sh: ANSI colors, section headers, NTP check, incompatible software detection/removal, NAT detection for PersistentKeepalive
- setup-dashboard.sh: same shell script improvements plus pg-tde key generation and Podman secret handling
- agent auth module: 131-line unit test suite for signed command verification (Ed25519, nonce, timestamp, permission checks)
* refactor(auth): split handlers.rs into handlers/ submodule; add WS origin validation
auth/handlers split by responsibility:
- register.rs: register + bootstrap_admin
- login.rs: login (rate-limit, anti-enum, single-session)
- session.rs: logout + refresh (token rotation)
- me.rs: GET /auth/me
- password.rs: POST /auth/change-password
- preferences.rs: GET/POST /auth/me/preferences, POST /auth/me/single-session
- mod.rs: re-exports + shared helpers (build_jwt_keys, extract_ip, extract_ua)
WebSocket origin validation (CSWSH prevention, security.md §12.4):
- events_ws: validate_ws_origin checks Origin header on WS upgrade
- metrics_ws: reuses validate_ws_origin from events_ws
- Absent Origin → allow (non-browser clients, integration tests)
- Domain configured → only https://{domain} allowed
- No domain → any https:// allowed (direct IP:19443 access)
* fix(security): audit log hash chain verification + offline agent pending_sync
audit_log hash chain (security.md §12.10):
- store_audit_entries now fetches last known entry_hash for the agent before
inserting any batch; verifies each entry's previous_hash matches in order
- On mismatch: rejects entire batch, fires audit_integrity_failure alert,
inserts agent_events row, broadcasts to frontend — no partial inserts
- AuditSyncEntry derives Clone to allow in-order sorting of batch
global_rule_sync pending for offline agents (agent.md):
- push_global_rules now inserts global_rule_sync rows with synced_at=NULL
for every offline agent after pushing to online agents
- push_pending_global_sync on WS reconnect finds these rows and pushes
the full current global ruleset — no rules lost during agent downtime
* fix(ui): cursor-pointer + disabled:cursor-not-allowed on Button; robots metadata object format
Button base variant: add cursor-pointer (interactive feedback) and
disabled:cursor-not-allowed (overrides pointer-events-none gap in some
browsers where the parent still shows default cursor).
layout.tsx: robots changed from string "noindex, nofollow" to object
{ index: false, follow: false } per Next.js Metadata type.
* feat(agent): split metrics by frequency; fix WireGuard config path
Metrics frequency (agent.md spec):
- CPU/RAM/disk: every 5 seconds (was 2s uniform)
- Container stats via podman stats: every 10 seconds (was missing entirely)
- ws_client.rs: separate container_ticker at 10s; sends container_metrics frame
- handlers/metrics.rs: separate system/container loops with correct intervals
- metrics/mod.rs: add ContainerMetrics + ContainerStat types; sample_containers()
runs podman stats --no-stream --format json and parses CPU/memory
WireGuard config path (agent.md spec):
- Source of truth: /etc/lynx/wireguard/lynx-wg.conf (owned by lynx-agent, 600)
- Symlink: /etc/wireguard/wg-lynx-agent.conf -> /etc/lynx/wireguard/lynx-wg.conf
for wg-quick compatibility (systemctl enable wg-quick@wg-lynx-agent)
* feat(dashboard): close remaining CLAUDE.md gaps
- alerts::fire now accepts &AppState and broadcasts security_alert events
via events_tx to all subscribed frontend WebSocket sessions
- 90-day scheduler rotates PostgreSQL app-user password and Redis password
(both via Podman secret --replace); manual rotate_keys "all" scope does
the same
- LoginRequest carries redirect_to (relative paths only; external URLs
silently discarded) returned in login response
- extract_ip prefers IPv4 over IPv6 from x-forwarded-for list per spec
* fix(lint): resolve all clippy -D warnings + fmt in agent and server
- Auto-fix and manual fix of 44 dashboard-server clippy errors:
useless anyhow conversions, redundant closures, needless borrows,
redundant pattern matching, empty doc comment line
- Auto-fix and manual fix of 27 agent clippy errors:
useless conversions, collapsible if/match, redundant closure,
PathBuf→Path, dead_code on future-use helpers
- cargo fmt applied to both crates — all diffs resolved
* test(dashboard): fix all 17 Playwright E2E tests
- Add custom Zod error messages to register schema matching i18n strings
- Add [locale]/page.tsx to handle redirect from /en → /en/login
- Fix submit-button test: delay POST route instead of invalid RSC mock
- Add PLAYWRIGHT_NO_SERVER env var support in playwright.config.ts
* fix(dashboard): install rustls ring provider at startup
Required for TLS code paths — prevents panic when rustls tries to
auto-select a crypto provider and ring is not the default.
* ci: add toolchain: stable to all dtolnay/rust-toolchain usages
The action requires a 'toolchain' input. Missing input caused
all Rust CI jobs to fail immediately after startup_failure was
fixed by adding third-party actions to the allowlist.
* fix(ci): remove sqlx-mysql CVE and fix compose formatting
- Set sqlx default-features=false in workspace dep to exclude mysql/sqlite
drivers. Adds macros feature explicitly. Eliminates RUSTSEC-2023-0071
(Marvin Attack in rsa crate pulled in by sqlx-mysql).
- Add audit.toml ignoring RUSTSEC-2023-0071 for residual Cargo.lock entry:
sqlx-mysql remains as a resolved optional dep but is not compiled
(verified via cargo tree --workspace).
- Run cargo fmt on lynx-compose package to pass CI fmt check.
* fix(ci): ignore RUSTSEC-2023-0071 in cargo audit
sqlx-mysql is an unresolvable optional dep in Cargo.lock (sqlx-macros-core
pulls it for compile-time query support) but is not compiled into any binary.
No fixed upgrade available for rsa 0.9.10 — advisory explicitly ignored.
* fix(security): validate IDs as UUIDs before URL interpolation in Server Actions
Adds validateId() helper that rejects non-UUID strings, applied to all
agentId/orgId/projId/tunnelId/ruleId/sessionId/userId parameters before
they are interpolated into fetch URLs. Eliminates CodeQL SSRF alerts on
user-controlled URL paths.
* ci(dashboard-ui): pin all action SHAs; quote CI env var as string
* fix(ui): add validateName() and inline container URL builds to eliminate SSRF alerts
Adds validateName() to api.ts for container name validation.
Removes base()/post() helpers from containers.ts; inlines fetch calls
with explicit validateId/validateName so CodeQL can trace all URL segments.
* test(e2e): waitForURL on root redirect test to handle RSC redirect timing
* fix(ui): use .bind() for local nft actions to avoid passing arrow fns to Client Component
* fix(ui): pass placeholder values to migrationAgentsProgress to satisfy next-intl interpolation
* ci: add explicit permissions block to all workflows (CodeQL hardening)
* chore: remove tracked junk files and update gitignore
Remove .fuse_hidden FUSE temp files and .playwright-mcp session artifacts
accidentally committed. Add patterns to .gitignore to prevent recurrence:
.fuse_hidden*, .playwright-mcp/, playwright-report/, test-results/.
* fix(gitignore): correct playwright-mcp ignore pattern
* refactor: update UI components and dependencies across the dashboard application
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#7)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.2
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(dashboard): remove development mode, production only
Delete docker-compose.dev.yml, remove dead /api/* rewrite from next.config.ts
(Next.js rewrites don't proxy WebSocket; nginx handles routing in production),
drop dev/test:watch/test:e2e:ui scripts from package.json, simplify
playwright.config.ts to always use bun run start, clean up .env.example.
* feat(nftables): add output chain support and seed global protection rules
- Add port_end and direction columns to nftables_rules; extend kind CHECK
with 8 new types: drop_invalid_state, tcp_flag_null, tcp_flag_xmas,
tcp_flag_ack_new, icmp_ping_limit, allow_icmp_errors, allow_ndp,
block_output_port
- Seed 7 input protection rules and 21 output port-block rules in lynx-global
(SMTP, Telnet, FTP, NetBIOS, SMB, IRC, RPC, TFTP, X11, SOCKS, LDAP, mDNS,
BitTorrent) so all fresh installs start with hardened defaults
- Add rules_to_nft_output_chain() and port_spec() to nftables compiler;
push_both_chains() sends input + output signed commands per agent
- Agent: add nft_global_output_body and nft_local_output_body to AppState;
render lynx-output chain from both; persist to nftables_state DB
- Agent migration 005: seed lynx-global-output and lynx-local-output rows
* style(ui): apply biome formatting across dashboard ui
* fix: replace gen_random_uuid() with uuidv7() — all tables must use UUID v7
gen_random_uuid() generates v4 UUIDs. The project rule is UUID v7 everywhere.
PostgreSQL 18 provides uuidv7() built-in.
- 019_security_alerts, 003_nginx_configs: fix DEFAULT in CREATE TABLE
- 015_roles_permissions, 022_nftables_protection: fix uuidv7() in seeded VALUES
- New migrations 023 and 006: ALTER TABLE to fix DEFAULT on already-created tables
in existing databases
* feat(ui): switch font from Geist to Poppins
* chore(github): add repo metadata files
Add CODEOWNERS, issue template, CONTRIBUTING, README, and SECURITY policy.
* ci: add URL audit script and job to CI pipelines
Ensure all outbound download URLs in update modules are from allowed
GitHub domains. Script scans a fixed set of files; unsupported URLs
require explicit suppression comment or ALLOWED update.
* ci: add release workflows for agent and dashboard
Build musl static binaries for x86_64 and arm64, sign each artifact
with Ed25519 (RELEASE_SIGN_KEY secret), and publish to GitHub Releases.
Dashboard workflow also bundles the Next.js standalone binary and
static assets tarball, each signed separately.
* chore(deps): scan full cargo workspace in dependabot daily
Point cargo ecosystem at workspace root /lynx instead of a single
member crate. Switch to daily schedule and use the new rust label.
* feat(db): add arch column to agents table
Stores the agent architecture (x86_64 / arm64) reported via heartbeat
so the dashboard can build the correct GitHub Release download URL.
* feat(agent): report arch in heartbeat, hardcode release key, add fallback updater
- Heartbeat now includes arch field (x86_64 / arm64)
- Release verify key hardcoded in binary; removed env-var lookup
- SSRF guard: validate_github_url added to update::perform_update
- Track last_dashboard_contact timestamp in state
- Fallback updater: if dashboard absent >6h, poll GitHub directly
and self-update without waiting for a dashboard command
* feat(dashboard): track agent arch and use in arch-aware update dispatch
- ws_hub: persist arch from heartbeat into agents table
- heartbeat + scheduler: read arch from DB when building download URL
- Scheduler now runs release check immediately at startup instead of
waiting for the first hourly tick
* feat(dashboard): implement full binary swap pipeline for releases
- update.rs: download+verify all artifacts before touching any files,
swap frontend binary and extract assets tarball, then swap backend
and exit for Podman restart. Uses Podman socket to stop/start the
frontend container during swap.
- docker-compose: replace build-from-source with prebuilt alpine
containers that mount binaries from host paths. healthcheck uses
wget (present in alpine) instead of curl.
- setup-dashboard.sh: detect arch, fetch latest release tag, download
and verify (Ed25519) backend + frontend binary + assets at install
time before starting services.
* fix: strengthen security hardening across agent and dashboard, including improved input validation, secure file permissions, SSRF protection, constant-time authentication checks, and hardened WebSocket origins.
* refactor: enhance system security with constant-time cryptographic checks, input validation, and updated contribution documentation.
* fix(security): prevent SQL injection in DB password rotation
ALTER USER does not support parameterized queries. Previous code used
string interpolation with single-quote escaping that could be bypassed.
Replaced with dollar-quoting ($$hex$$) — passwords are hex-encoded
[0-9a-f] so the dollar sign can never appear inside them.
Affects both the agent (lynx_agent_app) and dashboard (lynx_dashboard_app)
rotation handlers.
* fix(agent): eliminate shell injection in rootless Podman execution
runuser -l -c "podman args" passes the command through a shell, making
any user-controlled argument a potential injection vector. Replaced with
runuser -u user -- podman arg1 arg2 which hands args directly to execve.
Also explicitly sets HOME and XDG_RUNTIME_DIR since -u does not load the
login environment, which rootless Podman requires.
* fix(agent): add SSRF protection with DNS pinning for binary downloads
Resolves the DNS TOCTOU vulnerability where a hostname could resolve to
a different IP between the validation check and the actual connection,
allowing DNS rebinding to reach internal services.
Fix: resolve hostname once, validate all returned IPs against RFC1918/
loopback/link-local/unspecified ranges, then pin the reqwest client to
the validated IP via .resolve() so no second DNS lookup occurs.
Applies to both the primary update path (update/mod.rs) and the fallback
GitHub API poller (update/fallback.rs). Adds the url crate for URL parsing.
* fix(agent): harden WireGuard handler — interface validation, file permissions, zeroize secrets
* fix(agent): prevent path traversal in nginx cert path construction
* fix(security): heartbeat ACK requires Ed25519 signed command — bearer token alone insufficient
* fix(security): validate audit log hash chain on HTTP sync path
* fix(security): WS origin validation, vps:read permission checks, WG IP error propagation
* fix(auth): per-username rate limit, SHA256 ct_eq for setup token, constant-time IP/UA comparison
* fix(auth): enforce nbf and iat claims in JWT validation — reject future-issued tokens
* fix(security): strict domain and branding input validation — block path traversal vectors
* fix(security): validate IP/CIDR format and port range in nftables rule handlers
* fix(security): add HSTS header to all HTTPS responses
* fix(db): extend cert_type constraint to include cloudflare and custom cert types
* fix(scripts): replace eval PKG_UPDATE with direct case statement — eliminate eval injection
* feat(site): add GitHub Pages landing page
Dark amber-themed static landing with hero, architecture diagram,
feature cards, install guide, and stack badges. Poppins + JetBrains
Mono fonts. .nojekyll disables Jekyll processing.
* fix(ci): close security audit gaps across all components
- Add bun audit (--audit-level high) to dashboard-ui workflow — was
required by project docs, package.json had the script, CI never ran it
- Add cargo audit to lynx-compose workflow — only Rust component without
CVE scanning
- Pin upload-artifact, download-artifact, and action-gh-release to commit
SHAs in release workflows — mutable tags are a supply chain risk
- Verify musl bun binary with SHASUMS256.txt before unzip in
release-dashboard workflow — previously downloaded with no integrity check
- Drop security-events: write from all CI workflows — permission was
declared but SARIF was never uploaded (dead permission, violates least
privilege)
* fix(agent): extend nftables_state chain constraint before seeding output rows
Migration 005 inserted 'lynx-global-output' and 'lynx-local-output'
but the CHECK constraint from migration 004 only permitted 'lynx-global'
and 'lynx-local', causing migration 5 to fail on every fresh install.
Fix: ALTER TABLE to drop and re-add the constraint with all four valid
chain values before the INSERT.
Also applies cargo fmt across agent and dashboard-server (whitespace
and line-length reformats only, no logic changes).
* fix(db): renumber conflicting dashboard migrations and fix IPv6 link-local mask
Migrations 022_nftables_protection and 023_fix_uuid_defaults were added
after dashboard@1.0.1 shipped (which already contains 022_user_preferences
and 023_single_session). sqlx rejected the duplicate version numbers.
Renumbered to 026 and 027 — first available slots after the new 024
(agents_arch) and 025 (domain_cert_types) migrations.
Also fixes a clippy::bad_bit_mask in agent SSRF guard: the fe80::/10
link-local check used mask 0xff00, which zeroes the lower byte and can
never equal 0xfe80. Correct mask for a /10 prefix is 0xffc0.
* fix(server): refactor update params to struct; bypass rate limit in CI tests
perform_dashboard_update had 9 arguments — refactored to
DashboardUpdateParams struct to satisfy clippy::too_many_arguments.
Integration tests share a Redis instance and run in parallel, causing
the per-username login rate limit (10/15min) to be exhausted when
multiple test binaries all log in as testadmin concurrently.
Added LYNX_SKIP_RATE_LIMIT env-var bypass in rate_limit::check() and
set it in the CI test step — production deployments never set this var.
* fix(tests): serialize dashboard-server tests and flush testadmin rate-limit key
The env-var bypass (LYNX_SKIP_RATE_LIMIT) broke redis_fail tests that
rely on Redis being the first failure point to produce 503. Reverted.
Root cause: parallel test binaries all logging in as testadmin exceed the
10/15min per-username limit. Fix:
- Add --test-threads=1 to CI cargo test — serialises tests within each
binary, so at most ~4 concurrent testadmin logins across binaries (well
under the limit of 10).
- Flush rl:login:u:testadmin from Redis in test_state() before each test
so the counter resets between tests running in the same binary.
* fix(ui): remove duplicate migration label props from settings page
* ci(lint-shell): always run shellcheck on PRs to main, not just on .sh changes
* chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.2...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump actions/download-artifact from 4.3.0 to 8.0.1 (#15)
* refactor: move agent and dashboard into lynx/ subfolder
* chore: add GitHub Sponsors funding button
* chore: add .gitignore, exclude CLAUDE.md
* chore: regenerate node_modules folder to resolve dependency inconsistencies
* chore: add missing node_modules dependencies and source files
* feat: initialize Next.js dashboard project with Biome and Tailwind CSS setup
* feat: initialize dashboard UI with Next.js, Biome, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with Tailwind CSS, Biome, and Shadcn UI configurations
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and Biome configuration
* feat: initialize Next.js dashboard project with Biome configuration and base UI components
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn/ui components
* feat: initialize Next.js dashboard UI project with shadcn/ui and Tailwind CSS setup
* feat: initialize Next.js dashboard project with Shadcn UI and project documentation
* feat: initialize Next.js dashboard UI with shadcn/ui components
* feat: initialize Next.js dashboard project with Shadcn UI components and configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and Dependabot configuration
* feat: initialize dashboard UI with Tailwind CSS, Next.js configuration, and foundational components
* feat: scaffold new Next.js dashboard UI with component library and configuration
* feat: initialize dashboard UI with Next.js 16, shadcn/ui components, and project installation script
* feat: scaffold Next.js dashboard UI and implement system management utility scripts
* feat: implement Next.js dashboard scaffolding and automate system migration to Podman and nftables
* feat: initialize Next.js dashboard UI with Tailwind CSS and Shadcn, and add installation idempotency to nftables script
* feat: initialize dashboard Next.js project and harden Podman storage, logging, and policy configurations
* feat: initialize Next.js dashboard UI and add installation scripts for dashboard and agent
* feat: initialize dashboard UI project structure and agent installation scripts
* feat: scaffold Lynx dashboard UI with shadcn/ui and implement agent installation scripts
* feat: initialize Lynx dashboard UI and deployment scripts
* feat: initialize dashboard project structure, UI components, and automation scripts
* feat: initialize Next.js dashboard UI project and add installation scripts
* feat: initialize dashboard UI and add agent/infrastructure installation scripts
* feat: initialize Next.js dashboard project with Shadcn UI and add agent installation scripts
* feat: initialize dashboard UI framework and add agent/dashboard installation scripts
* feat: initialize dashboard UI project with Next.js, Tailwind CSS, and shadcn components
* feat: initialize dashboard UI project structure with Tailwind CSS, components, and GitHub issue templates
* feat: initialize Next.js dashboard UI project and update lint-shell workflow branch filter
* feat: initialize dashboard UI with Next.js, Tailwind CSS, shadcn/ui components, and PR validation workflow
* feat: initialize Next.js dashboard UI project with Tailwind CSS and Shadcn configuration
* feat: initialize dashboard UI with Next.js, Tailwind CSS, and shadcn component library
* feat: initialize dashboard UI with standard components and app structure
* feat: initialize dashboard UI with comprehensive component library and layout structure
* feat: initialize dashboard UI with Next.js template and standardized component structure
* feat: initialize Next.js dashboard UI project with Tailwind CSS and base directory structure
* feat: integrate shadcn/ui and configure agentic development skills for dashboard UI
* feat: scaffold shadcn/ui integration with comprehensive agent rules and documentation
* feat: implement dashboard backend, container orchestration service, and agent-assisted UI documentation
* feat: implement dashboard server with Docker support and refactor compose tests to a dedicated directory
* feat: implement core compose utilities, file parsing tests, and dashboard infrastructure including Docker and shadcn agent setup.
* feat: initialize monorepo structure with dashboard server, agent, and shadcn/ui dashboard interface
* chore: initialize project configuration in Cargo.toml
* docs: add styling guidelines and agent documentation to UI dashboard
* feat: initialize dashboard server and agent modules while expanding Docker Compose build and configuration support
* feat: implement file-watch engine with develop support and initialize dashboard infrastructure
* feat: initialize lynx project structure including dashboard UI, backend server, agent, and docker configuration
* feat: implement dashboard scaffolding and refactor compose type modules
* feat: implement dashboard project scaffolding with containerized server and UI services
* feat: scaffold monorepo structure with Rust backend and Next.js dashboard UI
* feat: implement file-watch engine, add workspace configuration, and scaffold dashboard UI and agent modules
* feat: scaffold dashboard service, agent module, and UI architecture with Tailwind CSS
* chore(compose): bump version to 0.2.0
* feat: initialize Lynx dashboard and agent infrastructure with base configurations and CI support
* feat: scaffold dashboard infrastructure and improve compose parser security and robustness
* feat: initialize dashboard frontend and server with containerization and agent documentation
* feat: scaffold dashboard web UI and server, add workspace configuration, and implement path traversal protection in volume engine
* feat(dashboard): implement auth backend + full UI with i18n
Dashboard server: fix JWT key format (EdDSA via JWK instead of DER),
smoke-tested register/login/refresh/logout flow.
UI: add next-intl (en/es), proxy.ts with locale routing + token
refresh, /login and /register pages with Server Actions + HttpOnly
cookie handling, /a…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
perform_update()now backs up current binary to/etc/lynx/bin/lynx-agent.prevbefore atomic swap — enables.prevrecovery if new binary fails to startspawn_startup_health_guard()polls/healthevery 2s for 30s on startup:/etc/lynx/CRITICALif present.prev, exit 1 (systemd restarts with old binary).prevunavailable → write/etc/lynx/CRITICAL, exit 1 (manual recovery needed)tmp_path()helperEnables §12.15 test scenario: both new binary and
.prevfail →/etc/lynx/CRITICALcreated, containers keep running, recovery via manual binary upload.