diff --git a/BULK_ACL_FEATURE.md b/BULK_ACL_FEATURE.md new file mode 100644 index 00000000..0eebe8fb --- /dev/null +++ b/BULK_ACL_FEATURE.md @@ -0,0 +1,177 @@ +# Bulk ACL Application Feature + +## Overview +Implemented a bulk ACL (Access Control List) application feature that allows users to quickly apply or remove access lists from multiple proxy hosts at once, eliminating the need to edit each host individually. + +## User Workflow Improvements + +### Previous Workflow (Manual) +1. Create proxy hosts +2. Create access list +3. **Edit each host individually** to apply the ACL (tedious for many hosts) + +### New Workflow (Bulk) +1. Create proxy hosts +2. Create access list +3. **Select multiple hosts** → Bulk Actions → Apply/Remove ACL (one operation) + +## Implementation Details + +### Backend (`backend/internal/api/handlers/proxy_host_handler.go`) + +**New Endpoint**: `PUT /api/v1/proxy-hosts/bulk-update-acl` + +**Request Body**: +```json +{ + "host_uuids": ["uuid-1", "uuid-2", "uuid-3"], + "access_list_id": 42 // or null to remove ACL +} +``` + +**Response**: +```json +{ + "updated": 2, + "errors": [ + {"uuid": "uuid-3", "error": "proxy host not found"} + ] +} +``` + +**Features**: +- Updates multiple hosts in a single database transaction +- Applies Caddy config once for all updates (efficient) +- Partial failure handling (returns both successes and errors) +- Validates host existence before applying ACL +- Supports both applying and removing ACLs (null = remove) + +### Frontend + +#### API Client (`frontend/src/api/proxyHosts.ts`) +```typescript +export const bulkUpdateACL = async ( + hostUUIDs: string[], + accessListID: number | null +): Promise +``` + +#### React Query Hook (`frontend/src/hooks/useProxyHosts.ts`) +```typescript +const { bulkUpdateACL, isBulkUpdating } = useProxyHosts() + +// Usage +await bulkUpdateACL(['uuid-1', 'uuid-2'], 42) // Apply ACL 42 +await bulkUpdateACL(['uuid-1', 'uuid-2'], null) // Remove ACL +``` + +#### UI Components (`frontend/src/pages/ProxyHosts.tsx`) + +**Multi-Select Checkboxes**: +- Checkbox column added to proxy hosts table +- "Select All" checkbox in table header +- Individual checkboxes per row + +**Bulk Actions UI**: +- "Bulk Actions" button appears when hosts are selected +- Shows count of selected hosts +- Opens modal with ACL selection dropdown + +**Modal Features**: +- Lists all enabled access lists +- "Remove Access List" option (sets null) +- Real-time feedback on success/failure +- Toast notifications for user feedback + +## Testing + +### Backend Tests (`proxy_host_handler_test.go`) +- ✅ `TestProxyHostHandler_BulkUpdateACL_Success` - Apply ACL to multiple hosts +- ✅ `TestProxyHostHandler_BulkUpdateACL_RemoveACL` - Remove ACL (null value) +- ✅ `TestProxyHostHandler_BulkUpdateACL_PartialFailure` - Mixed success/failure +- ✅ `TestProxyHostHandler_BulkUpdateACL_EmptyUUIDs` - Validation error +- ✅ `TestProxyHostHandler_BulkUpdateACL_InvalidJSON` - Malformed request + +### Frontend Tests +**API Tests** (`proxyHosts-bulk.test.ts`): +- ✅ Apply ACL to multiple hosts +- ✅ Remove ACL with null value +- ✅ Handle partial failures +- ✅ Handle empty host list +- ✅ Propagate API errors + +**Hook Tests** (`useProxyHosts-bulk.test.tsx`): +- ✅ Apply ACL via mutation +- ✅ Remove ACL via mutation +- ✅ Query invalidation after success +- ✅ Error handling +- ✅ Loading state tracking + +**Test Results**: +- Backend: All tests passing (106+ tests) +- Frontend: All tests passing (132 tests) + +## Usage Examples + +### Example 1: Apply ACL to Multiple Hosts +```typescript +// Select hosts in UI +setSelectedHosts(new Set(['host-1-uuid', 'host-2-uuid', 'host-3-uuid'])) + +// User clicks "Bulk Actions" → Selects ACL from dropdown +await bulkUpdateACL(['host-1-uuid', 'host-2-uuid', 'host-3-uuid'], 5) + +// Result: "Access list applied to 3 host(s)" +``` + +### Example 2: Remove ACL from Hosts +```typescript +// User selects "Remove Access List" from dropdown +await bulkUpdateACL(['host-1-uuid', 'host-2-uuid'], null) + +// Result: "Access list removed from 2 host(s)" +``` + +### Example 3: Partial Failure Handling +```typescript +const result = await bulkUpdateACL(['valid-uuid', 'invalid-uuid'], 10) + +// result = { +// updated: 1, +// errors: [{ uuid: 'invalid-uuid', error: 'proxy host not found' }] +// } + +// Toast: "Updated 1 host(s), 1 failed" +``` + +## Benefits + +1. **Time Savings**: Apply ACLs to dozens of hosts in one click vs. editing each individually +2. **User-Friendly**: Clear visual feedback with checkboxes and selection count +3. **Error Resilient**: Partial failures don't block the entire operation +4. **Efficient**: Single Caddy config reload for all updates +5. **Flexible**: Supports both applying and removing ACLs +6. **Well-Tested**: Comprehensive test coverage for all scenarios + +## Future Enhancements (Optional) + +- Add bulk ACL application from Access Lists page (when creating/editing ACL) +- Bulk enable/disable hosts +- Bulk delete hosts +- Bulk certificate assignment +- Filter hosts before selection (e.g., "Select all hosts without ACL") + +## Related Files Modified + +### Backend +- `backend/internal/api/handlers/proxy_host_handler.go` (+73 lines) +- `backend/internal/api/handlers/proxy_host_handler_test.go` (+140 lines) + +### Frontend +- `frontend/src/api/proxyHosts.ts` (+19 lines) +- `frontend/src/hooks/useProxyHosts.ts` (+11 lines) +- `frontend/src/pages/ProxyHosts.tsx` (+95 lines) +- `frontend/src/api/__tests__/proxyHosts-bulk.test.ts` (+93 lines, new file) +- `frontend/src/hooks/__tests__/useProxyHosts-bulk.test.tsx` (+149 lines, new file) + +**Total**: ~580 lines added (including tests) diff --git a/SECURITY_IMPLEMENTATION_PLAN.md b/SECURITY_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..1909458d --- /dev/null +++ b/SECURITY_IMPLEMENTATION_PLAN.md @@ -0,0 +1,113 @@ +# Security Services Implementation Plan + +## Overview +This document outlines the plan to implement a modular Security Dashboard in Charon (previously 'CPM+'). The goal is to provide optional, high-value security integrations (CrowdSec, WAF, ACLs, Rate Limiting) while keeping the core Docker image lightweight. + +## Core Philosophy +1. **Optionality**: All security services are disabled by default. +2. **Environment Driven**: Activation is controlled via `CHARON_SECURITY_*` environment variables (legacy `CPM_SECURITY_*` names supported for backward compatibility). +3. **Minimal Footprint**: + * Lightweight Caddy modules (WAF, Bouncers) are compiled into the binary (negligible size impact). + * Heavy standalone agents (e.g., CrowdSec Agent) are only installed at runtime if explicitly enabled in "Local" mode. +4. **Unified Dashboard**: A single pane of glass in the UI to view status and configuration. + +--- + +## 1. Environment Variables +We will introduce a new set of environment variables to control these services. + +| Variable | Values | Description | +| :--- | :--- | :--- | +| `CHARON_SECURITY_CROWDSEC_MODE` (legacy `CPM_SECURITY_CROWDSEC_MODE`) | `disabled` (default), `local`, `external` | `local` installs agent inside container; `external` uses remote agent. | +| `CPM_SECURITY_CROWDSEC_API_URL` | URL (e.g., `http://crowdsec:8080`) | Required if mode is `external`. | +| `CPM_SECURITY_CROWDSEC_API_KEY` | String | Required if mode is `external`. | +| `CPM_SECURITY_WAF_MODE` | `disabled` (default), `enabled` | Enables Coraza WAF with OWASP Core Rule Set (CRS). | +| `CPM_SECURITY_RATELIMIT_MODE` | `disabled` (default), `enabled` | Enables global rate limiting controls. | +| `CPM_SECURITY_ACL_MODE` | `disabled` (default), `enabled` | Enables IP-based Access Control Lists. | + +--- + +## 2. Backend Implementation + +### A. Dockerfile Updates +We need to compile the necessary Caddy modules into our binary. This adds minimal size overhead but enables the features natively. +* **Action**: Update `Dockerfile` `caddy-builder` stage to include: + * `github.com/corazawaf/coraza-caddy/v2` (WAF) + * `github.com/hslatman/caddy-crowdsec-bouncer` (CrowdSec Bouncer) + +### B. Configuration Management (`internal/config`) +* **Action**: Update `Config` struct to parse `CHARON_SECURITY_*` variables while still accepting `CPM_SECURITY_*` as legacy fallbacks. +* **Action**: Create `SecurityConfig` struct to hold these values. + +### C. Runtime Installation (`docker-entrypoint.sh`) +To satisfy the "install locally" requirement for CrowdSec without bloating the image: +* **Action**: Modify `docker-entrypoint.sh` to check `CHARON_SECURITY_CROWDSEC_MODE` (and fallback to `CPM_SECURITY_CROWDSEC_MODE`). +* **Logic**: If `local`, execute `apk add --no-cache crowdsec` (and dependencies) before starting the app. This keeps the base image small for users who don't use it. + +### D. API Endpoints (`internal/api`) +* **New Endpoint**: `GET /api/v1/security/status` + * Returns the enabled/disabled state of each service. + * Returns basic metrics if available (e.g., "WAF: Active", "CrowdSec: Connected"). + +--- + +## 3. Frontend Implementation + +### A. Navigation +* **Action**: Add "Security" item to the Sidebar in `Layout.tsx`. + +### B. Security Dashboard (`src/pages/Security.tsx`) +* **Layout**: Grid of cards representing each service. +* **Empty State**: If all services are disabled, show a clean "Security Not Enabled" state with a link to the GitHub Pages documentation on how to enable them. + +### C. Service Cards +1. **CrowdSec Card**: + * **Status**: Active (Local/External) / Disabled. + * **Content**: If Local, show basic stats (last push, alerts). If External, show connection status. + * **Action**: Link to CrowdSec Console or Dashboard. +2. **WAF Card**: + * **Status**: Active / Disabled. + * **Content**: "OWASP CRS Loaded". +3. **Access Control Lists (ACL)**: + * **Status**: Active / Disabled. + * **Action**: "Manage Blocklists" (opens modal/page to edit IP lists). +4. **Rate Limiting**: + * **Status**: Active / Disabled. + * **Action**: "Configure Limits" (opens modal to set global requests/second). + +--- + +## 4. Service-Specific Logic + +### CrowdSec +* **Local**: + * Installs CrowdSec agent via `apk`. + * Generates `acquis.yaml` to read Caddy logs. + * Configures Caddy bouncer to talk to `localhost:8080`. +* **External**: + * Configures Caddy bouncer to talk to `CPM_SECURITY_CROWDSEC_API_URL`. + +### WAF (Coraza) +* **Implementation**: + * When enabled, inject `coraza_waf` directive into the global Caddyfile or per-host. + * Use default OWASP Core Rule Set (CRS). + +### IP ACLs +* **Implementation**: + * Create a snippet `(ip_filter)` in Caddyfile. + * Use `@matcher` with `remote_ip` to block/allow IPs. + * UI allows adding CIDR ranges to this list. + +### Rate Limiting +* **Implementation**: + * Use `rate_limit` directive. + * Allow user to define "zones" (e.g., API, Static) in the UI. + +--- + +## 5. Documentation +* **New Doc**: `docs/security.md` +* **Content**: + * Explanation of each service. + * How to configure Env Vars. + * Trade-offs of "Local" CrowdSec (startup time vs convenience). diff --git a/backend/go.mod b/backend/go.mod index 53db9249..ef918df6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -21,7 +21,8 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect @@ -34,14 +35,15 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/fatih/color v1.15.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/gzip v1.2.5 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -68,7 +70,7 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect @@ -77,9 +79,9 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect - go.uber.org/mock v0.5.0 // indirect + go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/arch v0.20.0 // indirect + golang.org/x/arch v0.22.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect @@ -87,7 +89,7 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.38.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 516d18a8..a5941890 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,8 +4,12 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -39,6 +43,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= +github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= @@ -56,10 +64,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -145,6 +157,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -189,10 +203,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= @@ -220,6 +238,8 @@ google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 756924d4..d850f4b8 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -23,6 +24,9 @@ import ( // Register wires up API routes and performs automatic migrations. func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { + // Enable gzip compression for API responses (reduces payload size ~70%) + router.Use(gzip.Gzip(gzip.DefaultCompression)) + // Apply security headers middleware globally // This sets CSP, HSTS, X-Frame-Options, etc. securityHeadersCfg := middleware.SecurityHeadersConfig{ diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index 2c343922..8bd0428d 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -1,18 +1,57 @@ package database import ( + "database/sql" "fmt" + "strings" "gorm.io/driver/sqlite" "gorm.io/gorm" ) -// Connect opens a SQLite database connection. +// Connect opens a SQLite database connection with optimized settings. +// Uses WAL mode for better concurrent read/write performance. func Connect(dbPath string) (*gorm.DB, error) { - db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + // Add SQLite performance pragmas if not already present + dsn := dbPath + if !strings.Contains(dsn, "?") { + dsn += "?" + } else { + dsn += "&" + } + // WAL mode: better concurrent access, faster writes + // busy_timeout: wait up to 5s instead of failing immediately on lock + // cache: shared cache for better memory usage + // synchronous=NORMAL: good balance of safety and speed + dsn += "_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_cache_size=-64000" + + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + // Skip default transaction for single operations (faster) + SkipDefaultTransaction: true, + // Prepare statements for reuse + PrepareStmt: true, + }) if err != nil { return nil, fmt.Errorf("open database: %w", err) } + // Configure connection pool + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("get underlying db: %w", err) + } + configurePool(sqlDB) + return db, nil } + +// configurePool sets connection pool settings for SQLite. +// SQLite handles concurrency differently than server databases, +// so we use conservative settings. +func configurePool(sqlDB *sql.DB) { + // SQLite is file-based, so we limit connections + // but keep some idle for reuse + sqlDB.SetMaxOpenConns(1) // SQLite only allows one writer at a time + sqlDB.SetMaxIdleConns(1) // Keep one connection ready + sqlDB.SetConnMaxLifetime(0) // Don't close idle connections +} diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx index d97605c1..8d288a7e 100644 --- a/frontend/src/components/RequireAuth.tsx +++ b/frontend/src/components/RequireAuth.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; +import { LoadingOverlay } from './LoadingStates'; const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { isAuthenticated, isLoading } = useAuth(); const location = useLocation(); if (isLoading) { - return
Loading...
; // Or a spinner + return ; // Consistent loading UX } if (!isAuthenticated) { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d8028105..76861983 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,7 +5,18 @@ import App from './App.tsx' import { ThemeProvider } from './context/ThemeContext' import './index.css' -const queryClient = new QueryClient() +// Global query client with optimized defaults for performance +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 30, // 30 seconds - reduces unnecessary refetches + gcTime: 1000 * 60 * 5, // 5 minutes garbage collection + refetchOnWindowFocus: false, // Prevents refetch when switching tabs + refetchOnReconnect: 'always', // Refetch when network reconnects + retry: 1, // Only retry failed requests once + }, + }, +}) ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 98f95afe..68d54e6d 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react' import { useProxyHosts } from '../hooks/useProxyHosts' import { useRemoteServers } from '../hooks/useRemoteServers' import { useCertificates } from '../hooks/useCertificates' +import { useQuery } from '@tanstack/react-query' import { checkHealth } from '../api/health' import { Link } from 'react-router-dom' import UptimeWidget from '../components/UptimeWidget' @@ -10,19 +10,14 @@ export default function Dashboard() { const { hosts } = useProxyHosts() const { servers } = useRemoteServers() const { certificates } = useCertificates() - const [health, setHealth] = useState<{ status: string } | null>(null) - useEffect(() => { - const fetchHealth = async () => { - try { - const result = await checkHealth() - setHealth(result) - } catch { - setHealth({ status: 'error' }) - } - } - fetchHealth() - }, []) + // Use React Query for health check - benefits from global caching + const { data: health } = useQuery({ + queryKey: ['health'], + queryFn: checkHealth, + staleTime: 1000 * 60, // 1 minute for health checks + refetchInterval: 1000 * 60, // Auto-refresh every minute + }) const enabledHosts = hosts.filter(h => h.enabled).length const enabledServers = servers.filter(s => s.enabled).length diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d8b97671..5c50365f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,6 +15,19 @@ export default defineConfig({ }, build: { outDir: 'dist', - sourcemap: true + sourcemap: true, + // Code splitting for better caching and parallel loading + rollupOptions: { + output: { + manualChunks: { + // React ecosystem - changes rarely + 'react-vendor': ['react', 'react-dom', 'react-router-dom'], + // TanStack Query - changes rarely + 'query': ['@tanstack/react-query'], + // Icons - large but cacheable + 'icons': ['lucide-react'], + } + } + } } })