diff --git a/dashcaddy-api/.env.example b/dashcaddy-api/.env.example deleted file mode 100644 index b4b52c8..0000000 --- a/dashcaddy-api/.env.example +++ /dev/null @@ -1,36 +0,0 @@ -# DashCaddy API Environment Variables -# Copy this file to .env and fill in your actual values -# NEVER commit .env to git! - -# JWT Secret (auto-generated if not set) -# JWT_SECRET=your-secret-key-here - -# Credential Storage -# CREDENTIALS_FILE=./credentials.json - -# Docker Configuration -# DOCKER_SOCKET=/var/run/docker.sock - -# Caddy Admin API -# CADDY_ADMIN_URL=http://localhost:2019 - -# DNS Configuration (Technitium) -# DNS_API_URL=http://localhost:5380 -# DNS_TOKEN=your-dns-token-here - -# Port Configuration -# PORT=3001 - -# Environment -# NODE_ENV=production - -# Notification Providers (optional) -# DISCORD_WEBHOOK_URL= -# TELEGRAM_BOT_TOKEN= -# TELEGRAM_CHAT_ID= -# NTFY_SERVER_URL=https://ntfy.sh -# NTFY_TOPIC= - -# Tailscale OAuth (optional) -# TAILSCALE_CLIENT_ID= -# TAILSCALE_CLIENT_SECRET= diff --git a/dashcaddy-api/.eslintignore b/dashcaddy-api/.eslintignore deleted file mode 100644 index 896481b..0000000 --- a/dashcaddy-api/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -coverage/ -dist/ -*.min.js diff --git a/dashcaddy-api/.eslintrc.js b/dashcaddy-api/.eslintrc.js deleted file mode 100644 index 9235272..0000000 --- a/dashcaddy-api/.eslintrc.js +++ /dev/null @@ -1,56 +0,0 @@ -module.exports = { - env: { - node: true, - es2021: true, - jest: true, - }, - extends: 'eslint:recommended', - parserOptions: { - ecmaVersion: 2021, - }, - rules: { - // Possible errors - 'no-await-in-loop': 'warn', - 'no-console': 'off', // We use console in server code - 'no-template-curly-in-string': 'error', - - // Best practices - 'curly': ['error', 'multi-line'], - 'eqeqeq': ['error', 'always', { null: 'ignore' }], - 'no-eval': 'error', - 'no-implied-eval': 'error', - 'no-return-await': 'error', - 'no-throw-literal': 'error', - 'prefer-promise-reject-errors': 'error', - 'require-await': 'warn', - - // Variables - 'no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }], - 'no-use-before-define': ['error', { - functions: false, - classes: true, - }], - - // Stylistic - 'comma-dangle': ['error', 'always-multiline'], - 'quotes': ['error', 'single', { avoidEscape: true }], - 'semi': ['error', 'always'], - 'indent': ['error', 2, { SwitchCase: 1 }], - 'max-len': ['warn', { - code: 120, - ignoreUrls: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, - }], - - // ES6 - 'arrow-spacing': 'error', - 'no-var': 'error', - 'prefer-const': 'error', - 'prefer-arrow-callback': 'warn', - 'prefer-template': 'warn', - }, -}; diff --git a/dashcaddy-api/.license-counter b/dashcaddy-api/.license-counter deleted file mode 100644 index 56a6051..0000000 --- a/dashcaddy-api/.license-counter +++ /dev/null @@ -1 +0,0 @@ -1 \ No newline at end of file diff --git a/dashcaddy-api/.prettierrc b/dashcaddy-api/.prettierrc deleted file mode 100644 index 7ef273f..0000000 --- a/dashcaddy-api/.prettierrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "semi": true, - "singleQuote": true, - "trailingComma": "es5", - "tabWidth": 2, - "printWidth": 120, - "arrowParens": "always" -} diff --git a/dashcaddy-api/BUFFER_SECURITY.md b/dashcaddy-api/BUFFER_SECURITY.md deleted file mode 100644 index e69de29..0000000 diff --git a/dashcaddy-api/DEPENDENCIES.md b/dashcaddy-api/DEPENDENCIES.md deleted file mode 100644 index 18c08a3..0000000 --- a/dashcaddy-api/DEPENDENCIES.md +++ /dev/null @@ -1,344 +0,0 @@ -# DashCaddy API Dependencies Map - -**Generated:** 2026-03-22 -**Purpose:** Document current `ctx` (context object) usage across routes to guide refactoring - ---- - -## Overview - -The DashCaddy API currently uses a **god object pattern** where a single `ctx` (context) object is passed to all route modules. This object contains 50+ properties mixing: - -- Utilities (asyncHandler, errorResponse, log) -- Domain objects (docker, caddy, dns, session) -- Managers (credentialManager, authManager, healthChecker) -- Configuration (SERVICES_FILE, CONFIG_FILE, siteConfig) -- Template data (APP_TEMPLATES, RECIPE_TEMPLATES) - -**Problem:** Routes don't declare what they actually need — dependencies are hidden, making testing and refactoring difficult. - -**Goal:** Move to explicit dependency injection where each route declares exactly what it needs. - ---- - -## Most-Used Properties (Refactor Priority) - -| Property | Routes Using It | Type | Notes | -|----------|-----------------|------|-------| -| `ctx.asyncHandler` | 35 | Utility | Wrap async route handlers, universal dependency | -| `ctx.errorResponse` | 27 | Utility | Standard error formatting | -| `ctx.log` | 20 | Utility | Logger instance | -| `ctx.docker` | 15 | Domain | Dockerode wrapper + helpers | -| `ctx.caddy` | 12 | Domain | Caddyfile manipulation + reload | -| `ctx.fetchT` | 12 | Utility | Timeout-wrapped fetch | -| `ctx.servicesStateManager` | 12 | Manager | services.json state management | -| `ctx.credentialManager` | 12 | Manager | Encrypted credential storage | -| `ctx.siteConfig` | 10 | Config | Site-wide configuration | -| `ctx.buildDomain` | 9 | Utility | Domain construction helper | -| `ctx.dns` | 9 | Domain | DNS API wrapper | - ---- - -## Route Dependencies (By Route File) - -### High-Complexity Routes (10+ dependencies) - -#### `apps/deploy.js` (15 dependencies) -```javascript -// Current -module.exports = (ctx) => { ... }; - -// After refactor (explicit deps) -module.exports = ({ - APP_TEMPLATES, - addServiceToConfig, - asyncHandler, - buildDomain, - buildServiceUrl, - caddy, - dns, - docker, - errorResponse, - log, - logError, - notification, - portLockManager, - safeErrorMessage, - siteConfig, -}) => { ... }; -``` - -**Refactor Priority:** HIGH (core deployment logic, frequently used) - ---- - -#### `config/backup.js` (16 dependencies) -```javascript -// Dependencies -CONFIG_FILE, NOTIFICATIONS_FILE, SERVICES_FILE, TAILSCALE_CONFIG_FILE, -TOTP_CONFIG_FILE, asyncHandler, caddy, credentialManager, dns, errorResponse, -fetchT, loadDnsCredentials, loadNotificationConfig, loadSiteConfig, log, totpConfig -``` - -**Refactor Priority:** MEDIUM (backup/restore is critical but less frequent) -**Opportunity:** Extract backup logic to service layer - ---- - -#### `services.js` (13 dependencies) -```javascript -// Dependencies -SERVICES_FILE, asyncHandler, buildDomain, buildServiceUrl, caddy, -credentialManager, dns, errorResponse, log, resyncHealthChecker, -safeErrorMessage, servicesStateManager, siteConfig -``` - -**Refactor Priority:** HIGH (core service management, most-used route) - ---- - -### Medium-Complexity Routes (6-9 dependencies) - -- `apps/restore.js` (9 deps) -- `auth/sso-gate.js` (10 deps) -- `dns.js` (8 deps) -- `health.js` (8 deps) -- `recipes/manage.js` (8 deps) -- `sites.js` (12 deps) -- `tailscale.js` (9 deps) - ---- - -### Low-Complexity Routes (1-5 dependencies) - -Good candidates for early refactoring (simpler, less risky): - -- `backups.js` (2 deps: asyncHandler, backupManager) -- `containers.js` (3 deps: asyncHandler, docker, log) -- `credentials.js` (3 deps: asyncHandler, credentialManager, errorResponse) -- `license.js` (3 deps: asyncHandler, errorResponse, licenseManager) -- `monitoring.js` (3 deps: asyncHandler, docker, resourceMonitor) -- `notifications.js` (3 deps: asyncHandler, errorResponse, notification) - ---- - -## Domain Object Breakdown - -### `ctx.docker` (used by 15 routes) -**Properties/Methods:** -- `find()` — find container by name -- `getUsedPorts()` — list ports in use -- `security` — docker-security module - -**Routes:** -- apps/deploy.js, apps/helpers.js, apps/removal.js, apps/restore.js, apps/templates.js -- arr/config.js, arr/detect.js, arr/helpers.js -- browse.js, containers.js, context.js, logs.js, monitoring.js -- recipes/deploy.js, recipes/manage.js - -**Refactor Suggestion:** Extract to standalone service (`services/docker-service.js`) - ---- - -### `ctx.caddy` (used by 12 routes) -**Properties/Methods:** -- `modify()` — modify Caddyfile -- `read()` — read Caddyfile -- `reload()` — reload Caddy -- `generateConfig()` — generate route config -- `verifySite()` — test site accessibility -- `adminUrl`, `filePath` — config properties - -**Routes:** -- apps/deploy.js, apps/helpers.js, apps/removal.js, apps/restore.js, apps/templates.js -- config/backup.js, context.js -- recipes/deploy.js, recipes/manage.js -- services.js, sites.js, tailscale.js - -**Refactor Suggestion:** Extract to `services/caddy-service.js` - ---- - -### `ctx.dns` (used by 9 routes) -**Properties/Methods:** -- `call()` — DNS API call -- `buildUrl()` — construct DNS API URL -- `requireToken()` — token validation -- `ensureToken()` — auto-refresh token if needed -- `createRecord()` — create DNS record -- `getToken()`, `setToken()` — token management -- `refresh()` — force token refresh - -**Routes:** -- apps/deploy.js, apps/removal.js, apps/restore.js, apps/templates.js -- config/backup.js, context.js, dns.js -- services.js, sites.js - -**Refactor Suggestion:** Extract to `services/dns-service.js` - ---- - -## Utility Functions (Universal Dependencies) - -### `ctx.asyncHandler` (35 routes) -**What it does:** Wraps async Express route handlers to catch errors - -**Current pattern:** -```javascript -router.get('/endpoint', ctx.asyncHandler(async (req, res) => { - // ... async logic -})); -``` - -**Refactor suggestion:** Keep as utility, but import directly instead of via ctx -```javascript -const { asyncHandler } = require('../utils/async-handler'); - -router.get('/endpoint', asyncHandler(async (req, res) => { - // ... async logic -})); -``` - ---- - -### `ctx.log` (20 routes) -**What it does:** Structured logger (log.info, log.error, log.warn) - -**Refactor suggestion:** Import logger utility directly -```javascript -const log = require('../utils/logger'); -``` - ---- - -### `ctx.errorResponse` (27 routes) -**What it does:** Standard error response formatting - -**Refactor suggestion:** Utility function, import directly -```javascript -const { errorResponse } = require('../utils/responses'); -``` - ---- - -## Manager Objects - -### `ctx.credentialManager` (12 routes) -**What it does:** AES-256-GCM encrypted credential storage - -**Routes using it:** -- arr/credentials.js, arr/detect.js, arr/helpers.js -- auth/sso-gate.js, auth/totp.js -- config/backup.js, credentials.js, dns.js -- services.js, tailscale.js - -**Refactor suggestion:** Keep as manager, inject explicitly - ---- - -### `ctx.servicesStateManager` (12 routes) -**What it does:** Thread-safe services.json state management - -**Refactor suggestion:** Keep as manager, inject explicitly - ---- - -### `ctx.healthChecker` (1 route: health.js) -**Refactor suggestion:** Already well-isolated, just make explicit - ---- - -## Configuration Objects - -### `ctx.siteConfig` (10 routes) -**What it does:** Site-wide configuration (domain, TLS, etc.) - -**Refactor suggestion:** Load once at app startup, inject where needed - ---- - -### File path constants (used by 6 routes) -- `SERVICES_FILE` (6 routes) -- `CONFIG_FILE` (3 routes) -- `TOTP_CONFIG_FILE` (1 route) -- `NOTIFICATIONS_FILE` (1 route) -- `ERROR_LOG_FILE` (1 route) - -**Refactor suggestion:** Move to `src/config/paths.js`, import directly - ---- - -## Refactoring Strategy - -### Phase 1: Low-Risk Routes (Start Here) -Refactor simple routes with 1-5 dependencies: -1. `backups.js` (2 deps) -2. `containers.js` (3 deps) -3. `credentials.js` (3 deps) -4. `license.js` (3 deps) -5. `monitoring.js` (3 deps) - -**Why:** Low complexity, easier to verify correctness, builds confidence - ---- - -### Phase 2: Extract Common Utilities -Extract universal dependencies to standalone modules: -1. `utils/async-handler.js` (used by 35 routes) -2. `utils/responses.js` (errorResponse, ok, etc.) -3. `utils/logger.js` (structured logging) -4. `utils/http.js` (fetchT wrapper) - -**Why:** Reduces ctx bloat, makes imports explicit - ---- - -### Phase 3: Extract Domain Services -Convert domain objects to services: -1. `services/docker-service.js` (15 routes) -2. `services/caddy-service.js` (12 routes) -3. `services/dns-service.js` (9 routes) - -**Why:** Separate business logic from HTTP handlers, testable in isolation - ---- - -### Phase 4: High-Complexity Routes -Refactor routes with 10+ dependencies: -1. `services.js` (13 deps) — most-used route -2. `apps/deploy.js` (15 deps) — core deployment -3. `config/backup.js` (16 deps) — extract backup service -4. `sites.js` (12 deps) - -**Why:** Highest impact on maintainability - ---- - -## Success Metrics - -### Before Refactor -- God object `ctx` with 50+ properties -- Hidden dependencies (routes don't declare what they use) -- Difficult to mock for testing (need entire ctx object) - -### After Refactor -- ✅ Routes declare explicit dependencies -- ✅ Utilities imported directly (no ctx indirection) -- ✅ Domain logic extracted to services -- ✅ Easy to mock (inject only what's needed) -- ✅ Self-documenting (route signature shows dependencies) - ---- - -## Next Steps - -1. ✅ Document current dependencies (this file) -2. Refactor Phase 1 routes (low-risk, 1-5 deps) -3. Extract utilities (asyncHandler, errorResponse, log) -4. Extract domain services (docker, caddy, dns) -5. Refactor high-complexity routes -6. Remove `ctx` god object entirely - ---- - -*Generated by analyzing route files for `ctx.*` usage patterns.* diff --git a/dashcaddy-api/DOMAIN_STRATEGY.md b/dashcaddy-api/DOMAIN_STRATEGY.md deleted file mode 100644 index e69de29..0000000 diff --git a/dashcaddy-api/__tests__/api-endpoints.test.js b/dashcaddy-api/__tests__/api-endpoints.test.js deleted file mode 100644 index 05b5366..0000000 --- a/dashcaddy-api/__tests__/api-endpoints.test.js +++ /dev/null @@ -1,423 +0,0 @@ -/** - * API Endpoint Tests - * - * Comprehensive tests for critical DashCaddy API endpoints - * Tests the migrated StateManager integration and core functionality - */ - -const request = require('supertest'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// Create a test instance of the app -// Note: We need to mock the service file to avoid affecting production -const testServicesFile = path.join(os.tmpdir(), `test-services-${Date.now()}.json`); -const testConfigFile = path.join(os.tmpdir(), `test-config-${Date.now()}.json`); - -// Set test environment -process.env.SERVICES_FILE = testServicesFile; -process.env.CONFIG_FILE = testConfigFile; -process.env.CADDYFILE_PATH = path.join(os.tmpdir(), 'test-Caddyfile'); -process.env.CADDY_ADMIN_URL = 'http://localhost:2019'; -process.env.ENABLE_HEALTH_CHECKER = 'false'; // Disable to avoid background processes -process.env.NODE_ENV = 'test'; - -// Initialize test files -fs.writeFileSync(testServicesFile, '[]', 'utf8'); -fs.writeFileSync(testConfigFile, '{}', 'utf8'); -fs.writeFileSync(process.env.CADDYFILE_PATH, '# Test Caddyfile', 'utf8'); - -// Now require the app (after env setup) -const app = require('../server'); - -describe('API Endpoints', () => { - - // Clean up before each test - beforeEach(() => { - fs.writeFileSync(testServicesFile, '[]', 'utf8'); - }); - - // Clean up after all tests - afterAll(() => { - try { - fs.unlinkSync(testServicesFile); - fs.unlinkSync(testConfigFile); - fs.unlinkSync(process.env.CADDYFILE_PATH); - } catch (e) { - // Ignore cleanup errors - } - }); - - describe('GET /api/health', () => { - test('should return healthy status', async () => { - const res = await request(app).get('/api/health'); - - expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty('status', 'ok'); - expect(res.body).toHaveProperty('timestamp'); - }); - }); - - describe('GET /api/services', () => { - test('should return empty array initially', async () => { - const res = await request(app).get('/api/services'); - - expect(res.statusCode).toBe(200); - expect(Array.isArray(res.body)).toBe(true); - expect(res.body.length).toBe(0); - }); - - test('should return services after adding', async () => { - // Add a service first - await request(app) - .post('/api/services') - .send({ - id: 'test-service', - name: 'Test Service', - logo: '/assets/test.png', - ip: 'localhost', - tailscaleOnly: false, - }); - - // Now get services - const res = await request(app).get('/api/services'); - - expect(res.statusCode).toBe(200); - expect(res.body.length).toBe(1); - expect(res.body[0]).toMatchObject({ - id: 'test-service', - name: 'Test Service', - }); - }); - - test('should use StateManager (thread-safe)', async () => { - // This test verifies StateManager is being used - // by checking that the file is read correctly - - // Manually write to file - const testData = [{ id: 'manual', name: 'Manual Service' }]; - fs.writeFileSync(testServicesFile, JSON.stringify(testData, null, 2)); - - const res = await request(app).get('/api/services'); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual(testData); - }); - }); - - describe('POST /api/services', () => { - test('should add a new service', async () => { - const newService = { - id: 'plex', - name: 'Plex', - logo: '/assets/plex.png', - ip: 'localhost', - tailscaleOnly: false, - }; - - const res = await request(app) - .post('/api/services') - .send(newService); - - expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty('success', true); - - // Verify service was added - const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8')); - expect(services.length).toBe(1); - expect(services[0].id).toBe(newService.id); - expect(services[0].name).toBe(newService.name); - expect(services[0].logo).toBe(newService.logo); - }); - - test('should reject duplicate service IDs', async () => { - const service = { - id: 'duplicate', - name: 'Duplicate Service', - }; - - // Add first time - await request(app).post('/api/services').send(service); - - // Try to add again - const res = await request(app).post('/api/services').send(service); - - expect(res.statusCode).toBe(409); // Conflict is the correct status code - expect(res.body).toHaveProperty('success', false); - expect(res.body.error).toContain('already exists'); - }); - - test('should validate required fields', async () => { - const res = await request(app) - .post('/api/services') - .send({ - // Missing 'id' and 'name' - logo: '/assets/test.png', - }); - - expect(res.statusCode).toBe(400); - expect(res.body).toHaveProperty('success', false); - }); - - test('should sanitize user input (XSS protection)', async () => { - const maliciousService = { - id: 'test', - name: '', - logo: '/assets/test.png', - }; - - const res = await request(app) - .post('/api/services') - .send(maliciousService); - - // Input should be sanitized or rejected - const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8')); - - // If the service was added, script tags should be removed or escaped - if (services.length > 0) { - expect(services[0].id).not.toContain('', upstream: 'localhost:8080' }); - - expect(res.statusCode).toBe(400); - expect(res.body.error).toContain('DC-301'); - }); - - test('POST /api/site should reject invalid upstream format', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'test.sami', upstream: 'not-valid' }); - - expect(res.statusCode).toBe(400); - expect(res.body.error).toContain('upstream'); - }); - - test('POST /api/site/external should reject URLs with Caddyfile injection chars', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ - subdomain: 'test', - externalUrl: 'https://evil.com/path{inject}', - }); - - // Should be rejected — either 400 (our validation) or 500 (URL constructor throws on {}) - expect([400, 500]).toContain(res.statusCode); - // Must never succeed - expect(res.statusCode).not.toBe(200); - }); - - test('POST /api/site/external should reject URLs with newlines', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ - subdomain: 'test', - externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234', - }); - - expect(res.statusCode).toBe(400); - }); - - test('POST /api/site/external should reject missing fields', async () => { - const res = await request(app) - .post('/api/site/external') - .send({}); - - expect(res.statusCode).toBe(400); - }); - - test('POST /api/site/external should reject invalid subdomain', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ - subdomain: '../etc/passwd', - externalUrl: 'https://example.com', - }); - - expect(res.statusCode).toBe(400); - }); -}); - -// ============================================================ -// ERROR LOGS — No stack trace leak -// ============================================================ -describe('Error Logs — No Stack Trace Leak', () => { - beforeAll(async () => { - // Write a fake error log with stack traces - const logContent = [ - '[2026-03-07 12:00:00] server: Something failed', - 'Error: Internal failure', - ' at Object. (/app/server.js:123:45)', - ' at Module._compile (node:internal/modules/cjs/loader:1234:14)', - '================================================================================', - '[2026-03-07 12:01:00] dns: DNS timeout', - 'Error: connect ECONNREFUSED 192.168.1.1:5380', - ' at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1234:16)', - '================================================================================', - ].join('\n'); - // Write to the server's error log file location - // The server uses ctx.ERROR_LOG_FILE — we need to check what that resolves to - await fsp.writeFile(testErrorLogFile, logContent); - }); - - test('GET /api/error-logs should not include details/stack traces', async () => { - const res = await request(app).get('/api/error-logs'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - - // If there are logs, verify none contain 'details' field - if (res.body.logs.length > 0) { - for (const log of res.body.logs) { - expect(log).not.toHaveProperty('details'); - // Verify it has the safe fields - if (log.timestamp) { - expect(log).toHaveProperty('timestamp'); - expect(log).toHaveProperty('context'); - expect(log).toHaveProperty('error'); - } - } - } - }); -}); - -// ============================================================ -// CONTAINERS — ID validation -// ============================================================ -describe('Container ID Validation', () => { - test('GET /api/containers/:id/check-update should 404 for nonexistent container', async () => { - const res = await request(app).get('/api/containers/nonexistent123/check-update'); - - // Should return 404 (not found) not 500 (unhandled error) - expect([404]).toContain(res.statusCode); - }); - - test('POST /api/containers/:id/update should 404 for nonexistent container', async () => { - const res = await request(app).post('/api/containers/nonexistent123/update'); - - expect([404]).toContain(res.statusCode); - }); - - test('GET /api/logs/container/:id should 404 for nonexistent container', async () => { - const res = await request(app).get('/api/logs/container/nonexistent123'); - - expect([404]).toContain(res.statusCode); - }); - - test('GET /api/logs/stream/:id should 404 for nonexistent container', async () => { - const res = await request(app).get('/api/logs/stream/nonexistent123'); - - expect([404]).toContain(res.statusCode); - }); -}); - -// ============================================================ -// LOG FILE — Path traversal prevention -// ============================================================ -describe('Log File Path Traversal', () => { - test('GET /api/logs/file should reject missing path', async () => { - const res = await request(app).get('/api/logs/file'); - - expect(res.statusCode).toBe(400); - }); - - test('GET /api/logs/file should reject traversal paths', async () => { - const res = await request(app) - .get('/api/logs/file') - .query({ path: '/etc/shadow' }); - - // Should be 403 (not allowed) or 404 (not found), never 200 - expect([403, 404]).toContain(res.statusCode); - }); - - test('GET /api/logs/file should reject Windows system paths', async () => { - const res = await request(app) - .get('/api/logs/file') - .query({ path: 'C:\\Windows\\System32\\config\\SAM' }); - - expect([403, 404]).toContain(res.statusCode); - }); - - test('GET /api/logs/file should reject parent directory traversal', async () => { - const res = await request(app) - .get('/api/logs/file') - .query({ path: '/var/log/../../etc/passwd' }); - - expect([403, 404]).toContain(res.statusCode); - }); -}); - -// ============================================================ -// BACKUP — No encryption key in export, TOTP re-auth for restore -// ============================================================ -describe('Backup Security', () => { - test('GET /api/backup/export should not include encryption key', async () => { - const res = await request(app).get('/api/backup/export'); - - if (res.statusCode === 200 && res.body.backup) { - const backup = res.body.backup; - // Verify encryptionKey is NOT in the backup files - expect(backup.files).not.toHaveProperty('encryptionKey'); - // Verify TOTP backup doesn't include manualKey - if (backup.totp) { - expect(backup.totp).not.toHaveProperty('manualKey'); - } - } - }); - - test('POST /api/backup/restore should reject invalid backup format', async () => { - const res = await request(app) - .post('/api/backup/restore') - .send({ backup: { invalid: true } }); - - expect(res.statusCode).toBe(400); - }); - - test('POST /api/backup/restore should not restore encryptionKey even if provided', async () => { - const res = await request(app) - .post('/api/backup/restore') - .send({ - backup: { - version: '1.0', - files: { - encryptionKey: { - type: 'text', - content: 'malicious-key-data', - }, - }, - }, - }); - - // The encryptionKey should be skipped (not in fileMapping) - if (res.statusCode === 200) { - // If it succeeded, verify encryptionKey was skipped - expect(res.body.results.restored).not.toContain('encryptionKey'); - } - }); -}); - -// ============================================================ -// SESSION COOKIE — Secure flag -// ============================================================ -describe('Session Cookie Security', () => { - test('session cookies should include Secure flag', async () => { - // TOTP verify would set a session cookie on success - // We can check the middleware by looking at any response that sets cookies - const res = await request(app) - .post('/api/totp/verify') - .send({ code: '123456' }); - - // Even though verify fails, check cookie format if any cookies are set - const cookies = res.headers['set-cookie']; - if (cookies) { - for (const cookie of Array.isArray(cookies) ? cookies : [cookies]) { - if (cookie.includes('dashcaddy_session')) { - expect(cookie.toLowerCase()).toContain('secure'); - expect(cookie.toLowerCase()).toContain('httponly'); - expect(cookie.toLowerCase()).toContain('samesite'); - } - } - } - }); -}); - -// ============================================================ -// CUSTOM VOLUME — Host path validation -// ============================================================ -describe('Custom Volume Path Validation', () => { - // This tests the processTemplateVariables function indirectly - // The helpers.js validates custom volume hostPath against allowed roots - - test('should not allow arbitrary host paths in volume overrides', async () => { - // Deploy endpoint would use processTemplateVariables - // Sending a custom volume with a dangerous path - const res = await request(app) - .post('/api/apps/deploy') - .send({ - appId: 'plex', - subdomain: 'test-plex', - ip: '192.168.1.100', - port: '32400', - customVolumes: [{ - containerPath: '/config', - hostPath: '/etc/shadow', - }], - }); - - // The deploy will likely fail for other reasons (no Docker, etc.) - // But if it reaches volume processing, the dangerous path should be rejected - // The key check: it shouldn't return 200 with /etc/shadow mounted - if (res.statusCode === 200) { - // If somehow succeeded, verify the dangerous path wasn't used - expect(JSON.stringify(res.body)).not.toContain('/etc/shadow'); - } - }); -}); - -// ============================================================ -// LOGO DELETE — Path traversal prevention -// ============================================================ -describe('Logo Delete Path Traversal', () => { - test('DELETE /api/logo should safely handle config with traversal paths', async () => { - // Write config with a malicious logo path - const configWithMaliciousLogo = { - customLogo: '/assets/../../etc/passwd', - customLogoDark: '/assets/../../../root/.ssh/id_rsa', - }; - await fsp.writeFile(testConfigFile, JSON.stringify(configWithMaliciousLogo), 'utf8'); - - const res = await request(app).delete('/api/logo'); - - // Should succeed (reset branding) without deleting files outside assets dir - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - - // Reset config for other tests - await fsp.writeFile(testConfigFile, '{}', 'utf8'); - }); -}); - -// ============================================================ -// DNS — SSRF prevention (server parameter validation) -// ============================================================ -describe('DNS Server SSRF Prevention', () => { - test('DELETE /api/dns/record should not succeed with arbitrary server IPs', async () => { - const res = await request(app) - .delete('/api/dns/record') - .query({ - domain: 'test.sami', - type: 'A', - server: '169.254.169.254', // AWS metadata endpoint - }); - - // Must never succeed — 400 (server rejected), 401 (no token), or 500 (dns not configured in test) - expect(res.statusCode).not.toBe(200); - }); - - test('POST /api/dns/record should not succeed with arbitrary server IPs', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ - domain: 'test.sami', - ipAddress: '192.168.1.1', - server: '10.0.0.1', // Not a configured DNS server - }); - - expect(res.statusCode).not.toBe(200); - }); - - test('GET /api/dns/resolve should not succeed with arbitrary server IPs', async () => { - const res = await request(app) - .get('/api/dns/resolve') - .query({ - domain: 'test.sami', - server: '127.0.0.1', - }); - - expect(res.statusCode).not.toBe(200); - }); - - test('GET /api/dns/logs should reject arbitrary server IPs', async () => { - const res = await request(app) - .get('/api/dns/logs') - .query({ server: '192.168.1.1' }); - - expect([400]).toContain(res.statusCode); - }); - - test('GET /api/dns/check-update should reject arbitrary server IPs', async () => { - const res = await request(app) - .get('/api/dns/check-update') - .query({ server: '8.8.8.8' }); - - expect([400]).toContain(res.statusCode); - }); - - test('POST /api/dns/update should reject arbitrary server IPs', async () => { - const res = await request(app) - .post('/api/dns/update') - .query({ server: '1.1.1.1' }); - - expect([400]).toContain(res.statusCode); - }); -}); - -// ============================================================ -// _httpFetch — Response size limit -// ============================================================ -describe('HTTP Fetch Response Size Limit', () => { - // This is tested indirectly — the _httpFetch function has a 10MB limit - // We can verify the constant exists by checking the server module - test('server should define MAX_RESPONSE_SIZE constant', () => { - // Read server.js and verify the limit is defined - const serverSource = fs.readFileSync( - path.join(__dirname, '..', 'server.js'), 'utf8', - ); - expect(serverSource).toContain('MAX_RESPONSE_SIZE'); - expect(serverSource).toContain('10 * 1024 * 1024'); - }); -}); - -// ============================================================ -// MIDDLEWARE — Session cookie format -// ============================================================ -describe('Middleware Security', () => { - test('middleware should set Secure flag on cookies', () => { - const middlewareSource = fs.readFileSync( - path.join(__dirname, '..', 'middleware.js'), 'utf8', - ); - // Verify the Set-Cookie string includes Secure - expect(middlewareSource).toContain('; Secure;'); - }); -}); - -// ============================================================ -// SAVECONFIG — Atomic operations -// ============================================================ -describe('Config Save Atomicity', () => { - test('saveConfig should use state manager for locking', () => { - const serverSource = fs.readFileSync( - path.join(__dirname, '..', 'server.js'), 'utf8', - ); - // Verify saveConfig uses configStateManager.update (not raw fs.writeFile) - expect(serverSource).toContain('configStateManager.update'); - }); -}); - -// ============================================================ -// SITES — External URL validation -// ============================================================ -describe('External URL Security', () => { - test('sites.js should validate URL components for unsafe chars', () => { - const sitesSource = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'sites.js'), 'utf8', - ); - // Verify the unsafe character regex exists - expect(sitesSource).toContain('unsafeCaddyChars'); - expect(sitesSource).toMatch(/[{}\\n\\r]/); - }); -}); - -// ============================================================ -// CREDENTIAL MANAGER — Locking -// ============================================================ -describe('Credential Manager File Locking', () => { - test('credential-manager should use proper-lockfile', () => { - const cmSource = fs.readFileSync( - path.join(__dirname, '..', 'credential-manager.js'), 'utf8', - ); - expect(cmSource).toContain('proper-lockfile'); - expect(cmSource).toContain('_lockedUpdate'); - }); -}); - -// ============================================================ -// TOTP CONFIG — No plaintext secret in file -// ============================================================ -describe('TOTP Config File Security', () => { - test('loadTotpConfig should delete secret from file data', () => { - const serverSource = fs.readFileSync( - path.join(__dirname, '..', 'server.js'), 'utf8', - ); - // Verify the secret deletion exists in loadTotpConfig - expect(serverSource).toContain('delete loaded.secret'); - }); - - test('totp verify-setup should not write secret to config file', () => { - const totpSource = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8', - ); - // Verify totpConfig.secret assignment is NOT present - expect(totpSource).not.toContain('totpConfig.secret = pendingSecret'); - expect(totpSource).not.toContain('totpConfig.secret ='); - }); -}); - -// ============================================================ -// HELPERS — Volume path validation -// ============================================================ -describe('Helpers — Volume Security', () => { - test('helpers.js should validate hostPath against allowed roots', () => { - const helpersSource = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'apps', 'helpers.js'), 'utf8', - ); - expect(helpersSource).toContain('allowedRoots'); - expect(helpersSource).toContain('platformPaths.dockerData'); - expect(helpersSource).toContain('Custom volume host path rejected'); - }); -}); - -// ============================================================ -// ERROR LOGS — No details field -// ============================================================ -describe('Error Logs — Response Format', () => { - test('errorlogs.js should not include details field', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8', - ); - // The parsed log object should only have timestamp, context, error - // NOT details (which contains stack traces) - const returnBlock = source.match(/return \{[\s\S]*?\}/); - if (returnBlock) { - expect(returnBlock[0]).not.toContain('details'); - } - }); -}); - -// ============================================================ -// ASSETS — path.basename for logo deletion -// ============================================================ -describe('Assets — Logo Path Safety', () => { - test('assets.js should use path.basename for logo filename extraction', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'assets.js'), 'utf8', - ); - expect(source).toContain('path.basename(logoPath)'); - // Should NOT use string replace for path extraction - expect(source).not.toContain("logoPath.replace('/assets/', '')"); - }); -}); - -// ============================================================ -// BACKUP — encryptionKey excluded -// ============================================================ -describe('Backup — Encryption Key Exclusion', () => { - test('backup.js should not include encryptionKey in filesToBackup', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8', - ); - // Should have a comment about deliberate exclusion - expect(source).toContain('encryptionKey deliberately excluded'); - // Should NOT have encryptionKey as a key in filesToBackup array - expect(source).not.toMatch(/\{\s*key:\s*'encryptionKey'/); - }); - - test('backup.js restore fileMapping should not include encryptionKey', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8', - ); - // The RESTORE route's fileMapping (after "encryptionKey excluded" comment) must not have it - // The preview route's fileMapping is allowed to have it (informational only) - const restoreSection = source.substring(source.indexOf('encryptionKey excluded')); - const restoreMapping = restoreSection.match(/const fileMapping = \{[\s\S]*?\};/); - if (restoreMapping) { - expect(restoreMapping[0]).not.toContain('encryptionKey:'); - } - }); - - test('backup.js should require TOTP for sensitive restores', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8', - ); - expect(source).toContain('sensitiveKeys'); - expect(source).toContain('totpCode'); - expect(source).toContain('TOTP code required'); - }); -}); - -// ============================================================ -// DNS — validateDnsServer function -// ============================================================ -describe('DNS — Server Validation Function', () => { - test('dns.js should define validateDnsServer', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8', - ); - expect(source).toContain('function validateDnsServer'); - expect(source).toContain('configuredIps'); - expect(source).toContain('validatorLib.isIP'); - }); -}); - -// ============================================================ -// CONTAINERS — getVerifiedContainer usage -// ============================================================ -describe('Containers — Verified Container Access', () => { - test('containers.js update route should use getVerifiedContainer', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8', - ); - // update and check-update should both use getVerifiedContainer - const updateSection = source.substring(source.indexOf("'/:id/update'")); - expect(updateSection).toContain('getVerifiedContainer'); - - const checkUpdateSection = source.substring(source.indexOf("'/:id/check-update'")); - expect(checkUpdateSection).toContain('getVerifiedContainer'); - }); -}); - -// ============================================================ -// LOGS — Symlink resolution -// ============================================================ -describe('Logs — Symlink Resolution', () => { - test('logs.js should use realpath for symlink resolution', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8', - ); - expect(source).toContain('fsp.realpath'); - expect(source).toContain('path.sep'); - }); - - test('logs.js container routes should verify container exists', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8', - ); - // Both container/:id and stream/:id should have inspect + NotFoundError - expect(source).toContain('container.inspect()'); - expect(source).toContain('NotFoundError'); - }); -}); diff --git a/dashcaddy-api/__tests__/server-validation.test.js b/dashcaddy-api/__tests__/server-validation.test.js deleted file mode 100644 index 8bb667f..0000000 --- a/dashcaddy-api/__tests__/server-validation.test.js +++ /dev/null @@ -1,417 +0,0 @@ -/** - * Integration tests for server.js input validation - * Tests that routes properly reject invalid input before reaching business logic - */ - -const request = require('supertest'); -const app = require('../server'); - -describe('POST /api/assets/upload - directory traversal prevention', () => { - test('rejects filename with path separators', async () => { - const res = await request(app) - .post('/api/assets/upload') - .send({ filename: '../../../etc/passwd', data: 'data:image/png;base64,iVBOR' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/path separator/i); - }); - - test('rejects filename with backslash', async () => { - const res = await request(app) - .post('/api/assets/upload') - .send({ filename: '..\\..\\config.json', data: 'data:image/png;base64,iVBOR' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/path separator/i); - }); - - test('rejects filename with dot-dot', async () => { - const res = await request(app) - .post('/api/assets/upload') - .send({ filename: '..evil.png', data: 'data:image/png;base64,iVBOR' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/path separator/i); - }); - - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/assets/upload') - .send({ filename: 'test.png' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); -}); - -describe('POST /api/site - Caddyfile injection prevention', () => { - test('rejects invalid domain format', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'evil;rm -rf /', upstream: '127.0.0.1:8080' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects domain with spaces', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'evil domain', upstream: '127.0.0.1:8080' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects invalid upstream format', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'test.sami', upstream: 'not a valid upstream' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid upstream/i); - }); - - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'test.sami' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); -}); - -describe('POST /api/site/external - URL and subdomain validation', () => { - test('rejects invalid subdomain', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ subdomain: '-invalid', externalUrl: 'https://example.com' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid subdomain/i); - }); - - test('rejects subdomain with special chars', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ subdomain: 'test;evil', externalUrl: 'https://example.com' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid subdomain/i); - }); - - test('rejects invalid URL', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ subdomain: 'myapp', externalUrl: 'not-a-url' }); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); - - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ subdomain: 'myapp' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); -}); - -// DNS routes require a token to bypass the 401 token check and reach validation -const FAKE_TOKEN = 'aaaa1111bbbb2222cccc3333dddd4444'; - -describe('POST /api/dns/record - DNS injection prevention', () => { - test('rejects invalid domain format', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'evil;command', ip: '10.0.0.1', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects invalid IP address', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', ip: 'not-an-ip', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid ip/i); - }); - - test('rejects TTL out of range (too low)', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 5, token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/ttl/i); - }); - - test('rejects TTL out of range (too high)', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 100000, token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/ttl/i); - }); - - test('rejects invalid server IP', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', ip: '10.0.0.1', server: 'not-an-ip', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid dns server/i); - }); - - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - }); -}); - -describe('DELETE /api/dns/record - DNS injection prevention', () => { - test('rejects invalid domain', async () => { - const res = await request(app) - .delete('/api/dns/record') - .query({ domain: 'evil;drop table', token: 'abc123def456' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects invalid record type', async () => { - const res = await request(app) - .delete('/api/dns/record') - .query({ domain: 'test.sami', type: 'INVALID', token: 'abc123def456' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid dns record type/i); - }); - - test('rejects invalid ipAddress', async () => { - const res = await request(app) - .delete('/api/dns/record') - .query({ domain: 'test.sami', ipAddress: 'not-ip', token: 'abc123def456' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid ip/i); - }); -}); - -describe('GET /api/dns/resolve - DNS injection prevention', () => { - test('rejects invalid domain', async () => { - const res = await request(app) - .get('/api/dns/resolve') - .query({ domain: 'evil;command', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects invalid server IP', async () => { - const res = await request(app) - .get('/api/dns/resolve') - .query({ domain: 'test.sami', server: 'not-an-ip', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid dns server/i); - }); -}); - -describe('POST /api/apps/deploy - deployment validation', () => { - test('rejects invalid subdomain', async () => { - const res = await request(app) - .post('/api/apps/deploy') - .send({ appId: 'plex', config: { subdomain: '-bad-sub' } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid subdomain/i); - }); - - test('rejects invalid port', async () => { - const res = await request(app) - .post('/api/apps/deploy') - .send({ appId: 'plex', config: { subdomain: 'test', port: 99999 } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid port/i); - }); - - test('rejects invalid IP', async () => { - const res = await request(app) - .post('/api/apps/deploy') - .send({ appId: 'plex', config: { subdomain: 'test', ip: 'not-ip' } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid ip/i); - }); - - test('rejects unknown template', async () => { - const res = await request(app) - .post('/api/apps/deploy') - .send({ appId: 'nonexistent-app-xyz', config: { subdomain: 'test' } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid app template/i); - }); -}); - -describe('POST /api/dns/credentials - credential validation', () => { - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'admin' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); - - test('rejects username exceeding max length', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'a'.repeat(101), password: 'secret' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/maximum length/i); - }); - - test('rejects username with injection chars', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'admin;rm -rf /', password: 'secret' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid characters/i); - }); - - test('rejects username with pipe', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'admin|evil', password: 'secret' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid characters/i); - }); - - test('rejects invalid server IP', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'admin', password: 'secret', server: 'not-ip' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid dns server/i); - }); -}); - -describe('POST /api/services - service config validation', () => { - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/services') - .send({ id: 'test' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); - - test('rejects invalid service id format', async () => { - const res = await request(app) - .post('/api/services') - .send({ id: 'invalid id with spaces!', name: 'Test' }); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); -}); - -describe('PUT /api/services - bulk import validation', () => { - test('rejects non-array body', async () => { - const res = await request(app) - .put('/api/services') - .send({ id: 'test', name: 'Test' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/array/i); - }); - - test('rejects service with invalid id', async () => { - const res = await request(app) - .put('/api/services') - .send([{ id: 'invalid id!', name: 'Test' }]); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); -}); - -describe('POST /api/services/update - service update validation', () => { - test('rejects missing subdomains', async () => { - const res = await request(app) - .post('/api/services/update') - .send({ oldSubdomain: 'test' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); - - test('rejects invalid subdomain format', async () => { - const res = await request(app) - .post('/api/services/update') - .send({ oldSubdomain: '-bad', newSubdomain: 'good' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid subdomain/i); - }); - - test('rejects invalid port', async () => { - const res = await request(app) - .post('/api/services/update') - .send({ oldSubdomain: 'old', newSubdomain: 'new', port: 70000 }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid port/i); - }); - - test('rejects invalid IP', async () => { - const res = await request(app) - .post('/api/services/update') - .send({ oldSubdomain: 'old', newSubdomain: 'new', ip: 'not-ip' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid ip/i); - }); -}); - -describe('POST /api/arr/test-connection - SSRF prevention', () => { - test('rejects invalid URL', async () => { - const res = await request(app) - .post('/api/arr/test-connection') - .send({ service: 'radarr', url: 'not-a-url', apiKey: 'abc123def456' }); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); - - test('rejects invalid API key format', async () => { - const res = await request(app) - .post('/api/arr/test-connection') - .send({ service: 'radarr', url: 'http://localhost:7878', apiKey: 'a;b' }); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); -}); - -describe('POST /api/notifications/config - notification provider validation', () => { - test('rejects invalid Discord webhook URL', async () => { - const res = await request(app) - .post('/api/notifications/config') - .send({ providers: { discord: { webhookUrl: 'not-a-url' } } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/discord webhook/i); - }); - - test('rejects invalid ntfy server URL', async () => { - const res = await request(app) - .post('/api/notifications/config') - .send({ providers: { ntfy: { serverUrl: 'ftp://bad' } } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/ntfy server/i); - }); - - test('rejects invalid ntfy topic', async () => { - const res = await request(app) - .post('/api/notifications/config') - .send({ providers: { ntfy: { topic: 'has spaces and $pecial!' } } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/ntfy topic/i); - }); - - test('accepts valid config', async () => { - const res = await request(app) - .post('/api/notifications/config') - .send({ enabled: true }); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); -}); - -describe('Rate limiting headers', () => { - test('returns rate limit headers on API responses', async () => { - const res = await request(app).get('/api/health'); - // Health endpoint is skipped by rate limiter, but general endpoints should have headers - expect(res.status).toBe(200); - }); - - test('general API endpoint has rate limiting configured', async () => { - const res = await request(app).get('/api/services'); - // Rate limiting is skipped in test env, so verify the endpoint is accessible - expect(res.status).toBe(200); - }); -}); diff --git a/dashcaddy-api/__tests__/sites.test.js b/dashcaddy-api/__tests__/sites.test.js deleted file mode 100644 index e6913f5..0000000 --- a/dashcaddy-api/__tests__/sites.test.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Sites Route Tests - * - * Tests Caddyfile management, site configuration, and external site endpoints - */ - -const request = require('supertest'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const testServicesFile = path.join(os.tmpdir(), `sites-services-${Date.now()}.json`); -const testConfigFile = path.join(os.tmpdir(), `sites-config-${Date.now()}.json`); -const testCaddyfile = path.join(os.tmpdir(), `sites-Caddyfile-${Date.now()}`); - -process.env.SERVICES_FILE = testServicesFile; -process.env.CONFIG_FILE = testConfigFile; -process.env.CADDYFILE_PATH = testCaddyfile; -process.env.ENABLE_HEALTH_CHECKER = 'false'; -process.env.NODE_ENV = 'test'; - -fs.writeFileSync(testServicesFile, '[]', 'utf8'); -fs.writeFileSync(testConfigFile, '{}', 'utf8'); -fs.writeFileSync(testCaddyfile, '# Test Caddyfile', 'utf8'); - -const app = require('../server'); - -describe('Sites Routes', () => { - afterAll(() => { - for (const f of [testServicesFile, testConfigFile, testCaddyfile]) { - try { fs.unlinkSync(f); } catch (e) { /* ignore */ } - } - }); - - describe('GET /api/caddyfile', () => { - test('should return Caddyfile contents', async () => { - const res = await request(app).get('/api/caddyfile'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.content).toContain('Test Caddyfile'); - }); - }); - - describe('GET /api/apps/templates', () => { - test('should return all templates with categories', async () => { - const res = await request(app).get('/api/apps/templates'); - - expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty('templates'); - expect(res.body).toHaveProperty('categories'); - expect(Object.keys(res.body.templates).length).toBeGreaterThan(50); - }); - }); - - describe('GET /api/apps/templates/:appId', () => { - test('should return specific template', async () => { - const res = await request(app).get('/api/apps/templates/plex'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.template.name).toBe('Plex'); - expect(res.body.template.docker).toBeDefined(); - }); - - test('should return 404 for unknown template', async () => { - const res = await request(app).get('/api/apps/templates/nonexistent'); - - expect(res.statusCode).toBe(404); - }); - }); - - describe('POST /api/site/external', () => { - test('should reject missing required fields', async () => { - const res = await request(app) - .post('/api/site/external') - .send({}); - - expect(res.statusCode).toBe(400); - }); - - test('should reject invalid subdomain', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ - subdomain: 'INVALID SUBDOMAIN!', - targetUrl: 'https://example.com', - name: 'Test', - }); - - expect(res.statusCode).toBe(400); - }); - }); - - describe('GET /api/caddy/cas', () => { - test('should return CA list from Caddyfile', async () => { - const res = await request(app).get('/api/caddy/cas'); - - expect(res.statusCode).toBe(200); - expect(res.body.status).toBe('success'); - expect(Array.isArray(res.body.data.cas)).toBe(true); - }); - }); -}); diff --git a/dashcaddy-api/__tests__/state-manager.test.js b/dashcaddy-api/__tests__/state-manager.test.js deleted file mode 100644 index 8c411d1..0000000 --- a/dashcaddy-api/__tests__/state-manager.test.js +++ /dev/null @@ -1,249 +0,0 @@ -/** - * State Manager Tests - * - * Tests the thread-safe state management with file locking - */ - -const StateManager = require('../state-manager'); -const fs = require('fs').promises; -const path = require('path'); -const os = require('os'); - -// Dedicated temp subdirectory avoids cross-test file collisions -const testDir = path.join(os.tmpdir(), `state-manager-test-${Date.now()}`); -const testFile = path.join(testDir, 'test-state.json'); - -describe('StateManager', () => { - let stateManager; - - beforeAll(async () => { - await fs.mkdir(testDir, { recursive: true }); - }); - - beforeEach(async () => { - // Clean up test file + stale lockfiles - for (const f of [testFile, `${testFile}.lock`]) { - try { await fs.unlink(f); } catch (e) { /* ignore */ } - } - - stateManager = new StateManager(testFile, { - lockRetries: 20, - lockRetryInterval: 50, - lockTimeout: 15000, - }); - }); - - afterEach(async () => { - for (const f of [testFile, `${testFile}.lock`]) { - try { await fs.unlink(f); } catch (e) { /* ignore */ } - } - }); - - afterAll(async () => { - try { await fs.rm(testDir, { recursive: true }); } catch (e) { /* ignore */ } - }); - - describe('Basic Operations', () => { - test('creates file with empty array if not exists', async () => { - const data = await stateManager.read(); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(0); - }); - - test('write and read roundtrip', async () => { - const testData = [ - { id: '1', name: 'Test Service 1' }, - { id: '2', name: 'Test Service 2' }, - ]; - - await stateManager.write(testData); - const data = await stateManager.read(); - - expect(data).toEqual(testData); - }); - - test('update with callback function', async () => { - await stateManager.write([{ id: '1', name: 'Service 1' }]); - - const updated = await stateManager.update(items => { - items.push({ id: '2', name: 'Service 2' }); - return items; - }); - - expect(updated.length).toBe(2); - expect(updated[1].name).toBe('Service 2'); - }); - }); - - describe('Convenience Methods', () => { - test('addItem adds to array', async () => { - await stateManager.addItem({ id: '1', name: 'Service 1' }); - await stateManager.addItem({ id: '2', name: 'Service 2' }); - - const items = await stateManager.read(); - expect(items.length).toBe(2); - }); - - test('removeItem removes by ID', async () => { - await stateManager.write([ - { id: '1', name: 'Service 1' }, - { id: '2', name: 'Service 2' }, - { id: '3', name: 'Service 3' }, - ]); - - await stateManager.removeItem('2'); - - const items = await stateManager.read(); - expect(items.length).toBe(2); - expect(items.find(i => i.id === '2')).toBeUndefined(); - }); - - test('updateItem updates by ID', async () => { - await stateManager.write([ - { id: '1', name: 'Service 1', status: 'offline' }, - ]); - - await stateManager.updateItem('1', { status: 'online' }); - - const item = await stateManager.findItem('1'); - expect(item.status).toBe('online'); - expect(item.name).toBe('Service 1'); // Unchanged - }); - - test('findItem returns null for non-existent ID', async () => { - await stateManager.write([{ id: '1', name: 'Service 1' }]); - - const item = await stateManager.findItem('999'); - expect(item).toBeNull(); - }); - }); - - describe('Concurrent Access', () => { - test('concurrent writes do not corrupt data', async () => { - // Start with empty array - await stateManager.write([]); - - // Simulate 10 concurrent writes - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push( - stateManager.update(items => { - items.push({ id: `service-${i}`, name: `Service ${i}` }); - return items; - }), - ); - } - - await Promise.all(promises); - - // Verify all items were added - const items = await stateManager.read(); - expect(items.length).toBe(10); - - // Verify JSON is valid (not corrupted) - const fileContent = await fs.readFile(testFile, 'utf8'); - expect(() => JSON.parse(fileContent)).not.toThrow(); - }); - - test('concurrent reads while writing', async () => { - await stateManager.write([{ id: '1', name: 'Initial' }]); - - const writePromise = stateManager.update(async items => { - // Simulate slow operation - await new Promise(resolve => setTimeout(resolve, 100)); - items.push({ id: '2', name: 'New' }); - return items; - }); - - const readPromises = []; - for (let i = 0; i < 5; i++) { - readPromises.push(stateManager.read()); - } - - await Promise.all([writePromise, ...readPromises]); - - // Should complete without errors - const final = await stateManager.read(); - expect(final.length).toBe(2); - }); - }); - - describe('Error Handling', () => { - test('throws error on invalid JSON', async () => { - // Write invalid JSON directly - await fs.writeFile(testFile, '{invalid json', 'utf8'); - - await expect(stateManager.read()).rejects.toThrow(); - }); - - test('handles missing file gracefully', async () => { - await fs.unlink(testFile); - - const data = await stateManager.read(); - expect(Array.isArray(data)).toBe(true); - }); - - test('update callback errors are caught', async () => { - await expect( - stateManager.update(() => { - throw new Error('Test error'); - }), - ).rejects.toThrow('Test error'); - }); - }); - - describe('Lock Management', () => { - test('isLocked detects locked state', async () => { - const lockfile = require('proper-lockfile'); - - // Manually lock the file - const release = await lockfile.lock(testFile); - - const locked = await stateManager.isLocked(); - expect(locked).toBe(true); - - await release(); - - const unlocked = await stateManager.isLocked(); - expect(unlocked).toBe(false); - }); - - test('forceUnlock removes stuck lock', async () => { - const lockfile = require('proper-lockfile'); - - // Create a stuck lock - await lockfile.lock(testFile); - - await stateManager.forceUnlock(); - - // Should be able to write now - await expect(stateManager.write([])).resolves.not.toThrow(); - }); - }); - - describe('Performance', () => { - test('handles large datasets efficiently', async () => { - const largeDataset = []; - for (let i = 0; i < 1000; i++) { - largeDataset.push({ - id: `service-${i}`, - name: `Service ${i}`, - url: `https://service-${i}.example.com`, - status: 'online', - }); - } - - const startTime = Date.now(); - await stateManager.write(largeDataset); - const writeTime = Date.now() - startTime; - - const readStart = Date.now(); - const data = await stateManager.read(); - const readTime = Date.now() - readStart; - - expect(data.length).toBe(1000); - expect(writeTime).toBeLessThan(1000); // Should write in <1s - expect(readTime).toBeLessThan(100); // Should read in <100ms - }); - }); -}); diff --git a/dashcaddy-api/__tests__/tailscale.test.js b/dashcaddy-api/__tests__/tailscale.test.js deleted file mode 100644 index 6055139..0000000 --- a/dashcaddy-api/__tests__/tailscale.test.js +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Tailscale Route Tests - * - * Tests Tailscale status, configuration, and connection-checking endpoints. - * The Tailscale routes are mounted at /api/tailscale/ on the API router: - * - GET /api/tailscale/status — Tailscale status - * - POST /api/tailscale/config — Update Tailscale configuration - * - GET /api/tailscale/check-connection — Check if request comes from Tailscale IP - * - GET /api/tailscale/devices — List Tailscale devices - * - POST /api/tailscale/protect-service — Toggle Tailscale-only for a service - * - POST /api/tailscale/oauth-config — OAuth credential setup (requires live API) - * - GET /api/tailscale/api-devices — Enriched device list from API - * - POST /api/tailscale/sync — Trigger API sync - * - GET /api/tailscale/acl — Fetch ACL policy - */ - -const request = require('supertest'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const testServicesFile = path.join(os.tmpdir(), `tailscale-services-${Date.now()}.json`); -const testConfigFile = path.join(os.tmpdir(), `tailscale-config-${Date.now()}.json`); - -process.env.SERVICES_FILE = testServicesFile; -process.env.CONFIG_FILE = testConfigFile; -process.env.ENABLE_HEALTH_CHECKER = 'false'; -process.env.NODE_ENV = 'test'; - -fs.writeFileSync(testServicesFile, '[]', 'utf8'); -fs.writeFileSync(testConfigFile, '{}', 'utf8'); - -const app = require('../server'); - -describe('Tailscale Routes', () => { - afterAll(() => { - try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ } - try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ } - }); - - describe('GET /api/tailscale/status', () => { - test('should return 200 with status data', async () => { - const res = await request(app).get('/api/tailscale/status'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - - // If Tailscale is not installed in test env, expect installed: false - if (!res.body.installed) { - expect(res.body.installed).toBe(false); - expect(res.body.connected).toBe(false); - expect(res.body.message).toBeDefined(); - } else { - // If installed, expect richer data - expect(res.body).toHaveProperty('connected'); - expect(res.body).toHaveProperty('self'); - expect(res.body).toHaveProperty('config'); - expect(res.body).toHaveProperty('devices'); - expect(res.body).toHaveProperty('deviceCount'); - } - }); - }); - - describe('GET /api/tailscale/check-connection', () => { - test('should return 200 with connection info', async () => { - const res = await request(app).get('/api/tailscale/check-connection'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body).toHaveProperty('isTailscale'); - expect(typeof res.body.isTailscale).toBe('boolean'); - expect(res.body).toHaveProperty('clientIP'); - }); - - test('should detect non-Tailscale IP for localhost requests', async () => { - const res = await request(app).get('/api/tailscale/check-connection'); - - expect(res.statusCode).toBe(200); - // Supertest connects via loopback, not a 100.x.x.x address - expect(res.body.isTailscale).toBe(false); - }); - }); - - describe('GET /api/tailscale/devices', () => { - test('should return 200 with devices array', async () => { - const res = await request(app).get('/api/tailscale/devices'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body).toHaveProperty('devices'); - expect(Array.isArray(res.body.devices)).toBe(true); - }); - }); - - describe('POST /api/tailscale/oauth-config', () => { - test('should reject missing required fields', async () => { - const res = await request(app) - .post('/api/tailscale/oauth-config') - .send({}); - - expect(res.statusCode).toBe(400); - }); - - test('should reject partial credentials', async () => { - const res = await request(app) - .post('/api/tailscale/oauth-config') - .send({ clientId: 'test-id' }); - - expect(res.statusCode).toBe(400); - }); - }); - - describe('GET /api/tailscale/api-devices', () => { - test('should return 400 when OAuth is not configured', async () => { - const res = await request(app).get('/api/tailscale/api-devices'); - - expect(res.statusCode).toBe(400); - }); - }); - - describe('POST /api/tailscale/sync', () => { - test('should return 400 when OAuth is not configured', async () => { - const res = await request(app).post('/api/tailscale/sync'); - - expect(res.statusCode).toBe(400); - }); - }); - - describe('POST /api/tailscale/protect-service', () => { - test('should reject missing subdomain', async () => { - const res = await request(app) - .post('/api/tailscale/protect-service') - .send({}); - - expect(res.statusCode).toBe(400); - }); - }); -}); diff --git a/dashcaddy-api/__tests__/update-manager.test.js b/dashcaddy-api/__tests__/update-manager.test.js deleted file mode 100644 index 8157064..0000000 --- a/dashcaddy-api/__tests__/update-manager.test.js +++ /dev/null @@ -1,192 +0,0 @@ -// update-manager.js creates a Docker instance at module level. -// On test machines without Docker, this is fine — Docker methods are only called -// in async methods that we won't invoke in unit tests. - -const updateManager = require('../update-manager'); - -beforeEach(() => { - // Reset singleton state - updateManager.history = []; - updateManager.availableUpdates = new Map(); - updateManager.config = { autoUpdate: {} }; - updateManager.checking = false; - if (updateManager.checkInterval) { - clearInterval(updateManager.checkInterval); - updateManager.checkInterval = null; - } -}); - -afterAll(() => { - updateManager.stop(); -}); - -describe('extractTag', () => { - test('extracts tag from "nginx:latest"', () => { - expect(updateManager.extractTag('nginx:latest')).toBe('latest'); - }); - - test('returns "latest" when no tag specified', () => { - expect(updateManager.extractTag('nginx')).toBe('latest'); - }); - - test('extracts tag from registry/repo:tag format', () => { - expect(updateManager.extractTag('docker.io/library/nginx:1.21')).toBe('1.21'); - }); - - test('handles tags with dots and hyphens', () => { - expect(updateManager.extractTag('myapp:v1.2.3-rc1')).toBe('v1.2.3-rc1'); - }); -}); - -describe('parseAuthHeader', () => { - test('returns null for null header', () => { - expect(updateManager.parseAuthHeader(null)).toBeNull(); - }); - - test('returns null for non-Bearer header', () => { - expect(updateManager.parseAuthHeader('Basic realm="test"')).toBeNull(); - }); - - test('parses Bearer realm URL', () => { - const header = 'Bearer realm="https://auth.docker.io/token"'; - const result = updateManager.parseAuthHeader(header); - expect(result).toContain('https://auth.docker.io/token'); - }); - - test('includes service parameter', () => { - const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"'; - const result = updateManager.parseAuthHeader(header); - expect(result).toContain('service=registry.docker.io'); - }); - - test('includes scope parameter', () => { - const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"'; - const result = updateManager.parseAuthHeader(header); - expect(result).toContain('scope='); - }); -}); - -describe('getAvailableUpdates', () => { - test('returns empty array initially', () => { - expect(updateManager.getAvailableUpdates()).toEqual([]); - }); - - test('returns array from availableUpdates map', () => { - updateManager.availableUpdates.set('c1', { containerId: 'c1', imageName: 'nginx' }); - const updates = updateManager.getAvailableUpdates(); - expect(updates).toHaveLength(1); - expect(updates[0].containerId).toBe('c1'); - }); -}); - -describe('getHistory', () => { - test('returns entries in reverse order', () => { - updateManager.addToHistory({ containerId: 'c1', status: 'success' }); - updateManager.addToHistory({ containerId: 'c2', status: 'success' }); - const history = updateManager.getHistory(); - expect(history[0].containerId).toBe('c2'); - }); - - test('returns empty array when no history', () => { - expect(updateManager.getHistory()).toEqual([]); - }); - - test('respects limit parameter', () => { - for (let i = 0; i < 10; i++) { - updateManager.addToHistory({ containerId: `c${i}` }); - } - expect(updateManager.getHistory(3)).toHaveLength(3); - }); -}); - -describe('addToHistory', () => { - test('appends entry', () => { - updateManager.addToHistory({ containerId: 'c1' }); - expect(updateManager.history).toHaveLength(1); - }); - - test('trims to 100 entries', () => { - for (let i = 0; i < 105; i++) { - updateManager.addToHistory({ containerId: `c${i}` }); - } - expect(updateManager.history.length).toBeLessThanOrEqual(100); - }); -}); - -describe('configureAutoUpdate', () => { - test('creates autoUpdate config section', () => { - updateManager.configureAutoUpdate('c1', { enabled: true }); - expect(updateManager.config.autoUpdate['c1']).toBeDefined(); - }); - - test('stores container-specific config', () => { - updateManager.configureAutoUpdate('c1', { - enabled: true, - schedule: 'daily', - securityOnly: true, - }); - expect(updateManager.config.autoUpdate['c1'].schedule).toBe('daily'); - expect(updateManager.config.autoUpdate['c1'].securityOnly).toBe(true); - }); - - test('defaults autoRollback to true', () => { - updateManager.configureAutoUpdate('c1', { enabled: true }); - expect(updateManager.config.autoUpdate['c1'].autoRollback).toBe(true); - }); - - test('defaults schedule to weekly', () => { - updateManager.configureAutoUpdate('c1', {}); - expect(updateManager.config.autoUpdate['c1'].schedule).toBe('weekly'); - }); -}); - -describe('scheduleUpdate', () => { - test('throws for past scheduled time', () => { - const past = new Date(Date.now() - 60000).toISOString(); - expect(() => updateManager.scheduleUpdate('c1', past)).toThrow('Scheduled time must be in the future'); - }); - - test('accepts future scheduled time', () => { - jest.useFakeTimers(); - const future = new Date(Date.now() + 60000).toISOString(); - expect(() => updateManager.scheduleUpdate('c1', future)).not.toThrow(); - jest.useRealTimers(); - }); -}); - -describe('getChangelog', () => { - test('returns placeholder response', async () => { - const result = await updateManager.getChangelog('nginx:latest'); - expect(result.imageName).toBe('nginx:latest'); - expect(result.changelog).toBeDefined(); - }); -}); - -describe('start / stop', () => { - test('start sets checking flag', () => { - jest.useFakeTimers(); - updateManager.start(); - expect(updateManager.checking).toBe(true); - updateManager.stop(); - jest.useRealTimers(); - }); - - test('stop clears interval', () => { - jest.useFakeTimers(); - updateManager.start(); - updateManager.stop(); - expect(updateManager.checking).toBe(false); - expect(updateManager.checkInterval).toBeNull(); - jest.useRealTimers(); - }); - - test('start is idempotent', () => { - jest.useFakeTimers(); - updateManager.start(); - const first = updateManager.checkInterval; - updateManager.start(); - expect(updateManager.checkInterval).toBe(first); - updateManager.stop(); - jest.useRealTimers(); - }); -}); diff --git a/dashcaddy-api/app-templates.js b/dashcaddy-api/app-templates.js index 3f06b68..e5b13b8 100644 --- a/dashcaddy-api/app-templates.js +++ b/dashcaddy-api/app-templates.js @@ -3,2495 +3,2495 @@ const APP_TEMPLATES = { // === MEDIA & ENTERTAINMENT === - 'plex': { - name: 'Plex', - description: 'Stream your personal media collection anywhere', - icon: '🎬', - logo: '/assets/plex.png', - category: 'Media', + "plex": { + name: "Plex", + description: "Stream your personal media collection anywhere", + icon: "🎬", + logo: "/assets/plex.png", + category: "Media", popularity: 95, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'plexinc/pms-docker:latest', - ports: ['{{PORT}}:32400'], + image: "plexinc/pms-docker:latest", + ports: ["{{PORT}}:32400"], volumes: [ - '/opt/plex/config:/config', - '/opt/plex/transcode:/transcode', - '{{MEDIA_PATH}}:/data', + "/opt/plex/config:/config", + "/opt/plex/transcode:/transcode", + "{{MEDIA_PATH}}:/data" ], environment: { - 'PLEX_CLAIM': '', - 'ADVERTISE_IP': 'http://{{HOST_IP}}:{{PORT}}/', - 'PLEX_UID': '1000', - 'PLEX_GID': '1000', - }, + "PLEX_CLAIM": "", + "ADVERTISE_IP": "http://{{HOST_IP}}:{{PORT}}/", + "PLEX_UID": "1000", + "PLEX_GID": "1000" + } }, - subdomain: 'plex', + subdomain: "plex", defaultPort: 32400, - healthCheck: '/web/index.html', + healthCheck: "/web/index.html", subpathSupport: 'none', mediaMount: { required: true, - containerPath: '/data', - label: 'Media Library', - description: 'Folder containing your movies, TV shows, music, etc.', - defaultPath: '/media', + containerPath: "/data", + label: "Media Library", + description: "Folder containing your movies, TV shows, music, etc.", + defaultPath: "/media" }, claimToken: { - envVar: 'PLEX_CLAIM', - label: 'Plex Claim Token', - description: 'Get from https://plex.tv/claim - expires in 4 minutes!', - placeholder: 'claim-xxxxxxxxxxxxxxxxxxxx', - helpUrl: 'https://plex.tv/claim', + envVar: "PLEX_CLAIM", + label: "Plex Claim Token", + description: "Get from https://plex.tv/claim - expires in 4 minutes!", + placeholder: "claim-xxxxxxxxxxxxxxxxxxxx", + helpUrl: "https://plex.tv/claim" }, setupInstructions: [ - 'Get your claim token from https://plex.tv/claim', - 'Add your media libraries in the web interface', - 'Configure remote access settings', + "Get your claim token from https://plex.tv/claim", + "Add your media libraries in the web interface", + "Configure remote access settings" ], - requiredVolumes: ['config', 'media'], - optionalVolumes: ['transcode'], + requiredVolumes: ["config", "media"], + optionalVolumes: ["transcode"] }, - 'jellyfin': { - name: 'Jellyfin', - description: 'Free software media system - alternative to Plex', - icon: '🍿', - logo: '/assets/jellyfin.png', - category: 'Media', + "jellyfin": { + name: "Jellyfin", + description: "Free software media system - alternative to Plex", + icon: "🍿", + logo: "/assets/jellyfin.png", + category: "Media", popularity: 88, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'jellyfin/jellyfin:latest', - ports: ['{{PORT}}:8096'], + image: "jellyfin/jellyfin:latest", + ports: ["{{PORT}}:8096"], volumes: [ - '/opt/jellyfin/config:/config', - '/opt/jellyfin/cache:/cache', - '{{MEDIA_PATH}}:/media', + "/opt/jellyfin/config:/config", + "/opt/jellyfin/cache:/cache", + "{{MEDIA_PATH}}:/media" ], environment: { - 'JELLYFIN_PublishedServerUrl': 'https://{{SUBDOMAIN}}.sami', - }, + "JELLYFIN_PublishedServerUrl": "https://{{SUBDOMAIN}}.sami" + } }, - subdomain: 'jellyfin', + subdomain: "jellyfin", defaultPort: 8096, - healthCheck: '/health', + healthCheck: "/health", subpathSupport: 'native', urlBaseEnv: 'JELLYFIN_BaseUrl', mediaMount: { required: true, - containerPath: '/media', - label: 'Media Library', - description: 'Folder containing your movies, TV shows, music, etc.', - defaultPath: '/media', + containerPath: "/media", + label: "Media Library", + description: "Folder containing your movies, TV shows, music, etc.", + defaultPath: "/media" }, setupInstructions: [ - 'Complete the initial setup wizard', - 'Add your media libraries', - 'Configure user accounts and permissions', - ], + "Complete the initial setup wizard", + "Add your media libraries", + "Configure user accounts and permissions" + ] }, - 'emby': { - name: 'Emby', - description: 'Personal media server with apps for all devices', - icon: '🎥', - logo: '/assets/emby.png', - category: 'Media', + "emby": { + name: "Emby", + description: "Personal media server with apps for all devices", + icon: "🎥", + logo: "/assets/emby.png", + category: "Media", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'emby/embyserver:latest', - ports: ['{{PORT}}:8096'], + image: "emby/embyserver:latest", + ports: ["{{PORT}}:8096"], volumes: [ - '/opt/emby/config:/config', - '/opt/emby/cache:/cache', - '{{MEDIA_PATH}}:/media', + "/opt/emby/config:/config", + "/opt/emby/cache:/cache", + "{{MEDIA_PATH}}:/media" ], environment: { - 'UID': '1000', - 'GID': '1000', - }, + "UID": "1000", + "GID": "1000" + } }, - subdomain: 'emby', + subdomain: "emby", defaultPort: 8096, - healthCheck: '/emby/web/', + healthCheck: "/emby/web/", subpathSupport: 'none', mediaMount: { required: true, - containerPath: '/media', - label: 'Media Library', - description: 'Folder containing your movies, TV shows, music, etc.', - defaultPath: '/media', + containerPath: "/media", + label: "Media Library", + description: "Folder containing your movies, TV shows, music, etc.", + defaultPath: "/media" }, setupInstructions: [ - 'Complete the initial setup wizard at the web interface', - 'Add your media libraries (Movies, TV Shows, Music)', - 'Configure user accounts and permissions', - 'Install Emby apps on your devices for remote access', - ], + "Complete the initial setup wizard at the web interface", + "Add your media libraries (Movies, TV Shows, Music)", + "Configure user accounts and permissions", + "Install Emby apps on your devices for remote access" + ] }, - 'sonarr': { - name: 'Sonarr', - description: 'Smart PVR for newsgroup and bittorrent users', - icon: '📺', - category: 'Media Management', + "sonarr": { + name: "Sonarr", + description: "Smart PVR for newsgroup and bittorrent users", + icon: "📺", + category: "Media Management", popularity: 82, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'linuxserver/sonarr:latest', - ports: ['{{PORT}}:8989'], + image: "linuxserver/sonarr:latest", + ports: ["{{PORT}}:8989"], volumes: [ - '/opt/sonarr/config:/config', - '/downloads:/downloads', - '/tv:/tv', + "/opt/sonarr/config:/config", + "/downloads:/downloads", + "/tv:/tv" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'sonarr', + subdomain: "sonarr", defaultPort: 8989, - healthCheck: '/api/v3/system/status', + healthCheck: "/api/v3/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', setupInstructions: [ - 'Configure download clients (qBittorrent, etc.)', - 'Add indexers for content discovery', - 'Set up root folders for TV shows', - ], + "Configure download clients (qBittorrent, etc.)", + "Add indexers for content discovery", + "Set up root folders for TV shows" + ] }, - 'radarr': { - name: 'Radarr', - description: 'Movie collection manager for Usenet and BitTorrent', - icon: '🎭', - category: 'Media Management', + "radarr": { + name: "Radarr", + description: "Movie collection manager for Usenet and BitTorrent", + icon: "🎭", + category: "Media Management", popularity: 80, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'linuxserver/radarr:latest', - ports: ['{{PORT}}:7878'], + image: "linuxserver/radarr:latest", + ports: ["{{PORT}}:7878"], volumes: [ - '/opt/radarr/config:/config', - '/downloads:/downloads', - '/movies:/movies', + "/opt/radarr/config:/config", + "/downloads:/downloads", + "/movies:/movies" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'radarr', + subdomain: "radarr", defaultPort: 7878, - healthCheck: '/api/v3/system/status', + healthCheck: "/api/v3/system/status", subpathSupport: 'native', - urlBaseEnv: 'URL_BASE', + urlBaseEnv: 'URL_BASE' }, - 'prowlarr': { - name: 'Prowlarr', - description: 'Indexer manager/proxy for *arr applications', - icon: '🔍', - category: 'Media Management', + "prowlarr": { + name: "Prowlarr", + description: "Indexer manager/proxy for *arr applications", + icon: "🔍", + category: "Media Management", popularity: 75, - difficulty: 'Advanced', + difficulty: "Advanced", docker: { - image: 'linuxserver/prowlarr:latest', - ports: ['{{PORT}}:9696'], - volumes: ['/opt/prowlarr/config:/config'], + image: "linuxserver/prowlarr:latest", + ports: ["{{PORT}}:9696"], + volumes: ["/opt/prowlarr/config:/config"], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'prowlarr', + subdomain: "prowlarr", defaultPort: 9696, - healthCheck: '/api/v1/system/status', + healthCheck: "/api/v1/system/status", subpathSupport: 'native', - urlBaseEnv: 'URL_BASE', + urlBaseEnv: 'URL_BASE' }, - 'qbittorrent': { - name: 'qBittorrent', - description: 'Lightweight BitTorrent client with web UI', - icon: '⬇️', - category: 'Downloads', + "qbittorrent": { + name: "qBittorrent", + description: "Lightweight BitTorrent client with web UI", + icon: "⬇️", + category: "Downloads", popularity: 90, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'linuxserver/qbittorrent:latest', - ports: ['{{PORT}}:8080', '6881:6881', '6881:6881/udp'], + image: "linuxserver/qbittorrent:latest", + ports: ["{{PORT}}:8080", "6881:6881", "6881:6881/udp"], volumes: [ - '/opt/qbittorrent/config:/config', - '/downloads:/downloads', + "/opt/qbittorrent/config:/config", + "/downloads:/downloads" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - 'WEBUI_PORT': '8080', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}", + "WEBUI_PORT": "8080" + } }, - subdomain: 'torrent', + subdomain: "torrent", defaultPort: 8080, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'WEBUI_BASE_PATH', setupInstructions: [ - 'Default login: admin/adminadmin', - 'Change default password immediately', - 'Configure download paths', - ], + "Default login: admin/adminadmin", + "Change default password immediately", + "Configure download paths" + ] }, // === PRODUCTIVITY & TOOLS === - 'nextcloud': { - name: 'Nextcloud', - description: 'Self-hosted productivity platform and file sync', - icon: '☁️', - category: 'Productivity', + "nextcloud": { + name: "Nextcloud", + description: "Self-hosted productivity platform and file sync", + icon: "☁️", + category: "Productivity", popularity: 92, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'nextcloud:latest', - ports: ['{{PORT}}:80'], + image: "nextcloud:latest", + ports: ["{{PORT}}:80"], volumes: [ - '/opt/nextcloud/html:/var/www/html', - '/opt/nextcloud/data:/var/www/html/data', + "/opt/nextcloud/html:/var/www/html", + "/opt/nextcloud/data:/var/www/html/data" ], environment: { - 'NEXTCLOUD_ADMIN_USER': 'admin', - 'NEXTCLOUD_ADMIN_PASSWORD': '{{NEXTCLOUD_ADMIN_PASSWORD}}', - 'NEXTCLOUD_TRUSTED_DOMAINS': '{{SUBDOMAIN}}.sami', - }, + "NEXTCLOUD_ADMIN_USER": "admin", + "NEXTCLOUD_ADMIN_PASSWORD": "{{NEXTCLOUD_ADMIN_PASSWORD}}", + "NEXTCLOUD_TRUSTED_DOMAINS": "{{SUBDOMAIN}}.sami" + } }, - subdomain: 'cloud', + subdomain: "cloud", defaultPort: 8080, - healthCheck: '/status.php', + healthCheck: "/status.php", subpathSupport: 'none', setupInstructions: [ - 'Change the default admin password', - 'Configure trusted domains', - 'Install recommended apps', + "Change the default admin password", + "Configure trusted domains", + "Install recommended apps" ], secrets: [ { - envVar: 'NEXTCLOUD_ADMIN_PASSWORD', - label: 'Admin Password', - description: 'Secure password for Nextcloud admin account', - type: 'password', + envVar: "NEXTCLOUD_ADMIN_PASSWORD", + label: "Admin Password", + description: "Secure password for Nextcloud admin account", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'vscode-server': { - name: 'VS Code Server', - description: 'Visual Studio Code in your browser', - icon: '💻', - category: 'Development', + "vscode-server": { + name: "VS Code Server", + description: "Visual Studio Code in your browser", + icon: "💻", + category: "Development", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'codercom/code-server:latest', - ports: ['{{PORT}}:8080'], + image: "codercom/code-server:latest", + ports: ["{{PORT}}:8080"], volumes: [ - '/opt/vscode/config:/home/coder/.config', - '/opt/vscode/projects:/home/coder/projects', + "/opt/vscode/config:/home/coder/.config", + "/opt/vscode/projects:/home/coder/projects" ], environment: { - 'PASSWORD': '{{VSCODE_PASSWORD}}', - }, + "PASSWORD": "{{VSCODE_PASSWORD}}" + } }, - subdomain: 'code', + subdomain: "code", defaultPort: 8443, - healthCheck: '/healthz', + healthCheck: "/healthz", subpathSupport: 'strip', secrets: [ { - envVar: 'VSCODE_PASSWORD', - label: 'Access Password', - description: 'Password to access VS Code Server web interface', - type: 'password', + envVar: "VSCODE_PASSWORD", + label: "Access Password", + description: "Password to access VS Code Server web interface", + type: "password", required: true, - generate: 'alphanumeric', - length: 24, - }, - ], + generate: "alphanumeric", + length: 24 + } + ] }, // === MONITORING & ADMIN === - 'portainer': { - name: 'Portainer', - description: 'Docker container management UI', - icon: '🐳', - category: 'Management', + "portainer": { + name: "Portainer", + description: "Docker container management UI", + icon: "🐳", + category: "Management", popularity: 88, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'portainer/portainer-ce:latest', - ports: ['{{PORT}}:9000'], + image: "portainer/portainer-ce:latest", + ports: ["{{PORT}}:9000"], volumes: [ - '/var/run/docker.sock:/var/run/docker.sock', - '/opt/portainer/data:/data', - ], + "/var/run/docker.sock:/var/run/docker.sock", + "/opt/portainer/data:/data" + ] }, - subdomain: 'portainer', + subdomain: "portainer", defaultPort: 9000, - healthCheck: '/api/status', - subpathSupport: 'strip', + healthCheck: "/api/status", + subpathSupport: 'strip' }, - 'grafana': { - name: 'Grafana', - description: 'Analytics and interactive visualization platform', - icon: '📊', - category: 'Monitoring', + "grafana": { + name: "Grafana", + description: "Analytics and interactive visualization platform", + icon: "📊", + category: "Monitoring", popularity: 78, - difficulty: 'Advanced', + difficulty: "Advanced", docker: { - image: 'grafana/grafana:latest', - ports: ['{{PORT}}:3000'], - volumes: ['/opt/grafana/data:/var/lib/grafana'], + image: "grafana/grafana:latest", + ports: ["{{PORT}}:3000"], + volumes: ["/opt/grafana/data:/var/lib/grafana"], environment: { - 'GF_SECURITY_ADMIN_PASSWORD': '{{GRAFANA_ADMIN_PASSWORD}}', - }, + "GF_SECURITY_ADMIN_PASSWORD": "{{GRAFANA_ADMIN_PASSWORD}}" + } }, - subdomain: 'grafana', + subdomain: "grafana", defaultPort: 3000, - healthCheck: '/api/health', + healthCheck: "/api/health", subpathSupport: 'native', urlBaseEnv: 'GF_SERVER_ROOT_URL', secrets: [ { - envVar: 'GRAFANA_ADMIN_PASSWORD', - label: 'Admin Password', - description: 'Password for Grafana admin user', - type: 'password', + envVar: "GRAFANA_ADMIN_PASSWORD", + label: "Admin Password", + description: "Password for Grafana admin user", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'uptime-kuma': { - name: 'Uptime Kuma', - description: 'Self-hosted monitoring tool like Uptime Robot', - icon: '📈', - category: 'Monitoring', + "uptime-kuma": { + name: "Uptime Kuma", + description: "Self-hosted monitoring tool like Uptime Robot", + icon: "📈", + category: "Monitoring", popularity: 82, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'louislam/uptime-kuma:latest', - ports: ['{{PORT}}:3001'], - volumes: ['/opt/uptime-kuma:/app/data'], + image: "louislam/uptime-kuma:latest", + ports: ["{{PORT}}:3001"], + volumes: ["/opt/uptime-kuma:/app/data"] }, - subdomain: 'uptime', + subdomain: "uptime", defaultPort: 3002, - healthCheck: '/', - subpathSupport: 'strip', + healthCheck: "/", + subpathSupport: 'strip' }, // === NETWORKING & SECURITY === - 'pihole': { - name: 'Pi-hole', - description: 'Network-wide ad blocker and DNS sinkhole', - icon: '🛡️', - category: 'Networking', + "pihole": { + name: "Pi-hole", + description: "Network-wide ad blocker and DNS sinkhole", + icon: "🛡️", + category: "Networking", popularity: 90, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'pihole/pihole:latest', - ports: ['{{PORT}}:80', '53:53', '53:53/udp'], + image: "pihole/pihole:latest", + ports: ["{{PORT}}:80", "53:53", "53:53/udp"], volumes: [ - '/opt/pihole/etc:/etc/pihole', - '/opt/pihole/dnsmasq:/etc/dnsmasq.d', + "/opt/pihole/etc:/etc/pihole", + "/opt/pihole/dnsmasq:/etc/dnsmasq.d" ], environment: { - 'WEBPASSWORD': '{{PIHOLE_WEB_PASSWORD}}', - 'TZ': '{{TIMEZONE}}', - }, + "WEBPASSWORD": "{{PIHOLE_WEB_PASSWORD}}", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'pihole', + subdomain: "pihole", defaultPort: 80, - healthCheck: '/admin/', + healthCheck: "/admin/", subpathSupport: 'strip', secrets: [ { - envVar: 'PIHOLE_WEB_PASSWORD', - label: 'Web Interface Password', - description: 'Password for Pi-hole admin web interface', - type: 'password', + envVar: "PIHOLE_WEB_PASSWORD", + label: "Web Interface Password", + description: "Password for Pi-hole admin web interface", + type: "password", required: true, - generate: 'alphanumeric', - length: 24, - }, - ], + generate: "alphanumeric", + length: 24 + } + ] }, - 'wireguard': { - name: 'WireGuard VPN', - description: 'Fast, modern, secure VPN tunnel', - icon: '🔒', - category: 'Networking', + "wireguard": { + name: "WireGuard VPN", + description: "Fast, modern, secure VPN tunnel", + icon: "🔒", + category: "Networking", popularity: 75, - difficulty: 'Advanced', + difficulty: "Advanced", docker: { - image: 'linuxserver/wireguard:latest', - ports: ['{{PORT}}:51820/udp'], - volumes: ['/opt/wireguard/config:/config'], + image: "linuxserver/wireguard:latest", + ports: ["{{PORT}}:51820/udp"], + volumes: ["/opt/wireguard/config:/config"], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - 'SERVERURL': '{{HOST_IP}}', - 'SERVERPORT': '{{PORT}}', - 'PEERS': '1', + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}", + "SERVERURL": "{{HOST_IP}}", + "SERVERPORT": "{{PORT}}", + "PEERS": "1" }, - capabilities: ['NET_ADMIN', 'SYS_MODULE'], + capabilities: ["NET_ADMIN", "SYS_MODULE"] }, - subdomain: 'vpn', + subdomain: "vpn", defaultPort: 51820, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Configure your external IP/domain', - 'Set up port forwarding on router', - 'Download client configs from /config/peer1/', - ], + "Configure your external IP/domain", + "Set up port forwarding on router", + "Download client configs from /config/peer1/" + ] }, // === DNS SERVERS === - 'technitium': { - name: 'Technitium DNS Server', - description: 'Modern DNS server with web UI for managing private zones', - icon: '🌐', - category: 'DNS', + "technitium": { + name: "Technitium DNS Server", + description: "Modern DNS server with web UI for managing private zones", + icon: "🌐", + category: "DNS", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", features: [ - 'Web-based management interface', - 'Private zone management for .sami domain', - 'DHCP server integration', - 'DNS-over-HTTPS and DNS-over-TLS support', - 'Built-in DNSSEC support', + "Web-based management interface", + "Private zone management for .sami domain", + "DHCP server integration", + "DNS-over-HTTPS and DNS-over-TLS support", + "Built-in DNSSEC support" ], docker: { - image: 'technitium/dns-server:latest', - ports: ['{{PORT}}:5380', '53:53', '53:53/udp'], - volumes: ['/opt/technitium/config:/etc/dns'], + image: "technitium/dns-server:latest", + ports: ["{{PORT}}:5380", "53:53", "53:53/udp"], + volumes: ["/opt/technitium/config:/etc/dns"], environment: { - 'DNS_SERVER_DOMAIN': 'dns1.sami', - 'DNS_SERVER_ADMIN_PASSWORD': '{{DNS_ADMIN_PASSWORD}}', - }, + "DNS_SERVER_DOMAIN": "dns1.sami", + "DNS_SERVER_ADMIN_PASSWORD": "{{DNS_ADMIN_PASSWORD}}" + } }, - subdomain: 'dns1', + subdomain: "dns1", defaultPort: 5380, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Access web interface at https://dns1.sami', - 'Login with admin credentials', + "Access web interface at https://dns1.sami", + "Login with admin credentials", "Create a primary zone for 'sami' domain", - 'Add A records for your services (e.g., plex.sami -> 192.168.1.100)', - 'Configure your devices to use this DNS server', + "Add A records for your services (e.g., plex.sami -> 192.168.1.100)", + "Configure your devices to use this DNS server" ], - requiredVolumes: ['config'], + requiredVolumes: ["config"], optionalVolumes: [], secrets: [ { - envVar: 'DNS_ADMIN_PASSWORD', - label: 'Admin Password', - description: 'Password for Technitium DNS admin account', - type: 'password', + envVar: "DNS_ADMIN_PASSWORD", + label: "Admin Password", + description: "Password for Technitium DNS admin account", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'bind9': { - name: 'BIND9 DNS Server', - description: 'Industry-standard DNS server - powerful and flexible', - icon: '🔧', - category: 'DNS', + "bind9": { + name: "BIND9 DNS Server", + description: "Industry-standard DNS server - powerful and flexible", + icon: "🔧", + category: "DNS", popularity: 80, - difficulty: 'Advanced', + difficulty: "Advanced", features: [ - 'Industry standard DNS server', - 'Full RFC compliance', - 'Advanced zone management', - 'DNSSEC support', - 'High performance and reliability', + "Industry standard DNS server", + "Full RFC compliance", + "Advanced zone management", + "DNSSEC support", + "High performance and reliability" ], docker: { - image: 'ubuntu/bind9:latest', - ports: ['53:53', '53:53/udp', '{{PORT}}:953'], + image: "ubuntu/bind9:latest", + ports: ["53:53", "53:53/udp", "{{PORT}}:953"], volumes: [ - '/opt/bind9/config:/etc/bind', - '/opt/bind9/cache:/var/cache/bind', - '/opt/bind9/records:/var/lib/bind', + "/opt/bind9/config:/etc/bind", + "/opt/bind9/cache:/var/cache/bind", + "/opt/bind9/records:/var/lib/bind" ], environment: { - 'BIND9_USER': 'root', - 'TZ': '{{TIMEZONE}}', - }, + "BIND9_USER": "root", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'dns2', + subdomain: "dns2", defaultPort: 953, healthCheck: null, subpathSupport: 'strip', setupInstructions: [ - 'Configure zone files in /opt/bind9/config/', - 'Create named.conf.local for your .sami zone', - 'Add zone file: /opt/bind9/records/db.sami', - 'Restart container to apply changes', - 'Test with: dig @localhost sami', + "Configure zone files in /opt/bind9/config/", + "Create named.conf.local for your .sami zone", + "Add zone file: /opt/bind9/records/db.sami", + "Restart container to apply changes", + "Test with: dig @localhost sami" ], - requiredVolumes: ['config', 'records'], - optionalVolumes: ['cache'], + requiredVolumes: ["config", "records"], + optionalVolumes: ["cache"] }, - 'powerdns': { - name: 'PowerDNS', - description: 'High-performance DNS server with SQL backend', - icon: '⚡', - category: 'DNS', + "powerdns": { + name: "PowerDNS", + description: "High-performance DNS server with SQL backend", + icon: "⚡", + category: "DNS", popularity: 75, - difficulty: 'Intermediate', + difficulty: "Intermediate", features: [ - 'SQL database backend (MySQL/PostgreSQL)', - 'RESTful API for automation', - 'PowerDNS Admin web interface available', - 'Geographic load balancing', - 'DNSSEC support', + "SQL database backend (MySQL/PostgreSQL)", + "RESTful API for automation", + "PowerDNS Admin web interface available", + "Geographic load balancing", + "DNSSEC support" ], docker: { - image: 'pschiffe/pdns-mysql:latest', - ports: ['53:53', '53:53/udp', '{{PORT}}:8081'], - volumes: ['/opt/powerdns/data:/var/lib/mysql'], + image: "pschiffe/pdns-mysql:latest", + ports: ["53:53", "53:53/udp", "{{PORT}}:8081"], + volumes: ["/opt/powerdns/data:/var/lib/mysql"], environment: { - 'PDNS_api': 'yes', - 'PDNS_api_key': '{{POWERDNS_API_KEY}}', - 'PDNS_webserver': 'yes', - 'PDNS_webserver_address': '0.0.0.0', - 'PDNS_webserver_allow_from': '0.0.0.0/0', - 'MYSQL_ROOT_PASSWORD': '{{MYSQL_ROOT_PASSWORD}}', - }, + "PDNS_api": "yes", + "PDNS_api_key": "{{POWERDNS_API_KEY}}", + "PDNS_webserver": "yes", + "PDNS_webserver_address": "0.0.0.0", + "PDNS_webserver_allow_from": "0.0.0.0/0", + "MYSQL_ROOT_PASSWORD": "{{MYSQL_ROOT_PASSWORD}}" + } }, - subdomain: 'dns3', + subdomain: "dns3", defaultPort: 8081, - healthCheck: '/api/v1/servers', + healthCheck: "/api/v1/servers", subpathSupport: 'strip', setupInstructions: [ - 'Access API at https://dns3.sami:8081', - 'Use API key for authentication', - 'Create zone via API or PowerDNS Admin', - 'Add records for your .sami domain', - 'Configure devices to use DNS server', + "Access API at https://dns3.sami:8081", + "Use API key for authentication", + "Create zone via API or PowerDNS Admin", + "Add records for your .sami domain", + "Configure devices to use DNS server" ], - requiredVolumes: ['data'], + requiredVolumes: ["data"], optionalVolumes: [], secrets: [ { - envVar: 'POWERDNS_API_KEY', - label: 'API Key', - description: 'API key for PowerDNS webserver authentication', - type: 'password', + envVar: "POWERDNS_API_KEY", + label: "API Key", + description: "API key for PowerDNS webserver authentication", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, + generate: "alphanumeric", + length: 32 }, { - envVar: 'MYSQL_ROOT_PASSWORD', - label: 'MySQL Root Password', - description: 'Root password for embedded MySQL database', - type: 'password', + envVar: "MYSQL_ROOT_PASSWORD", + label: "MySQL Root Password", + description: "Root password for embedded MySQL database", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'coredns': { - name: 'CoreDNS', - description: 'Cloud-native DNS server - lightweight and flexible', - icon: '☁️', - category: 'DNS', + "coredns": { + name: "CoreDNS", + description: "Cloud-native DNS server - lightweight and flexible", + icon: "☁️", + category: "DNS", popularity: 70, - difficulty: 'Intermediate', + difficulty: "Intermediate", features: [ - 'Plugin-based architecture', - 'Kubernetes-native (used in K8s)', - 'Lightweight and fast', - 'Prometheus metrics', - 'Easy configuration via Corefile', + "Plugin-based architecture", + "Kubernetes-native (used in K8s)", + "Lightweight and fast", + "Prometheus metrics", + "Easy configuration via Corefile" ], docker: { - image: 'coredns/coredns:latest', - ports: ['53:53', '53:53/udp'], - volumes: ['/opt/coredns/config:/etc/coredns'], + image: "coredns/coredns:latest", + ports: ["53:53", "53:53/udp"], + volumes: ["/opt/coredns/config:/etc/coredns"], environment: {}, - command: ['-conf', '/etc/coredns/Corefile'], + command: ["-conf", "/etc/coredns/Corefile"] }, - subdomain: 'dns4', + subdomain: "dns4", defaultPort: 53, healthCheck: null, subpathSupport: 'strip', setupInstructions: [ - 'Create Corefile in /opt/coredns/config/', - 'Define .sami zone with file plugin', - 'Create zone file with your records', - 'Restart container to load config', - 'Test with: dig @localhost test.sami', + "Create Corefile in /opt/coredns/config/", + "Define .sami zone with file plugin", + "Create zone file with your records", + "Restart container to load config", + "Test with: dig @localhost test.sami" ], - requiredVolumes: ['config'], - optionalVolumes: [], + requiredVolumes: ["config"], + optionalVolumes: [] }, // === FILE MANAGEMENT === - 'filebrowser': { - name: 'FileBrowser', - description: 'Web-based file manager with sharing capabilities', - icon: '📁', - category: 'Files', + "filebrowser": { + name: "FileBrowser", + description: "Web-based file manager with sharing capabilities", + icon: "📁", + category: "Files", popularity: 88, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'filebrowser/filebrowser:latest', - ports: ['{{PORT}}:80'], + image: "filebrowser/filebrowser:latest", + ports: ["{{PORT}}:80"], volumes: [ - '/opt/filebrowser/data:/srv', - '/opt/filebrowser/database:/database', + "/opt/filebrowser/data:/srv", + "/opt/filebrowser/database:/database" ], - environment: {}, + environment: {} }, - subdomain: 'files', + subdomain: "files", defaultPort: 8085, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Default login: admin/admin', - 'Change default password immediately', - 'Configure user permissions and shares', - ], + "Default login: admin/admin", + "Change default password immediately", + "Configure user permissions and shares" + ] }, - 'syncthing': { - name: 'Syncthing', - description: 'Continuous file synchronization between devices', - icon: '🔄', - category: 'Files', + "syncthing": { + name: "Syncthing", + description: "Continuous file synchronization between devices", + icon: "🔄", + category: "Files", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'linuxserver/syncthing:latest', - ports: ['{{PORT}}:8384', '22000:22000', '21027:21027/udp'], + image: "linuxserver/syncthing:latest", + ports: ["{{PORT}}:8384", "22000:22000", "21027:21027/udp"], volumes: [ - '/opt/syncthing/config:/config', - '/opt/syncthing/data:/data', + "/opt/syncthing/config:/config", + "/opt/syncthing/data:/data" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'sync', + subdomain: "sync", defaultPort: 8384, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Add devices using their Device IDs', - 'Configure shared folders', - 'Set up folder synchronization', - ], + "Add devices using their Device IDs", + "Configure shared folders", + "Set up folder synchronization" + ] }, // === COMMUNICATION & EMAIL === - 'mailserver': { - name: 'Docker Mailserver', - description: 'Full-featured email server with SMTP, IMAP, spam filtering', - icon: '📧', - category: 'Communication', + "mailserver": { + name: "Docker Mailserver", + description: "Full-featured email server with SMTP, IMAP, spam filtering", + icon: "📧", + category: "Communication", popularity: 70, - difficulty: 'Advanced', + difficulty: "Advanced", docker: { - image: 'mailserver/docker-mailserver:latest', - ports: ['{{PORT}}:25', '143:143', '587:587', '993:993'], + image: "mailserver/docker-mailserver:latest", + ports: ["{{PORT}}:25", "143:143", "587:587", "993:993"], volumes: [ - '/opt/mailserver/data:/var/mail', - '/opt/mailserver/state:/var/mail-state', - '/opt/mailserver/logs:/var/log/mail', - '/opt/mailserver/config:/tmp/docker-mailserver', + "/opt/mailserver/data:/var/mail", + "/opt/mailserver/state:/var/mail-state", + "/opt/mailserver/logs:/var/log/mail", + "/opt/mailserver/config:/tmp/docker-mailserver" ], environment: { - 'ENABLE_SPAMASSASSIN': '1', - 'ENABLE_CLAMAV': '1', - 'ENABLE_FAIL2BAN': '1', - 'ONE_DIR': '1', - 'TZ': '{{TIMEZONE}}', - }, + "ENABLE_SPAMASSASSIN": "1", + "ENABLE_CLAMAV": "1", + "ENABLE_FAIL2BAN": "1", + "ONE_DIR": "1", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'mail', + subdomain: "mail", defaultPort: 25, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Configure DNS records (MX, SPF, DKIM, DMARC)', - 'Create email accounts using setup.sh', - 'Set up SSL certificates for secure connections', - ], + "Configure DNS records (MX, SPF, DKIM, DMARC)", + "Create email accounts using setup.sh", + "Set up SSL certificates for secure connections" + ] }, - 'roundcube': { - name: 'Roundcube', - description: 'Modern webmail client with rich features', - icon: '💌', - category: 'Communication', + "roundcube": { + name: "Roundcube", + description: "Modern webmail client with rich features", + icon: "💌", + category: "Communication", popularity: 72, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'roundcube/roundcubemail:latest', - ports: ['{{PORT}}:80'], + image: "roundcube/roundcubemail:latest", + ports: ["{{PORT}}:80"], volumes: [ - '/opt/roundcube/config:/var/roundcube/config', - '/opt/roundcube/db:/var/roundcube/db', + "/opt/roundcube/config:/var/roundcube/config", + "/opt/roundcube/db:/var/roundcube/db" ], environment: { - 'ROUNDCUBEMAIL_DEFAULT_HOST': 'mail.{{SUBDOMAIN}}.sami', - 'ROUNDCUBEMAIL_SMTP_SERVER': 'mail.{{SUBDOMAIN}}.sami', - }, + "ROUNDCUBEMAIL_DEFAULT_HOST": "mail.{{SUBDOMAIN}}.sami", + "ROUNDCUBEMAIL_SMTP_SERVER": "mail.{{SUBDOMAIN}}.sami" + } }, - subdomain: 'webmail', + subdomain: "webmail", defaultPort: 8086, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Configure IMAP/SMTP server settings', - 'Set up database connection', - 'Customize appearance and plugins', - ], + "Configure IMAP/SMTP server settings", + "Set up database connection", + "Customize appearance and plugins" + ] }, - 'matrix': { - name: 'Matrix Synapse', - description: 'Decentralized, secure messaging and collaboration', - icon: '💬', - category: 'Communication', + "matrix": { + name: "Matrix Synapse", + description: "Decentralized, secure messaging and collaboration", + icon: "💬", + category: "Communication", popularity: 75, - difficulty: 'Advanced', + difficulty: "Advanced", docker: { - image: 'matrixdotorg/synapse:latest', - ports: ['{{PORT}}:8008'], - volumes: ['/opt/matrix/data:/data'], + image: "matrixdotorg/synapse:latest", + ports: ["{{PORT}}:8008"], + volumes: ["/opt/matrix/data:/data"], environment: { - 'SYNAPSE_SERVER_NAME': '{{SUBDOMAIN}}.sami', - 'SYNAPSE_REPORT_STATS': 'no', - }, + "SYNAPSE_SERVER_NAME": "{{SUBDOMAIN}}.sami", + "SYNAPSE_REPORT_STATS": "no" + } }, - subdomain: 'matrix', + subdomain: "matrix", defaultPort: 8008, - healthCheck: '/_matrix/client/versions', + healthCheck: "/_matrix/client/versions", subpathSupport: 'none', setupInstructions: [ - 'Generate initial config with --generate', - 'Configure homeserver.yaml', - 'Set up federation if needed', - ], + "Generate initial config with --generate", + "Configure homeserver.yaml", + "Set up federation if needed" + ] }, - 'rocketchat': { - name: 'Rocket.Chat', - description: 'Team collaboration platform like Slack', - icon: '🚀', - category: 'Communication', + "rocketchat": { + name: "Rocket.Chat", + description: "Team collaboration platform like Slack", + icon: "🚀", + category: "Communication", popularity: 78, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'rocket.chat:latest', - ports: ['{{PORT}}:3000'], - volumes: ['/opt/rocketchat/uploads:/app/uploads'], + image: "rocket.chat:latest", + ports: ["{{PORT}}:3000"], + volumes: ["/opt/rocketchat/uploads:/app/uploads"], environment: { - 'ROOT_URL': 'https://{{SUBDOMAIN}}.sami', - 'MONGO_URL': 'mongodb://mongo:27017/rocketchat', - }, + "ROOT_URL": "https://{{SUBDOMAIN}}.sami", + "MONGO_URL": "mongodb://mongo:27017/rocketchat" + } }, - subdomain: 'chat', + subdomain: "chat", defaultPort: 3004, - healthCheck: '/api/info', + healthCheck: "/api/info", subpathSupport: 'strip', setupInstructions: [ - 'Requires MongoDB - deploy mongo container first', - 'Complete admin setup wizard', - 'Configure OAuth and integrations', - ], + "Requires MongoDB - deploy mongo container first", + "Complete admin setup wizard", + "Configure OAuth and integrations" + ] }, // === HOME AUTOMATION === - 'homeassistant': { - name: 'Home Assistant', - description: 'Open source home automation platform', - icon: '🏠', - category: 'Home Automation', + "homeassistant": { + name: "Home Assistant", + description: "Open source home automation platform", + icon: "🏠", + category: "Home Automation", popularity: 92, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'homeassistant/home-assistant:stable', - ports: ['{{PORT}}:8123'], + image: "homeassistant/home-assistant:stable", + ports: ["{{PORT}}:8123"], volumes: [ - '/opt/homeassistant/config:/config', - '/etc/localtime:/etc/localtime:ro', + "/opt/homeassistant/config:/config", + "/etc/localtime:/etc/localtime:ro" ], environment: { - 'TZ': '{{TIMEZONE}}', - }, + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'home', + subdomain: "home", defaultPort: 8123, - healthCheck: '/api/', + healthCheck: "/api/", subpathSupport: 'strip', setupInstructions: [ - 'Complete onboarding wizard', - 'Add integrations for your smart devices', - 'Create automations and dashboards', - ], + "Complete onboarding wizard", + "Add integrations for your smart devices", + "Create automations and dashboards" + ] }, - 'nodered': { - name: 'Node-RED', - description: 'Flow-based programming for IoT and automation', - icon: '🔴', - category: 'Home Automation', + "nodered": { + name: "Node-RED", + description: "Flow-based programming for IoT and automation", + icon: "🔴", + category: "Home Automation", popularity: 80, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'nodered/node-red:latest', - ports: ['{{PORT}}:1880'], - volumes: ['/opt/nodered/data:/data'], + image: "nodered/node-red:latest", + ports: ["{{PORT}}:1880"], + volumes: ["/opt/nodered/data:/data"], environment: { - 'TZ': '{{TIMEZONE}}', - }, + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'nodered', + subdomain: "nodered", defaultPort: 1880, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Install additional nodes from palette', - 'Create flows for automation', - 'Connect to Home Assistant or MQTT', - ], + "Install additional nodes from palette", + "Create flows for automation", + "Connect to Home Assistant or MQTT" + ] }, // === DATABASES === - 'postgres': { - name: 'PostgreSQL', - description: 'Advanced open-source relational database', - icon: '🐘', - category: 'Database', + "postgres": { + name: "PostgreSQL", + description: "Advanced open-source relational database", + icon: "🐘", + category: "Database", popularity: 85, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'postgres:16-alpine', - ports: ['{{PORT}}:5432'], - volumes: ['/opt/postgres/data:/var/lib/postgresql/data'], + image: "postgres:16-alpine", + ports: ["{{PORT}}:5432"], + volumes: ["/opt/postgres/data:/var/lib/postgresql/data"], environment: { - 'POSTGRES_USER': 'admin', - 'POSTGRES_PASSWORD': '{{POSTGRES_PASSWORD}}', - 'POSTGRES_DB': 'default', - }, + "POSTGRES_USER": "admin", + "POSTGRES_PASSWORD": "{{POSTGRES_PASSWORD}}", + "POSTGRES_DB": "default" + } }, - subdomain: 'postgres', + subdomain: "postgres", defaultPort: 5432, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Change default password immediately', - 'Create databases and users as needed', - 'Configure pg_hba.conf for remote access', + "Change default password immediately", + "Create databases and users as needed", + "Configure pg_hba.conf for remote access" ], secrets: [ { - envVar: 'POSTGRES_PASSWORD', - label: 'Admin Password', - description: 'Password for PostgreSQL admin user', - type: 'password', + envVar: "POSTGRES_PASSWORD", + label: "Admin Password", + description: "Password for PostgreSQL admin user", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'redis': { - name: 'Redis', - description: 'In-memory data structure store and cache', - icon: '🔴', - category: 'Database', + "redis": { + name: "Redis", + description: "In-memory data structure store and cache", + icon: "🔴", + category: "Database", popularity: 82, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'redis:alpine', - ports: ['{{PORT}}:6379'], - volumes: ['/opt/redis/data:/data'], - environment: {}, + image: "redis:alpine", + ports: ["{{PORT}}:6379"], + volumes: ["/opt/redis/data:/data"], + environment: {} }, - subdomain: 'redis', + subdomain: "redis", defaultPort: 6379, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Configure redis.conf for persistence', - 'Set up authentication if needed', - 'Configure maxmemory policy', - ], + "Configure redis.conf for persistence", + "Set up authentication if needed", + "Configure maxmemory policy" + ] }, - 'mongodb': { - name: 'MongoDB', - description: 'Document-oriented NoSQL database', - icon: '🍃', - category: 'Database', + "mongodb": { + name: "MongoDB", + description: "Document-oriented NoSQL database", + icon: "🍃", + category: "Database", popularity: 80, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'mongo:latest', - ports: ['{{PORT}}:27017'], - volumes: ['/opt/mongodb/data:/data/db'], + image: "mongo:latest", + ports: ["{{PORT}}:27017"], + volumes: ["/opt/mongodb/data:/data/db"], environment: { - 'MONGO_INITDB_ROOT_USERNAME': 'admin', - 'MONGO_INITDB_ROOT_PASSWORD': '{{MONGO_ROOT_PASSWORD}}', - }, + "MONGO_INITDB_ROOT_USERNAME": "admin", + "MONGO_INITDB_ROOT_PASSWORD": "{{MONGO_ROOT_PASSWORD}}" + } }, - subdomain: 'mongo', + subdomain: "mongo", defaultPort: 27017, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Change default admin password', - 'Create application databases and users', - 'Configure replica set if needed', + "Change default admin password", + "Create application databases and users", + "Configure replica set if needed" ], secrets: [ { - envVar: 'MONGO_ROOT_PASSWORD', - label: 'Root Password', - description: 'Root password for MongoDB admin user', - type: 'password', + envVar: "MONGO_ROOT_PASSWORD", + label: "Root Password", + description: "Root password for MongoDB admin user", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'adminer': { - name: 'Adminer', - description: 'Lightweight database management in single PHP file', - icon: '🗄️', - category: 'Database', + "adminer": { + name: "Adminer", + description: "Lightweight database management in single PHP file", + icon: "🗄️", + category: "Database", popularity: 75, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'adminer:latest', - ports: ['{{PORT}}:8080'], + image: "adminer:latest", + ports: ["{{PORT}}:8080"], volumes: [], environment: { - 'ADMINER_DEFAULT_SERVER': 'postgres', - }, + "ADMINER_DEFAULT_SERVER": "postgres" + } }, - subdomain: 'adminer', + subdomain: "adminer", defaultPort: 8087, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Connect to your database servers', - 'Supports MySQL, PostgreSQL, SQLite, etc.', - ], + "Connect to your database servers", + "Supports MySQL, PostgreSQL, SQLite, etc." + ] }, // === SECURITY & AUTH === - 'vaultwarden': { - name: 'Vaultwarden', - description: 'Lightweight Bitwarden-compatible password manager', - icon: '🔑', - category: 'Security', + "vaultwarden": { + name: "Vaultwarden", + description: "Lightweight Bitwarden-compatible password manager", + icon: "🔑", + category: "Security", popularity: 90, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'vaultwarden/server:latest', - ports: ['{{PORT}}:80'], - volumes: ['/opt/vaultwarden/data:/data'], + image: "vaultwarden/server:latest", + ports: ["{{PORT}}:80"], + volumes: ["/opt/vaultwarden/data:/data"], environment: { - 'DOMAIN': 'https://{{SUBDOMAIN}}.sami', - 'ADMIN_TOKEN': '{{VAULTWARDEN_ADMIN_TOKEN}}', - }, + "DOMAIN": "https://{{SUBDOMAIN}}.sami", + "ADMIN_TOKEN": "{{VAULTWARDEN_ADMIN_TOKEN}}" + } }, - subdomain: 'vault', + subdomain: "vault", defaultPort: 8088, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Change admin token immediately', - 'Create your account', - 'Install browser extensions and mobile apps', + "Change admin token immediately", + "Create your account", + "Install browser extensions and mobile apps" ], secrets: [ { - envVar: 'VAULTWARDEN_ADMIN_TOKEN', - label: 'Admin Token', - description: 'Admin panel access token for Vaultwarden', - type: 'password', + envVar: "VAULTWARDEN_ADMIN_TOKEN", + label: "Admin Token", + description: "Admin panel access token for Vaultwarden", + type: "password", required: true, - generate: 'alphanumeric', - length: 48, - }, - ], + generate: "alphanumeric", + length: 48 + } + ] }, - 'dashca': { - name: 'DashCA', - description: 'One-click root CA certificate installer for your network', - icon: '🔐', - logo: '/assets/certificate-icon.png', - category: 'Security', + "dashca": { + name: "DashCA", + description: "One-click root CA certificate installer for your network", + icon: "🔐", + logo: "/assets/certificate-icon.png", + category: "Security", popularity: 95, - difficulty: 'Easy', + difficulty: "Easy", isStaticSite: true, // Special flag for non-Docker deployments - subdomain: 'ca', + subdomain: "ca", defaultPort: null, // Static site, no port needed healthCheck: null, subpathSupport: 'strip', features: [ - 'Automatic OS detection', - 'One-click installation', - 'Supports Windows, macOS, Linux, iOS, Android', - 'Apple mobileconfig for easy iOS/macOS setup', - 'QR code for mobile access', - 'Certificate expiration monitoring', + "Automatic OS detection", + "One-click installation", + "Supports Windows, macOS, Linux, iOS, Android", + "Apple mobileconfig for easy iOS/macOS setup", + "QR code for mobile access", + "Certificate expiration monitoring" ], setupInstructions: [ - 'New devices: visit http://ca.sami (HTTP, no certificate needed)', + "New devices: visit http://ca.sami (HTTP, no certificate needed)", "Click the 'Install Certificate' button for your platform", - 'Follow platform-specific instructions', - 'Verify all *.sami domains now show secure connections', + "Follow platform-specific instructions", + "Verify all *.sami domains now show secure connections" ], - tags: ['security', 'certificates', 'ssl', 'tls', 'infrastructure'], + tags: ["security", "certificates", "ssl", "tls", "infrastructure"] }, - 'weather': { - name: 'Weather', - description: 'Live weather widget with temperature, conditions, and wind', - icon: '🌤️', - category: 'Utilities', + "weather": { + name: "Weather", + description: "Live weather widget with temperature, conditions, and wind", + icon: "🌤️", + category: "Utilities", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", isDashboardWidget: true, - widgetSelector: '.weather-widget-container', + widgetSelector: ".weather-widget-container", subdomain: null, defaultPort: null, healthCheck: null, subpathSupport: 'strip', features: [ - 'Current temperature and conditions', - 'Wind speed and direction', - 'Weather icon with emoji fallback', - 'Configurable ZIP code', - 'Auto-refreshes periodically', + "Current temperature and conditions", + "Wind speed and direction", + "Weather icon with emoji fallback", + "Configurable ZIP code", + "Auto-refreshes periodically" ], setupInstructions: [ - 'Click the gear icon on the widget to set your ZIP code', - 'Weather appears in the top bar next to the logo', + "Click the gear icon on the widget to set your ZIP code", + "Weather appears in the top bar next to the logo" ], - tags: ['weather', 'widget', 'dashboard', 'utility'], + tags: ["weather", "widget", "dashboard", "utility"] }, - 'digital-clock': { - name: 'Digital Clock', - description: 'Live digital clock with time, date, and day of week', - icon: '🕐', - category: 'Utilities', + "digital-clock": { + name: "Digital Clock", + description: "Live digital clock with time, date, and day of week", + icon: "🕐", + category: "Utilities", popularity: 80, - difficulty: 'Easy', + difficulty: "Easy", isDashboardWidget: true, - widgetSelector: '.clock-widget-container', + widgetSelector: ".clock-widget-container", subdomain: null, defaultPort: null, healthCheck: null, subpathSupport: 'strip', features: [ - '12-hour format with AM/PM', - 'Live seconds display', - 'Full date with day of week', - 'Responsive sizing across all devices', - 'Matches dashboard theme automatically', + "12-hour format with AM/PM", + "Live seconds display", + "Full date with day of week", + "Responsive sizing across all devices", + "Matches dashboard theme automatically" ], setupInstructions: [ - 'Clock appears in the top bar to the right of the weather widget', - 'No configuration needed — runs automatically', + "Clock appears in the top bar to the right of the weather widget", + "No configuration needed — runs automatically" ], - tags: ['clock', 'time', 'widget', 'dashboard', 'utility'], + tags: ["clock", "time", "widget", "dashboard", "utility"] }, // === MEDIA MANAGEMENT (Additional) === - 'lidarr': { - name: 'Lidarr', - description: 'Music collection manager for Usenet and BitTorrent', - icon: '🎵', - category: 'Media Management', + "lidarr": { + name: "Lidarr", + description: "Music collection manager for Usenet and BitTorrent", + icon: "🎵", + category: "Media Management", popularity: 70, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'linuxserver/lidarr:latest', - ports: ['{{PORT}}:8686'], + image: "linuxserver/lidarr:latest", + ports: ["{{PORT}}:8686"], volumes: [ - '/opt/lidarr/config:/config', - '/downloads:/downloads', - '/music:/music', + "/opt/lidarr/config:/config", + "/downloads:/downloads", + "/music:/music" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'lidarr', + subdomain: "lidarr", defaultPort: 8686, - healthCheck: '/api/v1/system/status', + healthCheck: "/api/v1/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', setupInstructions: [ - 'Configure download clients', - 'Add indexers', - 'Set up root folders for music', - ], + "Configure download clients", + "Add indexers", + "Set up root folders for music" + ] }, - 'readarr': { - name: 'Readarr', - description: 'Book and audiobook collection manager', - icon: '📚', - category: 'Media Management', + "readarr": { + name: "Readarr", + description: "Book and audiobook collection manager", + icon: "📚", + category: "Media Management", popularity: 65, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'linuxserver/readarr:develop', - ports: ['{{PORT}}:8787'], + image: "linuxserver/readarr:develop", + ports: ["{{PORT}}:8787"], volumes: [ - '/opt/readarr/config:/config', - '/downloads:/downloads', - '/books:/books', + "/opt/readarr/config:/config", + "/downloads:/downloads", + "/books:/books" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'readarr', + subdomain: "readarr", defaultPort: 8787, - healthCheck: '/api/v1/system/status', + healthCheck: "/api/v1/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', setupInstructions: [ - 'Configure download clients', - 'Add indexers for books', - 'Set up root folders', - ], + "Configure download clients", + "Add indexers for books", + "Set up root folders" + ] }, - 'bazarr': { - name: 'Bazarr', - description: 'Automatic subtitle downloader for Sonarr and Radarr', - icon: '💬', - category: 'Media Management', + "bazarr": { + name: "Bazarr", + description: "Automatic subtitle downloader for Sonarr and Radarr", + icon: "💬", + category: "Media Management", popularity: 72, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'linuxserver/bazarr:latest', - ports: ['{{PORT}}:6767'], + image: "linuxserver/bazarr:latest", + ports: ["{{PORT}}:6767"], volumes: [ - '/opt/bazarr/config:/config', - '/movies:/movies', - '/tv:/tv', + "/opt/bazarr/config:/config", + "/movies:/movies", + "/tv:/tv" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'bazarr', + subdomain: "bazarr", defaultPort: 6767, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'BASE_URL', setupInstructions: [ - 'Connect to Sonarr and Radarr', - 'Configure subtitle providers', - 'Set language preferences', - ], + "Connect to Sonarr and Radarr", + "Configure subtitle providers", + "Set language preferences" + ] }, - 'seerr': { - name: 'Seerr', - description: 'Media request and discovery manager for Plex, Jellyfin, and Emby', - icon: '🎫', - category: 'Media Management', + "seerr": { + name: "Seerr", + description: "Media request and discovery manager for Plex, Jellyfin, and Emby", + icon: "🎫", + category: "Media Management", popularity: 82, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'ghcr.io/seerr-team/seerr:latest', - ports: ['{{PORT}}:5055'], - volumes: ['/opt/seerr/config:/app/config'], + image: "ghcr.io/seerr-team/seerr:latest", + ports: ["{{PORT}}:5055"], + volumes: ["/opt/seerr/config:/app/config"], environment: { - 'TZ': '{{TIMEZONE}}', + "TZ": "{{TIMEZONE}}" }, - init: true, + init: true }, - subdomain: 'requests', + subdomain: "requests", defaultPort: 5055, - healthCheck: '/api/v1/status', + healthCheck: "/api/v1/status", subpathSupport: 'native', urlBaseEnv: 'BASE_PATH', setupInstructions: [ - 'Connect to Plex, Jellyfin, or Emby server', - 'Link Sonarr and Radarr', - 'Configure user permissions', - ], + "Connect to Plex, Jellyfin, or Emby server", + "Link Sonarr and Radarr", + "Configure user permissions" + ] }, - 'tautulli': { - name: 'Tautulli', - description: 'Plex media server monitoring and statistics', - icon: '📊', - category: 'Media Management', + "tautulli": { + name: "Tautulli", + description: "Plex media server monitoring and statistics", + icon: "📊", + category: "Media Management", popularity: 78, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'linuxserver/tautulli:latest', - ports: ['{{PORT}}:8181'], - volumes: ['/opt/tautulli/config:/config'], + image: "linuxserver/tautulli:latest", + ports: ["{{PORT}}:8181"], + volumes: ["/opt/tautulli/config:/config"], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'tautulli', + subdomain: "tautulli", defaultPort: 8181, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'TAUTULLI_HTTP_ROOT', setupInstructions: [ - 'Connect to Plex server', - 'Configure notifications', - 'Set up newsletters', - ], + "Connect to Plex server", + "Configure notifications", + "Set up newsletters" + ] }, // === DEVELOPMENT TOOLS === - 'gitea': { - name: 'Gitea', - description: 'Lightweight self-hosted Git service', - icon: '🦊', - category: 'Development', + "gitea": { + name: "Gitea", + description: "Lightweight self-hosted Git service", + icon: "🦊", + category: "Development", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'gitea/gitea:latest', - ports: ['{{PORT}}:3000', '2222:22'], + image: "gitea/gitea:latest", + ports: ["{{PORT}}:3000", "2222:22"], volumes: [ - '/opt/gitea/data:/data', - '/etc/timezone:/etc/timezone:ro', - '/etc/localtime:/etc/localtime:ro', + "/opt/gitea/data:/data", + "/etc/timezone:/etc/timezone:ro", + "/etc/localtime:/etc/localtime:ro" ], environment: { - 'USER_UID': '1000', - 'USER_GID': '1000', - }, + "USER_UID": "1000", + "USER_GID": "1000" + } }, - subdomain: 'gitea', + subdomain: "gitea", defaultPort: 3005, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'GITEA__server__ROOT_URL', setupInstructions: [ - 'Complete initial setup wizard', - 'Create admin account', - 'Configure SSH access', - ], + "Complete initial setup wizard", + "Create admin account", + "Configure SSH access" + ] }, - 'jenkins': { - name: 'Jenkins', - description: 'Automation server for CI/CD pipelines', - icon: '🔧', - category: 'Development', + "jenkins": { + name: "Jenkins", + description: "Automation server for CI/CD pipelines", + icon: "🔧", + category: "Development", popularity: 75, - difficulty: 'Advanced', + difficulty: "Advanced", docker: { - image: 'jenkins/jenkins:lts', - ports: ['{{PORT}}:8080', '50000:50000'], - volumes: ['/opt/jenkins/data:/var/jenkins_home'], - environment: {}, + image: "jenkins/jenkins:lts", + ports: ["{{PORT}}:8080", "50000:50000"], + volumes: ["/opt/jenkins/data:/var/jenkins_home"], + environment: {} }, - subdomain: 'jenkins', + subdomain: "jenkins", defaultPort: 8089, - healthCheck: '/login', + healthCheck: "/login", subpathSupport: 'strip', setupInstructions: [ - 'Get initial admin password from logs', - 'Install suggested plugins', - 'Create admin user', - ], + "Get initial admin password from logs", + "Install suggested plugins", + "Create admin user" + ] }, - 'drone': { - name: 'Drone CI', - description: 'Container-native continuous delivery platform', - icon: '🐝', - category: 'Development', + "drone": { + name: "Drone CI", + description: "Container-native continuous delivery platform", + icon: "🐝", + category: "Development", popularity: 70, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'drone/drone:latest', - ports: ['{{PORT}}:80'], - volumes: ['/opt/drone/data:/data'], + image: "drone/drone:latest", + ports: ["{{PORT}}:80"], + volumes: ["/opt/drone/data:/data"], environment: { - 'DRONE_GITEA_SERVER': 'https://git.sami', - 'DRONE_RPC_SECRET': '{{DRONE_RPC_SECRET}}', - 'DRONE_SERVER_HOST': '{{SUBDOMAIN}}.sami', - 'DRONE_SERVER_PROTO': 'https', - }, + "DRONE_GITEA_SERVER": "https://git.sami", + "DRONE_RPC_SECRET": "{{DRONE_RPC_SECRET}}", + "DRONE_SERVER_HOST": "{{SUBDOMAIN}}.sami", + "DRONE_SERVER_PROTO": "https" + } }, - subdomain: 'drone', + subdomain: "drone", defaultPort: 8090, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Configure Git provider integration', - 'Set up shared secret', - 'Deploy Drone runners', + "Configure Git provider integration", + "Set up shared secret", + "Deploy Drone runners" ], secrets: [ { - envVar: 'DRONE_RPC_SECRET', - label: 'RPC Secret', - description: 'Shared secret for Drone server and runner communication', - type: 'password', + envVar: "DRONE_RPC_SECRET", + label: "RPC Secret", + description: "Shared secret for Drone server and runner communication", + type: "password", required: true, - generate: 'alphanumeric', - length: 64, - }, - ], + generate: "alphanumeric", + length: 64 + } + ] }, // === NOTES & WIKI === - 'bookstack': { - name: 'BookStack', - description: 'Simple wiki and documentation platform', - icon: '📖', - category: 'Productivity', + "bookstack": { + name: "BookStack", + description: "Simple wiki and documentation platform", + icon: "📖", + category: "Productivity", popularity: 80, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'linuxserver/bookstack:latest', - ports: ['{{PORT}}:80'], - volumes: ['/opt/bookstack/config:/config'], + image: "linuxserver/bookstack:latest", + ports: ["{{PORT}}:80"], + volumes: ["/opt/bookstack/config:/config"], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'APP_URL': 'https://{{SUBDOMAIN}}.sami', - 'DB_HOST': 'mariadb', - 'DB_DATABASE': 'bookstack', - 'DB_USERNAME': 'bookstack', - 'DB_PASSWORD': '{{BOOKSTACK_DB_PASSWORD}}', - }, + "PUID": "1000", + "PGID": "1000", + "APP_URL": "https://{{SUBDOMAIN}}.sami", + "DB_HOST": "mariadb", + "DB_DATABASE": "bookstack", + "DB_USERNAME": "bookstack", + "DB_PASSWORD": "{{BOOKSTACK_DB_PASSWORD}}" + } }, - subdomain: 'wiki', + subdomain: "wiki", defaultPort: 8091, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Requires MariaDB/MySQL database', - 'Default login: admin@admin.com / password', - 'Change default credentials', + "Requires MariaDB/MySQL database", + "Default login: admin@admin.com / password", + "Change default credentials" ], secrets: [ { - envVar: 'BOOKSTACK_DB_PASSWORD', - label: 'Database Password', - description: 'Password for BookStack database user', - type: 'password', + envVar: "BOOKSTACK_DB_PASSWORD", + label: "Database Password", + description: "Password for BookStack database user", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'outline': { - name: 'Outline', - description: 'Modern team knowledge base and wiki', - icon: '📝', - category: 'Productivity', + "outline": { + name: "Outline", + description: "Modern team knowledge base and wiki", + icon: "📝", + category: "Productivity", popularity: 75, - difficulty: 'Advanced', + difficulty: "Advanced", docker: { - image: 'outlinewiki/outline:latest', - ports: ['{{PORT}}:3000'], - volumes: ['/opt/outline/data:/var/lib/outline/data'], + image: "outlinewiki/outline:latest", + ports: ["{{PORT}}:3000"], + volumes: ["/opt/outline/data:/var/lib/outline/data"], environment: { - 'URL': 'https://{{SUBDOMAIN}}.sami', - 'SECRET_KEY': '{{OUTLINE_SECRET_KEY}}', - 'DATABASE_URL': 'postgres://outline:{{OUTLINE_DB_PASSWORD}}@postgres:5432/outline', - }, + "URL": "https://{{SUBDOMAIN}}.sami", + "SECRET_KEY": "{{OUTLINE_SECRET_KEY}}", + "DATABASE_URL": "postgres://outline:{{OUTLINE_DB_PASSWORD}}@postgres:5432/outline" + } }, - subdomain: 'outline', + subdomain: "outline", defaultPort: 3006, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Requires PostgreSQL and Redis', - 'Configure OAuth provider', - 'Set up S3-compatible storage', + "Requires PostgreSQL and Redis", + "Configure OAuth provider", + "Set up S3-compatible storage" ], secrets: [ { - envVar: 'OUTLINE_SECRET_KEY', - label: 'Secret Key', - description: 'Secret key for encrypting session data', - type: 'password', + envVar: "OUTLINE_SECRET_KEY", + label: "Secret Key", + description: "Secret key for encrypting session data", + type: "password", required: true, - generate: 'alphanumeric', - length: 64, + generate: "alphanumeric", + length: 64 }, { - envVar: 'OUTLINE_DB_PASSWORD', - label: 'Database Password', - description: 'Password for Outline PostgreSQL database user', - type: 'password', + envVar: "OUTLINE_DB_PASSWORD", + label: "Database Password", + description: "Password for Outline PostgreSQL database user", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'standardnotes': { - name: 'Standard Notes', - description: 'End-to-end encrypted notes app', - icon: '🔒', - category: 'Productivity', + "standardnotes": { + name: "Standard Notes", + description: "End-to-end encrypted notes app", + icon: "🔒", + category: "Productivity", popularity: 72, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'standardnotes/server:latest', - ports: ['{{PORT}}:3000'], - volumes: ['/opt/standardnotes/data:/var/lib/server'], + image: "standardnotes/server:latest", + ports: ["{{PORT}}:3000"], + volumes: ["/opt/standardnotes/data:/var/lib/server"], environment: { - 'RAILS_ENV': 'production', - }, + "RAILS_ENV": "production" + } }, - subdomain: 'notes', + subdomain: "notes", defaultPort: 3007, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Configure environment variables', - 'Set up database connection', - 'Install Standard Notes apps', - ], + "Configure environment variables", + "Set up database connection", + "Install Standard Notes apps" + ] }, // === PHOTOS & GALLERIES === - 'immich': { - name: 'Immich', - description: 'Self-hosted Google Photos alternative', - icon: '📸', - category: 'Photos', + "immich": { + name: "Immich", + description: "Self-hosted Google Photos alternative", + icon: "📸", + category: "Photos", popularity: 90, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'ghcr.io/immich-app/immich-server:latest', - ports: ['{{PORT}}:2283'], + image: "ghcr.io/immich-app/immich-server:latest", + ports: ["{{PORT}}:2283"], volumes: [ - '/opt/immich/upload:/usr/src/app/upload', - '/opt/immich/library:/usr/src/app/library', + "/opt/immich/upload:/usr/src/app/upload", + "/opt/immich/library:/usr/src/app/library" ], environment: { - 'DB_HOSTNAME': 'postgres', - 'DB_USERNAME': 'immich', - 'DB_PASSWORD': '{{IMMICH_DB_PASSWORD}}', - 'DB_DATABASE_NAME': 'immich', - 'REDIS_HOSTNAME': 'redis', - }, + "DB_HOSTNAME": "postgres", + "DB_USERNAME": "immich", + "DB_PASSWORD": "{{IMMICH_DB_PASSWORD}}", + "DB_DATABASE_NAME": "immich", + "REDIS_HOSTNAME": "redis" + } }, - subdomain: 'photos', + subdomain: "photos", defaultPort: 2283, - healthCheck: '/api/server-info/ping', + healthCheck: "/api/server-info/ping", subpathSupport: 'strip', setupInstructions: [ - 'Requires PostgreSQL and Redis', - 'Install mobile apps for backup', - 'Configure machine learning for face detection', + "Requires PostgreSQL and Redis", + "Install mobile apps for backup", + "Configure machine learning for face detection" ], secrets: [ { - envVar: 'IMMICH_DB_PASSWORD', - label: 'Database Password', - description: 'Password for Immich PostgreSQL database user', - type: 'password', + envVar: "IMMICH_DB_PASSWORD", + label: "Database Password", + description: "Password for Immich PostgreSQL database user", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, - 'photoprism': { - name: 'PhotoPrism', - description: 'AI-powered photo management', - icon: '🖼️', - category: 'Photos', + "photoprism": { + name: "PhotoPrism", + description: "AI-powered photo management", + icon: "🖼️", + category: "Photos", popularity: 85, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'photoprism/photoprism:latest', - ports: ['{{PORT}}:2342'], + image: "photoprism/photoprism:latest", + ports: ["{{PORT}}:2342"], volumes: [ - '/opt/photoprism/storage:/photoprism/storage', - '/opt/photoprism/originals:/photoprism/originals', + "/opt/photoprism/storage:/photoprism/storage", + "/opt/photoprism/originals:/photoprism/originals" ], environment: { - 'PHOTOPRISM_ADMIN_PASSWORD': '{{PHOTOPRISM_ADMIN_PASSWORD}}', - 'PHOTOPRISM_SITE_URL': 'https://{{SUBDOMAIN}}.sami/', - 'PHOTOPRISM_DATABASE_DRIVER': 'sqlite', - }, + "PHOTOPRISM_ADMIN_PASSWORD": "{{PHOTOPRISM_ADMIN_PASSWORD}}", + "PHOTOPRISM_SITE_URL": "https://{{SUBDOMAIN}}.sami/", + "PHOTOPRISM_DATABASE_DRIVER": "sqlite" + } }, - subdomain: 'gallery', + subdomain: "gallery", defaultPort: 2342, - healthCheck: '/api/v1/status', + healthCheck: "/api/v1/status", subpathSupport: 'strip', setupInstructions: [ - 'Change admin password', - 'Import your photos', - 'Run indexing for AI features', + "Change admin password", + "Import your photos", + "Run indexing for AI features" ], secrets: [ { - envVar: 'PHOTOPRISM_ADMIN_PASSWORD', - label: 'Admin Password', - description: 'Password for PhotoPrism admin account', - type: 'password', + envVar: "PHOTOPRISM_ADMIN_PASSWORD", + label: "Admin Password", + description: "Password for PhotoPrism admin account", + type: "password", required: true, - generate: 'alphanumeric', - length: 32, - }, - ], + generate: "alphanumeric", + length: 32 + } + ] }, // === DOWNLOAD MANAGERS === - 'sabnzbd': { - name: 'SABnzbd', - description: 'Binary newsreader for Usenet downloads', - icon: '📰', - category: 'Downloads', + "sabnzbd": { + name: "SABnzbd", + description: "Binary newsreader for Usenet downloads", + icon: "📰", + category: "Downloads", popularity: 75, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'linuxserver/sabnzbd:latest', - ports: ['{{PORT}}:8080'], + image: "linuxserver/sabnzbd:latest", + ports: ["{{PORT}}:8080"], volumes: [ - '/opt/sabnzbd/config:/config', - '/downloads:/downloads', + "/opt/sabnzbd/config:/config", + "/downloads:/downloads" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'sabnzbd', + subdomain: "sabnzbd", defaultPort: 8092, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'SABNZBD_URL_BASE', setupInstructions: [ - 'Configure Usenet server credentials', - 'Set up download categories', - 'Configure post-processing scripts', - ], + "Configure Usenet server credentials", + "Set up download categories", + "Configure post-processing scripts" + ] }, - 'nzbget': { - name: 'NZBGet', - description: 'Efficient Usenet downloader', - icon: '📥', - category: 'Downloads', + "nzbget": { + name: "NZBGet", + description: "Efficient Usenet downloader", + icon: "📥", + category: "Downloads", popularity: 70, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'linuxserver/nzbget:latest', - ports: ['{{PORT}}:6789'], + image: "linuxserver/nzbget:latest", + ports: ["{{PORT}}:6789"], volumes: [ - '/opt/nzbget/config:/config', - '/downloads:/downloads', + "/opt/nzbget/config:/config", + "/downloads:/downloads" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'nzbget', + subdomain: "nzbget", defaultPort: 6789, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Default login: nzbget/tegbzn6789', - 'Configure news servers', - 'Set up categories and paths', - ], + "Default login: nzbget/tegbzn6789", + "Configure news servers", + "Set up categories and paths" + ] }, - 'transmission': { - name: 'Transmission', - description: 'Lightweight BitTorrent client', - icon: '🌊', - category: 'Downloads', + "transmission": { + name: "Transmission", + description: "Lightweight BitTorrent client", + icon: "🌊", + category: "Downloads", popularity: 80, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'linuxserver/transmission:latest', - ports: ['{{PORT}}:9091', '51413:51413', '51413:51413/udp'], + image: "linuxserver/transmission:latest", + ports: ["{{PORT}}:9091", "51413:51413", "51413:51413/udp"], volumes: [ - '/opt/transmission/config:/config', - '/downloads:/downloads', + "/opt/transmission/config:/config", + "/downloads:/downloads" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'transmission', + subdomain: "transmission", defaultPort: 9092, - healthCheck: '/transmission/web/', + healthCheck: "/transmission/web/", subpathSupport: 'native', urlBaseEnv: 'TRANSMISSION_WEB_HOME', setupInstructions: [ - 'Configure download paths', - 'Set bandwidth limits', - 'Configure blocklists if needed', - ], + "Configure download paths", + "Set bandwidth limits", + "Configure blocklists if needed" + ] }, - 'jdownloader': { - name: 'JDownloader 2', - description: 'Download manager for file hosting sites', - icon: '⬇️', - category: 'Downloads', + "jdownloader": { + name: "JDownloader 2", + description: "Download manager for file hosting sites", + icon: "⬇️", + category: "Downloads", popularity: 72, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'jlesage/jdownloader-2:latest', - ports: ['{{PORT}}:5800'], + image: "jlesage/jdownloader-2:latest", + ports: ["{{PORT}}:5800"], volumes: [ - '/opt/jdownloader/config:/config', - '/downloads:/output', + "/opt/jdownloader/config:/config", + "/downloads:/output" ], - environment: {}, + environment: {} }, - subdomain: 'jdownloader', + subdomain: "jdownloader", defaultPort: 5800, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Access web interface to configure', - 'Link to MyJDownloader account', - 'Configure download paths', - ], + "Access web interface to configure", + "Link to MyJDownloader account", + "Configure download paths" + ] }, // === STREAMING & MEDIA === - 'navidrome': { - name: 'Navidrome', - description: 'Modern music server and streamer', - icon: '🎶', - category: 'Media', + "navidrome": { + name: "Navidrome", + description: "Modern music server and streamer", + icon: "🎶", + category: "Media", popularity: 80, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'deluan/navidrome:latest', - ports: ['{{PORT}}:4533'], + image: "deluan/navidrome:latest", + ports: ["{{PORT}}:4533"], volumes: [ - '/opt/navidrome/data:/data', - '/music:/music:ro', + "/opt/navidrome/data:/data", + "/music:/music:ro" ], environment: { - 'ND_SCANSCHEDULE': '1h', - 'ND_LOGLEVEL': 'info', - }, + "ND_SCANSCHEDULE": "1h", + "ND_LOGLEVEL": "info" + } }, - subdomain: 'music', + subdomain: "music", defaultPort: 4533, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Point to your music library', - 'Create user accounts', - 'Install Subsonic-compatible apps', - ], + "Point to your music library", + "Create user accounts", + "Install Subsonic-compatible apps" + ] }, - 'airsonic': { - name: 'Airsonic Advanced', - description: 'Free web-based media streamer', - icon: '🎧', - category: 'Media', + "airsonic": { + name: "Airsonic Advanced", + description: "Free web-based media streamer", + icon: "🎧", + category: "Media", popularity: 68, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'linuxserver/airsonic-advanced:latest', - ports: ['{{PORT}}:4040'], + image: "linuxserver/airsonic-advanced:latest", + ports: ["{{PORT}}:4040"], volumes: [ - '/opt/airsonic/config:/config', - '/music:/music', - '/podcasts:/podcasts', + "/opt/airsonic/config:/config", + "/music:/music", + "/podcasts:/podcasts" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'airsonic', + subdomain: "airsonic", defaultPort: 4040, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Default login: admin/admin', - 'Configure media folders', - 'Set up transcoding', - ], + "Default login: admin/admin", + "Configure media folders", + "Set up transcoding" + ] }, // === MISC UTILITIES === - 'homepage': { - name: 'Homepage', - description: 'Highly customizable application dashboard', - icon: '🏡', - category: 'Utilities', + "homepage": { + name: "Homepage", + description: "Highly customizable application dashboard", + icon: "🏡", + category: "Utilities", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'ghcr.io/gethomepage/homepage:latest', - ports: ['{{PORT}}:3000'], + image: "ghcr.io/gethomepage/homepage:latest", + ports: ["{{PORT}}:3000"], volumes: [ - '/opt/homepage/config:/app/config', - '/var/run/docker.sock:/var/run/docker.sock:ro', + "/opt/homepage/config:/app/config", + "/var/run/docker.sock:/var/run/docker.sock:ro" ], - environment: {}, + environment: {} }, - subdomain: 'dashboard', + subdomain: "dashboard", defaultPort: 3008, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Edit config files to add services', - 'Configure widgets', - 'Customize appearance', - ], + "Edit config files to add services", + "Configure widgets", + "Customize appearance" + ] }, - 'homarr': { - name: 'Homarr', - description: 'Sleek dashboard for all your services', - icon: '🎯', - category: 'Utilities', + "homarr": { + name: "Homarr", + description: "Sleek dashboard for all your services", + icon: "🎯", + category: "Utilities", popularity: 82, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'ghcr.io/ajnart/homarr:latest', - ports: ['{{PORT}}:7575'], + image: "ghcr.io/ajnart/homarr:latest", + ports: ["{{PORT}}:7575"], volumes: [ - '/opt/homarr/configs:/app/data/configs', - '/opt/homarr/icons:/app/public/icons', - '/var/run/docker.sock:/var/run/docker.sock:ro', + "/opt/homarr/configs:/app/data/configs", + "/opt/homarr/icons:/app/public/icons", + "/var/run/docker.sock:/var/run/docker.sock:ro" ], - environment: {}, + environment: {} }, - subdomain: 'homarr', + subdomain: "homarr", defaultPort: 7575, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Add your services via UI', - 'Configure integrations', - 'Customize layout and appearance', - ], + "Add your services via UI", + "Configure integrations", + "Customize layout and appearance" + ] }, - 'changedetection': { - name: 'Change Detection', - description: 'Monitor websites for changes', - icon: '👁️', - category: 'Utilities', + "changedetection": { + name: "Change Detection", + description: "Monitor websites for changes", + icon: "👁️", + category: "Utilities", popularity: 70, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'ghcr.io/dgtlmoon/changedetection.io:latest', - ports: ['{{PORT}}:5000'], - volumes: ['/opt/changedetection/data:/datastore'], - environment: {}, + image: "ghcr.io/dgtlmoon/changedetection.io:latest", + ports: ["{{PORT}}:5000"], + volumes: ["/opt/changedetection/data:/datastore"], + environment: {} }, - subdomain: 'watch', + subdomain: "watch", defaultPort: 5001, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Add URLs to monitor', - 'Configure check frequency', - 'Set up notifications', - ], + "Add URLs to monitor", + "Configure check frequency", + "Set up notifications" + ] }, - 'speedtest': { - name: 'Speedtest Tracker', - description: 'Internet speed monitoring over time', - icon: '⚡', - category: 'Monitoring', + "speedtest": { + name: "Speedtest Tracker", + description: "Internet speed monitoring over time", + icon: "⚡", + category: "Monitoring", popularity: 75, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'ghcr.io/alexjustesen/speedtest-tracker:latest', - ports: ['{{PORT}}:80'], - volumes: ['/opt/speedtest/config:/config'], + image: "ghcr.io/alexjustesen/speedtest-tracker:latest", + ports: ["{{PORT}}:80"], + volumes: ["/opt/speedtest/config:/config"], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'DB_CONNECTION': 'sqlite', - }, + "PUID": "1000", + "PGID": "1000", + "DB_CONNECTION": "sqlite" + } }, - subdomain: 'speedtest', + subdomain: "speedtest", defaultPort: 8093, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Configure test schedule', - 'View historical data', - 'Set up notifications for slow speeds', - ], + "Configure test schedule", + "View historical data", + "Set up notifications for slow speeds" + ] }, - 'whoami': { - name: 'Whoami', - description: 'Simple HTTP request debugging service', - icon: '🔍', - category: 'Utilities', + "whoami": { + name: "Whoami", + description: "Simple HTTP request debugging service", + icon: "🔍", + category: "Utilities", popularity: 60, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'traefik/whoami:latest', - ports: ['{{PORT}}:80'], + image: "traefik/whoami:latest", + ports: ["{{PORT}}:80"], volumes: [], - environment: {}, + environment: {} }, - subdomain: 'whoami', + subdomain: "whoami", defaultPort: 8094, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Useful for testing reverse proxy setup', - 'Shows request headers and info', - ], + "Useful for testing reverse proxy setup", + "Shows request headers and info" + ] }, // === NEW APPS === - 'stirling-pdf': { - name: 'Stirling PDF', - description: 'Self-hosted PDF manipulation tool - merge, split, convert, and more', - icon: '\uD83D\uDCC4', - logo: '/assets/stirling-pdf.png', - category: 'Utilities', + "stirling-pdf": { + name: "Stirling PDF", + description: "Self-hosted PDF manipulation tool - merge, split, convert, and more", + icon: "\uD83D\uDCC4", + logo: "/assets/stirling-pdf.png", + category: "Utilities", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'frooodle/s-pdf:latest', - ports: ['{{PORT}}:8080'], + image: "frooodle/s-pdf:latest", + ports: ["{{PORT}}:8080"], volumes: [ - '/opt/stirling-pdf/data:/usr/share/tessdata', - '/opt/stirling-pdf/config:/configs', + "/opt/stirling-pdf/data:/usr/share/tessdata", + "/opt/stirling-pdf/config:/configs" ], environment: { - 'DOCKER_ENABLE_SECURITY': 'false', - }, + "DOCKER_ENABLE_SECURITY": "false" + } }, - subdomain: 'pdf', + subdomain: "pdf", defaultPort: 8084, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Access the web interface to start manipulating PDFs', - 'Supports merge, split, rotate, convert, compress, and more', - 'Optional OCR support via Tesseract', - ], + "Access the web interface to start manipulating PDFs", + "Supports merge, split, rotate, convert, compress, and more", + "Optional OCR support via Tesseract" + ] }, - 'actual-budget': { - name: 'Actual Budget', - description: 'Privacy-focused budgeting app with envelope budgeting', - icon: '\uD83D\uDCB0', - logo: '/assets/actual-budget.png', - category: 'Productivity', + "actual-budget": { + name: "Actual Budget", + description: "Privacy-focused budgeting app with envelope budgeting", + icon: "\uD83D\uDCB0", + logo: "/assets/actual-budget.png", + category: "Productivity", popularity: 78, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'actualbudget/actual-server:latest', - ports: ['{{PORT}}:5006'], + image: "actualbudget/actual-server:latest", + ports: ["{{PORT}}:5006"], volumes: [ - '/opt/actual-budget/data:/data', + "/opt/actual-budget/data:/data" ], - environment: {}, + environment: {} }, - subdomain: 'budget', + subdomain: "budget", defaultPort: 5006, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Create your first budget in the web interface', - 'Import transactions from your bank (OFX, QFX, CSV)', - 'Set up envelope categories for spending control', - ], + "Create your first budget in the web interface", + "Import transactions from your bank (OFX, QFX, CSV)", + "Set up envelope categories for spending control" + ] }, - 'mealie': { - name: 'Mealie', - description: 'Recipe manager and meal planner with grocery lists', - icon: '\uD83C\uDF73', - logo: '/assets/mealie.png', - category: 'Productivity', + "mealie": { + name: "Mealie", + description: "Recipe manager and meal planner with grocery lists", + icon: "\uD83C\uDF73", + logo: "/assets/mealie.png", + category: "Productivity", popularity: 76, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'ghcr.io/mealie-recipes/mealie:latest', - ports: ['{{PORT}}:9000'], + image: "ghcr.io/mealie-recipes/mealie:latest", + ports: ["{{PORT}}:9000"], volumes: [ - '/opt/mealie/data:/app/data', + "/opt/mealie/data:/app/data" ], environment: { - 'ALLOW_SIGNUP': 'true', - 'MAX_WORKERS': '1', - 'WEB_CONCURRENCY': '1', - 'BASE_URL': 'https://{{SUBDOMAIN}}.sami', - }, + "ALLOW_SIGNUP": "true", + "MAX_WORKERS": "1", + "WEB_CONCURRENCY": "1", + "BASE_URL": "https://{{SUBDOMAIN}}.sami" + } }, - subdomain: 'mealie', + subdomain: "mealie", defaultPort: 9925, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Default login: changeme@example.com / MyPassword', - 'Import recipes from URLs or add them manually', - 'Create meal plans and generate shopping lists', - ], + "Default login: changeme@example.com / MyPassword", + "Import recipes from URLs or add them manually", + "Create meal plans and generate shopping lists" + ] }, - 'paperless-ngx': { - name: 'Paperless-ngx', - description: 'Document management system - scan, organize, and search documents', - icon: '\uD83D\uDCDA', - logo: '/assets/paperless-ngx.png', - category: 'Productivity', + "paperless-ngx": { + name: "Paperless-ngx", + description: "Document management system - scan, organize, and search documents", + icon: "\uD83D\uDCDA", + logo: "/assets/paperless-ngx.png", + category: "Productivity", popularity: 82, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'ghcr.io/paperless-ngx/paperless-ngx:latest', - ports: ['{{PORT}}:8000'], + image: "ghcr.io/paperless-ngx/paperless-ngx:latest", + ports: ["{{PORT}}:8000"], volumes: [ - '/opt/paperless/data:/usr/src/paperless/data', - '/opt/paperless/media:/usr/src/paperless/media', - '/opt/paperless/consume:/usr/src/paperless/consume', + "/opt/paperless/data:/usr/src/paperless/data", + "/opt/paperless/media:/usr/src/paperless/media", + "/opt/paperless/consume:/usr/src/paperless/consume" ], environment: { - 'PAPERLESS_URL': 'https://{{SUBDOMAIN}}.sami', - 'USERMAP_UID': '1000', - 'USERMAP_GID': '1000', - 'PAPERLESS_TIME_ZONE': '{{TIMEZONE}}', - 'PAPERLESS_OCR_LANGUAGE': 'eng', - 'PAPERLESS_SECRET_KEY': '{{GENERATED_SECRET}}', - }, + "PAPERLESS_URL": "https://{{SUBDOMAIN}}.sami", + "USERMAP_UID": "1000", + "USERMAP_GID": "1000", + "PAPERLESS_TIME_ZONE": "{{TIMEZONE}}", + "PAPERLESS_OCR_LANGUAGE": "eng", + "PAPERLESS_SECRET_KEY": "{{GENERATED_SECRET}}" + } }, - subdomain: 'paperless', + subdomain: "paperless", defaultPort: 8095, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Create admin account via: docker exec -it python3 manage.py createsuperuser', - 'Drop documents into the consume folder for automatic import', - 'Configure tags and correspondents for organization', - ], + "Create admin account via: docker exec -it python3 manage.py createsuperuser", + "Drop documents into the consume folder for automatic import", + "Configure tags and correspondents for organization" + ] }, - 'audiobookshelf': { - name: 'Audiobookshelf', - description: 'Self-hosted audiobook and podcast server', - icon: '\uD83C\uDFA7', - logo: '/assets/audiobookshelf.png', - category: 'Media', + "audiobookshelf": { + name: "Audiobookshelf", + description: "Self-hosted audiobook and podcast server", + icon: "\uD83C\uDFA7", + logo: "/assets/audiobookshelf.png", + category: "Media", popularity: 80, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'ghcr.io/advplyr/audiobookshelf:latest', - ports: ['{{PORT}}:80'], + image: "ghcr.io/advplyr/audiobookshelf:latest", + ports: ["{{PORT}}:80"], volumes: [ - '/opt/audiobookshelf/config:/config', - '/opt/audiobookshelf/metadata:/metadata', - '{{MEDIA_PATH}}:/audiobooks', + "/opt/audiobookshelf/config:/config", + "/opt/audiobookshelf/metadata:/metadata", + "{{MEDIA_PATH}}:/audiobooks" ], - environment: {}, + environment: {} }, - subdomain: 'audiobooks', + subdomain: "audiobooks", defaultPort: 13378, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', mediaMount: { required: true, - containerPath: '/audiobooks', - label: 'Audiobook Library', - description: 'Folder containing your audiobooks and podcasts', - defaultPath: '/media/audiobooks', + containerPath: "/audiobooks", + label: "Audiobook Library", + description: "Folder containing your audiobooks and podcasts", + defaultPath: "/media/audiobooks" }, setupInstructions: [ - 'Create your account on first access', - 'Add your audiobook library folders', - 'Download the mobile app for offline listening', - ], + "Create your account on first access", + "Add your audiobook library folders", + "Download the mobile app for offline listening" + ] }, - 'calibre-web': { - name: 'Calibre-Web', - description: 'Web-based ebook manager and reader', - icon: '\uD83D\uDCD6', - logo: '/assets/calibre-web.png', - category: 'Media', + "calibre-web": { + name: "Calibre-Web", + description: "Web-based ebook manager and reader", + icon: "\uD83D\uDCD6", + logo: "/assets/calibre-web.png", + category: "Media", popularity: 74, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'lscr.io/linuxserver/calibre-web:latest', - ports: ['{{PORT}}:8083'], + image: "lscr.io/linuxserver/calibre-web:latest", + ports: ["{{PORT}}:8083"], volumes: [ - '/opt/calibre-web/config:/config', - '{{MEDIA_PATH}}:/books', + "/opt/calibre-web/config:/config", + "{{MEDIA_PATH}}:/books" ], environment: { - 'PUID': '1000', - 'PGID': '1000', - 'TZ': '{{TIMEZONE}}', - }, + "PUID": "1000", + "PGID": "1000", + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'books', + subdomain: "books", defaultPort: 8083, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', mediaMount: { required: true, - containerPath: '/books', - label: 'Ebook Library', - description: 'Folder containing your Calibre library (with metadata.db)', - defaultPath: '/media/books', + containerPath: "/books", + label: "Ebook Library", + description: "Folder containing your Calibre library (with metadata.db)", + defaultPath: "/media/books" }, setupInstructions: [ - 'Default login: admin / admin123', - 'Point to your Calibre database location on first setup', - 'Supports EPUB, PDF, MOBI, and more formats', - ], + "Default login: admin / admin123", + "Point to your Calibre database location on first setup", + "Supports EPUB, PDF, MOBI, and more formats" + ] }, - 'komga': { - name: 'Komga', - description: 'Comic and manga media server with web reader', - icon: '\uD83D\uDCDA', - logo: '/assets/komga.png', - category: 'Media', + "komga": { + name: "Komga", + description: "Comic and manga media server with web reader", + icon: "\uD83D\uDCDA", + logo: "/assets/komga.png", + category: "Media", popularity: 70, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'gotson/komga:latest', - ports: ['{{PORT}}:25600'], + image: "gotson/komga:latest", + ports: ["{{PORT}}:25600"], volumes: [ - '/opt/komga/config:/config', - '{{MEDIA_PATH}}:/data', + "/opt/komga/config:/config", + "{{MEDIA_PATH}}:/data" ], environment: { - 'TZ': '{{TIMEZONE}}', - }, + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'komga', + subdomain: "komga", defaultPort: 25600, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', mediaMount: { required: true, - containerPath: '/data', - label: 'Comics Library', - description: 'Folder containing your comics and manga', - defaultPath: '/media/comics', + containerPath: "/data", + label: "Comics Library", + description: "Folder containing your comics and manga", + defaultPath: "/media/comics" }, setupInstructions: [ - 'Create admin account on first access', - 'Add your comic libraries (CBZ, CBR, PDF supported)', - 'Use OPDS for third-party reader apps', - ], + "Create admin account on first access", + "Add your comic libraries (CBZ, CBR, PDF supported)", + "Use OPDS for third-party reader apps" + ] }, - 'kavita': { - name: 'Kavita', - description: 'Digital reading platform for manga, comics, and books', - icon: '\uD83D\uDCD6', - logo: '/assets/kavita.png', - category: 'Media', + "kavita": { + name: "Kavita", + description: "Digital reading platform for manga, comics, and books", + icon: "\uD83D\uDCD6", + logo: "/assets/kavita.png", + category: "Media", popularity: 72, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'jvmilazz0/kavita:latest', - ports: ['{{PORT}}:5000'], + image: "jvmilazz0/kavita:latest", + ports: ["{{PORT}}:5000"], volumes: [ - '/opt/kavita/config:/kavita/config', - '{{MEDIA_PATH}}:/data', + "/opt/kavita/config:/kavita/config", + "{{MEDIA_PATH}}:/data" ], - environment: {}, + environment: {} }, - subdomain: 'kavita', + subdomain: "kavita", defaultPort: 5004, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', mediaMount: { required: true, - containerPath: '/data', - label: 'Reading Library', - description: 'Folder containing your manga, comics, and ebooks', - defaultPath: '/media/reading', + containerPath: "/data", + label: "Reading Library", + description: "Folder containing your manga, comics, and ebooks", + defaultPath: "/media/reading" }, setupInstructions: [ - 'Create admin account on first access', - 'Add library folders for manga, comics, or books', - 'Supports EPUB, PDF, CBZ, CBR formats', - ], + "Create admin account on first access", + "Add library folders for manga, comics, or books", + "Supports EPUB, PDF, CBZ, CBR formats" + ] }, - 'trilium': { - name: 'Trilium Notes', - description: 'Hierarchical knowledge base and note-taking app', - icon: '\uD83D\uDDD2\uFE0F', - logo: '/assets/trilium.png', - category: 'Productivity', + "trilium": { + name: "Trilium Notes", + description: "Hierarchical knowledge base and note-taking app", + icon: "\uD83D\uDDD2\uFE0F", + logo: "/assets/trilium.png", + category: "Productivity", popularity: 75, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'zadam/trilium:latest', - ports: ['{{PORT}}:8080'], + image: "zadam/trilium:latest", + ports: ["{{PORT}}:8080"], volumes: [ - '/opt/trilium/data:/home/node/trilium-data', + "/opt/trilium/data:/home/node/trilium-data" ], - environment: {}, + environment: {} }, - subdomain: 'notes', + subdomain: "notes", defaultPort: 8085, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Set your password on first access', - 'Organize notes in a tree hierarchy', - 'Supports rich text, code blocks, math equations, and diagrams', - ], + "Set your password on first access", + "Organize notes in a tree hierarchy", + "Supports rich text, code blocks, math equations, and diagrams" + ] }, - 'excalidraw': { - name: 'Excalidraw', - description: 'Collaborative virtual whiteboard for sketching and diagrams', - icon: '\uD83C\uDFA8', - logo: '/assets/excalidraw.png', - category: 'Productivity', + "excalidraw": { + name: "Excalidraw", + description: "Collaborative virtual whiteboard for sketching and diagrams", + icon: "\uD83C\uDFA8", + logo: "/assets/excalidraw.png", + category: "Productivity", popularity: 73, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'excalidraw/excalidraw:latest', - ports: ['{{PORT}}:80'], + image: "excalidraw/excalidraw:latest", + ports: ["{{PORT}}:80"], volumes: [], - environment: {}, + environment: {} }, - subdomain: 'draw', + subdomain: "draw", defaultPort: 8086, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Start drawing immediately - no account needed', - 'Share drawings via link for real-time collaboration', - 'Export as PNG, SVG, or Excalidraw file', - ], + "Start drawing immediately - no account needed", + "Share drawings via link for real-time collaboration", + "Export as PNG, SVG, or Excalidraw file" + ] }, - 'it-tools': { - name: 'IT Tools', - description: 'Collection of handy developer and IT tools in one place', - icon: '\uD83E\uDDF0', - logo: '/assets/it-tools.png', - category: 'Utilities', + "it-tools": { + name: "IT Tools", + description: "Collection of handy developer and IT tools in one place", + icon: "\uD83E\uDDF0", + logo: "/assets/it-tools.png", + category: "Utilities", popularity: 79, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'corentinth/it-tools:latest', - ports: ['{{PORT}}:80'], + image: "corentinth/it-tools:latest", + ports: ["{{PORT}}:80"], volumes: [], - environment: {}, + environment: {} }, - subdomain: 'tools', + subdomain: "tools", defaultPort: 8087, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'Access the web interface for instant tools access', - 'Includes: hash generators, UUID, JWT decoder, base64, regex tester, and 70+ more', - 'No configuration needed', - ], + "Access the web interface for instant tools access", + "Includes: hash generators, UUID, JWT decoder, base64, regex tester, and 70+ more", + "No configuration needed" + ] }, - 'dozzle': { - name: 'Dozzle', - description: 'Real-time Docker container log viewer', - icon: '\uD83D\uDCDC', - logo: '/assets/dozzle.png', - category: 'Monitoring', + "dozzle": { + name: "Dozzle", + description: "Real-time Docker container log viewer", + icon: "\uD83D\uDCDC", + logo: "/assets/dozzle.png", + category: "Monitoring", popularity: 77, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'amir20/dozzle:latest', - ports: ['{{PORT}}:8080'], + image: "amir20/dozzle:latest", + ports: ["{{PORT}}:8080"], volumes: [ - '/var/run/docker.sock:/var/run/docker.sock:ro', + "/var/run/docker.sock:/var/run/docker.sock:ro" ], - environment: {}, + environment: {} }, - subdomain: 'logs', + subdomain: "logs", defaultPort: 8088, - healthCheck: '/', + healthCheck: "/", subpathSupport: 'strip', setupInstructions: [ - 'View real-time logs from all running containers', - 'Filter and search across container logs', - 'No configuration needed - auto-discovers containers', - ], + "View real-time logs from all running containers", + "Filter and search across container logs", + "No configuration needed - auto-discovers containers" + ] }, - 'watchtower': { - name: 'Watchtower', - description: 'Automatic Docker container image updates', - icon: '\uD83D\uDC53', - logo: '/assets/watchtower.png', - category: 'Management', + "watchtower": { + name: "Watchtower", + description: "Automatic Docker container image updates", + icon: "\uD83D\uDC53", + logo: "/assets/watchtower.png", + category: "Management", popularity: 81, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'containrrr/watchtower:latest', - ports: ['{{PORT}}:8080'], + image: "containrrr/watchtower:latest", + ports: ["{{PORT}}:8080"], volumes: [ - '/var/run/docker.sock:/var/run/docker.sock', + "/var/run/docker.sock:/var/run/docker.sock" ], environment: { - 'WATCHTOWER_CLEANUP': 'true', - 'WATCHTOWER_SCHEDULE': '0 0 4 * * *', - 'WATCHTOWER_HTTP_API_METRICS': 'true', - 'WATCHTOWER_HTTP_API_TOKEN': '{{GENERATED_SECRET}}', - }, + "WATCHTOWER_CLEANUP": "true", + "WATCHTOWER_SCHEDULE": "0 0 4 * * *", + "WATCHTOWER_HTTP_API_METRICS": "true", + "WATCHTOWER_HTTP_API_TOKEN": "{{GENERATED_SECRET}}" + } }, - subdomain: 'watchtower', + subdomain: "watchtower", defaultPort: 8089, - healthCheck: '/v1/update', + healthCheck: "/v1/update", subpathSupport: 'strip', setupInstructions: [ - 'Watchtower checks for image updates daily at 4 AM by default', - 'Customize schedule via WATCHTOWER_SCHEDULE (cron format)', - 'Add labels to exclude specific containers from updates', - ], + "Watchtower checks for image updates daily at 4 AM by default", + "Customize schedule via WATCHTOWER_SCHEDULE (cron format)", + "Add labels to exclude specific containers from updates" + ] }, - 'authentik': { - name: 'Authentik', - description: 'Identity provider and single sign-on platform', - icon: '\uD83D\uDD10', - logo: '/assets/authentik.png', - category: 'Security', + "authentik": { + name: "Authentik", + description: "Identity provider and single sign-on platform", + icon: "\uD83D\uDD10", + logo: "/assets/authentik.png", + category: "Security", popularity: 80, - difficulty: 'Advanced', + difficulty: "Advanced", docker: { - image: 'ghcr.io/goauthentik/server:latest', - ports: ['{{PORT}}:9000'], + image: "ghcr.io/goauthentik/server:latest", + ports: ["{{PORT}}:9000"], volumes: [ - '/opt/authentik/media:/media', - '/opt/authentik/templates:/templates', + "/opt/authentik/media:/media", + "/opt/authentik/templates:/templates" ], environment: { - 'AUTHENTIK_SECRET_KEY': '{{GENERATED_SECRET}}', - 'AUTHENTIK_ERROR_REPORTING__ENABLED': 'false', - }, + "AUTHENTIK_SECRET_KEY": "{{GENERATED_SECRET}}", + "AUTHENTIK_ERROR_REPORTING__ENABLED": "false" + } }, - subdomain: 'auth', + subdomain: "auth", defaultPort: 9010, - healthCheck: '/-/health/live/', + healthCheck: "/-/health/live/", subpathSupport: 'strip', setupInstructions: [ - 'Requires a PostgreSQL database and Redis instance', - 'Consider deploying via the Dev Environment recipe for full stack', - 'Set up flows for authentication, enrollment, and recovery', - 'Configure OAuth2/OIDC providers for SSO with other apps', - ], + "Requires a PostgreSQL database and Redis instance", + "Consider deploying via the Dev Environment recipe for full stack", + "Set up flows for authentication, enrollment, and recovery", + "Configure OAuth2/OIDC providers for SSO with other apps" + ] }, - 'crowdsec': { - name: 'CrowdSec', - description: 'Collaborative intrusion prevention system', - icon: '\uD83D\uDEE1\uFE0F', - logo: '/assets/crowdsec.png', - category: 'Security', + "crowdsec": { + name: "CrowdSec", + description: "Collaborative intrusion prevention system", + icon: "\uD83D\uDEE1\uFE0F", + logo: "/assets/crowdsec.png", + category: "Security", popularity: 74, - difficulty: 'Intermediate', + difficulty: "Intermediate", docker: { - image: 'crowdsecurity/crowdsec:latest', - ports: ['{{PORT}}:8080'], + image: "crowdsecurity/crowdsec:latest", + ports: ["{{PORT}}:8080"], volumes: [ - '/opt/crowdsec/config:/etc/crowdsec', - '/opt/crowdsec/data:/var/lib/crowdsec/data', - '/var/log:/var/log:ro', + "/opt/crowdsec/config:/etc/crowdsec", + "/opt/crowdsec/data:/var/lib/crowdsec/data", + "/var/log:/var/log:ro" ], - environment: {}, + environment: {} }, - subdomain: 'crowdsec', + subdomain: "crowdsec", defaultPort: 8091, - healthCheck: '/health', + healthCheck: "/health", subpathSupport: 'strip', setupInstructions: [ - 'Register at app.crowdsec.net for community threat intelligence', - 'Install bouncers on your reverse proxy for active blocking', - 'CrowdSec analyzes logs and shares threat data with the community', - ], + "Register at app.crowdsec.net for community threat intelligence", + "Install bouncers on your reverse proxy for active blocking", + "CrowdSec analyzes logs and shares threat data with the community" + ] }, - 'minecraft': { - name: 'Minecraft Server', - description: 'Minecraft Java Edition dedicated server', - icon: '\u26CF\uFE0F', - logo: '/assets/minecraft.png', - category: 'Gaming', + "minecraft": { + name: "Minecraft Server", + description: "Minecraft Java Edition dedicated server", + icon: "\u26CF\uFE0F", + logo: "/assets/minecraft.png", + category: "Gaming", popularity: 85, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'itzg/minecraft-server:latest', - ports: ['{{PORT}}:25565'], + image: "itzg/minecraft-server:latest", + ports: ["{{PORT}}:25565"], volumes: [ - '/opt/minecraft/data:/data', + "/opt/minecraft/data:/data" ], environment: { - 'EULA': 'TRUE', - 'TYPE': 'VANILLA', - 'VERSION': 'LATEST', - 'MEMORY': '2G', - 'MAX_PLAYERS': '20', - 'MOTD': 'DashCaddy Minecraft Server', - }, + "EULA": "TRUE", + "TYPE": "VANILLA", + "VERSION": "LATEST", + "MEMORY": "2G", + "MAX_PLAYERS": "20", + "MOTD": "DashCaddy Minecraft Server" + } }, - subdomain: 'mc', + subdomain: "mc", defaultPort: 25565, healthCheck: null, subpathSupport: 'none', setupInstructions: [ - 'Server accepts the Minecraft EULA automatically', - 'Connect with your Minecraft client to the server IP:port', - 'Configure server.properties in the data volume for customization', - 'Supports Vanilla, Paper, Forge, Fabric via TYPE environment variable', - ], + "Server accepts the Minecraft EULA automatically", + "Connect with your Minecraft client to the server IP:port", + "Configure server.properties in the data volume for customization", + "Supports Vanilla, Paper, Forge, Fabric via TYPE environment variable" + ] }, - 'valheim': { - name: 'Valheim Server', - description: 'Valheim dedicated server for multiplayer Viking adventures', - icon: '\u2694\uFE0F', - logo: '/assets/valheim.png', - category: 'Gaming', + "valheim": { + name: "Valheim Server", + description: "Valheim dedicated server for multiplayer Viking adventures", + icon: "\u2694\uFE0F", + logo: "/assets/valheim.png", + category: "Gaming", popularity: 72, - difficulty: 'Easy', + difficulty: "Easy", docker: { - image: 'lloesche/valheim-server:latest', - ports: ['{{PORT}}:2456/udp', '2457:2457/udp', '2458:2458/udp'], + image: "lloesche/valheim-server:latest", + ports: ["{{PORT}}:2456/udp", "2457:2457/udp", "2458:2458/udp"], volumes: [ - '/opt/valheim/config:/config', - '/opt/valheim/data:/opt/valheim', + "/opt/valheim/config:/config", + "/opt/valheim/data:/opt/valheim" ], environment: { - 'SERVER_NAME': 'DashCaddy Valheim', - 'WORLD_NAME': 'DashCaddyWorld', - 'SERVER_PASS': '{{GENERATED_SECRET}}', - 'SERVER_PUBLIC': 'false', - }, + "SERVER_NAME": "DashCaddy Valheim", + "WORLD_NAME": "DashCaddyWorld", + "SERVER_PASS": "{{GENERATED_SECRET}}", + "SERVER_PUBLIC": "false" + } }, - subdomain: 'valheim', + subdomain: "valheim", defaultPort: 2456, healthCheck: null, subpathSupport: 'none', setupInstructions: [ - 'Connect via Steam: Add Server > IP:2456', - 'Default server password is auto-generated (check environment variables)', - 'World data is persisted in the data volume', - 'Requires at least 4GB RAM for smooth operation', - ], - }, + "Connect via Steam: Add Server > IP:2456", + "Default server password is auto-generated (check environment variables)", + "World data is persisted in the data volume", + "Requires at least 4GB RAM for smooth operation" + ] + } }; // Template categories for organization const TEMPLATE_CATEGORIES = { - 'Media': { icon: '🎬', color: '#e74c3c' }, - 'Media Management': { icon: '📋', color: '#3498db' }, - 'Downloads': { icon: '⬇️', color: '#2ecc71' }, - 'Productivity': { icon: '📝', color: '#f39c12' }, - 'Development': { icon: '💻', color: '#9b59b6' }, - 'Management': { icon: '⚙️', color: '#34495e' }, - 'Monitoring': { icon: '📊', color: '#1abc9c' }, - 'Networking': { icon: '🌐', color: '#e67e22' }, - 'DNS': { icon: '🌐', color: '#3498db' }, - 'Files': { icon: '📁', color: '#3498db' }, - 'Communication': { icon: '💬', color: '#9b59b6' }, - 'Home Automation': { icon: '🏠', color: '#27ae60' }, - 'Database': { icon: '🗄️', color: '#8e44ad' }, - 'Security': { icon: '🔐', color: '#c0392b' }, - 'Photos': { icon: '📸', color: '#16a085' }, - 'Utilities': { icon: '\uD83D\uDEE0\uFE0F', color: '#7f8c8d' }, - 'Gaming': { icon: '\uD83C\uDFAE', color: '#e91e63' }, + "Media": { icon: "🎬", color: "#e74c3c" }, + "Media Management": { icon: "📋", color: "#3498db" }, + "Downloads": { icon: "⬇️", color: "#2ecc71" }, + "Productivity": { icon: "📝", color: "#f39c12" }, + "Development": { icon: "💻", color: "#9b59b6" }, + "Management": { icon: "⚙️", color: "#34495e" }, + "Monitoring": { icon: "📊", color: "#1abc9c" }, + "Networking": { icon: "🌐", color: "#e67e22" }, + "DNS": { icon: "🌐", color: "#3498db" }, + "Files": { icon: "📁", color: "#3498db" }, + "Communication": { icon: "💬", color: "#9b59b6" }, + "Home Automation": { icon: "🏠", color: "#27ae60" }, + "Database": { icon: "🗄️", color: "#8e44ad" }, + "Security": { icon: "🔐", color: "#c0392b" }, + "Photos": { icon: "📸", color: "#16a085" }, + "Utilities": { icon: "\uD83D\uDEE0\uFE0F", color: "#7f8c8d" }, + "Gaming": { icon: "\uD83C\uDFAE", color: "#e91e63" } }; // Difficulty levels const DIFFICULTY_LEVELS = { - 'Easy': { color: '#2ecc71', description: 'Quick setup, minimal configuration' }, - 'Intermediate': { color: '#f39c12', description: 'Some configuration required' }, - 'Advanced': { color: '#e74c3c', description: 'Complex setup, technical knowledge needed' }, + "Easy": { color: "#2ecc71", description: "Quick setup, minimal configuration" }, + "Intermediate": { color: "#f39c12", description: "Some configuration required" }, + "Advanced": { color: "#e74c3c", description: "Complex setup, technical knowledge needed" } }; module.exports = { APP_TEMPLATES, TEMPLATE_CATEGORIES, - DIFFICULTY_LEVELS, + DIFFICULTY_LEVELS }; \ No newline at end of file diff --git a/dashcaddy-api/assets/.htaccess b/dashcaddy-api/assets/.htaccess new file mode 100644 index 0000000..86ba288 --- /dev/null +++ b/dashcaddy-api/assets/.htaccess @@ -0,0 +1,21 @@ +# Font file headers to prevent sanitizer issues + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, POST, OPTIONS" + Header set Access-Control-Allow-Headers "Content-Type" + Header set Cache-Control "public, max-age=31536000" + + # Proper MIME types + + AddType font/woff2 .woff2 + AddType font/woff .woff + AddType font/ttf .ttf + AddType application/vnd.ms-fontobject .eot + + + +# Prevent direct access to font conversion scripts + + Order allow,deny + Deny from all + \ No newline at end of file diff --git a/dashcaddy-api/BUFFER_AUDIT.md b/dashcaddy-api/assets/New Text Document.txt similarity index 100% rename from dashcaddy-api/BUFFER_AUDIT.md rename to dashcaddy-api/assets/New Text Document.txt diff --git a/dashcaddy-api/assets/SAMI-CLOUD.png b/dashcaddy-api/assets/SAMI-CLOUD.png new file mode 100644 index 0000000..d4d00f5 Binary files /dev/null and b/dashcaddy-api/assets/SAMI-CLOUD.png differ diff --git a/dashcaddy-api/assets/SAMI-CLOUD.svg b/dashcaddy-api/assets/SAMI-CLOUD.svg new file mode 100644 index 0000000..d8074eb --- /dev/null +++ b/dashcaddy-api/assets/SAMI-CLOUD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/Slink.png b/dashcaddy-api/assets/Slink.png new file mode 100644 index 0000000..951ecfe Binary files /dev/null and b/dashcaddy-api/assets/Slink.png differ diff --git a/dashcaddy-api/assets/apple-touch-icon.png b/dashcaddy-api/assets/apple-touch-icon.png new file mode 100644 index 0000000..c6fa919 Binary files /dev/null and b/dashcaddy-api/assets/apple-touch-icon.png differ diff --git a/dashcaddy-api/assets/certificate-icon.png b/dashcaddy-api/assets/certificate-icon.png new file mode 100644 index 0000000..a82a54f Binary files /dev/null and b/dashcaddy-api/assets/certificate-icon.png differ diff --git a/dashcaddy-api/assets/chat.png b/dashcaddy-api/assets/chat.png new file mode 100644 index 0000000..63735ad Binary files /dev/null and b/dashcaddy-api/assets/chat.png differ diff --git a/dashcaddy-api/assets/cloud-favicon-512.png b/dashcaddy-api/assets/cloud-favicon-512.png new file mode 100644 index 0000000..af4acae Binary files /dev/null and b/dashcaddy-api/assets/cloud-favicon-512.png differ diff --git a/dashcaddy-api/assets/custom-logo.png b/dashcaddy-api/assets/custom-logo.png new file mode 100644 index 0000000..d4d00f5 Binary files /dev/null and b/dashcaddy-api/assets/custom-logo.png differ diff --git a/dashcaddy-api/assets/custom-logo.svg b/dashcaddy-api/assets/custom-logo.svg new file mode 100644 index 0000000..d8074eb --- /dev/null +++ b/dashcaddy-api/assets/custom-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/dashcaddy-favicon.ico b/dashcaddy-api/assets/dashcaddy-favicon.ico new file mode 100644 index 0000000..a796077 Binary files /dev/null and b/dashcaddy-api/assets/dashcaddy-favicon.ico differ diff --git a/dashcaddy-api/assets/dashcaddy-favicon.png b/dashcaddy-api/assets/dashcaddy-favicon.png new file mode 100644 index 0000000..4fb8ce9 Binary files /dev/null and b/dashcaddy-api/assets/dashcaddy-favicon.png differ diff --git a/dashcaddy-api/assets/dashcaddy-logo.png b/dashcaddy-api/assets/dashcaddy-logo.png new file mode 100644 index 0000000..a82a54f Binary files /dev/null and b/dashcaddy-api/assets/dashcaddy-logo.png differ diff --git a/dashcaddy-api/assets/dashcaddy-logo.svg b/dashcaddy-api/assets/dashcaddy-logo.svg new file mode 100644 index 0000000..d8074eb --- /dev/null +++ b/dashcaddy-api/assets/dashcaddy-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/dns-template-selector.js b/dashcaddy-api/assets/dns-template-selector.js new file mode 100644 index 0000000..ef2b266 --- /dev/null +++ b/dashcaddy-api/assets/dns-template-selector.js @@ -0,0 +1,321 @@ +/** + * DNS Template Selector + * Presents DNS server template options when user chooses to set up DNS + */ + +(function(window) { + 'use strict'; + + class DnsTemplateSelector { + constructor(progressTracker) { + this.progressTracker = progressTracker; + this.modal = null; + this.onTemplateSelected = null; + console.log('[DnsTemplateSelector] Module loaded'); + } + + /** + * Get available DNS server templates from app templates + * @returns {Array} Array of DNS template objects + */ + getDnsTemplates() { + // In a real implementation, this would fetch from app-templates.js + // For now, return hardcoded templates matching what we added + return [ + { + id: 'technitium', + name: 'Technitium DNS Server', + description: 'Modern DNS server with web UI for managing private zones', + icon: '🌐', + difficulty: 'Easy', + features: [ + 'Web-based management interface', + 'Private zone management for .sami domain', + 'DHCP server integration', + 'DNS-over-HTTPS and DNS-over-TLS support' + ], + recommended: true + }, + { + id: 'bind9', + name: 'BIND9 DNS Server', + description: 'Industry-standard DNS server - powerful and flexible', + icon: '🔧', + difficulty: 'Advanced', + features: [ + 'Industry standard DNS server', + 'Full RFC compliance', + 'Advanced zone management', + 'DNSSEC support' + ], + recommended: false + }, + { + id: 'pihole', + name: 'Pi-hole', + description: 'Network-wide ad blocker with DNS capabilities', + icon: '🛡️', + difficulty: 'Intermediate', + features: [ + 'Ad blocking at DNS level', + 'Web interface for management', + 'DHCP server included', + 'Query logging and statistics' + ], + recommended: false + }, + { + id: 'powerdns', + name: 'PowerDNS', + description: 'High-performance DNS server with SQL backend', + icon: '⚡', + difficulty: 'Intermediate', + features: [ + 'SQL database backend', + 'RESTful API for automation', + 'Geographic load balancing', + 'DNSSEC support' + ], + recommended: false + }, + { + id: 'coredns', + name: 'CoreDNS', + description: 'Cloud-native DNS server - lightweight and flexible', + icon: '☁️', + difficulty: 'Intermediate', + features: [ + 'Plugin-based architecture', + 'Kubernetes-native', + 'Lightweight and fast', + 'Prometheus metrics' + ], + recommended: false + } + ]; + } + + /** + * Show DNS template selection modal + */ + showTemplateSelector() { + // Create modal if it doesn't exist + if (!this.modal) { + this.createModal(); + } + + // Populate with templates + this.populateTemplates(); + + // Show modal + this.modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + } + + /** + * Create the modal HTML structure + * @private + */ + createModal() { + const modal = document.createElement('div'); + modal.id = 'dns-template-modal'; + modal.className = 'dns-template-modal'; + modal.innerHTML = ` +
+
+

🌐 Choose a DNS Server

+

Setting up a DNS server is essential for managing your private .sami domain

+ +
+
+ +
+ +
+ `; + + document.body.appendChild(modal); + this.modal = modal; + + // Add event listeners + modal.querySelector('.dns-template-close').addEventListener('click', () => this.close()); + modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater()); + + // Close on overlay click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + this.close(); + } + }); + + // Close on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.style.display === 'flex') { + this.close(); + } + }); + } + + /** + * Populate modal with DNS templates + * @private + */ + populateTemplates() { + const grid = document.getElementById('dns-template-grid'); + if (!grid) return; + + const templates = this.getDnsTemplates(); + grid.innerHTML = ''; + + templates.forEach(template => { + const card = this.createTemplateCard(template); + grid.appendChild(card); + }); + } + + /** + * Create a template card element + * @private + */ + createTemplateCard(template) { + const card = document.createElement('div'); + card.className = 'dns-template-card'; + if (template.recommended) { + card.classList.add('recommended'); + } + + const difficultyClass = template.difficulty.toLowerCase(); + + card.innerHTML = ` + ${template.recommended ? '' : ''} +
${template.icon}
+

${template.name}

+

${template.description}

+
+ ${template.difficulty} +
+
    + ${template.features.slice(0, 3).map(f => `
  • ${f}
  • `).join('')} +
+ + `; + + // Add click handler to select button + const selectBtn = card.querySelector('.dns-template-select-btn'); + selectBtn.addEventListener('click', () => this.handleTemplateSelection(template)); + + return card; + } + + /** + * Handle template selection + * @private + */ + handleTemplateSelection(template) { + console.log(`[DnsTemplateSelector] Template selected: ${template.id}`); + + // Close modal + this.close(); + + // Trigger callback if set + if (this.onTemplateSelected) { + this.onTemplateSelected(template); + } else { + // Default behavior: open app selector with DNS filter + this.openAppSelector(template.id); + } + } + + /** + * Handle "Set up later" button + * @private + */ + handleSetupLater() { + console.log('[DnsTemplateSelector] DNS setup deferred'); + + // Mark as deferred in progress tracker + if (this.progressTracker) { + this.progressTracker.markDnsSetupDeferred(); + } + + // Close modal + this.close(); + + // Show notification + this.showNotification('DNS setup deferred. You can set it up later from the App Selector.'); + } + + /** + * Open app selector with specific template + * @private + */ + openAppSelector(templateId) { + // Try to open the app selector modal if it exists + const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]'); + if (appSelectorBtn) { + appSelectorBtn.click(); + + // Wait a bit then filter to the selected template + setTimeout(() => { + const searchInput = document.querySelector('#app-search'); + if (searchInput) { + searchInput.value = templateId; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 300); + } else { + // Fallback: show instructions + this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`); + } + } + + /** + * Show notification message + * @private + */ + showNotification(message) { + // Simple notification - could be enhanced + const notification = document.createElement('div'); + notification.className = 'dns-template-notification'; + notification.textContent = message; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: var(--card-base); + color: var(--fg); + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 10001; + max-width: 300px; + `; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transition = 'opacity 0.3s'; + setTimeout(() => notification.remove(), 300); + }, 3000); + } + + /** + * Close the modal + */ + close() { + if (this.modal) { + this.modal.style.display = 'none'; + document.body.style.overflow = ''; + } + } + } + + window.DnsTemplateSelector = DnsTemplateSelector; + console.log('[DnsTemplateSelector] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/driver.min.css b/dashcaddy-api/assets/driver.min.css new file mode 100644 index 0000000..7ce0bd6 --- /dev/null +++ b/dashcaddy-api/assets/driver.min.css @@ -0,0 +1 @@ +.driver-active .driver-overlay,.driver-active *{pointer-events:none}.driver-active .driver-active-element,.driver-active .driver-active-element *,.driver-popover,.driver-popover *{pointer-events:auto}@keyframes animate-fade-in{0%{opacity:0}to{opacity:1}}.driver-fade .driver-overlay{animation:animate-fade-in .2s ease-in-out}.driver-fade .driver-popover{animation:animate-fade-in .2s}.driver-popover{all:unset;box-sizing:border-box;color:#2d2d2d;margin:0;padding:15px;border-radius:5px;min-width:250px;max-width:300px;box-shadow:0 1px 10px #0006;z-index:1000000000;position:fixed;top:0;right:0;background-color:#fff}.driver-popover *{font-family:Helvetica Neue,Inter,ui-sans-serif,"Apple Color Emoji",Helvetica,Arial,sans-serif}.driver-popover-title{font:19px/normal sans-serif;font-weight:700;display:block;position:relative;line-height:1.5;zoom:1;margin:0}.driver-popover-close-btn{all:unset;position:absolute;top:0;right:0;width:32px;height:28px;cursor:pointer;font-size:18px;font-weight:500;color:#d2d2d2;z-index:1;text-align:center;transition:color;transition-duration:.2s}.driver-popover-close-btn:hover,.driver-popover-close-btn:focus{color:#2d2d2d}.driver-popover-title[style*=block]+.driver-popover-description{margin-top:5px}.driver-popover-description{margin-bottom:0;font:14px/normal sans-serif;line-height:1.5;font-weight:400;zoom:1}.driver-popover-footer{margin-top:15px;text-align:right;zoom:1;display:flex;align-items:center;justify-content:space-between}.driver-popover-progress-text{font-size:13px;font-weight:400;color:#727272;zoom:1}.driver-popover-footer button{all:unset;display:inline-block;box-sizing:border-box;padding:3px 7px;text-decoration:none;text-shadow:1px 1px 0 #fff;background-color:#fff;color:#2d2d2d;font:12px/normal sans-serif;cursor:pointer;outline:0;zoom:1;line-height:1.3;border:1px solid #ccc;border-radius:3px}.driver-popover-footer .driver-popover-btn-disabled{opacity:.5;pointer-events:none}:not(body):has(>.driver-active-element){overflow:hidden!important}.driver-no-interaction,.driver-no-interaction *{pointer-events:none!important}.driver-popover-footer button:hover,.driver-popover-footer button:focus{background-color:#f7f7f7}.driver-popover-navigation-btns{display:flex;flex-grow:1;justify-content:flex-end}.driver-popover-navigation-btns button+button{margin-left:4px}.driver-popover-arrow{content:"";position:absolute;border:5px solid #fff}.driver-popover-arrow-side-over{display:none}.driver-popover-arrow-side-left{left:100%;border-right-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-right{right:100%;border-left-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-top{top:100%;border-right-color:transparent;border-bottom-color:transparent;border-left-color:transparent}.driver-popover-arrow-side-bottom{bottom:100%;border-left-color:transparent;border-top-color:transparent;border-right-color:transparent}.driver-popover-arrow-side-center{display:none}.driver-popover-arrow-side-left.driver-popover-arrow-align-start,.driver-popover-arrow-side-right.driver-popover-arrow-align-start{top:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-start,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start{left:15px}.driver-popover-arrow-align-end.driver-popover-arrow-side-left,.driver-popover-arrow-align-end.driver-popover-arrow-side-right{bottom:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-end,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end{right:15px}.driver-popover-arrow-side-left.driver-popover-arrow-align-center,.driver-popover-arrow-side-right.driver-popover-arrow-align-center{top:50%;margin-top:-5px}.driver-popover-arrow-side-top.driver-popover-arrow-align-center,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center{left:50%;margin-left:-5px}.driver-popover-arrow-none{display:none} diff --git a/dashcaddy-api/assets/driver.min.js b/dashcaddy-api/assets/driver.min.js new file mode 100644 index 0000000..87acd6e --- /dev/null +++ b/dashcaddy-api/assets/driver.min.js @@ -0,0 +1,2 @@ +this.driver=this.driver||{};this.driver.js=function(D){"use strict";let F={};function z(e={}){F={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?F[e]:F}function W(e,o,t,i){return(e/=i/2)<1?t/2*e*e+o:-t/2*(--e*(e-2)-1)+o}function q(e){const o='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(t=>{const i=t.matches(o),p=Array.from(t.querySelectorAll(o));return[...i?[t]:[],...p]}).filter(t=>getComputedStyle(t).pointerEvents!=="none"&&ae(t))}function V(e){if(!e||se(e))return;const o=a("smoothScroll");e.scrollIntoView({behavior:!o||re(e)?"auto":"smooth",inline:"center",block:"center"})}function re(e){if(!e||!e.parentElement)return;const o=e.parentElement;return o.scrollHeight>o.clientHeight}function se(e){const o=e.getBoundingClientRect();return o.top>=0&&o.left>=0&&o.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&o.right<=(window.innerWidth||document.documentElement.clientWidth)}function ae(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let N={};function b(e,o){N[e]=o}function l(e){return e?N[e]:N}function K(){N={}}let E={};function O(e,o){E[e]=o}function _(e){var o;(o=E[e])==null||o.call(E)}function ce(){E={}}function le(e,o,t,i){let p=l("__activeStagePosition");const n=p||t.getBoundingClientRect(),f=i.getBoundingClientRect(),w=W(e,n.x,f.x-n.x,o),r=W(e,n.y,f.y-n.y,o),v=W(e,n.width,f.width-n.width,o),s=W(e,n.height,f.height-n.height,o);p={x:w,y:r,width:v,height:s},Y(p),b("__activeStagePosition",p)}function X(e){if(!e)return;const o=e.getBoundingClientRect(),t={x:o.x,y:o.y,width:o.width,height:o.height};b("__activeStagePosition",t),Y(t)}function de(){const e=l("__activeStagePosition"),o=l("__overlaySvg");if(!e)return;if(!o){console.warn("No stage svg found.");return}const t=window.innerWidth,i=window.innerHeight;o.setAttribute("viewBox",`0 0 ${t} ${i}`)}function pe(e){const o=ue(e);document.body.appendChild(o),G(o,t=>{t.target.tagName==="path"&&_("overlayClick")}),b("__overlaySvg",o)}function Y(e){const o=l("__overlaySvg");if(!o){pe(e);return}const t=o.firstElementChild;if((t==null?void 0:t.tagName)!=="path")throw new Error("no path element found in stage svg");t.setAttribute("d",j(e))}function ue(e){const o=window.innerWidth,t=window.innerHeight,i=document.createElementNS("http://www.w3.org/2000/svg","svg");i.classList.add("driver-overlay","driver-overlay-animated"),i.setAttribute("viewBox",`0 0 ${o} ${t}`),i.setAttribute("xmlSpace","preserve"),i.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),i.setAttribute("version","1.1"),i.setAttribute("preserveAspectRatio","xMinYMin slice"),i.style.fillRule="evenodd",i.style.clipRule="evenodd",i.style.strokeLinejoin="round",i.style.strokeMiterlimit="2",i.style.zIndex="10000",i.style.position="fixed",i.style.top="0",i.style.left="0",i.style.width="100%",i.style.height="100%";const p=document.createElementNS("http://www.w3.org/2000/svg","path");return p.setAttribute("d",j(e)),p.style.fill=a("overlayColor")||"rgb(0,0,0)",p.style.opacity=`${a("overlayOpacity")}`,p.style.pointerEvents="auto",p.style.cursor="auto",i.appendChild(p),i}function j(e){const o=window.innerWidth,t=window.innerHeight,i=a("stagePadding")||0,p=a("stageRadius")||0,n=e.width+i*2,f=e.height+i*2,w=Math.min(p,n/2,f/2),r=Math.floor(Math.max(w,0)),v=e.x-i+r,s=e.y-i,c=n-r*2,d=f-r*2;return`M${o},0L0,0L0,${t}L${o},${t}L${o},0Z + M${v},${s} h${c} a${r},${r} 0 0 1 ${r},${r} v${d} a${r},${r} 0 0 1 -${r},${r} h-${c} a${r},${r} 0 0 1 -${r},-${r} v-${d} a${r},${r} 0 0 1 ${r},-${r} z`}function ve(){const e=l("__overlaySvg");e&&e.remove()}function fe(){const e=document.getElementById("driver-dummy-element");if(e)return e;let o=document.createElement("div");return o.id="driver-dummy-element",o.style.width="0",o.style.height="0",o.style.pointerEvents="none",o.style.opacity="0",o.style.position="fixed",o.style.top="50%",o.style.left="50%",document.body.appendChild(o),o}function Q(e){const{element:o}=e;let t=typeof o=="string"?document.querySelector(o):o;t||(t=fe()),ge(t,e)}function he(){const e=l("__activeElement"),o=l("__activeStep");e&&(X(e),de(),oe(e,o))}function ge(e,o){const i=Date.now(),p=l("__activeStep"),n=l("__activeElement")||e,f=!n||n===e,w=e.id==="driver-dummy-element",r=n.id==="driver-dummy-element",v=a("animate"),s=o.onHighlightStarted||a("onHighlightStarted"),c=(o==null?void 0:o.onHighlighted)||a("onHighlighted"),d=(p==null?void 0:p.onDeselected)||a("onDeselected"),m=a(),g=l();!f&&d&&d(r?void 0:n,p,{config:m,state:g}),s&&s(w?void 0:e,o,{config:m,state:g});const u=!f&&v;let h=!1;xe(),b("previousStep",p),b("previousElement",n),b("activeStep",o),b("activeElement",e);const P=()=>{if(l("__transitionCallback")!==P)return;const x=Date.now()-i,y=400-x<=400/2;o.popover&&y&&!h&&u&&(J(e,o),h=!0),a("animate")&&x<400?le(x,400,n,e):(X(e),c&&c(w?void 0:e,o,{config:a(),state:l()}),b("__transitionCallback",void 0),b("__previousStep",p),b("__previousElement",n),b("__activeStep",o),b("__activeElement",e)),window.requestAnimationFrame(P)};b("__transitionCallback",P),window.requestAnimationFrame(P),V(e),!u&&o.popover&&J(e,o),n.classList.remove("driver-active-element","driver-no-interaction"),n.removeAttribute("aria-haspopup"),n.removeAttribute("aria-expanded"),n.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function we(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(o=>{o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls")})}function A(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),b("__resizeTimeout",window.requestAnimationFrame(he))}function me(e){var r;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),p=(r=l("popover"))==null?void 0:r.wrapper,n=q([...p?[p]:[],...i?[i]:[]]),f=n[0],w=n[n.length-1];if(e.preventDefault(),e.shiftKey){const v=n[n.indexOf(document.activeElement)-1]||w;v==null||v.focus()}else{const v=n[n.indexOf(document.activeElement)+1]||f;v==null||v.focus()}}function Z(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?_("escapePress"):e.key==="ArrowRight"?_("arrowRightPress"):e.key==="ArrowLeft"&&_("arrowLeftPress"))}function G(e,o,t){const i=(n,f)=>{const w=n.target;e.contains(w)&&((!t||t(w))&&(n.preventDefault(),n.stopPropagation(),n.stopImmediatePropagation()),f==null||f(n))};document.addEventListener("pointerdown",i,!0),document.addEventListener("mousedown",i,!0),document.addEventListener("pointerup",i,!0),document.addEventListener("mouseup",i,!0),document.addEventListener("click",n=>{i(n,o)},!0)}function ye(){window.addEventListener("keyup",Z,!1),window.addEventListener("keydown",me,!1),window.addEventListener("resize",A),window.addEventListener("scroll",A)}function be(){window.removeEventListener("keyup",Z),window.removeEventListener("resize",A),window.removeEventListener("scroll",A)}function xe(){const e=l("popover");e&&(e.wrapper.style.display="none")}function J(e,o){var C,y;let t=l("popover");t&&document.body.removeChild(t.wrapper),t=Pe(),document.body.appendChild(t.wrapper);const{title:i,description:p,showButtons:n,disableButtons:f,showProgress:w,nextBtnText:r=a("nextBtnText")||"Next →",prevBtnText:v=a("prevBtnText")||"← Previous",progressText:s=a("progressText")||"{current} of {total}"}=o.popover||{};t.nextButton.innerHTML=r,t.previousButton.innerHTML=v,t.progress.innerHTML=s,i?(t.title.innerHTML=i,t.title.style.display="block"):t.title.style.display="none",p?(t.description.innerHTML=p,t.description.style.display="block"):t.description.style.display="none";const c=n||a("showButtons"),d=w||a("showProgress")||!1,m=(c==null?void 0:c.includes("next"))||(c==null?void 0:c.includes("previous"))||d;t.closeButton.style.display=c.includes("close")?"block":"none",m?(t.footer.style.display="flex",t.progress.style.display=d?"block":"none",t.nextButton.style.display=c.includes("next")?"block":"none",t.previousButton.style.display=c.includes("previous")?"block":"none"):t.footer.style.display="none";const g=f||a("disableButtons")||[];g!=null&&g.includes("next")&&(t.nextButton.disabled=!0,t.nextButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("previous")&&(t.previousButton.disabled=!0,t.previousButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("close")&&(t.closeButton.disabled=!0,t.closeButton.classList.add("driver-popover-btn-disabled"));const u=t.wrapper;u.style.display="block",u.style.left="",u.style.top="",u.style.bottom="",u.style.right="",u.id="driver-popover-content",u.setAttribute("role","dialog"),u.setAttribute("aria-labelledby","driver-popover-title"),u.setAttribute("aria-describedby","driver-popover-description");const h=t.arrow;h.className="driver-popover-arrow";const P=((C=o.popover)==null?void 0:C.popoverClass)||a("popoverClass")||"";u.className=`driver-popover ${P}`.trim(),G(t.wrapper,k=>{var M,R,I;const T=k.target,H=((M=o.popover)==null?void 0:M.onNextClick)||a("onNextClick"),$=((R=o.popover)==null?void 0:R.onPrevClick)||a("onPrevClick"),B=((I=o.popover)==null?void 0:I.onCloseClick)||a("onCloseClick");if(T.classList.contains("driver-popover-next-btn"))return H?H(e,o,{config:a(),state:l()}):_("nextClick");if(T.classList.contains("driver-popover-prev-btn"))return $?$(e,o,{config:a(),state:l()}):_("prevClick");if(T.classList.contains("driver-popover-close-btn"))return B?B(e,o,{config:a(),state:l()}):_("closeClick")},k=>!(t!=null&&t.description.contains(k))&&!(t!=null&&t.title.contains(k))&&typeof k.className=="string"&&k.className.includes("driver-popover")),b("popover",t);const S=((y=o.popover)==null?void 0:y.onPopoverRender)||a("onPopoverRender");S&&S(t,{config:a(),state:l()}),oe(e,o),V(u);const L=e.classList.contains("driver-dummy-element"),x=q([u,...L?[]:[e]]);x.length>0&&x[0].focus()}function U(){const e=l("popover");if(!(e!=null&&e.wrapper))return;const o=e.wrapper.getBoundingClientRect(),t=a("stagePadding")||0,i=a("popoverOffset")||0;return{width:o.width+t+i,height:o.height+t+i,realWidth:o.width,realHeight:o.height}}function ee(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.top-p,window.innerHeight-i.realHeight-n.width),n.width):e==="end"?Math.max(Math.min(t.top-(i==null?void 0:i.realHeight)+t.height+p,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):e==="center"?Math.max(Math.min(t.top+t.height/2-(i==null?void 0:i.realHeight)/2,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):0}function te(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.left-p,window.innerWidth-i.realWidth-n.width),n.width):e==="end"?Math.max(Math.min(t.left-(i==null?void 0:i.realWidth)+t.width+p,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):e==="center"?Math.max(Math.min(t.left+t.width/2-(i==null?void 0:i.realWidth)/2,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):0}function oe(e,o){const t=l("popover");if(!t)return;const{align:i="start",side:p="left"}=(o==null?void 0:o.popover)||{},n=i,f=e.id==="driver-dummy-element"?"over":p,w=a("stagePadding")||0,r=U(),v=t.arrow.getBoundingClientRect(),s=e.getBoundingClientRect(),c=s.top-r.height;let d=c>=0;const m=window.innerHeight-(s.bottom+r.height);let g=m>=0;const u=s.left-r.width;let h=u>=0;const P=window.innerWidth-(s.right+r.width);let S=P>=0;const L=!d&&!g&&!h&&!S;let x=f;if(f==="top"&&d?S=h=g=!1:f==="bottom"&&g?S=h=d=!1:f==="left"&&h?S=d=g=!1:f==="right"&&S&&(h=d=g=!1),f==="over"){const C=window.innerWidth/2-r.realWidth/2,y=window.innerHeight/2-r.realHeight/2;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto"}else if(L){const C=window.innerWidth/2-(r==null?void 0:r.realWidth)/2,y=10;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.bottom=`${y}px`,t.wrapper.style.top="auto"}else if(h){const C=Math.min(u,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="left"}else if(S){const C=Math.min(P,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.right=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.left="auto",x="right"}else if(d){const C=Math.min(c,window.innerHeight-r.realHeight-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.top=`${C}px`,t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="top"}else if(g){const C=Math.min(m,window.innerHeight-(r==null?void 0:r.realHeight)-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom=`${C}px`,t.wrapper.style.top="auto",t.wrapper.style.right="auto",x="bottom"}L?t.arrow.classList.add("driver-popover-arrow-none"):Ce(n,x,e)}function Ce(e,o,t){const i=l("popover");if(!i)return;const p=t.getBoundingClientRect(),n=U(),f=i.arrow,w=n.width,r=window.innerWidth,v=p.width,s=p.left,c=n.height,d=window.innerHeight,m=p.top,g=p.height;f.className="driver-popover-arrow";let u=o,h=e;o==="top"?(s+v<=0?(u="right",h="end"):s+v-w<=0&&(u="top",h="start"),s>=r?(u="left",h="end"):s+w>=r&&(u="top",h="end")):o==="bottom"?(s+v<=0?(u="right",h="start"):s+v-w<=0&&(u="bottom",h="start"),s>=r?(u="left",h="start"):s+w>=r&&(u="bottom",h="end")):o==="left"?(m+g<=0?(u="bottom",h="end"):m+g-c<=0&&(u="left",h="start"),m>=d?(u="top",h="end"):m+c>=d&&(u="left",h="end")):o==="right"&&(m+g<=0?(u="bottom",h="start"):m+g-c<=0&&(u="right",h="start"),m>=d?(u="top",h="start"):m+c>=d&&(u="right",h="end")),u?(f.classList.add(`driver-popover-arrow-side-${u}`),f.classList.add(`driver-popover-arrow-align-${h}`)):f.classList.add("driver-popover-arrow-none")}function Pe(){const e=document.createElement("div");e.classList.add("driver-popover");const o=document.createElement("div");o.classList.add("driver-popover-arrow");const t=document.createElement("header");t.id="driver-popover-title",t.classList.add("driver-popover-title"),t.style.display="none",t.innerText="Popover Title";const i=document.createElement("div");i.id="driver-popover-description",i.classList.add("driver-popover-description"),i.style.display="none",i.innerText="Popover description is here";const p=document.createElement("button");p.type="button",p.classList.add("driver-popover-close-btn"),p.setAttribute("aria-label","Close"),p.innerHTML="×";const n=document.createElement("footer");n.classList.add("driver-popover-footer");const f=document.createElement("span");f.classList.add("driver-popover-progress-text"),f.innerText="";const w=document.createElement("span");w.classList.add("driver-popover-navigation-btns");const r=document.createElement("button");r.type="button",r.classList.add("driver-popover-prev-btn"),r.innerHTML="← Previous";const v=document.createElement("button");return v.type="button",v.classList.add("driver-popover-next-btn"),v.innerHTML="Next →",w.appendChild(r),w.appendChild(v),n.appendChild(f),n.appendChild(w),e.appendChild(p),e.appendChild(o),e.appendChild(t),e.appendChild(i),e.appendChild(n),{wrapper:e,arrow:o,title:t,description:i,footer:n,previousButton:r,nextButton:v,closeButton:p,footerButtons:w,progress:f}}function Se(){var o;const e=l("popover");e&&((o=e.wrapper.parentElement)==null||o.removeChild(e.wrapper))}const Le="";function ke(e={}){z(e);function o(){a("allowClose")&&v()}function t(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s+1;c[d]?r(d):v()}function i(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s-1;c[d]?r(d):v()}function p(s){(a("steps")||[])[s]?r(s):v()}function n(){var h;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined"||typeof l("activeIndex")=="undefined")return;const u=((h=d.popover)==null?void 0:h.onPrevClick)||a("onPrevClick");if(u)return u(m,d,{config:a(),state:l()});i()}function f(){var u;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined")return;const g=((u=d.popover)==null?void 0:u.onNextClick)||a("onNextClick");if(g)return g(m,d,{config:a(),state:l()});t()}function w(){l("isInitialized")||(b("isInitialized",!0),document.body.classList.add("driver-active",a("animate")?"driver-fade":"driver-simple"),ye(),O("overlayClick",o),O("escapePress",o),O("arrowLeftPress",n),O("arrowRightPress",f))}function r(s=0){var H,$,B,M,R,I,ie,ne;const c=a("steps");if(!c){console.error("No steps to drive through"),v();return}if(!c[s]){v();return}b("__activeOnDestroyed",document.activeElement),b("activeIndex",s);const d=c[s],m=c[s+1],g=c[s-1],u=((H=d.popover)==null?void 0:H.doneBtnText)||a("doneBtnText")||"Done",h=a("allowClose"),P=typeof(($=d.popover)==null?void 0:$.showProgress)!="undefined"?(B=d.popover)==null?void 0:B.showProgress:a("showProgress"),L=(((M=d.popover)==null?void 0:M.progressText)||a("progressText")||"{{current}} of {{total}}").replace("{{current}}",`${s+1}`).replace("{{total}}",`${c.length}`),x=((R=d.popover)==null?void 0:R.showButtons)||a("showButtons"),C=["next","previous",...h?["close"]:[]].filter(_e=>!(x!=null&&x.length)||x.includes(_e)),y=((I=d.popover)==null?void 0:I.onNextClick)||a("onNextClick"),k=((ie=d.popover)==null?void 0:ie.onPrevClick)||a("onPrevClick"),T=((ne=d.popover)==null?void 0:ne.onCloseClick)||a("onCloseClick");Q({...d,popover:{showButtons:C,nextBtnText:m?void 0:u,disableButtons:[...g?[]:["previous"]],showProgress:P,progressText:L,onNextClick:y||(()=>{m?r(s+1):v()}),onPrevClick:k||(()=>{r(s-1)}),onCloseClick:T||(()=>{v()}),...(d==null?void 0:d.popover)||{}}})}function v(s=!0){const c=l("__activeElement"),d=l("__activeStep"),m=l("__activeOnDestroyed"),g=a("onDestroyStarted");if(s&&g){const P=!c||(c==null?void 0:c.id)==="driver-dummy-element";g(P?void 0:c,d,{config:a(),state:l()});return}const u=(d==null?void 0:d.onDeselected)||a("onDeselected"),h=a("onDestroyed");if(document.body.classList.remove("driver-active","driver-fade","driver-simple"),be(),Se(),we(),ve(),ce(),K(),c&&d){const P=c.id==="driver-dummy-element";u&&u(P?void 0:c,d,{config:a(),state:l()}),h&&h(P?void 0:c,d,{config:a(),state:l()})}m&&m.focus()}return{isActive:()=>l("isInitialized")||!1,refresh:A,drive:(s=0)=>{w(),r(s)},setConfig:z,setSteps:s=>{K(),z({...a(),steps:s})},getConfig:a,getState:l,getActiveIndex:()=>l("activeIndex"),isFirstStep:()=>l("activeIndex")===0,isLastStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&c===s.length-1},getActiveStep:()=>l("activeStep"),getActiveElement:()=>l("activeElement"),getPreviousElement:()=>l("previousElement"),getPreviousStep:()=>l("previousStep"),moveNext:t,movePrevious:i,moveTo:p,hasNextStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c+1]},hasPreviousStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c-1]},highlight:s=>{w(),Q({...s,popover:s.popover?{showButtons:[],showProgress:!1,progressText:"",...s.popover}:void 0})},destroy:()=>{v(!1)}}}return D.driver=ke,Object.defineProperty(D,Symbol.toStringTag,{value:"Module"}),D}({}); diff --git a/dashcaddy-api/assets/emby.png b/dashcaddy-api/assets/emby.png new file mode 100644 index 0000000..ad7a8cd Binary files /dev/null and b/dashcaddy-api/assets/emby.png differ diff --git a/dashcaddy-api/assets/error-handler.js b/dashcaddy-api/assets/error-handler.js new file mode 100644 index 0000000..4b27c21 --- /dev/null +++ b/dashcaddy-api/assets/error-handler.js @@ -0,0 +1,259 @@ +/** + * Error Handler + * Handles errors gracefully without breaking the onboarding tour + */ + +(function(window) { + 'use strict'; + + class ErrorHandler { + constructor() { + this.errors = []; + this.maxErrors = 50; // Keep last 50 errors + } + + /** + * Log an error without breaking the tour + * @param {string} context - Context where error occurred + * @param {Error|string} error - The error object or message + * @param {Object} metadata - Additional metadata + */ + logError(context, error, metadata = {}) { + const errorEntry = { + timestamp: new Date().toISOString(), + context, + message: error instanceof Error ? error.message : error, + stack: error instanceof Error ? error.stack : null, + metadata + }; + + // Add to errors array + this.errors.push(errorEntry); + + // Keep only last maxErrors + if (this.errors.length > this.maxErrors) { + this.errors.shift(); + } + + // Log to console + console.error(`[Onboarding Error] ${context}:`, error, metadata); + + // Optionally send to error tracking service + // this.sendToErrorTracking(errorEntry); + } + + /** + * Attempt to recover from an error and continue tour + * @param {Error} error - The error object + * @param {number} currentStep - Current step index + * @returns {Object} Recovery action + */ + recoverFromError(error, currentStep) { + const errorType = this.classifyError(error); + + switch (errorType) { + case 'ELEMENT_NOT_FOUND': + this.logError('Element Not Found', error, { currentStep }); + return { + action: 'SKIP_STEP', + nextStep: currentStep + 1, + message: 'Target element not found, skipping to next step' + }; + + case 'STORAGE_UNAVAILABLE': + this.logError('Storage Unavailable', error); + return { + action: 'USE_MEMORY_STORAGE', + message: 'Local storage unavailable, using in-memory storage' + }; + + case 'DRIVER_NOT_LOADED': + this.logError('Driver.js Not Loaded', error); + return { + action: 'ABORT_TOUR', + message: 'Driver.js library not loaded, cannot start tour' + }; + + case 'INVALID_TOOLTIP': + this.logError('Invalid Tooltip Configuration', error, { currentStep }); + return { + action: 'SKIP_STEP', + nextStep: currentStep + 1, + message: 'Invalid tooltip configuration, skipping' + }; + + case 'THEME_DETECTION_FAILED': + this.logError('Theme Detection Failed', error); + return { + action: 'USE_DEFAULT_THEME', + message: 'Using default dark theme' + }; + + default: + this.logError('Unknown Error', error, { currentStep }); + return { + action: 'ABORT_TOUR', + message: 'Unexpected error occurred, aborting tour' + }; + } + } + + /** + * Classify error type + * @private + * @param {Error} error - The error object + * @returns {string} Error type + */ + classifyError(error) { + const message = error.message || error.toString(); + + if (message.includes('element') && message.includes('not found')) { + return 'ELEMENT_NOT_FOUND'; + } + if (message.includes('storage') || message.includes('quota')) { + return 'STORAGE_UNAVAILABLE'; + } + if (message.includes('driver') || message.includes('undefined')) { + return 'DRIVER_NOT_LOADED'; + } + if (message.includes('invalid') || message.includes('validation')) { + return 'INVALID_TOOLTIP'; + } + if (message.includes('theme')) { + return 'THEME_DETECTION_FAILED'; + } + + return 'UNKNOWN'; + } + + /** + * Get all logged errors + * @returns {Array} Array of error entries + */ + getErrors() { + return [...this.errors]; + } + + /** + * Clear all logged errors + */ + clearErrors() { + this.errors = []; + } + + /** + * Get error statistics + * @returns {Object} Error statistics + */ + getStatistics() { + const stats = { + total: this.errors.length, + byContext: {}, + byType: {}, + recent: this.errors.slice(-10) + }; + + this.errors.forEach(error => { + // Count by context + stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1; + + // Count by type + const type = this.classifyError({ message: error.message }); + stats.byType[type] = (stats.byType[type] || 0) + 1; + }); + + return stats; + } + + /** + * Handle graceful degradation when Driver.js fails to load + * @returns {boolean} Whether fallback was successful + */ + handleDriverLoadFailure() { + this.logError('Driver.js Load Failure', 'Driver.js library failed to load'); + + // Show fallback message + const fallbackMessage = document.createElement('div'); + fallbackMessage.id = 'onboarding-fallback'; + fallbackMessage.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: var(--card-base, #2a2a2a); + color: var(--fg, #ffffff); + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 9999; + max-width: 300px; + font-size: 14px; + `; + fallbackMessage.innerHTML = ` + Welcome to DashCaddy!
+

+ The interactive tour is unavailable, but you can explore the dashboard freely. + Check the documentation for help getting started. +

+ `; + + document.body.appendChild(fallbackMessage); + + // Auto-remove after 10 seconds + setTimeout(() => { + if (fallbackMessage.parentNode) { + fallbackMessage.parentNode.removeChild(fallbackMessage); + } + }, 10000); + + return true; + } + + /** + * Handle storage unavailable scenario + * @returns {Object} In-memory storage fallback + */ + handleStorageUnavailable() { + this.logError('Storage Unavailable', 'Local storage is not available'); + + // Create in-memory storage + const memoryStorage = { + data: {}, + getItem(key) { + return this.data[key] || null; + }, + setItem(key, value) { + this.data[key] = value; + }, + removeItem(key) { + delete this.data[key]; + }, + clear() { + this.data = {}; + } + }; + + console.warn('[ErrorHandler] Using in-memory storage - progress will not persist'); + return memoryStorage; + } + + /** + * Send error to tracking service (placeholder) + * @private + * @param {Object} errorEntry - Error entry to send + */ + sendToErrorTracking(errorEntry) { + // Placeholder for error tracking integration + // Could integrate with Sentry, LogRocket, etc. + // Example: + // if (window.Sentry) { + // Sentry.captureException(new Error(errorEntry.message), { + // extra: errorEntry.metadata + // }); + // } + } + } + + window.ErrorHandler = ErrorHandler; + console.log('[ErrorHandler] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/favicon.ico b/dashcaddy-api/assets/favicon.ico new file mode 100644 index 0000000..77e6e9c Binary files /dev/null and b/dashcaddy-api/assets/favicon.ico differ diff --git a/dashcaddy-api/assets/favicon.png b/dashcaddy-api/assets/favicon.png new file mode 100644 index 0000000..e385d2a Binary files /dev/null and b/dashcaddy-api/assets/favicon.png differ diff --git a/dashcaddy-api/assets/favicon.svg b/dashcaddy-api/assets/favicon.svg new file mode 100644 index 0000000..adcf3b4 --- /dev/null +++ b/dashcaddy-api/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/filebrowser.png b/dashcaddy-api/assets/filebrowser.png new file mode 100644 index 0000000..0899c43 Binary files /dev/null and b/dashcaddy-api/assets/filebrowser.png differ diff --git a/dashcaddy-api/assets/fonts.css b/dashcaddy-api/assets/fonts.css new file mode 100644 index 0000000..f5e3463 --- /dev/null +++ b/dashcaddy-api/assets/fonts.css @@ -0,0 +1,91 @@ +/* Sami Sans Font Family - External CSS */ + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Regular.woff2') format('woff2'), + url('fonts/SamiSans-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Regular.woff2') format('woff2'), + url('fonts/SamiSans-Italic.ttf') format('truetype'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Medium.woff2') format('woff2'), + url('fonts/SamiSans-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'), + url('fonts/SamiSans-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Bold.woff2') format('woff2'), + url('fonts/SamiSans-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'), + url('fonts/SamiSans-ExtraBold.ttf') format('truetype'); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Black.woff2') format('woff2'), + url('fonts/SamiSans-Black.ttf') format('truetype'); + font-weight: 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Light.woff2') format('woff2'), + url('fonts/SamiSans-Light.ttf') format('truetype'); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'), + url('fonts/SamiSans-ExtraLight.ttf') format('truetype'); + font-weight: 200; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Thin.woff2') format('woff2'), + url('fonts/SamiSans-Thin.ttf') format('truetype'); + font-weight: 100; + font-style: normal; + font-display: swap; +} diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Black.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Black.ttf new file mode 100644 index 0000000..ea4b04f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Black.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-BlackItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-BlackItalic.ttf new file mode 100644 index 0000000..005a92a Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-BlackItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Bold.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Bold.ttf new file mode 100644 index 0000000..7ee1c38 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Bold.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-BoldItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-BoldItalic.ttf new file mode 100644 index 0000000..6f2f6ba Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-BoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Light.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Light.ttf new file mode 100644 index 0000000..269b97d Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Light.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-LightItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-LightItalic.ttf new file mode 100644 index 0000000..d10a4c6 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-LightItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Medium.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Medium.ttf new file mode 100644 index 0000000..0dca4b4 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Medium.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-MediumItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-MediumItalic.ttf new file mode 100644 index 0000000..4ab921f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-MediumItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Regular.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Regular.ttf new file mode 100644 index 0000000..2d3b18d Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Regular.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-RegularItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-RegularItalic.ttf new file mode 100644 index 0000000..6c15c7e Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-RegularItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Black.ttf b/dashcaddy-api/assets/fonts/SamiSans-Black.ttf new file mode 100644 index 0000000..c44894b Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Black.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Black.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Black.woff2 new file mode 100644 index 0000000..6929b36 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Black.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.ttf new file mode 100644 index 0000000..eccde7d Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.woff2 new file mode 100644 index 0000000..3048ee4 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Bold.ttf b/dashcaddy-api/assets/fonts/SamiSans-Bold.ttf new file mode 100644 index 0000000..6bc519f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Bold.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Bold.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Bold.woff2 new file mode 100644 index 0000000..185b3a3 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Bold.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.ttf new file mode 100644 index 0000000..a47ea80 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.woff2 new file mode 100644 index 0000000..62f36be Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.ttf b/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.ttf new file mode 100644 index 0000000..8cdb5a9 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.woff2 new file mode 100644 index 0000000..7ce126b Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.ttf new file mode 100644 index 0000000..9e0cc55 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.woff2 new file mode 100644 index 0000000..521db52 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.ttf b/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.ttf new file mode 100644 index 0000000..b39c51e Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.woff2 new file mode 100644 index 0000000..a0890de Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.ttf new file mode 100644 index 0000000..5222446 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.woff2 new file mode 100644 index 0000000..f23459a Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Italic.ttf b/dashcaddy-api/assets/fonts/SamiSans-Italic.ttf new file mode 100644 index 0000000..97fcbe3 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Italic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Italic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Italic.woff2 new file mode 100644 index 0000000..d7e72d1 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Italic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Light.ttf b/dashcaddy-api/assets/fonts/SamiSans-Light.ttf new file mode 100644 index 0000000..446e73c Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Light.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Light.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Light.woff2 new file mode 100644 index 0000000..9c4227f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Light.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-LightItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-LightItalic.ttf new file mode 100644 index 0000000..ab054fc Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-LightItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-LightItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-LightItalic.woff2 new file mode 100644 index 0000000..6ea5bc7 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-LightItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Medium.ttf b/dashcaddy-api/assets/fonts/SamiSans-Medium.ttf new file mode 100644 index 0000000..d440388 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Medium.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Medium.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Medium.woff2 new file mode 100644 index 0000000..bc2048c Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Medium.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.ttf new file mode 100644 index 0000000..d61074b Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.woff2 new file mode 100644 index 0000000..05f6488 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Regular.ttf b/dashcaddy-api/assets/fonts/SamiSans-Regular.ttf new file mode 100644 index 0000000..09cdb41 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Regular.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Regular.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Regular.woff2 new file mode 100644 index 0000000..55b58bd Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Regular.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-SemiBold.ttf b/dashcaddy-api/assets/fonts/SamiSans-SemiBold.ttf new file mode 100644 index 0000000..956a313 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-SemiBold.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-SemiBold.woff2 b/dashcaddy-api/assets/fonts/SamiSans-SemiBold.woff2 new file mode 100644 index 0000000..e14f439 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-SemiBold.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.ttf new file mode 100644 index 0000000..494082a Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.woff2 new file mode 100644 index 0000000..406aa7d Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Thin.ttf b/dashcaddy-api/assets/fonts/SamiSans-Thin.ttf new file mode 100644 index 0000000..1e50ae1 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Thin.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Thin.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Thin.woff2 new file mode 100644 index 0000000..281a96a Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Thin.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.ttf new file mode 100644 index 0000000..8eb93ba Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.woff2 new file mode 100644 index 0000000..03a9b3f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf new file mode 100644 index 0000000..ea4b04f Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf new file mode 100644 index 0000000..005a92a Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf new file mode 100644 index 0000000..7ee1c38 Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf new file mode 100644 index 0000000..6f2f6ba Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf new file mode 100644 index 0000000..269b97d Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf new file mode 100644 index 0000000..d10a4c6 Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf new file mode 100644 index 0000000..0dca4b4 Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf new file mode 100644 index 0000000..4ab921f Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf new file mode 100644 index 0000000..2d3b18d Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf new file mode 100644 index 0000000..6c15c7e Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf differ diff --git a/dashcaddy-api/assets/icon-192.png b/dashcaddy-api/assets/icon-192.png new file mode 100644 index 0000000..07a6369 Binary files /dev/null and b/dashcaddy-api/assets/icon-192.png differ diff --git a/dashcaddy-api/assets/icon-512.png b/dashcaddy-api/assets/icon-512.png new file mode 100644 index 0000000..af4acae Binary files /dev/null and b/dashcaddy-api/assets/icon-512.png differ diff --git a/dashcaddy-api/assets/jellyfin.png b/dashcaddy-api/assets/jellyfin.png new file mode 100644 index 0000000..c8c07ef Binary files /dev/null and b/dashcaddy-api/assets/jellyfin.png differ diff --git a/dashcaddy-api/assets/nginx.png b/dashcaddy-api/assets/nginx.png new file mode 100644 index 0000000..07a6369 Binary files /dev/null and b/dashcaddy-api/assets/nginx.png differ diff --git a/dashcaddy-api/assets/onboarding.css b/dashcaddy-api/assets/onboarding.css new file mode 100644 index 0000000..f8dc06b --- /dev/null +++ b/dashcaddy-api/assets/onboarding.css @@ -0,0 +1,354 @@ +/** + * Onboarding Tooltip Styles + * Custom styling for Driver.js tooltips to match DashCaddy theme + */ + +/* Driver.js overrides are injected dynamically by ThemeAdapter */ +/* This file contains additional custom styles */ + +.driver-popover { + max-width: 500px !important; + z-index: 10000 !important; +} + +.driver-popover-title { + font-size: 1.2rem !important; + margin-bottom: 12px !important; +} + +.driver-popover-description { + font-size: 0.95rem !important; + line-height: 1.6 !important; +} + +.driver-popover-description p { + margin: 8px 0 !important; +} + +.driver-popover-description ul { + margin: 8px 0 !important; + padding-left: 20px !important; +} + +.driver-popover-description li { + margin: 4px 0 !important; +} + +.driver-popover-description code { + background: rgba(0, 0, 0, 0.1) !important; + padding: 2px 6px !important; + border-radius: 3px !important; + font-family: 'Courier New', monospace !important; + font-size: 0.9em !important; +} + +.driver-popover-footer { + margin-top: 16px !important; + display: flex !important; + gap: 8px !important; + justify-content: flex-end !important; +} + +.driver-popover-footer button { + padding: 8px 16px !important; + border-radius: 8px !important; + font-size: 0.9rem !important; + cursor: pointer !important; + transition: all 0.2s ease !important; +} + +.driver-popover-footer button:hover { + transform: translateY(-1px) !important; +} + +.driver-popover-close-btn { + position: absolute !important; + top: 12px !important; + right: 12px !important; + width: 24px !important; + height: 24px !important; + border-radius: 50% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + opacity: 0.6 !important; + transition: opacity 0.2s ease !important; +} + +.driver-popover-close-btn:hover { + opacity: 1 !important; +} + +.driver-popover-arrow { + border-width: 8px !important; +} + +/* Progress indicator */ +.driver-popover-progress-text { + font-size: 0.85rem !important; + margin-bottom: 8px !important; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .driver-popover { + max-width: calc(100vw - 32px) !important; + } + + .driver-popover-title { + font-size: 1.1rem !important; + } + + .driver-popover-description { + font-size: 0.9rem !important; + } + + .driver-popover-footer button { + padding: 6px 12px !important; + font-size: 0.85rem !important; + } +} + +/* Restart tour button in dashboard */ +#restart-tour-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +#restart-tour-btn::before { + content: "🎓"; + font-size: 1.1em; +} + + +/* DNS Template Selector Modal */ +.dns-template-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 10000; + align-items: center; + justify-content: center; + padding: 20px; +} + +.dns-template-modal-content { + background: var(--card-base); + border-radius: 12px; + max-width: 900px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.dns-template-header { + padding: 30px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.dns-template-header h2 { + margin: 0 0 10px 0; + color: var(--fg); + font-size: 28px; +} + +.dns-template-header p { + margin: 0; + color: var(--fg-muted); + font-size: 14px; +} + +.dns-template-close { + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + font-size: 32px; + color: var(--fg-muted); + cursor: pointer; + padding: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; +} + +.dns-template-close:hover { + background: var(--hover); + color: var(--fg); +} + +.dns-template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + padding: 30px; +} + +.dns-template-card { + background: var(--card-hover); + border: 2px solid var(--border); + border-radius: 12px; + padding: 20px; + transition: all 0.3s; + position: relative; + display: flex; + flex-direction: column; +} + +.dns-template-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + border-color: var(--accent); +} + +.dns-template-card.recommended { + border-color: var(--accent); + background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%); +} + +.recommended-badge { + position: absolute; + top: -10px; + right: 20px; + background: var(--accent); + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.dns-template-icon { + font-size: 48px; + margin-bottom: 15px; + text-align: center; +} + +.dns-template-card h3 { + margin: 0 0 10px 0; + color: var(--fg); + font-size: 18px; + text-align: center; +} + +.dns-template-description { + color: var(--fg-muted); + font-size: 13px; + margin: 0 0 15px 0; + text-align: center; + flex-grow: 1; +} + +.dns-template-difficulty { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + text-align: center; + margin: 0 auto 15px auto; +} + +.difficulty-easy { + background: #2ecc71; + color: white; +} + +.difficulty-intermediate { + background: #f39c12; + color: white; +} + +.difficulty-advanced { + background: #e74c3c; + color: white; +} + +.dns-template-features { + list-style: none; + padding: 0; + margin: 0 0 20px 0; + font-size: 12px; + color: var(--fg-muted); +} + +.dns-template-features li { + padding: 6px 0; + padding-left: 20px; + position: relative; +} + +.dns-template-features li:before { + content: "✓"; + position: absolute; + left: 0; + color: var(--accent); + font-weight: bold; +} + +.dns-template-select-btn { + background: var(--accent); + color: white; + border: none; + padding: 12px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + width: 100%; +} + +.dns-template-select-btn:hover { + background: var(--accent-strong); + transform: scale(1.02); +} + +.dns-template-footer { + padding: 20px 30px; + border-top: 1px solid var(--border); + text-align: center; +} + +.dns-template-later-btn { + background: transparent; + color: var(--fg-muted); + border: 1px solid var(--border); + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.dns-template-later-btn:hover { + background: var(--hover); + color: var(--fg); + border-color: var(--fg-muted); +} + +/* Responsive design */ +@media (max-width: 768px) { + .dns-template-grid { + grid-template-columns: 1fr; + } + + .dns-template-modal-content { + max-height: 95vh; + } +} diff --git a/dashcaddy-api/assets/onboarding.js b/dashcaddy-api/assets/onboarding.js new file mode 100644 index 0000000..98949ac --- /dev/null +++ b/dashcaddy-api/assets/onboarding.js @@ -0,0 +1,177 @@ +/** + * DashCaddy User Onboarding System + * Main entry point for the tooltip-based onboarding experience + * + * This file initializes the onboarding system and coordinates between + * the various components (TourManager, ProgressTracker, ThemeAdapter, etc.) + */ + +(function() { + 'use strict'; + + let progressTracker; + let themeAdapter; + let tourManager; + let dnsTemplateSelector; + let errorHandler; + + /** + * Initialize the onboarding system + */ + async function initializeOnboarding() { + try { + console.log('[Onboarding] Initializing system...'); + + // Initialize Error Handler first + errorHandler = new ErrorHandler(); + console.log('[Onboarding] Error Handler initialized'); + + // Initialize Progress Tracker + progressTracker = new ProgressTracker('dashcaddy_onboarding'); + console.log('[Onboarding] Progress Tracker initialized'); + + // Initialize Theme Adapter + themeAdapter = new ThemeAdapter(); + console.log('[Onboarding] Theme Adapter initialized'); + + // Initialize DNS Template Selector + dnsTemplateSelector = new DnsTemplateSelector(progressTracker); + console.log('[Onboarding] DNS Template Selector initialized'); + + // Initialize Tour Manager + tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); + console.log('[Onboarding] Tour Manager initialized'); + + // Check if tour should auto-start + if (tourManager.shouldAutoStart()) { + console.log('[Onboarding] Auto-starting tour for first-time user'); + // Wait a bit for page to fully load + setTimeout(() => { + tourManager.startTour(); + }, 1000); + } else { + const tourCompleted = progressTracker.isTourCompleted(); + const currentStep = progressTracker.getCurrentStep(); + console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); + + // If tour is in progress, offer to resume + if (!tourCompleted && currentStep > 0) { + console.log('[Onboarding] Tour in progress, can be resumed manually'); + } + } + + // Add restart tour button to tools row + addRestartTourButton(); + + // Expose to global scope for manual triggering + window.DashCaddyOnboarding = { + startTour: () => tourManager.startTour(), + restartTour: () => tourManager.restartTour(), + showTooltip: (id) => tourManager.showTooltip(id), + showWhatsNew: () => tourManager.showWhatsNew(), + resetProgress: () => progressTracker.resetProgress(), + getErrors: () => errorHandler.getErrors(), + getErrorStats: () => errorHandler.getStatistics() + }; + + console.log('[Onboarding] System initialized successfully'); + } catch (error) { + console.error('[Onboarding] Initialization error:', error); + + // Use error handler if available + if (errorHandler) { + errorHandler.logError('Initialization', error); + } + + // Graceful degradation - don't break the dashboard + console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding'); + } + } + + /** + * Add restart tour button to tools row + */ + function addRestartTourButton() { + const toolsRow = document.querySelector('.tools'); + if (!toolsRow) return; + + const clickHandler = () => { + if (tourManager) { + console.log('[Onboarding] Starting tour via button click'); + tourManager.restartTour(); + } else { + console.error('[Onboarding] Tour manager not initialized'); + alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization'); + } + }; + + // If button already exists in the HTML, just attach the handler + const existing = document.getElementById('restart-tour-btn'); + if (existing) { + existing.onclick = clickHandler; + return; + } + + const button = document.createElement('button'); + button.id = 'restart-tour-btn'; + button.textContent = 'Help Tour'; + button.title = 'Restart the onboarding tour'; + button.onclick = clickHandler; + toolsRow.appendChild(button); + } + + /** + * Check if Driver.js is loaded + */ + function checkDriverLoaded() { + // Driver.js v1.x IIFE: window.driver.js.driver is the factory function + const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; + if (typeof driverFactory !== 'function') { + console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver); + return false; + } + return true; + } + + /** + * Wait for Driver.js to load, then initialize + */ + function waitForDriver() { + let retries = 0; + const maxRetries = 10; + + function attemptInit() { + if (checkDriverLoaded()) { + initializeOnboarding(); + } else { + retries++; + if (retries < maxRetries) { + // Retry after a short delay + setTimeout(attemptInit, 500); + } else { + // Max retries reached, show fallback + console.error('[Onboarding] Driver.js failed to load after multiple attempts'); + if (errorHandler) { + errorHandler.handleDriverLoadFailure(); + } else { + // Create temporary error handler for fallback + const tempHandler = new ErrorHandler(); + tempHandler.handleDriverLoadFailure(); + } + } + } + } + + attemptInit(); + } + + // Start initialization when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', waitForDriver); + } else { + waitForDriver(); + } + + console.log('[Onboarding] System loaded'); + +})(); diff --git a/dashcaddy-api/assets/pics.png b/dashcaddy-api/assets/pics.png new file mode 100644 index 0000000..951ecfe Binary files /dev/null and b/dashcaddy-api/assets/pics.png differ diff --git a/dashcaddy-api/assets/plex.png b/dashcaddy-api/assets/plex.png new file mode 100644 index 0000000..f30efe3 Binary files /dev/null and b/dashcaddy-api/assets/plex.png differ diff --git a/dashcaddy-api/assets/portainer.png b/dashcaddy-api/assets/portainer.png new file mode 100644 index 0000000..c0b0436 Binary files /dev/null and b/dashcaddy-api/assets/portainer.png differ diff --git a/dashcaddy-api/assets/progress-tracker.js b/dashcaddy-api/assets/progress-tracker.js new file mode 100644 index 0000000..cbfe3db --- /dev/null +++ b/dashcaddy-api/assets/progress-tracker.js @@ -0,0 +1,282 @@ +/** + * Progress Tracker + * Manages persistent storage of user progress through the onboarding flow + * using browser local storage. + * + * Storage Schema: + * { + * "version": "1.0", + * "tourCompleted": false, + * "completedTooltips": ["welcome", "dns-priority", ...], + * "currentStep": 3, + * "completionTimestamp": "2024-01-15T10:30:00Z", + * "dnsSetupDeferred": false, + * "lastVisit": "2024-01-15T10:30:00Z" + * } + */ + +(function(window) { + 'use strict'; + + /** + * ProgressTracker class + * Manages persistent storage of onboarding progress + * + * @class + * @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding') + */ + class ProgressTracker { + constructor(storageKey = 'dashcaddy_onboarding') { + this.storageKey = storageKey; + this.storageVersion = '1.0'; + + // Initialize storage if it doesn't exist + this._initializeStorage(); + + // Update last visit timestamp + this._updateLastVisit(); + } + + /** + * Initialize storage with default values if it doesn't exist + * @private + */ + _initializeStorage() { + const existing = this._getStorage(); + if (!existing || existing.version !== this.storageVersion) { + const defaultState = { + version: this.storageVersion, + tourCompleted: false, + completedTooltips: [], + currentStep: 0, + completionTimestamp: null, + dnsSetupDeferred: false, + lastVisit: new Date().toISOString() + }; + this._setStorage(defaultState); + } + } + + /** + * Get the current storage state + * @private + * @returns {Object|null} The storage state or null if unavailable + */ + _getStorage() { + try { + const data = localStorage.getItem(this.storageKey); + return data ? JSON.parse(data) : null; + } catch (error) { + console.error('[ProgressTracker] Error reading from storage:', error); + return null; + } + } + + /** + * Set the storage state + * @private + * @param {Object} state - The state to save + */ + _setStorage(state) { + try { + localStorage.setItem(this.storageKey, JSON.stringify(state)); + } catch (error) { + console.error('[ProgressTracker] Error writing to storage:', error); + // Handle quota exceeded or storage unavailable + // Fall back to session storage or in-memory storage + this._handleStorageError(error); + } + } + + /** + * Handle storage errors (quota exceeded, unavailable, etc.) + * @private + * @param {Error} error - The error that occurred + */ + _handleStorageError(error) { + // Try session storage as fallback + try { + sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage())); + console.warn('[ProgressTracker] Falling back to session storage'); + } catch (sessionError) { + console.error('[ProgressTracker] Session storage also unavailable:', sessionError); + // Could implement in-memory fallback here if needed + } + } + + /** + * Update the last visit timestamp + * @private + */ + _updateLastVisit() { + const state = this._getStorage(); + if (state) { + state.lastVisit = new Date().toISOString(); + this._setStorage(state); + } + } + + /** + * Check if a specific tooltip has been completed + * @param {string} tooltipId - The ID of the tooltip to check + * @returns {boolean} True if the tooltip has been completed + */ + isTooltipCompleted(tooltipId) { + const state = this._getStorage(); + if (!state) return false; + return state.completedTooltips.includes(tooltipId); + } + + /** + * Mark a tooltip as completed with timestamp + * @param {string} tooltipId - The ID of the tooltip to mark as completed + */ + markTooltipCompleted(tooltipId) { + const state = this._getStorage(); + if (!state) return; + + // Add tooltip to completed list if not already there + if (!state.completedTooltips.includes(tooltipId)) { + state.completedTooltips.push(tooltipId); + + // Store timestamp for this specific tooltip + if (!state.tooltipTimestamps) { + state.tooltipTimestamps = {}; + } + state.tooltipTimestamps[tooltipId] = new Date().toISOString(); + + this._setStorage(state); + } + } + + /** + * Check if the entire tour has been completed + * @returns {boolean} True if the tour is completed + */ + isTourCompleted() { + const state = this._getStorage(); + if (!state) return false; + return state.tourCompleted === true; + } + + /** + * Mark the entire tour as completed + */ + markTourCompleted() { + const state = this._getStorage(); + if (!state) return; + + state.tourCompleted = true; + state.completionTimestamp = new Date().toISOString(); + this._setStorage(state); + } + + /** + * Get the current step index + * @returns {number} The current step index (0-based) + */ + getCurrentStep() { + const state = this._getStorage(); + if (!state) return 0; + return state.currentStep || 0; + } + + /** + * Set the current step index + * @param {number} stepIndex - The step index to set (0-based) + */ + setCurrentStep(stepIndex) { + const state = this._getStorage(); + if (!state) return; + + state.currentStep = stepIndex; + this._setStorage(state); + } + + /** + * Reset all progress and clear storage + */ + resetProgress() { + const defaultState = { + version: this.storageVersion, + tourCompleted: false, + completedTooltips: [], + currentStep: 0, + completionTimestamp: null, + dnsSetupDeferred: false, + lastVisit: new Date().toISOString() + }; + this._setStorage(defaultState); + } + + /** + * Get the completion timestamp + * @returns {Date|null} The completion timestamp or null if not completed + */ + getCompletionTimestamp() { + const state = this._getStorage(); + if (!state || !state.completionTimestamp) return null; + return new Date(state.completionTimestamp); + } + + /** + * Check if DNS setup was deferred + * @returns {boolean} True if DNS setup was deferred + */ + isDnsSetupDeferred() { + const state = this._getStorage(); + if (!state) return false; + return state.dnsSetupDeferred === true; + } + + /** + * Mark DNS setup as deferred + */ + markDnsSetupDeferred() { + const state = this._getStorage(); + if (!state) return; + + state.dnsSetupDeferred = true; + this._setStorage(state); + } + + /** + * Get the timestamp for a specific tooltip completion + * @param {string} tooltipId - The ID of the tooltip + * @returns {Date|null} The timestamp or null if not completed + */ + getTooltipTimestamp(tooltipId) { + const state = this._getStorage(); + if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) { + return null; + } + return new Date(state.tooltipTimestamps[tooltipId]); + } + + /** + * Get all completed tooltip IDs + * @returns {string[]} Array of completed tooltip IDs + */ + getCompletedTooltips() { + const state = this._getStorage(); + if (!state) return []; + return state.completedTooltips || []; + } + + /** + * Get the last visit timestamp + * @returns {Date|null} The last visit timestamp + */ + getLastVisit() { + const state = this._getStorage(); + if (!state || !state.lastVisit) return null; + return new Date(state.lastVisit); + } + } + + // Export to global scope + window.ProgressTracker = ProgressTracker; + + console.log('[ProgressTracker] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/prowlarr.png b/dashcaddy-api/assets/prowlarr.png new file mode 100644 index 0000000..964f18b Binary files /dev/null and b/dashcaddy-api/assets/prowlarr.png differ diff --git a/dashcaddy-api/assets/qBittorrent.png b/dashcaddy-api/assets/qBittorrent.png new file mode 100644 index 0000000..97bdb6c Binary files /dev/null and b/dashcaddy-api/assets/qBittorrent.png differ diff --git a/dashcaddy-api/assets/radarr.png b/dashcaddy-api/assets/radarr.png new file mode 100644 index 0000000..49943a5 Binary files /dev/null and b/dashcaddy-api/assets/radarr.png differ diff --git a/dashcaddy-api/assets/router.png b/dashcaddy-api/assets/router.png new file mode 100644 index 0000000..241b9aa Binary files /dev/null and b/dashcaddy-api/assets/router.png differ diff --git a/dashcaddy-api/assets/sami-favicon.png b/dashcaddy-api/assets/sami-favicon.png new file mode 100644 index 0000000..1b8d421 Binary files /dev/null and b/dashcaddy-api/assets/sami-favicon.png differ diff --git a/dashcaddy-api/assets/sami-logo.png b/dashcaddy-api/assets/sami-logo.png new file mode 100644 index 0000000..2c6685e Binary files /dev/null and b/dashcaddy-api/assets/sami-logo.png differ diff --git a/dashcaddy-api/assets/site.webmanifest b/dashcaddy-api/assets/site.webmanifest new file mode 100644 index 0000000..e83acb0 --- /dev/null +++ b/dashcaddy-api/assets/site.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "SAMI-CLOUD Status", + "short_name": "SAMI-CLOUD", + "start_url": "index.html", + "display": "standalone", + "background_color": "#0b0f1a", + "theme_color": "#0e1116", + "icons": [ + { + "src": "assets/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "assets/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/dashcaddy-api/assets/sonarr.png b/dashcaddy-api/assets/sonarr.png new file mode 100644 index 0000000..c904551 Binary files /dev/null and b/dashcaddy-api/assets/sonarr.png differ diff --git a/dashcaddy-api/assets/syncthing.png b/dashcaddy-api/assets/syncthing.png new file mode 100644 index 0000000..ee5e1ec Binary files /dev/null and b/dashcaddy-api/assets/syncthing.png differ diff --git a/dashcaddy-api/assets/test-upload4.png b/dashcaddy-api/assets/test-upload4.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/dashcaddy-api/assets/test-upload4.png differ diff --git a/dashcaddy-api/assets/theme-adapter.js b/dashcaddy-api/assets/theme-adapter.js new file mode 100644 index 0000000..f58248f --- /dev/null +++ b/dashcaddy-api/assets/theme-adapter.js @@ -0,0 +1,308 @@ +/** + * Theme Adapter + * Ensures tooltips match the current dashboard theme + * Integrates with Driver.js to apply theme-specific styling + */ + +(function(window) { + 'use strict'; + + /** + * Theme configuration mapping for Driver.js + * Maps dashboard themes to Driver.js styling + */ + const THEME_CONFIGS = { + dark: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(0, 0, 0, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + light: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent-strong)', + overlayColor: 'rgba(0, 0, 0, 0.5)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent-strong)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + blue: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(25, 8, 172, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + nord: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(46, 52, 64, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + dracula: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(40, 42, 54, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + 'solarized-dark': { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(0, 43, 54, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + 'solarized-light': { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(253, 246, 227, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + } + }; + + /** + * ThemeAdapter class + * Manages theme integration for the tooltip system + */ + class ThemeAdapter { + constructor() { + this.currentTheme = this.getCurrentTheme(); + this.themeChangeCallbacks = []; + this._setupThemeChangeListener(); + } + + /** + * Get the current theme name from document root class + * @returns {string} Current theme name (e.g., 'dark', 'light', 'blue') + */ + getCurrentTheme() { + const root = document.documentElement; + const classList = Array.from(root.classList); + + // Check for theme classes + const themeClasses = ['light', 'blue', 'nord', 'dracula', 'solarized-dark', 'solarized-light']; + const foundTheme = themeClasses.find(theme => classList.includes(theme)); + + // Default to 'dark' if no theme class found + return foundTheme || 'dark'; + } + + /** + * Get Driver.js theme configuration for current theme + * @returns {Object} Theme configuration object + */ + getDriverTheme() { + const themeName = this.getCurrentTheme(); + const config = THEME_CONFIGS[themeName] || THEME_CONFIGS.dark; + + // Resolve CSS variables to actual values + const resolvedConfig = {}; + for (const [key, value] of Object.entries(config)) { + if (typeof value === 'string' && value.startsWith('var(')) { + // Extract CSS variable name + const varName = value.match(/var\((--[^)]+)\)/)?.[1]; + if (varName) { + const computedValue = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + resolvedConfig[key] = computedValue || value; + } else { + resolvedConfig[key] = value; + } + } else { + resolvedConfig[key] = value; + } + } + + return resolvedConfig; + } + + /** + * Register a callback for theme changes + * @param {Function} callback - Function to call when theme changes + */ + onThemeChange(callback) { + if (typeof callback === 'function') { + this.themeChangeCallbacks.push(callback); + } + } + + /** + * Setup theme change listener using MutationObserver + * @private + */ + _setupThemeChangeListener() { + const root = document.documentElement; + + // Create observer to watch for class changes on root element + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + const newTheme = this.getCurrentTheme(); + if (newTheme !== this.currentTheme) { + const oldTheme = this.currentTheme; + this.currentTheme = newTheme; + this._notifyThemeChange(newTheme, oldTheme); + } + } + }); + }); + + // Start observing + observer.observe(root, { + attributes: true, + attributeFilter: ['class'] + }); + + console.log('[ThemeAdapter] Theme change listener initialized'); + } + + /** + * Notify all registered callbacks of theme change + * @private + * @param {string} newTheme - New theme name + * @param {string} oldTheme - Old theme name + */ + _notifyThemeChange(newTheme, oldTheme) { + console.log(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`); + + this.themeChangeCallbacks.forEach(callback => { + try { + callback(newTheme, oldTheme); + } catch (error) { + console.error('[ThemeAdapter] Error in theme change callback:', error); + } + }); + } + + /** + * Apply theme to Driver.js instance + * @param {Object} driver - Driver.js instance + */ + applyTheme(driver) { + if (!driver) { + console.warn('[ThemeAdapter] No driver instance provided'); + return; + } + + const themeConfig = this.getDriverTheme(); + + // Apply theme configuration to driver + // Note: Driver.js v1.0+ uses CSS variables, so we inject a style element + this._injectDriverStyles(themeConfig); + + console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme); + } + + /** + * Inject custom styles for Driver.js based on theme + * @private + * @param {Object} themeConfig - Theme configuration + */ + _injectDriverStyles(themeConfig) { + // Remove existing theme styles + const existingStyle = document.getElementById('driver-theme-styles'); + if (existingStyle) { + existingStyle.remove(); + } + + // Create new style element + const style = document.createElement('style'); + style.id = 'driver-theme-styles'; + style.textContent = ` + .driver-popover { + background: ${themeConfig.backgroundColor} !important; + color: ${themeConfig.textColor} !important; + border: 1px solid ${themeConfig.borderColor} !important; + border-radius: 12px !important; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-title { + color: ${themeConfig.textColor} !important; + font-weight: 600 !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-description { + color: ${themeConfig.textColor} !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-footer button { + background: ${themeConfig.primaryColor} !important; + color: ${themeConfig.backgroundColor} !important; + border: none !important; + font-family: ${themeConfig.fontFamily} !important; + font-weight: 500 !important; + } + + .driver-popover-footer button:hover { + opacity: 0.9 !important; + } + + .driver-popover-close-btn { + color: ${themeConfig.textColor} !important; + } + + .driver-overlay { + background: ${themeConfig.overlayColor} !important; + } + + .driver-highlighted-element { + outline: 2px solid ${themeConfig.highlightColor} !important; + outline-offset: 4px !important; + } + + .driver-popover-progress-text { + color: ${themeConfig.textColor} !important; + opacity: 0.7 !important; + font-family: ${themeConfig.fontFamily} !important; + } + `; + + document.head.appendChild(style); + } + + /** + * Get all available theme names + * @returns {string[]} Array of theme names + */ + getAvailableThemes() { + return Object.keys(THEME_CONFIGS); + } + + /** + * Check if a theme is available + * @param {string} themeName - Theme name to check + * @returns {boolean} True if theme is available + */ + isThemeAvailable(themeName) { + return THEME_CONFIGS.hasOwnProperty(themeName); + } + } + + // Export to global scope + window.ThemeAdapter = ThemeAdapter; + + console.log('[ThemeAdapter] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/tooltip-definitions.js b/dashcaddy-api/assets/tooltip-definitions.js new file mode 100644 index 0000000..8ef36d2 --- /dev/null +++ b/dashcaddy-api/assets/tooltip-definitions.js @@ -0,0 +1,337 @@ +/** + * Tooltip Definitions + * Defines all tooltip content, positioning, and behavior for the onboarding system + */ + +(function(window) { + 'use strict'; + + /** + * Validate a tooltip definition + * @param {Object} tooltip - The tooltip definition to validate + * @returns {Object} { valid: boolean, errors: string[] } + */ + function validateTooltipDefinition(tooltip) { + const errors = []; + + // Required fields + if (!tooltip.id || typeof tooltip.id !== 'string') { + errors.push('Tooltip must have a valid string id'); + } + + if (!tooltip.element) { + errors.push('Tooltip must have an element selector or HTMLElement'); + } + + if (!tooltip.popover || typeof tooltip.popover !== 'object') { + errors.push('Tooltip must have a popover object'); + } else { + // Validate popover fields + if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') { + errors.push('Tooltip popover must have a valid string title'); + } + + if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') { + errors.push('Tooltip popover must have a valid string description'); + } + + // Validate position if provided + if (tooltip.popover.position) { + const validPositions = ['top', 'bottom', 'left', 'right', 'center']; + if (!validPositions.includes(tooltip.popover.position)) { + errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`); + } + } + + // Validate align if provided + if (tooltip.popover.align) { + const validAligns = ['start', 'center', 'end']; + if (!validAligns.includes(tooltip.popover.align)) { + errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`); + } + } + + // Validate showButtons if provided + if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) { + errors.push('showButtons must be an array'); + } + + // Validate callbacks if provided + const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater']; + callbacks.forEach(callback => { + if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') { + errors.push(`${callback} must be a function`); + } + }); + } + + // Validate condition if provided + if (tooltip.condition && typeof tooltip.condition !== 'function') { + errors.push('condition must be a function'); + } + + // Validate priority if provided + if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') { + errors.push('priority must be a number'); + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Validate an array of tooltip definitions + * @param {Array} tooltips - Array of tooltip definitions + * @returns {Object} { valid: boolean, errors: Object[] } + */ + function validateTooltipDefinitions(tooltips) { + if (!Array.isArray(tooltips)) { + return { + valid: false, + errors: [{ tooltip: null, errors: ['tooltips must be an array'] }] + }; + } + + const allErrors = []; + const ids = new Set(); + + tooltips.forEach((tooltip, index) => { + const validation = validateTooltipDefinition(tooltip); + + if (!validation.valid) { + allErrors.push({ + tooltip: tooltip.id || `index ${index}`, + errors: validation.errors + }); + } + + // Check for duplicate IDs + if (tooltip.id) { + if (ids.has(tooltip.id)) { + allErrors.push({ + tooltip: tooltip.id, + errors: [`Duplicate tooltip ID: ${tooltip.id}`] + }); + } + ids.add(tooltip.id); + } + }); + + return { + valid: allErrors.length === 0, + errors: allErrors + }; + } + + /** + * Error handler for tooltip system + */ + class TooltipError extends Error { + constructor(message, tooltipId = null) { + super(message); + this.name = 'TooltipError'; + this.tooltipId = tooltipId; + } + } + + /** + * Handle tooltip definition errors + * @param {Object} validation - Validation result + * @throws {TooltipError} If validation fails + */ + function handleValidationErrors(validation) { + if (!validation.valid) { + const errorMessages = validation.errors.map(e => + `${e.tooltip}: ${e.errors.join(', ')}` + ).join('\n'); + + console.error('[TooltipDefinitions] Validation errors:', errorMessages); + throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`); + } + } + + // Export to global scope + window.TooltipValidation = { + validateTooltipDefinition, + validateTooltipDefinitions, + handleValidationErrors, + TooltipError + }; + + console.log('[TooltipDefinitions] Validation module loaded'); + +})(window); + + +/** + * Tooltip Definitions Array + * Defines all tooltips for the onboarding tour + */ +const TOOLTIP_DEFINITIONS = [ + // 1. Welcome tooltip pointing to logo + { + id: 'welcome', + element: '#brand', + popover: { + title: 'Welcome to DashCaddy!', + description: ` +

Your personal dashboard for managing services with Caddy reverse proxy.

+

Let's take a quick tour to help you get started.

+

Tip: You can customize this logo in Settings.

+ `, + position: 'bottom', + align: 'start', + showButtons: ['next'], + showProgress: true + }, + priority: 1, + isNewFeature: false + }, + + // 2. Add Service button + { + id: 'add-service', + element: '#add-service-btn', + popover: { + title: 'Adding New Services', + description: ` +

Click + Add Service to deploy new apps or add existing services to your dashboard.

+

Choose from 50+ templates including:

+
    +
  • Media servers (Plex, Jellyfin, Emby)
  • +
  • Download managers (qBittorrent, Transmission)
  • +
  • DNS servers (Technitium, Pi-hole)
  • +
+ `, + position: 'bottom', + showButtons: ['previous', 'next'], + showProgress: true + }, + priority: 2, + isNewFeature: false, + condition: () => { + return document.getElementById('add-service-btn') !== null; + } + }, + + // 3. App Grid explanation + { + id: 'app-grid', + element: '#cards', + popover: { + title: 'Your Services', + description: ` +

This is your service grid where all your deployed applications appear.

+

Each card shows:

+
    +
  • Service status (online/offline)
  • +
  • Response time
  • +
  • Quick actions (restart, open, logs, settings)
  • +
+ `, + position: 'top', + showButtons: ['previous', 'next'], + showProgress: true + }, + priority: 3, + isNewFeature: false + }, + + // 4. Theme selector + { + id: 'theme-selector', + element: '#theme', + popover: { + title: 'Customize Your Theme', + description: ` +

DashCaddy comes with 7 themes. Click here to switch between them.

+

Your preference is saved automatically.

+ `, + position: 'bottom', + showButtons: ['previous', 'close'], + showProgress: true + }, + priority: 4, + isNewFeature: false, + condition: () => { + return document.getElementById('theme') !== null; + } + } +]; + +/** + * Get tooltip definitions + * @returns {Array} Array of tooltip definitions + */ +function getTooltipDefinitions() { + return TOOLTIP_DEFINITIONS; +} + +/** + * Get a specific tooltip by ID + * @param {string} id - Tooltip ID + * @returns {Object|null} Tooltip definition or null if not found + */ +function getTooltipById(id) { + return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null; +} + +/** + * Get tooltips filtered by condition + * @returns {Array} Array of tooltips that pass their condition check + */ +function getActiveTooltips() { + return TOOLTIP_DEFINITIONS.filter(tooltip => { + if (tooltip.condition && typeof tooltip.condition === 'function') { + try { + return tooltip.condition(); + } catch (error) { + console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error); + return false; + } + } + return true; + }); +} + +/** + * Get tooltips sorted by priority + * @returns {Array} Array of tooltips sorted by priority (ascending) + */ +function getSortedTooltips() { + const tooltips = getActiveTooltips(); + return tooltips.sort((a, b) => { + const priorityA = a.priority || 999; + const priorityB = b.priority || 999; + return priorityA - priorityB; + }); +} + +/** + * Get tooltips marked as new features + * @returns {Array} Array of tooltips marked with isNewFeature flag + */ +function getNewFeatureTooltips() { + const tooltips = getActiveTooltips(); + return tooltips.filter(tooltip => tooltip.isNewFeature === true) + .sort((a, b) => { + const priorityA = a.priority || 999; + const priorityB = b.priority || 999; + return priorityA - priorityB; + }); +} + +// Export to global scope +window.TooltipDefinitions = { + TOOLTIP_DEFINITIONS, + getTooltipDefinitions, + getTooltipById, + getActiveTooltips, + getSortedTooltips, + getNewFeatureTooltips +}; + +console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); + diff --git a/dashcaddy-api/assets/tour-manager.js b/dashcaddy-api/assets/tour-manager.js new file mode 100644 index 0000000..d972a7c --- /dev/null +++ b/dashcaddy-api/assets/tour-manager.js @@ -0,0 +1,363 @@ +/** + * Tour Manager + * Orchestrates the onboarding tour using Driver.js + */ + +(function(window) { + 'use strict'; + + class TourManager { + constructor(progressTracker, themeAdapter, dnsTemplateSelector) { + this.progressTracker = progressTracker; + this.themeAdapter = themeAdapter; + this.dnsTemplateSelector = dnsTemplateSelector; + this.driver = null; + this.currentStepIndex = 0; + this.isActive = false; + this.resizeHandler = null; + this.layoutChangeHandler = null; + } + + /** + * Initialize Driver.js with theme-aware configuration + */ + async initializeDriver() { + // Driver.js v1.x IIFE: window.driver.js.driver is the factory function + const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; + + if (typeof driverFactory !== 'function') { + console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver); + return false; + } + + const themeConfig = this.themeAdapter.getDriverTheme(); + + this.driver = driverFactory({ + showProgress: true, + showButtons: ['next', 'previous', 'close'], + allowClose: true, + overlayClickNext: false, + overlayOpacity: 0, + stagePadding: 0, + stageRadius: 0, + allowKeyboardControl: true, + popoverClass: 'dashcaddy-popover', + onDestroyed: () => this.onTourComplete(), + onDestroyStarted: () => { + if (!this.progressTracker.isTourCompleted()) { + this.onTourSkip(); + } + } + }); + + // Apply theme + this.themeAdapter.applyTheme(this.driver); + + // Listen for theme changes + this.themeAdapter.onThemeChange(() => { + this.themeAdapter.applyTheme(this.driver); + }); + + // Set up dynamic repositioning + this.setupDynamicRepositioning(); + + return true; + } + + /** + * Check if tour should auto-start + */ + shouldAutoStart() { + return !this.progressTracker.isTourCompleted() && + this.progressTracker.getCurrentStep() === 0; + } + + /** + * Start the onboarding tour + */ + async startTour() { + if (!this.driver) { + const initialized = await this.initializeDriver(); + if (!initialized) return; + } + + // Get active tooltips (filtered by conditions) + const allTooltips = window.TooltipDefinitions.getSortedTooltips(); + + // Filter out completed tooltips + const completedIds = this.progressTracker.getCompletedTooltips(); + const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id)); + + if (activeTooltips.length === 0) { + console.log('[TourManager] No tooltips to show'); + this.progressTracker.markTourCompleted(); + return; + } + + // Convert to Driver.js steps with navigation logic + const steps = activeTooltips.map((tooltip, index) => { + const isFirst = index === 0; + const isLast = index === activeTooltips.length - 1; + + const step = { + element: tooltip.element, + popover: { + title: tooltip.popover.title, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start', + showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), + showProgress: tooltip.popover.showProgress !== false, + onNextClick: () => { + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }, + onPrevClick: () => { + this.progressTracker.setCurrentStep(Math.max(0, index - 1)); + this.currentStepIndex = Math.max(0, index - 1); + this.driver.movePrevious(); + }, + onCloseClick: () => { + this.skipTour(); + } + } + }; + + // Add custom handlers for DNS tooltip + if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) { + step.popover.onSetupNowClick = () => { + console.log('[TourManager] Opening DNS template selector'); + this.dnsTemplateSelector.showTemplateSelector(); + // Mark tooltip as completed and move to next + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }; + + step.popover.onLaterClick = () => { + console.log('[TourManager] DNS setup deferred'); + this.progressTracker.markDnsSetupDeferred(); + // Mark tooltip as completed and move to next + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }; + } + + return step; + }); + + this.isActive = true; + this.driver.setSteps(steps); + this.driver.drive(); + } + + /** + * Resume tour from last step + */ + async resumeTour() { + const currentStep = this.progressTracker.getCurrentStep(); + if (currentStep > 0) { + await this.startTour(); + // Driver.js will start from beginning, we'd need to skip to current step + // This is a simplified implementation + } else { + await this.startTour(); + } + } + + /** + * Skip the entire tour + */ + skipTour() { + if (this.driver) { + this.driver.destroy(); + } + this.cleanupDynamicRepositioning(); + this.isActive = false; + } + + /** + * Restart tour from beginning + */ + async restartTour() { + this.progressTracker.resetProgress(); + await this.startTour(); + } + + /** + * Show a specific tooltip by ID + */ + async showTooltip(tooltipId) { + const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId); + if (!tooltip) { + console.error(`[TourManager] Tooltip not found: ${tooltipId}`); + return; + } + + if (!this.driver) { + await this.initializeDriver(); + } + + const step = { + element: tooltip.element, + popover: { + title: tooltip.popover.title, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start' + } + }; + + this.driver.highlight(step); + } + + /** + * Show "What's New" tour - only tooltips marked as new features + */ + async showWhatsNew() { + if (!this.driver) { + const initialized = await this.initializeDriver(); + if (!initialized) return; + } + + // Get only new feature tooltips + const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips(); + + if (newFeatureTooltips.length === 0) { + console.log('[TourManager] No new features to show'); + return; + } + + console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`); + + // Convert to Driver.js steps + const steps = newFeatureTooltips.map((tooltip, index) => { + const isFirst = index === 0; + const isLast = index === newFeatureTooltips.length - 1; + + return { + element: tooltip.element, + popover: { + title: `✨ NEW: ${tooltip.popover.title}`, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start', + showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), + showProgress: true, + onNextClick: () => { + this.driver.moveNext(); + }, + onPrevClick: () => { + this.driver.movePrevious(); + }, + onCloseClick: () => { + this.skipTour(); + } + } + }; + }); + + this.isActive = true; + this.driver.setSteps(steps); + this.driver.drive(); + } + + /** + * Set up dynamic repositioning for window resize and layout changes + */ + setupDynamicRepositioning() { + // Window resize handler with debouncing + let resizeTimeout; + this.resizeHandler = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + if (this.isActive && this.driver) { + console.log('[TourManager] Window resized, repositioning tooltip'); + this.driver.refresh(); + } + }, 150); // Debounce for 150ms + }; + + // Layout change handler (for theme changes, DOM mutations) + this.layoutChangeHandler = () => { + if (this.isActive && this.driver) { + console.log('[TourManager] Layout changed, repositioning tooltip'); + // Small delay to allow layout to settle + setTimeout(() => { + if (this.driver) { + this.driver.refresh(); + } + }, 100); + } + }; + + // Add event listeners + window.addEventListener('resize', this.resizeHandler); + + // Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition) + this.themeAdapter.onThemeChange(this.layoutChangeHandler); + } + + /** + * Clean up dynamic repositioning listeners + */ + cleanupDynamicRepositioning() { + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + } + } + + /** + * Get buttons to show for a specific step + * @private + */ + _getButtonsForStep(tooltip, isFirst, isLast) { + // Check if tooltip has custom buttons defined + if (tooltip.popover.showButtons) { + return tooltip.popover.showButtons; + } + + // Default button configuration + const buttons = []; + + if (!isFirst) { + buttons.push('previous'); + } + + if (!isLast) { + buttons.push('next'); + } else { + buttons.push('close'); + } + + return buttons; + } + + /** + * Handle tour completion + */ + onTourComplete() { + this.progressTracker.markTourCompleted(); + this.isActive = false; + console.log('[TourManager] Tour completed'); + } + + /** + * Handle tour skip + */ + onTourSkip() { + // Save current progress but don't mark as completed + console.log('[TourManager] Tour skipped'); + this.isActive = false; + } + } + + window.TourManager = TourManager; + console.log('[TourManager] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/transmission.png b/dashcaddy-api/assets/transmission.png new file mode 100644 index 0000000..57bedae Binary files /dev/null and b/dashcaddy-api/assets/transmission.png differ diff --git a/dashcaddy-api/assets/weather/clear-day.svg b/dashcaddy-api/assets/weather/clear-day.svg new file mode 100644 index 0000000..d0d36ca --- /dev/null +++ b/dashcaddy-api/assets/weather/clear-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/clear-night.svg b/dashcaddy-api/assets/weather/clear-night.svg new file mode 100644 index 0000000..bd3f1cb --- /dev/null +++ b/dashcaddy-api/assets/weather/clear-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/cloudy.svg b/dashcaddy-api/assets/weather/cloudy.svg new file mode 100644 index 0000000..b868d87 --- /dev/null +++ b/dashcaddy-api/assets/weather/cloudy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/drizzle.svg b/dashcaddy-api/assets/weather/drizzle.svg new file mode 100644 index 0000000..27513c8 --- /dev/null +++ b/dashcaddy-api/assets/weather/drizzle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/fog.svg b/dashcaddy-api/assets/weather/fog.svg new file mode 100644 index 0000000..12208db --- /dev/null +++ b/dashcaddy-api/assets/weather/fog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/partly-cloudy-day.svg b/dashcaddy-api/assets/weather/partly-cloudy-day.svg new file mode 100644 index 0000000..6fcec43 --- /dev/null +++ b/dashcaddy-api/assets/weather/partly-cloudy-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/partly-cloudy-night.svg b/dashcaddy-api/assets/weather/partly-cloudy-night.svg new file mode 100644 index 0000000..2c49905 --- /dev/null +++ b/dashcaddy-api/assets/weather/partly-cloudy-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/rain.svg b/dashcaddy-api/assets/weather/rain.svg new file mode 100644 index 0000000..74b33d3 --- /dev/null +++ b/dashcaddy-api/assets/weather/rain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/sleet.svg b/dashcaddy-api/assets/weather/sleet.svg new file mode 100644 index 0000000..03d6a3a --- /dev/null +++ b/dashcaddy-api/assets/weather/sleet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/snow.svg b/dashcaddy-api/assets/weather/snow.svg new file mode 100644 index 0000000..e444068 --- /dev/null +++ b/dashcaddy-api/assets/weather/snow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/thunderstorm.svg b/dashcaddy-api/assets/weather/thunderstorm.svg new file mode 100644 index 0000000..390f11b --- /dev/null +++ b/dashcaddy-api/assets/weather/thunderstorm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/wind.svg b/dashcaddy-api/assets/weather/wind.svg new file mode 100644 index 0000000..55d168c --- /dev/null +++ b/dashcaddy-api/assets/weather/wind.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/audit-logger.js b/dashcaddy-api/audit-logger.js index 60f614b..3fdf5b8 100644 --- a/dashcaddy-api/audit-logger.js +++ b/dashcaddy-api/audit-logger.js @@ -111,7 +111,7 @@ class AuditLogger { action: action || '', resource: resource || '', details: details || {}, - outcome: outcome || 'unknown', + outcome: outcome || 'unknown' }; await this.stateManager.update(entries => { diff --git a/dashcaddy-api/auth-manager.js b/dashcaddy-api/auth-manager.js index a7039b5..bb0a9bc 100644 --- a/dashcaddy-api/auth-manager.js +++ b/dashcaddy-api/auth-manager.js @@ -8,10 +8,8 @@ const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const credentialManager = require('./credential-manager'); const cryptoUtils = require('./crypto-utils'); -const { safeLog } = require('./logger-utils'); // JWT signing secret - derived from encryption key for consistency -// SECURITY: Loaded from secure storage, never logged const JWT_SECRET = cryptoUtils.loadOrCreateKey(); // Namespace for API keys in credential manager @@ -40,13 +38,12 @@ class AuthManager { { ...payload, iat: Math.floor(Date.now() / 1000), - scope: payload.scope || ['read', 'write'], + scope: payload.scope || ['read', 'write'] }, JWT_SECRET, - { expiresIn }, + { expiresIn } ); - // SECURITY: Log event only, never log the actual token console.log(`[AuthManager] Generated JWT for user: ${payload.sub}, expires in: ${expiresIn}`); return token; } catch (error) { @@ -67,14 +64,13 @@ class AuthManager { userId: decoded.sub, scope: decoded.scope || [], iat: decoded.iat, - exp: decoded.exp, + exp: decoded.exp }; } catch (error) { if (error.name === 'TokenExpiredError') { console.log('[AuthManager] JWT token expired'); } else if (error.name === 'JsonWebTokenError') { - // SECURITY: Never log the actual token - console.log('[AuthManager] JWT token invalid'); + console.log('[AuthManager] JWT token invalid:', error.message); } else { console.error('[AuthManager] JWT verification failed:', error.message); } @@ -111,7 +107,7 @@ class AuthManager { name, scopes, createdAt: new Date().toISOString(), - lastUsed: null, + lastUsed: null }; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; @@ -120,7 +116,6 @@ class AuthManager { // Cache metadata this.keyMetadataCache.set(keyId, metadata); - // SECURITY: Log event only, never log the actual API key console.log(`[AuthManager] Generated API key: ${name} (${keyId})`); return { @@ -128,7 +123,7 @@ class AuthManager { id: keyId, name, scopes, - createdAt: metadata.createdAt, + createdAt: metadata.createdAt }; } catch (error) { console.error('[AuthManager] API key generation failed:', error.message); @@ -179,7 +174,7 @@ class AuthManager { // Update last used timestamp (non-blocking) this.updateLastUsed(keyId, metadata).catch(err => - console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message), + console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message) ); console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`); @@ -187,7 +182,7 @@ class AuthManager { return { keyId, scopes: metadata.scopes || [], - name: metadata.name, + name: metadata.name }; } catch (error) { console.error('[AuthManager] API key verification failed:', error.message); @@ -282,7 +277,7 @@ class AuthManager { try { const updatedMetadata = { ...metadata, - lastUsed: new Date().toISOString(), + lastUsed: new Date().toISOString() }; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; diff --git a/dashcaddy-api/backup-manager.js b/dashcaddy-api/backup-manager.js index 3ec8b28..9a30d12 100644 --- a/dashcaddy-api/backup-manager.js +++ b/dashcaddy-api/backup-manager.js @@ -165,7 +165,7 @@ class BackupManager extends EventEmitter { locations: savedLocations, encrypted: !!backup.encrypt, compressed: true, - status: 'success', + status: 'success' }; this.addToHistory(historyEntry); @@ -187,7 +187,7 @@ class BackupManager extends EventEmitter { timestamp: new Date().toISOString(), duration, status: 'failed', - error: error.message, + error: error.message }; this.addToHistory(historyEntry); @@ -205,7 +205,7 @@ class BackupManager extends EventEmitter { version: '1.0', timestamp: new Date().toISOString(), hostname: require('os').hostname(), - data: {}, + data: {} }; for (const source of include) { @@ -332,10 +332,10 @@ class BackupManager extends EventEmitter { HostConfig: { Binds: [ `${volumeName}:/volume:ro`, - `${backupDir}:/backup`, + `${backupDir}:/backup` ], - AutoRemove: true, - }, + AutoRemove: true + } }); // Start and wait for completion @@ -354,7 +354,7 @@ class BackupManager extends EventEmitter { path: backupFile, size: stats.size, timestamp: new Date().toISOString(), - status: 'success', + status: 'success' }); } } catch (volumeError) { @@ -362,7 +362,7 @@ class BackupManager extends EventEmitter { backupResults.push({ name: volume.Name, status: 'failed', - error: volumeError.message, + error: volumeError.message }); } } @@ -371,7 +371,7 @@ class BackupManager extends EventEmitter { timestamp: new Date().toISOString(), totalVolumes: volumes.length, successCount: backupResults.filter(r => r.status === 'success').length, - volumes: backupResults, + volumes: backupResults }; } catch (error) { console.error('[BackupManager] Error backing up volumes:', error.message); @@ -425,10 +425,10 @@ class BackupManager extends EventEmitter { HostConfig: { Binds: [ `${volumeName}:/volume`, - `${backupDir}:/backup:ro`, + `${backupDir}:/backup:ro` ], - AutoRemove: true, - }, + AutoRemove: true + } }); await container.start(); @@ -442,7 +442,7 @@ class BackupManager extends EventEmitter { restoreResults.push({ name: volumeName, status: 'success', - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }); console.log(`[BackupManager] Volume ${volumeName} restored successfully`); @@ -451,7 +451,7 @@ class BackupManager extends EventEmitter { restoreResults.push({ name: volBackup.name, status: 'failed', - error: restoreError.message, + error: restoreError.message }); } } @@ -460,7 +460,7 @@ class BackupManager extends EventEmitter { timestamp: new Date().toISOString(), results: restoreResults, successCount: restoreResults.filter(r => r.status === 'success').length, - failedCount: restoreResults.filter(r => r.status === 'failed').length, + failedCount: restoreResults.filter(r => r.status === 'failed').length }; } @@ -498,7 +498,7 @@ class BackupManager extends EventEmitter { // Return: iv:authTag:encrypted (all base64) return Buffer.from( - `${iv.toString('base64') }:${ authTag.toString('base64') }:${ encrypted.toString('base64')}`, + iv.toString('base64') + ':' + authTag.toString('base64') + ':' + encrypted.toString('base64') ); } @@ -566,7 +566,7 @@ class BackupManager extends EventEmitter { return { type: 'local', path: filepath, - size: data.length, + size: data.length }; } @@ -652,7 +652,7 @@ class BackupManager extends EventEmitter { this.emit('restore-complete', { backupId, restored, - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }); console.log('[BackupManager] Restore completed successfully'); @@ -661,7 +661,7 @@ class BackupManager extends EventEmitter { this.emit('restore-failed', { backupId, error: error.message, - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }); throw error; } @@ -790,7 +790,7 @@ class BackupManager extends EventEmitter { return { backups: {}, - defaultRetention: { keep: 7 }, + defaultRetention: { keep: 7 } }; } diff --git a/dashcaddy-api/ca/CERTIFICATE-API.md b/dashcaddy-api/ca/CERTIFICATE-API.md new file mode 100644 index 0000000..fb5452a --- /dev/null +++ b/dashcaddy-api/ca/CERTIFICATE-API.md @@ -0,0 +1,306 @@ +# DashCA Certificate Generation API + +## Overview + +DashCA now provides automatic SSL certificate generation for services on your network. This allows services like Technitium DNS to automatically obtain valid SSL certificates signed by your internal CA. + +## Features + +- **Automatic Certificate Generation**: Generate SSL certificates for any domain on demand +- **Multiple Formats**: PFX, PEM, CRT, KEY, and full chain certificates +- **Certificate Caching**: Certificates are cached and reused if still valid (>30 days remaining) +- **No Authentication Required**: Public API endpoints for easy automation +- **Automatic Renewal**: Certificates are regenerated when they expire in less than 30 days + +## API Endpoints + +### 1. Generate/Download Certificate + +**GET** `/api/ca/cert/:domain` + +Generate and download an SSL certificate for the specified domain. + +**Parameters:** +- `:domain` (required) - The domain name (e.g., `dns1.sami`, `dns2.sami`) +- `format` (optional) - Certificate format: `pfx`, `pem`, `crt`, `key`, or `fullchain` (default: `pfx`) +- `password` (optional) - Password for PFX files (default: `dashcaddy`) + +**Examples:** + +```bash +# Download PFX certificate for dns1.sami +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=pfx&password=dashcaddy" + +# Download PEM bundle (private key + certificate + chain) +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=pem" + +# Download certificate only +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=crt" + +# Download private key only +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=key" + +# Download full certificate chain (cert + intermediate + root) +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=fullchain" +``` + +**PowerShell:** + +```powershell +Invoke-WebRequest -Uri "https://ca.sami/api/ca/cert/dns1.sami?format=pfx" -OutFile "dns1.pfx" +``` + +### 2. List Generated Certificates + +**GET** `/api/ca/certs` + +List all generated certificates with their status and expiration information. + +**Example:** + +```bash +curl https://ca.sami/api/ca/certs +``` + +**Response:** + +```json +{ + "success": true, + "certificates": [ + { + "domain": "dns1.sami", + "subject": "CN=dns1.sami", + "validFrom": "Feb 11 21:37:02 2026 GMT", + "validUntil": "Feb 11 21:37:02 2027 GMT", + "daysUntilExpiration": 364, + "fingerprint": "4E:74:F8:49:...", + "status": "valid" + } + ] +} +``` + +**Status values:** +- `valid` - Certificate is valid and has >30 days remaining +- `expiring-soon` - Certificate expires in less than 30 days +- `expired` - Certificate has expired + +## Certificate Details + +- **Validity**: 365 days (1 year) +- **Algorithm**: RSA 2048-bit (for broad compatibility) +- **Signature**: SHA-256 +- **Key Usage**: Key Encipherment, Data Encipherment, Digital Signature +- **Extended Key Usage**: Server Authentication +- **Subject Alternative Names**: Primary domain + wildcard subdomain (if applicable) + +Example: A certificate for `dns1.sami` includes both `dns1.sami` and `*.dns1.sami`. + +## Automation Scripts + +### PowerShell - Update Technitium DNS Certificate + +Located at: `C:\caddy\scripts\update-technitium-cert.ps1` + +```powershell +# Update certificate for a single DNS server +.\update-technitium-cert.ps1 ` + -Domain "dns1.sami" ` + -TechnitiumUrl "http://192.168.254.204:5380" ` + -CertPath "C:\ProgramData\Technitium\DnsServer\cert.pfx" + +# Update all DNS servers at once +.\update-all-dns-certs.ps1 +``` + +### Schedule Automatic Updates + +Create a Windows scheduled task to update certificates monthly: + +```powershell +# Create scheduled task for monthly certificate updates +schtasks /create ` + /tn "Update DNS Certificates" ` + /tr "powershell -ExecutionPolicy Bypass -File C:\caddy\scripts\update-all-dns-certs.ps1" ` + /sc monthly ` + /d 1 ` + /st 03:00 ` + /ru SYSTEM +``` + +## Manual Certificate Installation + +### Technitium DNS + +1. Download PFX certificate: + ```bash + curl -k -O "https://ca.sami/api/ca/cert/dns1.sami?format=pfx&password=dashcaddy" + ``` + +2. Copy to Technitium directory: + ``` + Copy dns1.sami.pfx to C:\ProgramData\Technitium\DnsServer\cert.pfx + ``` + +3. Restart Technitium DNS Server service: + ```powershell + Restart-Service "Technitium DNS Server" + ``` + +4. Access Technitium DNS web interface via HTTPS: + ``` + https://dns1.sami:5380 + ``` + +### Other Services + +For other services requiring SSL certificates, use the appropriate format: + +- **PFX**: Windows services, .NET applications, IIS +- **PEM**: Most Linux services (Apache, Nginx, HAProxy) +- **CRT + KEY**: Separate certificate and key files (Nginx, Apache) +- **Fullchain**: Full certificate chain for maximum compatibility + +## Security Considerations + +1. **Root CA Protection**: The root CA private key is mounted read-only in the API container +2. **Serial Number Tracking**: Each certificate gets a unique serial number stored per domain +3. **Password Protection**: PFX files are password-protected (default: `dashcaddy`) +4. **Network Access**: Certificate API is available on your Tailscale network only +5. **No Revocation**: Certificates cannot be revoked; wait for expiration or delete manually + +## Troubleshooting + +### Certificate Generation Fails + +Check if the PKI directory is accessible: +```bash +docker exec dashcaddy-api ls -la /app/pki/ +``` + +Should show: `root.crt`, `root.key`, `intermediate.crt`, `intermediate.key` + +### Certificate Not Trusted by Browser + +Install the root CA certificate on your device: +```bash +curl -O https://ca.sami/root.crt +``` + +Follow platform-specific installation instructions on the DashCA homepage. + +### Certificate Expired + +Request a new certificate - it will automatically regenerate: +```bash +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=pfx" +``` + +### Generated Certificates Location + +On the host system: +``` +C:\caddy\generated-certs\ + ├── dns1.sami\ + │ ├── server.key + │ ├── server.csr + │ ├── server.crt + │ ├── server.pfx + │ ├── server.pem + │ └── fullchain.pem + ├── dns2.sami\ + └── dns3.sami\ +``` + +## Integration Examples + +### curl + +```bash +# Simple download +curl -O https://ca.sami/api/ca/cert/myservice.sami?format=pfx + +# With custom password +curl -O "https://ca.sami/api/ca/cert/myservice.sami?format=pfx&password=mypassword" +``` + +### PowerShell + +```powershell +# Download and install +$certPath = "C:\certs\myservice.pfx" +Invoke-WebRequest -Uri "https://ca.sami/api/ca/cert/myservice.sami?format=pfx" -OutFile $certPath + +# Verify certificate +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certPath, "dashcaddy") +Write-Host "Certificate valid until: $($cert.NotAfter)" +``` + +### Bash/Linux + +```bash +#!/bin/bash +# Download and extract PEM files +curl -k "https://ca.sami/api/ca/cert/myservice.sami?format=pem" -o myservice.pem + +# Extract key and cert +openssl pkey -in myservice.pem -out myservice.key +openssl x509 -in myservice.pem -out myservice.crt +``` + +### Docker Compose + +```yaml +services: + myservice: + image: myimage + volumes: + - ./certs:/certs + environment: + SSL_CERT_FILE: /certs/myservice.crt + SSL_KEY_FILE: /certs/myservice.key + command: | + sh -c " + curl -k https://ca.sami/api/ca/cert/myservice.sami?format=crt -o /certs/myservice.crt && + curl -k https://ca.sami/api/ca/cert/myservice.sami?format=key -o /certs/myservice.key && + exec myservice + " +``` + +## DNS Server Configuration + +### DNS1 (Primary) +- **IP**: 192.168.254.204 +- **Domain**: dns1.sami +- **Certificate**: https://ca.sami/api/ca/cert/dns1.sami?format=pfx + +### DNS2 (Secondary) +- **IP**: 100.74.102.61 (Tailscale) +- **Domain**: dns2.sami +- **Certificate**: https://ca.sami/api/ca/cert/dns2.sami?format=pfx + +### DNS3 (Tertiary) +- **IP**: 100.89.216.23 (Tailscale) +- **Domain**: dns3.sami +- **Certificate**: https://ca.sami/api/ca/cert/dns3.sami?format=pfx + +## Future Enhancements + +Potential features for future versions: + +- Certificate revocation lists (CRL) +- OCSP responder +- Certificate renewal webhooks +- Email notifications for expiring certificates +- Web UI for certificate management +- Custom certificate validity periods +- Certificate templates for different service types +- Automatic DNS record validation + +## Support + +For issues or questions, check: +- DashCA homepage: https://ca.sami +- Certificate list: https://ca.sami/api/ca/certs +- Server logs: `docker logs dashcaddy-api` diff --git a/dashcaddy-api/ca/README.md b/dashcaddy-api/ca/README.md new file mode 100644 index 0000000..aa09881 --- /dev/null +++ b/dashcaddy-api/ca/README.md @@ -0,0 +1,281 @@ +# DashCA - Certificate Authority Distribution + +A self-hosted landing page for distributing your root CA certificate with one-click installation across all major platforms. + +## Quick Start + +### Regenerate All Certificate Formats + +```bash +cd scripts +bash generate-all.sh +``` + +This will: +1. Copy root.crt and intermediate.crt from Caddy PKI +2. Generate root.der (DER format for Windows) +3. Generate root.mobileconfig (Apple profile for iOS/macOS) +4. Extract certificate metadata to cert-info.json + +### Deploy to Production + +```bash +# Copy all files to production directory +cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/ +``` + +Or deploy via the dashboard app selector (preferred method). + +## File Structure + +``` +ca/ +├── index.html # Landing page with OS detection +├── root.crt # Root CA certificate (PEM format) +├── root.der # Root CA certificate (DER format) +├── root.mobileconfig # Apple configuration profile +├── intermediate.crt # Intermediate CA certificate +├── cert-info.json # Certificate metadata (auto-generated) +├── scripts/ +│ ├── install.ps1 # Windows PowerShell installer +│ ├── install.sh # Linux/macOS shell installer +│ ├── generate-cert-info.js # Extract certificate metadata +│ ├── generate-mobileconfig.js # Generate Apple profile +│ └── generate-all.sh # Wrapper script to regenerate all +└── assets/ + └── (icons, logos, etc.) +``` + +## Certificate Information + +**Source:** Caddy's built-in PKI at `C:/caddy/certs/pki/authorities/local/` + +- **Name:** Sami Home Network Root CA +- **Algorithm:** ECDSA P-256 with SHA-256 +- **Valid Until:** Dec 22, 2034 +- **Fingerprint:** `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E` + +## Installation Scripts + +### Windows (install.ps1) + +Features: +- Requires Administrator privileges +- Downloads certificate from ca.sami +- Verifies SHA-256 fingerprint +- Installs to LocalMachine\Root store +- Checks for existing installation + +**One-liner:** +```powershell +irm https://ca.sami/install.ps1 | iex +``` + +### Linux/macOS (install.sh) + +Features: +- Requires sudo/root +- Auto-detects OS (Debian, RedHat, Arch, macOS) +- Platform-specific installation commands +- Fingerprint verification with OpenSSL +- Checks for existing installation + +**One-liner:** +```bash +curl -fsSL https://ca.sami/install.sh | sudo bash +``` + +### Apple Devices (root.mobileconfig) + +Features: +- Works on both iOS and macOS +- XML configuration profile format +- Contains base64-encoded certificate +- Unique UUIDs per generation +- User must manually trust after installation (iOS) + +**Installation:** +1. Download root.mobileconfig +2. iOS: Settings prompts automatically +3. macOS: System Settings → Profiles → Install +4. iOS: Enable trust in Certificate Trust Settings + +## Landing Page Features + +The landing page (`index.html`) includes: + +- **OS Detection:** Automatically detects Windows, macOS, Linux, iOS, Android +- **Certificate Info Display:** Shows name, fingerprint, expiration, algorithm +- **QR Code:** For easy mobile access (powered by qrcodejs library) +- **Download Links:** All certificate formats and installation scripts +- **Platform Tabs:** Detailed instructions for each operating system +- **Copy-to-Clipboard:** For fingerprint and command-line scripts +- **DashCaddy Theme:** Dark mode with Sami Grotesk font + +**API Integration:** +- Loads certificate info from `/api/ca/info` endpoint +- Falls back to static info if API unavailable + +## Development Workflow + +1. **Edit Files:** Make changes in `e:/CaddyCerts/sites/ca/` +2. **Test Locally:** Open `index.html` in browser (file:// protocol works) +3. **Regenerate Certificates:** Run `scripts/generate-all.sh` if CA renewed +4. **Deploy:** Copy to production or use dashboard deployment +5. **Verify:** Visit https://ca.sami and test on target platforms + +## Updating After CA Renewal + +When Caddy regenerates its CA certificate (every ~10 years): + +### 1. Regenerate Certificate Formats + +```bash +cd e:/CaddyCerts/sites/ca/scripts +bash generate-all.sh +``` + +### 2. Update Fingerprints in Scripts + +The new fingerprint will be in `cert-info.json`. Update these files: + +**install.ps1** (line 17): +```powershell +$ExpectedFingerprint = "NEW:FIN:GER:PRINT:HERE" +``` + +**install.sh** (line 13): +```bash +EXPECTED_FP="NEW:FIN:GER:PRINT:HERE" +``` + +### 3. Deploy to Production + +```bash +cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/ +``` + +### 4. Notify Users + +- Add banner to dashboard +- Send notification via configured channels +- Update documentation with new expiration date + +## API Endpoints + +DashCA integrates with DashCaddy API: + +### GET /api/ca/info + +Returns certificate metadata: + +```json +{ + "success": true, + "certificate": { + "name": "Sami Home Network Root CA", + "fingerprint": "08:98:A5:...", + "validFrom": "Feb 12 07:44:51 2025 GMT", + "validUntil": "Dec 22 07:44:51 2034 GMT", + "daysUntilExpiration": 3235, + "algorithm": "ECDSA P-256 with SHA-256", + "serialNumber": "c1:dc:48:...", + "downloadUrl": "https://ca.sami/root.crt" + } +} +``` + +### GET /api/health/ca + +Returns CA expiration health status: + +```json +{ + "status": "healthy", + "message": "CA certificate valid for 3235 days", + "daysUntilExpiration": 3235, + "expiresAt": "Dec 22 07:44:51 2034 GMT" +} +``` + +**Status values:** +- `healthy`: >90 days remaining +- `warning`: 30-90 days +- `critical`: <30 days or expired +- `error`: Certificate not found or error reading + +## Troubleshooting + +### Certificate Not Found Error + +**Symptom:** Scripts fail with "certificate not found" +**Cause:** Caddy hasn't generated the local CA yet +**Solution:** Visit any *.sami domain to trigger CA generation + +### Fingerprint Mismatch + +**Symptom:** Install scripts reject certificate with fingerprint mismatch +**Cause:** CA was renewed but scripts not updated +**Solution:** Run `generate-all.sh` and update fingerprints in install scripts + +### iOS Profile Won't Install + +**Symptom:** .mobileconfig shows error when installing +**Cause:** Invalid XML or missing UUIDs +**Solution:** Regenerate with `node generate-mobileconfig.js` + +### Android Shows "Not Trusted" + +**Symptom:** Certificate installs but sites still show warnings +**Cause:** Android installs as "user" certificate; some apps don't trust user CAs +**Solution:** This is by design. System CA installation requires root access. + +### Landing Page Shows "Loading..." + +**Symptom:** Certificate info stuck on loading state +**Cause:** API endpoint not accessible +**Solution:** Check that dashcaddy-api server is running and `/api/ca/info` responds + +## Testing Checklist + +Before deploying to production: + +- [ ] All certificate formats generated successfully +- [ ] Landing page loads correctly in browser +- [ ] OS detection works (test multiple user agents) +- [ ] QR code renders and scans correctly +- [ ] Download links work for all file types +- [ ] API endpoint returns valid certificate info +- [ ] Copy-to-clipboard buttons work +- [ ] Platform instruction tabs function correctly +- [ ] Responsive design works on mobile viewport +- [ ] HTTPS access works after deployment + +## Security Notes + +- **Private Key:** NEVER serve the CA private key (`root.key`). Only public certificates are safe to distribute. +- **Fingerprint Verification:** Install scripts verify fingerprint to prevent MITM attacks +- **Access Control:** ca.sami should only be accessible on your Tailnet/internal network +- **HTTPS Enforcement:** The page itself uses HTTPS (via Caddy's internal CA) to protect the distribution +- **No Auto-Execution:** All installation methods require explicit user action + +## Contributing + +When adding features to DashCA: + +1. Test on multiple platforms before committing +2. Update this README with new features +3. Add relevant sections to troubleshooting guide +4. Update CLAUDE.md if deployment process changes +5. Ensure backward compatibility with existing certificates + +## Resources + +- **Caddy PKI Documentation:** https://caddyserver.com/docs/caddyfile/directives/tls#pki +- **mobileconfig Format:** https://developer.apple.com/documentation/devicemanagement +- **OpenSSL Certificate Commands:** https://www.openssl.org/docs/man1.1.1/man1/x509.html +- **QR Code Library:** https://github.com/davidshimjs/qrcodejs + +--- + +**Part of the DashCaddy project** - Unified management for Docker + Caddy + DNS diff --git a/dashcaddy-api/ca/index.html b/dashcaddy-api/ca/index.html new file mode 100644 index 0000000..3d83d78 --- /dev/null +++ b/dashcaddy-api/ca/index.html @@ -0,0 +1,1243 @@ + + + + + + DashCA - Certificate Authority + + + + + + + + +
+ +
+ +

DashCA

+

Certificate Authority Distribution

+
+ + +
+

+ 📜 + Certificate Information +

+
+
+
Certificate Name
+
+ Loading... +
+
+
+
Algorithm
+
+ Loading... +
+
+
+
Valid Until
+
+ Loading... +
+
+
+
Days Remaining
+
+ Loading... +
+
+
+
SHA-256 Fingerprint
+
+ Loading... + +
+
+
+
+ + +
+ +
+ Detecting your operating system... +
+
+ + +
+

📱 Quick Access for Mobile

+
+

Scan with your phone's camera to visit this page

+
+ + +
+

+ 🔐 + Service Certificates +

+

+ SSL certificates generated for services on your network +

+
+ +
+
+ Loading... + Checking +
+
Fetching certificate status...
+
+
+
+ + + + + +
+

+ 📖 + Installation Instructions +

+ +
+ + + + + +
+ + +
+
+
1
+
+

Run PowerShell as Administrator

+

Right-click the Start button and select "Windows PowerShell (Admin)" or "Terminal (Admin)"

+
+
+
+
2
+
+

Run the installation command

+

Copy and paste this command into PowerShell:

+
+ + irm https://ca.sami/install.ps1 | iex +
+
+
+
+
3
+
+

Verify installation

+

Visit any *.sami domain (like https://status.sami) - you should see a secure connection with no warnings.

+
+
+
+ + +
+
+
1
+
+

Download the certificate profile

+

Click "Apple Profile" above to download root.mobileconfig

+
+
+
+
2
+
+

Install the profile

+

Open System Settings → Privacy & Security → Profiles. Click the downloaded profile and click "Install".

+
+
+
+
3
+
+

Alternative: Command line installation

+

Open Terminal and run:

+
+ + curl -fsSL https://ca.sami/install.sh | sudo bash +
+
+
+
+ + +
+
+
1
+
+

Open Terminal

+

Open your terminal application (usually Ctrl+Alt+T)

+
+
+
+
2
+
+

Run the installation command

+

Copy and paste this command (will prompt for sudo password):

+
+ + curl -fsSL https://ca.sami/install.sh | sudo bash +
+
+
+
+
3
+
+

Supported distributions

+

The installer supports Debian/Ubuntu, RedHat/CentOS/Fedora, and Arch Linux. It will automatically detect your distribution and use the appropriate commands.

+
+
+
+ + +
+
+
1
+
+

Scan QR code or visit this page

+

Use your iPhone's camera to scan the QR code above, or visit https://ca.sami directly in Safari

+
+
+
+
2
+
+

Download the profile

+

Tap "Apple Profile" to download root.mobileconfig. You'll see a notification that the profile was downloaded.

+
+
+
+
3
+
+

Install the profile

+

Go to Settings (you should see a notification), tap on the downloaded profile, and tap "Install". Enter your passcode if prompted.

+
+
+
+
4
+
+

Trust the certificate

+

Go to Settings → General → About → Certificate Trust Settings. Enable full trust for "Sami Home Network Root CA".

+
+
+
+ + +
+
+
1
+
+

Download the certificate

+

Tap "Root Certificate (.crt)" above to download the certificate to your device

+
+
+
+
2
+
+

Install the certificate

+

Open Settings → Security → Encryption & credentials → Install a certificate → CA certificate. Select the downloaded file.

+
+
+
+
3
+
+

Note about user certificates

+

Android installs this as a "user" certificate. Some apps may not trust user certificates for security reasons. For full trust, you would need to install it as a system certificate (requires root access).

+
+
+
+
+ + + +
+ + + + diff --git a/dashcaddy-api/ca/install.ps1 b/dashcaddy-api/ca/install.ps1 new file mode 100644 index 0000000..cc28a37 --- /dev/null +++ b/dashcaddy-api/ca/install.ps1 @@ -0,0 +1,132 @@ +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Installs the Sami Home Network Root CA certificate to the Trusted Root Certification Authorities store. + +.DESCRIPTION + This script downloads the root CA certificate from ca.sami, verifies its fingerprint, + and installs it to the local machine's trusted root store. This allows all *.sami domains + to be trusted system-wide without browser warnings. + +.NOTES + Requires Administrator privileges. + For use with DashCA - https://ca.sami +#> + +$ErrorActionPreference = "Stop" + +# Configuration +$CertUrl = "https://ca.sami/root.crt" +$ExpectedFingerprint = "0898A563F5A1A2585F02D7A8A25487E6BC33969F9B5DB053622 07FAF9621290E" +$TempFile = "$env:TEMP\sami-root-ca.crt" + +# Colors +$Red = [System.ConsoleColor]::Red +$Green = [System.ConsoleColor]::Green +$Cyan = [System.ConsoleColor]::Cyan +$Yellow = [System.ConsoleColor]::Yellow + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host " DashCA Installer" -ForegroundColor $Cyan +Write-Host " Sami Home Network Root CA" -ForegroundColor $Cyan +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host "" + +# Step 1: Download certificate +Write-Host "[1/4] Downloading certificate from $CertUrl..." -ForegroundColor $Cyan +try { + $ProgressPreference = 'SilentlyContinue' # Disable progress bar for faster download + Invoke-WebRequest -Uri $CertUrl -OutFile $TempFile -UseBasicParsing -ErrorAction Stop + Write-Host " ✓ Certificate downloaded" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to download certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Write-Host "" + Write-Host "Troubleshooting:" -ForegroundColor $Yellow + Write-Host " - Ensure you are on the Tailnet/network where ca.sami is accessible" -ForegroundColor $Yellow + Write-Host " - Try accessing https://ca.sami in your browser first" -ForegroundColor $Yellow + exit 1 +} + +# Step 2: Verify fingerprint +Write-Host "[2/4] Verifying certificate fingerprint..." -ForegroundColor $Cyan +try { + $Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($TempFile) + $Fingerprint = $Cert.Thumbprint + + $NormalizedExpected = $ExpectedFingerprint -replace '[:\s]', '' + $NormalizedActual = $Fingerprint -replace '[:\s]', '' + + if ($NormalizedActual -ne $NormalizedExpected) { + Write-Host " ✗ Fingerprint mismatch!" -ForegroundColor $Red + Write-Host " Expected: $ExpectedFingerprint" -ForegroundColor $Yellow + Write-Host " Got: $Fingerprint" -ForegroundColor $Red + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "SECURITY WARNING: The downloaded certificate does not match the expected fingerprint." -ForegroundColor $Red + Write-Host "This could indicate a man-in-the-middle attack or certificate renewal." -ForegroundColor $Red + Write-Host "Please verify with your network administrator before proceeding." -ForegroundColor $Red + exit 1 + } + + Write-Host " ✓ Fingerprint verified: $Fingerprint" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to verify fingerprint" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + exit 1 +} + +# Step 3: Check if already installed +Write-Host "[3/4] Checking for existing certificate..." -ForegroundColor $Cyan +$ExistingCert = Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Fingerprint } +if ($ExistingCert) { + Write-Host " ℹ Certificate already installed" -ForegroundColor $Yellow + Write-Host " Subject: $($ExistingCert.Subject)" -ForegroundColor $Yellow + Write-Host " Not After: $($ExistingCert.NotAfter)" -ForegroundColor $Yellow + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "The Sami Home Network Root CA is already trusted on this system." -ForegroundColor $Green + Write-Host "No further action needed!" -ForegroundColor $Green + Write-Host "" + exit 0 +} +Write-Host " ✓ Certificate not yet installed, proceeding..." -ForegroundColor $Green + +# Step 4: Install certificate +Write-Host "[4/4] Installing certificate to Trusted Root store..." -ForegroundColor $Cyan +try { + $ImportedCert = Import-Certificate -FilePath $TempFile -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction Stop + Write-Host " ✓ Certificate installed successfully" -ForegroundColor $Green + Write-Host " Subject: $($ImportedCert.Subject)" -ForegroundColor $Green + Write-Host " Thumbprint: $($ImportedCert.Thumbprint)" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to install certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + Write-Host "" + Write-Host "Installation failed. Please ensure you are running as Administrator." -ForegroundColor $Red + exit 1 +} + +# Cleanup +Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Green +Write-Host " SUCCESS!" -ForegroundColor $Green +Write-Host "========================================" -ForegroundColor $Green +Write-Host "" +Write-Host "The Sami Home Network Root CA has been installed to your Trusted Root store." -ForegroundColor $Green +Write-Host "" +Write-Host "What's next:" -ForegroundColor $Cyan +Write-Host " ✓ All *.sami domains will now be trusted system-wide" -ForegroundColor $Green +Write-Host " ✓ Browsers (Edge, Chrome, Firefox) will no longer show security warnings" -ForegroundColor $Green +Write-Host " ✓ Applications will trust HTTPS connections to your local services" -ForegroundColor $Green +Write-Host "" +Write-Host "Test it out:" -ForegroundColor $Cyan +Write-Host " Visit https://status.sami or any other *.sami service" -ForegroundColor $Yellow +Write-Host " The connection should show as secure with no warnings" -ForegroundColor $Yellow +Write-Host "" diff --git a/dashcaddy-api/ca/install.sh b/dashcaddy-api/ca/install.sh new file mode 100644 index 0000000..0fcb365 --- /dev/null +++ b/dashcaddy-api/ca/install.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# +# DashCA Installer - Sami Home Network Root CA +# Installs the root CA certificate system-wide on Linux and macOS +# +# Usage: curl -fsSL https://ca.sami/install.sh | sudo bash +# +set -e + +# Configuration +CERT_URL="https://ca.sami/root.crt" +EXPECTED_FP="08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:9F:9B:5D:B0:53:62:20:7F:AF:96:21:29:0E" +CERT_NAME="Sami_Home_Network_Root_CA" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "" +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} DashCA Installer${NC}" +echo -e "${CYAN} Sami Home Network Root CA${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check for root/sudo +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}✗ This script requires root privileges${NC}" + echo "" + echo "Please run with sudo:" + echo -e " ${YELLOW}curl -fsSL https://ca.sami/install.sh | sudo bash${NC}" + echo "" + echo "Or download first, then run:" + echo -e " ${YELLOW}curl -o install.sh https://ca.sami/install.sh${NC}" + echo -e " ${YELLOW}sudo bash install.sh${NC}" + echo "" + exit 1 +fi + +# Detect OS +echo -e "${CYAN}[1/6] Detecting operating system...${NC}" +if [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + OS_NAME="macOS" +elif [[ -f /etc/os-release ]]; then + . /etc/os-release + if [[ "$ID" == "debian" ]] || [[ "$ID" == "ubuntu" ]] || [[ "$ID_LIKE" == *"debian"* ]]; then + OS="debian" + OS_NAME="Debian/Ubuntu" + elif [[ "$ID" == "fedora" ]] || [[ "$ID" == "rhel" ]] || [[ "$ID" == "centos" ]] || [[ "$ID_LIKE" == *"fedora"* ]] || [[ "$ID_LIKE" == *"rhel"* ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS/Fedora" + elif [[ "$ID" == "arch" ]] || [[ "$ID_LIKE" == *"arch"* ]]; then + OS="arch" + OS_NAME="Arch Linux" + else + OS="unknown" + OS_NAME="Unknown Linux" + fi +elif [[ -f /etc/redhat-release ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS" +elif [[ -f /etc/arch-release ]]; then + OS="arch" + OS_NAME="Arch Linux" +else + OS="unknown" + OS_NAME="Unknown" +fi + +if [[ "$OS" == "unknown" ]]; then + echo -e "${RED} ✗ Unsupported operating system${NC}" + echo "" + echo "This script supports:" + echo " - Debian/Ubuntu" + echo " - RedHat/CentOS/Fedora" + echo " - Arch Linux" + echo " - macOS" + echo "" + echo "For manual installation, download the certificate:" + echo -e " ${YELLOW}curl -O $CERT_URL${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Detected: $OS_NAME${NC}" + +# Download certificate +echo -e "${CYAN}[2/6] Downloading certificate from $CERT_URL...${NC}" +TEMP_CERT=$(mktemp) +if ! curl -fsSL "$CERT_URL" -o "$TEMP_CERT"; then + echo -e "${RED} ✗ Failed to download certificate${NC}" + echo "" + echo -e "${YELLOW}Troubleshooting:${NC}" + echo " - Ensure you are on the Tailnet/network where ca.sami is accessible" + echo " - Try accessing https://ca.sami in your browser first" + echo " - Check your network connection" + rm -f "$TEMP_CERT" + exit 1 +fi +echo -e "${GREEN} ✓ Certificate downloaded${NC}" + +# Verify fingerprint +echo -e "${CYAN}[3/6] Verifying certificate fingerprint...${NC}" +if ! command -v openssl &> /dev/null; then + echo -e "${RED} ✗ OpenSSL not found${NC}" + echo "Please install OpenSSL to verify certificate fingerprint" + rm -f "$TEMP_CERT" + exit 1 +fi + +ACTUAL_FP=$(openssl x509 -in "$TEMP_CERT" -noout -fingerprint -sha256 | cut -d= -f2) + +if [[ "$ACTUAL_FP" != "$EXPECTED_FP" ]]; then + echo -e "${RED} ✗ Fingerprint mismatch!${NC}" + echo -e "${YELLOW} Expected: $EXPECTED_FP${NC}" + echo -e "${RED} Got: $ACTUAL_FP${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${RED}SECURITY WARNING: The downloaded certificate does not match the expected fingerprint.${NC}" + echo -e "${RED}This could indicate a man-in-the-middle attack or certificate renewal.${NC}" + echo -e "${RED}Please verify with your network administrator before proceeding.${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Fingerprint verified${NC}" + +# Extract certificate details +echo -e "${CYAN}[4/6] Extracting certificate information...${NC}" +CERT_SUBJECT=$(openssl x509 -in "$TEMP_CERT" -noout -subject | sed 's/subject=//') +CERT_NOT_AFTER=$(openssl x509 -in "$TEMP_CERT" -noout -enddate | sed 's/notAfter=//') +echo -e "${GREEN} ✓ Subject: $CERT_SUBJECT${NC}" +echo -e "${GREEN} ✓ Valid until: $CERT_NOT_AFTER${NC}" + +# Check if already installed +echo -e "${CYAN}[5/6] Checking for existing installation...${NC}" +ALREADY_INSTALLED=false + +case "$OS" in + debian) + if [[ -f "/usr/local/share/ca-certificates/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + redhat) + if [[ -f "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + arch) + if [[ -f "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + macos) + if security find-certificate -a -c "$CERT_SUBJECT" /Library/Keychains/System.keychain &>/dev/null; then + ALREADY_INSTALLED=true + fi + ;; +esac + +if [[ "$ALREADY_INSTALLED" == "true" ]]; then + echo -e "${YELLOW} ℹ Certificate already installed${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${GREEN}The Sami Home Network Root CA is already trusted on this system.${NC}" + echo -e "${GREEN}No further action needed!${NC}" + echo "" + exit 0 +fi + +echo -e "${GREEN} ✓ Certificate not yet installed, proceeding...${NC}" + +# Install based on OS +echo -e "${CYAN}[6/6] Installing certificate...${NC}" +case "$OS" in + debian) + cp "$TEMP_CERT" "/usr/local/share/ca-certificates/${CERT_NAME}.crt" + update-ca-certificates + echo -e "${GREEN} ✓ Certificate installed via update-ca-certificates${NC}" + ;; + redhat) + cp "$TEMP_CERT" "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" + update-ca-trust + echo -e "${GREEN} ✓ Certificate installed via update-ca-trust${NC}" + ;; + arch) + cp "$TEMP_CERT" "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" + trust extract-compat + echo -e "${GREEN} ✓ Certificate installed via trust extract-compat${NC}" + ;; + macos) + security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$TEMP_CERT" + echo -e "${GREEN} ✓ Certificate installed to System Keychain${NC}" + ;; +esac + +# Cleanup +rm -f "$TEMP_CERT" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} SUCCESS!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "${GREEN}The Sami Home Network Root CA has been installed system-wide.${NC}" +echo "" +echo -e "${CYAN}What's next:${NC}" +echo -e " ${GREEN}✓${NC} All *.sami domains will now be trusted" +echo -e " ${GREEN}✓${NC} Browsers will no longer show security warnings" +echo -e " ${GREEN}✓${NC} Applications will trust HTTPS connections to your local services" +echo "" +echo -e "${CYAN}Test it out:${NC}" +echo -e " ${YELLOW}Visit https://status.sami or any other *.sami service${NC}" +echo -e " ${YELLOW}The connection should show as secure with no warnings${NC}" +echo "" diff --git a/dashcaddy-api/ca/intermediate.crt b/dashcaddy-api/ca/intermediate.crt new file mode 100644 index 0000000..d992ac2 --- /dev/null +++ b/dashcaddy-api/ca/intermediate.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/ca/root.crt b/dashcaddy-api/ca/root.crt new file mode 100644 index 0000000..97a4ce4 --- /dev/null +++ b/dashcaddy-api/ca/root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/ca/root.der b/dashcaddy-api/ca/root.der new file mode 100644 index 0000000..b22c573 Binary files /dev/null and b/dashcaddy-api/ca/root.der differ diff --git a/dashcaddy-api/ca/root.mobileconfig b/dashcaddy-api/ca/root.mobileconfig new file mode 100644 index 0000000..611fd5e --- /dev/null +++ b/dashcaddy-api/ca/root.mobileconfig @@ -0,0 +1,45 @@ + + + + + PayloadContent + + + PayloadCertificateFileName + root.crt + PayloadContent + + MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEiMCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3JrIFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgkwyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8Cyam9Y42t1K8Fx5q5iy+bs8w= + + PayloadDescription + Root CA certificate for Sami Home Network + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca.root-ca + PayloadType + com.apple.security.root + PayloadUUID + 059F6B88-E62A-4219-90D5-7FABBE83540A + PayloadVersion + 1 + + + PayloadDescription + Install the Sami Home Network Root CA to trust locally-issued certificates for *.sami domains. + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca + PayloadOrganization + Sami Home Network + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + AF495D1C-16AF-44A7-8C6C-173CC8E82FC3 + PayloadVersion + 1 + + diff --git a/dashcaddy-api/ca/scripts/generate-all.sh b/dashcaddy-api/ca/scripts/generate-all.sh new file mode 100644 index 0000000..3746d80 --- /dev/null +++ b/dashcaddy-api/ca/scripts/generate-all.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -e + +# DashCA Certificate Generation Script +# This script generates all required certificate formats + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CA_DIR="$(dirname "$SCRIPT_DIR")" +CADDY_CERT_DIR="C:/caddy/certs/pki/authorities/local" + +echo "======================================" +echo "DashCA Certificate Format Generator" +echo "======================================" +echo "" + +# Step 1: Copy certificates from Caddy +echo "[1/4] Copying certificates from Caddy PKI..." +if [ ! -f "$CADDY_CERT_DIR/root.crt" ]; then + echo "ERROR: Root certificate not found at $CADDY_CERT_DIR/root.crt" + exit 1 +fi + +cp "$CADDY_CERT_DIR/root.crt" "$CA_DIR/" +cp "$CADDY_CERT_DIR/intermediate.crt" "$CA_DIR/" 2>/dev/null || echo " (Intermediate certificate not found, skipping)" +echo " ✓ Certificates copied" + +# Step 2: Generate DER format +echo "[2/4] Generating DER format..." +openssl x509 -in "$CA_DIR/root.crt" -outform DER -out "$CA_DIR/root.der" +echo " ✓ DER format generated: root.der" + +# Step 3: Generate certificate info JSON +echo "[3/4] Extracting certificate metadata..." +node "$SCRIPT_DIR/generate-cert-info.js" + +# Step 4: Generate Apple mobileconfig +echo "[4/4] Generating Apple mobile configuration profile..." +node "$SCRIPT_DIR/generate-mobileconfig.js" + +echo "" +echo "======================================" +echo "✓ All certificate formats generated!" +echo "======================================" +echo "" +echo "Files created in: $CA_DIR" +ls -lh "$CA_DIR"/*.{crt,der,mobileconfig,json} 2>/dev/null || echo "Files created successfully" +echo "" +echo "To deploy to production:" +echo " cp -r $CA_DIR/* C:/caddy/sites/ca/" +echo "" diff --git a/dashcaddy-api/ca/scripts/generate-cert-info.js b/dashcaddy-api/ca/scripts/generate-cert-info.js new file mode 100644 index 0000000..d3d22c9 --- /dev/null +++ b/dashcaddy-api/ca/scripts/generate-cert-info.js @@ -0,0 +1,75 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const CERT_PATH = path.join(__dirname, '../root.crt'); +const OUTPUT_PATH = path.join(__dirname, '../cert-info.json'); + +function extractCertInfo() { + try { + console.log('Extracting certificate information from:', CERT_PATH); + + // Extract SHA-256 fingerprint + const fingerprint = execSync(`openssl x509 -in "${CERT_PATH}" -noout -fingerprint -sha256`) + .toString() + .trim() + .split('=')[1]; + + // Extract validity dates + const dates = execSync(`openssl x509 -in "${CERT_PATH}" -noout -dates`).toString(); + const notBefore = dates.match(/notBefore=(.*)/)[1].trim(); + const notAfter = dates.match(/notAfter=(.*)/)[1].trim(); + + // Extract subject + const subject = execSync(`openssl x509 -in "${CERT_PATH}" -noout -subject`) + .toString() + .trim() + .split('CN = ')[1] || execSync(`openssl x509 -in "${CERT_PATH}" -noout -subject`) + .toString() + .trim() + .split('CN=')[1]; + + // Extract serial number + const serialNumber = execSync(`openssl x509 -in "${CERT_PATH}" -noout -serial`) + .toString() + .trim() + .split('=')[1]; + + // Calculate days until expiration + const expirationDate = new Date(notAfter); + const today = new Date(); + const daysUntilExpiration = Math.floor((expirationDate - today) / (1000 * 60 * 60 * 24)); + + const certInfo = { + name: subject, + fingerprint: fingerprint, + validFrom: notBefore, + validUntil: notAfter, + daysUntilExpiration: daysUntilExpiration, + algorithm: 'ECDSA P-256 with SHA-256', + issuer: subject, // Self-signed root CA + serialNumber: serialNumber, + generatedAt: new Date().toISOString() + }; + + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(certInfo, null, 2)); + console.log('✓ Certificate information extracted successfully!'); + console.log(' Output:', OUTPUT_PATH); + console.log(' Name:', certInfo.name); + console.log(' Fingerprint:', certInfo.fingerprint); + console.log(' Valid until:', certInfo.validUntil); + console.log(' Days until expiration:', certInfo.daysUntilExpiration); + + return certInfo; + } catch (error) { + console.error('Error extracting certificate information:', error.message); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + extractCertInfo(); +} + +module.exports = { extractCertInfo }; diff --git a/dashcaddy-api/ca/scripts/generate-mobileconfig.js b/dashcaddy-api/ca/scripts/generate-mobileconfig.js new file mode 100644 index 0000000..77fe85e --- /dev/null +++ b/dashcaddy-api/ca/scripts/generate-mobileconfig.js @@ -0,0 +1,105 @@ +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); + +const CERT_PATH = path.join(__dirname, '../root.crt'); +const OUTPUT_PATH = path.join(__dirname, '../root.mobileconfig'); + +function generateUUID() { + return crypto.randomUUID().toUpperCase(); +} + +function generateMobileConfig() { + try { + console.log('Generating Apple mobile configuration profile...'); + console.log('Reading certificate from:', CERT_PATH); + + // Read certificate + const certPem = fs.readFileSync(CERT_PATH, 'utf8'); + + // Extract base64 content (remove PEM headers and newlines) + const certBase64 = certPem + .replace('-----BEGIN CERTIFICATE-----', '') + .replace('-----END CERTIFICATE-----', '') + .replace(/\s/g, ''); + + // Generate UUIDs for profile and payload + const profileUUID = generateUUID(); + const payloadUUID = generateUUID(); + + const mobileconfig = ` + + + + PayloadContent + + + PayloadCertificateFileName + root.crt + PayloadContent + + ${certBase64} + + PayloadDescription + Root CA certificate for Sami Home Network + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca.root-ca + PayloadType + com.apple.security.root + PayloadUUID + ${payloadUUID} + PayloadVersion + 1 + + + PayloadDescription + Install the Sami Home Network Root CA to trust locally-issued certificates for *.sami domains. + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca + PayloadOrganization + Sami Home Network + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + ${profileUUID} + PayloadVersion + 1 + + +`; + + fs.writeFileSync(OUTPUT_PATH, mobileconfig); + console.log('✓ Mobile configuration profile generated successfully!'); + console.log(' Output:', OUTPUT_PATH); + console.log(' Profile UUID:', profileUUID); + console.log(' Payload UUID:', payloadUUID); + console.log('\nTo install on iOS:'); + console.log(' 1. Download root.mobileconfig to your device'); + console.log(' 2. Open Settings app (it should prompt automatically)'); + console.log(' 3. Tap "Install Profile" and follow the prompts'); + console.log(' 4. Go to Settings > General > About > Certificate Trust Settings'); + console.log(' 5. Enable full trust for "Sami Home Network Root CA"'); + console.log('\nTo install on macOS:'); + console.log(' 1. Download root.mobileconfig'); + console.log(' 2. Open System Settings > Privacy & Security > Profiles'); + console.log(' 3. Click the profile and click Install'); + + return { profileUUID, payloadUUID }; + } catch (error) { + console.error('Error generating mobile configuration profile:', error.message); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + generateMobileConfig(); +} + +module.exports = { generateMobileConfig }; diff --git a/dashcaddy-api/ca/scripts/install.ps1 b/dashcaddy-api/ca/scripts/install.ps1 new file mode 100644 index 0000000..cc28a37 --- /dev/null +++ b/dashcaddy-api/ca/scripts/install.ps1 @@ -0,0 +1,132 @@ +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Installs the Sami Home Network Root CA certificate to the Trusted Root Certification Authorities store. + +.DESCRIPTION + This script downloads the root CA certificate from ca.sami, verifies its fingerprint, + and installs it to the local machine's trusted root store. This allows all *.sami domains + to be trusted system-wide without browser warnings. + +.NOTES + Requires Administrator privileges. + For use with DashCA - https://ca.sami +#> + +$ErrorActionPreference = "Stop" + +# Configuration +$CertUrl = "https://ca.sami/root.crt" +$ExpectedFingerprint = "0898A563F5A1A2585F02D7A8A25487E6BC33969F9B5DB053622 07FAF9621290E" +$TempFile = "$env:TEMP\sami-root-ca.crt" + +# Colors +$Red = [System.ConsoleColor]::Red +$Green = [System.ConsoleColor]::Green +$Cyan = [System.ConsoleColor]::Cyan +$Yellow = [System.ConsoleColor]::Yellow + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host " DashCA Installer" -ForegroundColor $Cyan +Write-Host " Sami Home Network Root CA" -ForegroundColor $Cyan +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host "" + +# Step 1: Download certificate +Write-Host "[1/4] Downloading certificate from $CertUrl..." -ForegroundColor $Cyan +try { + $ProgressPreference = 'SilentlyContinue' # Disable progress bar for faster download + Invoke-WebRequest -Uri $CertUrl -OutFile $TempFile -UseBasicParsing -ErrorAction Stop + Write-Host " ✓ Certificate downloaded" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to download certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Write-Host "" + Write-Host "Troubleshooting:" -ForegroundColor $Yellow + Write-Host " - Ensure you are on the Tailnet/network where ca.sami is accessible" -ForegroundColor $Yellow + Write-Host " - Try accessing https://ca.sami in your browser first" -ForegroundColor $Yellow + exit 1 +} + +# Step 2: Verify fingerprint +Write-Host "[2/4] Verifying certificate fingerprint..." -ForegroundColor $Cyan +try { + $Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($TempFile) + $Fingerprint = $Cert.Thumbprint + + $NormalizedExpected = $ExpectedFingerprint -replace '[:\s]', '' + $NormalizedActual = $Fingerprint -replace '[:\s]', '' + + if ($NormalizedActual -ne $NormalizedExpected) { + Write-Host " ✗ Fingerprint mismatch!" -ForegroundColor $Red + Write-Host " Expected: $ExpectedFingerprint" -ForegroundColor $Yellow + Write-Host " Got: $Fingerprint" -ForegroundColor $Red + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "SECURITY WARNING: The downloaded certificate does not match the expected fingerprint." -ForegroundColor $Red + Write-Host "This could indicate a man-in-the-middle attack or certificate renewal." -ForegroundColor $Red + Write-Host "Please verify with your network administrator before proceeding." -ForegroundColor $Red + exit 1 + } + + Write-Host " ✓ Fingerprint verified: $Fingerprint" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to verify fingerprint" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + exit 1 +} + +# Step 3: Check if already installed +Write-Host "[3/4] Checking for existing certificate..." -ForegroundColor $Cyan +$ExistingCert = Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Fingerprint } +if ($ExistingCert) { + Write-Host " ℹ Certificate already installed" -ForegroundColor $Yellow + Write-Host " Subject: $($ExistingCert.Subject)" -ForegroundColor $Yellow + Write-Host " Not After: $($ExistingCert.NotAfter)" -ForegroundColor $Yellow + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "The Sami Home Network Root CA is already trusted on this system." -ForegroundColor $Green + Write-Host "No further action needed!" -ForegroundColor $Green + Write-Host "" + exit 0 +} +Write-Host " ✓ Certificate not yet installed, proceeding..." -ForegroundColor $Green + +# Step 4: Install certificate +Write-Host "[4/4] Installing certificate to Trusted Root store..." -ForegroundColor $Cyan +try { + $ImportedCert = Import-Certificate -FilePath $TempFile -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction Stop + Write-Host " ✓ Certificate installed successfully" -ForegroundColor $Green + Write-Host " Subject: $($ImportedCert.Subject)" -ForegroundColor $Green + Write-Host " Thumbprint: $($ImportedCert.Thumbprint)" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to install certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + Write-Host "" + Write-Host "Installation failed. Please ensure you are running as Administrator." -ForegroundColor $Red + exit 1 +} + +# Cleanup +Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Green +Write-Host " SUCCESS!" -ForegroundColor $Green +Write-Host "========================================" -ForegroundColor $Green +Write-Host "" +Write-Host "The Sami Home Network Root CA has been installed to your Trusted Root store." -ForegroundColor $Green +Write-Host "" +Write-Host "What's next:" -ForegroundColor $Cyan +Write-Host " ✓ All *.sami domains will now be trusted system-wide" -ForegroundColor $Green +Write-Host " ✓ Browsers (Edge, Chrome, Firefox) will no longer show security warnings" -ForegroundColor $Green +Write-Host " ✓ Applications will trust HTTPS connections to your local services" -ForegroundColor $Green +Write-Host "" +Write-Host "Test it out:" -ForegroundColor $Cyan +Write-Host " Visit https://status.sami or any other *.sami service" -ForegroundColor $Yellow +Write-Host " The connection should show as secure with no warnings" -ForegroundColor $Yellow +Write-Host "" diff --git a/dashcaddy-api/ca/scripts/install.sh b/dashcaddy-api/ca/scripts/install.sh new file mode 100644 index 0000000..0fcb365 --- /dev/null +++ b/dashcaddy-api/ca/scripts/install.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# +# DashCA Installer - Sami Home Network Root CA +# Installs the root CA certificate system-wide on Linux and macOS +# +# Usage: curl -fsSL https://ca.sami/install.sh | sudo bash +# +set -e + +# Configuration +CERT_URL="https://ca.sami/root.crt" +EXPECTED_FP="08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:9F:9B:5D:B0:53:62:20:7F:AF:96:21:29:0E" +CERT_NAME="Sami_Home_Network_Root_CA" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "" +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} DashCA Installer${NC}" +echo -e "${CYAN} Sami Home Network Root CA${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check for root/sudo +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}✗ This script requires root privileges${NC}" + echo "" + echo "Please run with sudo:" + echo -e " ${YELLOW}curl -fsSL https://ca.sami/install.sh | sudo bash${NC}" + echo "" + echo "Or download first, then run:" + echo -e " ${YELLOW}curl -o install.sh https://ca.sami/install.sh${NC}" + echo -e " ${YELLOW}sudo bash install.sh${NC}" + echo "" + exit 1 +fi + +# Detect OS +echo -e "${CYAN}[1/6] Detecting operating system...${NC}" +if [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + OS_NAME="macOS" +elif [[ -f /etc/os-release ]]; then + . /etc/os-release + if [[ "$ID" == "debian" ]] || [[ "$ID" == "ubuntu" ]] || [[ "$ID_LIKE" == *"debian"* ]]; then + OS="debian" + OS_NAME="Debian/Ubuntu" + elif [[ "$ID" == "fedora" ]] || [[ "$ID" == "rhel" ]] || [[ "$ID" == "centos" ]] || [[ "$ID_LIKE" == *"fedora"* ]] || [[ "$ID_LIKE" == *"rhel"* ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS/Fedora" + elif [[ "$ID" == "arch" ]] || [[ "$ID_LIKE" == *"arch"* ]]; then + OS="arch" + OS_NAME="Arch Linux" + else + OS="unknown" + OS_NAME="Unknown Linux" + fi +elif [[ -f /etc/redhat-release ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS" +elif [[ -f /etc/arch-release ]]; then + OS="arch" + OS_NAME="Arch Linux" +else + OS="unknown" + OS_NAME="Unknown" +fi + +if [[ "$OS" == "unknown" ]]; then + echo -e "${RED} ✗ Unsupported operating system${NC}" + echo "" + echo "This script supports:" + echo " - Debian/Ubuntu" + echo " - RedHat/CentOS/Fedora" + echo " - Arch Linux" + echo " - macOS" + echo "" + echo "For manual installation, download the certificate:" + echo -e " ${YELLOW}curl -O $CERT_URL${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Detected: $OS_NAME${NC}" + +# Download certificate +echo -e "${CYAN}[2/6] Downloading certificate from $CERT_URL...${NC}" +TEMP_CERT=$(mktemp) +if ! curl -fsSL "$CERT_URL" -o "$TEMP_CERT"; then + echo -e "${RED} ✗ Failed to download certificate${NC}" + echo "" + echo -e "${YELLOW}Troubleshooting:${NC}" + echo " - Ensure you are on the Tailnet/network where ca.sami is accessible" + echo " - Try accessing https://ca.sami in your browser first" + echo " - Check your network connection" + rm -f "$TEMP_CERT" + exit 1 +fi +echo -e "${GREEN} ✓ Certificate downloaded${NC}" + +# Verify fingerprint +echo -e "${CYAN}[3/6] Verifying certificate fingerprint...${NC}" +if ! command -v openssl &> /dev/null; then + echo -e "${RED} ✗ OpenSSL not found${NC}" + echo "Please install OpenSSL to verify certificate fingerprint" + rm -f "$TEMP_CERT" + exit 1 +fi + +ACTUAL_FP=$(openssl x509 -in "$TEMP_CERT" -noout -fingerprint -sha256 | cut -d= -f2) + +if [[ "$ACTUAL_FP" != "$EXPECTED_FP" ]]; then + echo -e "${RED} ✗ Fingerprint mismatch!${NC}" + echo -e "${YELLOW} Expected: $EXPECTED_FP${NC}" + echo -e "${RED} Got: $ACTUAL_FP${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${RED}SECURITY WARNING: The downloaded certificate does not match the expected fingerprint.${NC}" + echo -e "${RED}This could indicate a man-in-the-middle attack or certificate renewal.${NC}" + echo -e "${RED}Please verify with your network administrator before proceeding.${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Fingerprint verified${NC}" + +# Extract certificate details +echo -e "${CYAN}[4/6] Extracting certificate information...${NC}" +CERT_SUBJECT=$(openssl x509 -in "$TEMP_CERT" -noout -subject | sed 's/subject=//') +CERT_NOT_AFTER=$(openssl x509 -in "$TEMP_CERT" -noout -enddate | sed 's/notAfter=//') +echo -e "${GREEN} ✓ Subject: $CERT_SUBJECT${NC}" +echo -e "${GREEN} ✓ Valid until: $CERT_NOT_AFTER${NC}" + +# Check if already installed +echo -e "${CYAN}[5/6] Checking for existing installation...${NC}" +ALREADY_INSTALLED=false + +case "$OS" in + debian) + if [[ -f "/usr/local/share/ca-certificates/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + redhat) + if [[ -f "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + arch) + if [[ -f "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + macos) + if security find-certificate -a -c "$CERT_SUBJECT" /Library/Keychains/System.keychain &>/dev/null; then + ALREADY_INSTALLED=true + fi + ;; +esac + +if [[ "$ALREADY_INSTALLED" == "true" ]]; then + echo -e "${YELLOW} ℹ Certificate already installed${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${GREEN}The Sami Home Network Root CA is already trusted on this system.${NC}" + echo -e "${GREEN}No further action needed!${NC}" + echo "" + exit 0 +fi + +echo -e "${GREEN} ✓ Certificate not yet installed, proceeding...${NC}" + +# Install based on OS +echo -e "${CYAN}[6/6] Installing certificate...${NC}" +case "$OS" in + debian) + cp "$TEMP_CERT" "/usr/local/share/ca-certificates/${CERT_NAME}.crt" + update-ca-certificates + echo -e "${GREEN} ✓ Certificate installed via update-ca-certificates${NC}" + ;; + redhat) + cp "$TEMP_CERT" "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" + update-ca-trust + echo -e "${GREEN} ✓ Certificate installed via update-ca-trust${NC}" + ;; + arch) + cp "$TEMP_CERT" "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" + trust extract-compat + echo -e "${GREEN} ✓ Certificate installed via trust extract-compat${NC}" + ;; + macos) + security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$TEMP_CERT" + echo -e "${GREEN} ✓ Certificate installed to System Keychain${NC}" + ;; +esac + +# Cleanup +rm -f "$TEMP_CERT" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} SUCCESS!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "${GREEN}The Sami Home Network Root CA has been installed system-wide.${NC}" +echo "" +echo -e "${CYAN}What's next:${NC}" +echo -e " ${GREEN}✓${NC} All *.sami domains will now be trusted" +echo -e " ${GREEN}✓${NC} Browsers will no longer show security warnings" +echo -e " ${GREEN}✓${NC} Applications will trust HTTPS connections to your local services" +echo "" +echo -e "${CYAN}Test it out:${NC}" +echo -e " ${YELLOW}Visit https://status.sami or any other *.sami service${NC}" +echo -e " ${YELLOW}The connection should show as secure with no warnings${NC}" +echo "" diff --git a/dashcaddy-api/cache-config.js b/dashcaddy-api/cache-config.js index 60da47d..7f1fa36 100644 --- a/dashcaddy-api/cache-config.js +++ b/dashcaddy-api/cache-config.js @@ -13,7 +13,7 @@ const CACHE_CONFIGS = { max: 500, // Max 500 different services ttl: 60 * 60 * 1000, // 1 hour TTL updateAgeOnGet: true, // Refresh TTL on access - ttlAutopurge: true, // Auto-cleanup expired entries + ttlAutopurge: true // Auto-cleanup expired entries }, // IP-based router sessions (Frontier NVG468MQ) @@ -21,7 +21,7 @@ const CACHE_CONFIGS = { max: 1000, // Support up to 1000 IP addresses ttl: 24 * 60 * 60 * 1000, // 24 hour TTL updateAgeOnGet: true, - ttlAutopurge: true, + ttlAutopurge: true }, // DNS server authentication tokens (Technitium) @@ -29,7 +29,7 @@ const CACHE_CONFIGS = { max: 50, // Max 50 DNS servers ttl: 6 * 60 * 60 * 1000, // 6 hour TTL (matches SESSION_TTL.DNS_TOKEN) updateAgeOnGet: false, // Don't refresh - tokens have fixed expiry - ttlAutopurge: true, + ttlAutopurge: true }, // Tailscale network status @@ -37,7 +37,7 @@ const CACHE_CONFIGS = { max: 1, // Only one status object ttl: 60 * 1000, // 1 minute TTL updateAgeOnGet: false, - ttlAutopurge: true, + ttlAutopurge: true }, // Tailscale API responses (devices, ACLs) @@ -45,8 +45,8 @@ const CACHE_CONFIGS = { max: 5, // devices + ACL + misc ttl: 5 * 60 * 1000, // 5 min (matches sync interval) updateAgeOnGet: false, - ttlAutopurge: true, - }, + ttlAutopurge: true + } }; /** diff --git a/dashcaddy-api/comprehensive-test.js b/dashcaddy-api/comprehensive-test.js index 812fc4c..b8d7071 100644 --- a/dashcaddy-api/comprehensive-test.js +++ b/dashcaddy-api/comprehensive-test.js @@ -17,15 +17,15 @@ const colors = { yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', - magenta: '\x1b[35m', + magenta: '\x1b[35m' }; -const testResults = { +let testResults = { passed: 0, failed: 0, warnings: 0, total: 0, - details: [], + details: [] }; function log(message, color = 'reset') { @@ -62,7 +62,7 @@ async function makeRequest(path, options = {}) { path: url.pathname + url.search, method: options.method || 'GET', headers: options.headers || {}, - timeout: options.timeout || 10000, + timeout: options.timeout || 10000 }; const req = http.request(requestOptions, (res) => { @@ -74,7 +74,7 @@ async function makeRequest(path, options = {}) { headers: res.headers, body: data, data: data && (data.startsWith('{') || data.startsWith('[')) ? - (() => { try { return JSON.parse(data); } catch(e) { return null; } })() : data, + (() => { try { return JSON.parse(data); } catch(e) { return null; } })() : data }); }); }); @@ -143,7 +143,7 @@ async function testCSRFProtection() { const response = await makeRequest('/api/test-endpoint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: { test: 'data' }, + body: { test: 'data' } }); if (response.data?.error?.includes('CSRF') || response.data?.message?.includes('CSRF')) { @@ -183,7 +183,7 @@ async function testRequestSizeLimits() { const response = await makeRequest('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(smallPayload), + body: JSON.stringify(smallPayload) }); if (response.statusCode !== 413) { @@ -465,7 +465,7 @@ async function runAllTests() { .forEach(t => log(` ⚠ ${t.name}: ${t.message}`, 'yellow')); } - log(`\n${ '═'.repeat(60)}`, 'cyan'); + log('\n' + '═'.repeat(60), 'cyan'); if (testResults.failed === 0) { log('\n✅ ALL AUTOMATED TESTS PASSED!', 'green'); diff --git a/dashcaddy-api/config-schema.js b/dashcaddy-api/config-schema.js index dca4cc1..9cf0948 100644 --- a/dashcaddy-api/config-schema.js +++ b/dashcaddy-api/config-schema.js @@ -6,7 +6,7 @@ const VALID_TIMEZONES_SAMPLE = [ 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai', - 'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland', + 'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland' ]; /** @@ -27,7 +27,7 @@ function validateConfig(config) { if (typeof config.tld !== 'string') { errors.push('tld must be a string'); } else { - const tld = config.tld.startsWith('.') ? config.tld : `.${ config.tld}`; + const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld; if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) { errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`); } @@ -117,7 +117,7 @@ function validateConfig(config) { 'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted', 'configurationType', 'defaults', 'customLogo', 'customFavicon', 'dashboardTitle', 'tailscale', 'license', 'skipped', - 'routingMode', 'domain', 'email', 'defaultIP', + 'routingMode', 'domain', 'email', 'defaultIP' ]; for (const key of Object.keys(config)) { if (!knownKeys.includes(key)) { diff --git a/dashcaddy-api/constants.js b/dashcaddy-api/constants.js index 72290cc..1c6b986 100644 --- a/dashcaddy-api/constants.js +++ b/dashcaddy-api/constants.js @@ -105,7 +105,7 @@ const DOCKER = { TIMEOUT: 30000, // 30s — timeout for docker pull/create operations LOG_CONFIG: { Type: 'json-file', - Config: { 'max-size': '10m', 'max-file': '3' }, // 30MB max per container + Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container }, MAINTENANCE: { INTERVAL: 24 * 60 * 60 * 1000, // 24 hours diff --git a/dashcaddy-api/credential-manager.js b/dashcaddy-api/credential-manager.js index 86116d6..8acdeb1 100644 --- a/dashcaddy-api/credential-manager.js +++ b/dashcaddy-api/credential-manager.js @@ -19,7 +19,7 @@ class CredentialManager { this.CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes this.lockOptions = { retries: { retries: 10, minTimeout: 100, maxTimeout: 300 }, - stale: 30000, + stale: 30000 }; console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`); @@ -185,7 +185,7 @@ class CredentialManager { const value = credentials[key].value; decryptedEntries[key] = { plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value, - metadata: credentials[key].metadata, + metadata: credentials[key].metadata }; } @@ -198,7 +198,7 @@ class CredentialManager { rotated[key] = { value: cryptoUtils.encrypt(decryptedEntries[key].plaintext), metadata: decryptedEntries[key].metadata, - rotatedAt: new Date().toISOString(), + rotatedAt: new Date().toISOString() }; } @@ -303,7 +303,7 @@ class CredentialManager { credentials[key] = { value: cryptoUtils.encrypt(value), metadata, - updatedAt: new Date().toISOString(), + updatedAt: new Date().toISOString() }; return credentials; }); @@ -360,7 +360,7 @@ class CredentialManager { const backup = { version: '1.0', exportedAt: new Date().toISOString(), - credentials, + credentials }; return cryptoUtils.encrypt(JSON.stringify(backup)); } diff --git a/dashcaddy-api/crypto-utils.js b/dashcaddy-api/crypto-utils.js index bb2e5bf..f534f10 100644 --- a/dashcaddy-api/crypto-utils.js +++ b/dashcaddy-api/crypto-utils.js @@ -336,5 +336,5 @@ module.exports = { deriveKey, rotateKey, decryptWithKey, - clearCachedKey, + clearCachedKey }; diff --git a/dashcaddy-api/csrf-protection.js b/dashcaddy-api/csrf-protection.js index 2d64802..2e7661b 100644 --- a/dashcaddy-api/csrf-protection.js +++ b/dashcaddy-api/csrf-protection.js @@ -68,7 +68,7 @@ function csrfCookieMiddleware(req, res, next) { secure: req.secure || req.protocol === 'https', // Only secure in HTTPS sameSite: 'strict', path: '/', - maxAge: 24 * 60 * 60 * 1000, // 24 hours + maxAge: 24 * 60 * 60 * 1000 // 24 hours }); next(); @@ -96,7 +96,7 @@ function csrfValidationMiddleware(req, res, next) { '/api/totp/verify', '/api/totp/verify-setup', '/health', - '/api/health', + '/api/health' ]; // Check if path starts with excluded prefix @@ -126,7 +126,7 @@ function csrfValidationMiddleware(req, res, next) { return res.status(403).json({ success: false, error: '[DC-100] CSRF token missing', - message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.', + message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.' }); } @@ -135,7 +135,7 @@ function csrfValidationMiddleware(req, res, next) { return res.status(403).json({ success: false, error: '[DC-100] CSRF token missing', - message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.', + message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.' }); } @@ -161,7 +161,7 @@ function csrfValidationMiddleware(req, res, next) { return res.status(403).json({ success: false, error: '[DC-101] CSRF token invalid', - message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.', + message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.' }); } } @@ -174,5 +174,5 @@ module.exports = { signToken, parseCookie, csrfCookieMiddleware, - csrfValidationMiddleware, + csrfValidationMiddleware }; diff --git a/dashcaddy-api/docker-compose.yml b/dashcaddy-api/docker-compose.yml deleted file mode 100644 index 96ca55c..0000000 --- a/dashcaddy-api/docker-compose.yml +++ /dev/null @@ -1,39 +0,0 @@ -services: - dashcaddy-api: - build: . - container_name: dashcaddy-api - ports: - - "3001:3001" - volumes: - - C:/Caddy/Caddyfile:/caddyfile:rw - - C:/Caddy/services.json:/app/services.json:rw - - C:/Caddy/dns-credentials.json:/app/dns-credentials.json:rw - - C:/Caddy/config.json:/app/config.json:rw - - C:/Caddy/totp-config.json:/app/totp-config.json:rw - - C:/Caddy/credentials.json:/app/credentials.json:rw - - C:/Caddy/.encryption-key:/app/.encryption-key:rw - - C:/Caddy/.license-secret:/app/.license-secret:ro - - C:/caddy/sites/status/assets:/app/assets:rw - - C:/caddy/sites/ca:/app/ca:ro - - C:/caddy/certs/pki/authorities/local:/app/pki:ro - - C:/caddy/generated-certs:/app/generated-certs:rw - - C:/caddy/sites/status/themes:/app/themes:rw - - /var/run/docker.sock:/var/run/docker.sock - # Media browser mounts - add your drives here for folder browsing - # Format: HostPath:/browse/DriveLetter:ro (read-only for safety) - - C:/:/browse/C:ro - - D:/:/browse/D:ro - - E:/:/browse/E:ro - environment: - - CADDYFILE_PATH=/caddyfile - - CADDY_ADMIN_URL=http://host.docker.internal:2019 - - ASSETS_PATH=/app/assets - - CREDENTIALS_FILE=/app/credentials.json - # Configure your network IPs here for quick selection in Add Service modal - - HOST_LAN_IP=192.168.254.204 - - HOST_TAILSCALE_IP=100.71.97.12 - # Media browser root mappings (container_path=host_path,...) - - MEDIA_BROWSE_ROOTS=/browse/C=C:/,/browse/D=D:/,/browse/E=E:/ - extra_hosts: - - "host.docker.internal:host-gateway" - restart: unless-stopped diff --git a/dashcaddy-api/docker-maintenance.js b/dashcaddy-api/docker-maintenance.js index fb1bc1f..25bcab1 100644 --- a/dashcaddy-api/docker-maintenance.js +++ b/dashcaddy-api/docker-maintenance.js @@ -55,7 +55,7 @@ class DockerMaintenance extends EventEmitter { spaceReclaimed: { images: 0, containers: 0, buildCache: 0, total: 0 }, diskUsage: null, warnings: [], - containersWithoutLogLimits: [], + containersWithoutLogLimits: [] }; try { @@ -72,7 +72,7 @@ class DockerMaintenance extends EventEmitter { try { const stopped = await docker.listContainers({ all: true, - filters: { status: ['exited', 'dead'] }, + filters: { status: ['exited', 'dead'] } }); for (const c of stopped) { // Skip DashCaddy-managed containers — user may want to restart them @@ -108,20 +108,20 @@ class DockerMaintenance extends EventEmitter { result.diskUsage = { images: { count: (df.Images || []).length, - sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0), + sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0) }, containers: { count: (df.Containers || []).length, - sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0), + sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0) }, volumes: { count: (df.Volumes?.Volumes || []).length, - sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0), + sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0) }, buildCache: { count: (df.BuildCache || []).length, - sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0), - }, + sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0) + } }; result.diskUsage.totalBytes = result.diskUsage.images.sizeBytes + @@ -149,7 +149,7 @@ class DockerMaintenance extends EventEmitter { if (!logConfig?.Config?.['max-size']) { result.containersWithoutLogLimits.push({ name: c.Names[0]?.replace(/^\//, '') || c.Id.slice(0, 12), - id: c.Id.slice(0, 12), + id: c.Id.slice(0, 12) }); } } catch (e) { @@ -158,7 +158,7 @@ class DockerMaintenance extends EventEmitter { } if (result.containersWithoutLogLimits.length > 0) { result.warnings.push( - `${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}`, + `${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}` ); } } catch (e) { @@ -204,7 +204,7 @@ class DockerMaintenance extends EventEmitter { return { running: this.running, lastRun: this.lastRun, - lastResult: this.lastResult, + lastResult: this.lastResult }; } } diff --git a/dashcaddy-api/docker-security.js b/dashcaddy-api/docker-security.js index f462f48..4a7df11 100644 --- a/dashcaddy-api/docker-security.js +++ b/dashcaddy-api/docker-security.js @@ -39,7 +39,7 @@ class DockerSecurity { trustedDigests: {}, verificationMode: VERIFICATION_MODE, allowUnverified: true, - updateTrustedOnPull: true, + updateTrustedOnPull: true }; } @@ -124,7 +124,7 @@ class DockerSecurity { method: 'GET', headers: { 'Accept': 'application/vnd.docker.distribution.manifest.v2+json', - }, + } }; if (token) { @@ -198,7 +198,7 @@ class DockerSecurity { imageName, actualDigest, trustedDigest: trustedDigest || null, - action: 'unknown', + action: 'unknown' }; if (!trustedDigest) { @@ -280,7 +280,7 @@ class DockerSecurity { imageName, action: this.mode === 'permissive' ? 'accept' : 'warn', error: error.message, - reason: `Verification error (${this.mode} mode)`, + reason: `Verification error (${this.mode} mode)` }; } } @@ -335,7 +335,7 @@ class DockerSecurity { mode: this.mode, trustedImagesCount: Object.keys(this.config.trustedDigests).length, configFile: SECURITY_CONFIG_FILE, - updateTrustedOnPull: this.config.updateTrustedOnPull, + updateTrustedOnPull: this.config.updateTrustedOnPull }; } } diff --git a/dashcaddy-api/generated-certs/dns1.sami/ca.srl b/dashcaddy-api/generated-certs/dns1.sami/ca.srl new file mode 100644 index 0000000..b95306d --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/ca.srl @@ -0,0 +1 @@ +13BAB06A9ABC2B407316D10046784104DC1ED3A0 diff --git a/dashcaddy-api/generated-certs/dns1.sami/fullchain.pem b/dashcaddy-api/generated-certs/dns1.sami/fullchain.pem new file mode 100644 index 0000000..62af75f --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/fullchain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUE7qwapq8K0BzFtEARnhBBNwe06AwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzcwMloXDTI3MDIxMTIxMzcwMlowFDESMBAGA1UEAwwJZG5zMS5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqmXXrlT4L2bo19bs +JXsmz+6QVwozbi6tUIbuhrpXP5rSzqwhKy48wbOlYUqBnMPNwlsGS+BE/m0OMh0M +dTtE91dfSXmoIV4nLZ8d92iWt64awsB5GqO6Vn2wyOZB4GBJ4ttAZpnPMX2EU6zx +f9S1N3VHgtyO+y8fG03eTqfB8HVibBpwChZJSYUqdMmJByyGYogTCXbkuKQE+szc +XaGnu6ckRQeTzwHns3h2Jx8lhyg9AMUtWQ8d+CT0PSobSxtBEMlCJvEW36bdJ4Bo +2fD+h0moAdvxOn5p12zhqYpgIGsz8y++iR0CkLP/qOLFIcaqND5ixx+FoQb5AFse +4JA5IwIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMxLnNhbWmCCyouZG5zMS5zYW1pMB0GA1UdDgQWBBQ6 +tqY38Y7OJqK5CPbHWMURSgjOdTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiAoxRh+u2DzgFumKTqlS6BwDXZmb4c5ANTb +mCj6CYulKQIhAP2dC0WpaCkSkEM56GhbiQAcdaS9x0Lb004Pi7IGwS6t +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/openssl.cnf b/dashcaddy-api/generated-certs/dns1.sami/openssl.cnf new file mode 100644 index 0000000..be9eced --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/openssl.cnf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = dns1.sami + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment, digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = dns1.sami +DNS.2 = *.dns1.sami \ No newline at end of file diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.crt b/dashcaddy-api/generated-certs/dns1.sami/server.crt new file mode 100644 index 0000000..f484571 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/server.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUE7qwapq8K0BzFtEARnhBBNwe06AwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzcwMloXDTI3MDIxMTIxMzcwMlowFDESMBAGA1UEAwwJZG5zMS5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqmXXrlT4L2bo19bs +JXsmz+6QVwozbi6tUIbuhrpXP5rSzqwhKy48wbOlYUqBnMPNwlsGS+BE/m0OMh0M +dTtE91dfSXmoIV4nLZ8d92iWt64awsB5GqO6Vn2wyOZB4GBJ4ttAZpnPMX2EU6zx +f9S1N3VHgtyO+y8fG03eTqfB8HVibBpwChZJSYUqdMmJByyGYogTCXbkuKQE+szc +XaGnu6ckRQeTzwHns3h2Jx8lhyg9AMUtWQ8d+CT0PSobSxtBEMlCJvEW36bdJ4Bo +2fD+h0moAdvxOn5p12zhqYpgIGsz8y++iR0CkLP/qOLFIcaqND5ixx+FoQb5AFse +4JA5IwIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMxLnNhbWmCCyouZG5zMS5zYW1pMB0GA1UdDgQWBBQ6 +tqY38Y7OJqK5CPbHWMURSgjOdTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiAoxRh+u2DzgFumKTqlS6BwDXZmb4c5ANTb +mCj6CYulKQIhAP2dC0WpaCkSkEM56GhbiQAcdaS9x0Lb004Pi7IGwS6t +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.csr b/dashcaddy-api/generated-certs/dns1.sami/server.csr new file mode 100644 index 0000000..2f8390a --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/server.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICWTCCAUECAQAwFDESMBAGA1UEAwwJZG5zMS5zYW1pMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAqmXXrlT4L2bo19bsJXsmz+6QVwozbi6tUIbuhrpX +P5rSzqwhKy48wbOlYUqBnMPNwlsGS+BE/m0OMh0MdTtE91dfSXmoIV4nLZ8d92iW +t64awsB5GqO6Vn2wyOZB4GBJ4ttAZpnPMX2EU6zxf9S1N3VHgtyO+y8fG03eTqfB +8HVibBpwChZJSYUqdMmJByyGYogTCXbkuKQE+szcXaGnu6ckRQeTzwHns3h2Jx8l +hyg9AMUtWQ8d+CT0PSobSxtBEMlCJvEW36bdJ4Bo2fD+h0moAdvxOn5p12zhqYpg +IGsz8y++iR0CkLP/qOLFIcaqND5ixx+FoQb5AFse4JA5IwIDAQABoAAwDQYJKoZI +hvcNAQELBQADggEBACS3FIp80TGvc3VuFXfWfzQ7JBDJ/bvQ1TqBZa5w0tMoRfpA +rIM3SOLKjMmbmA7jd2rDZx7CzdNevgSFuqAf8EWS5dvYtytK18Bm4suZtSf/l6VX +8GhCbNqzZsOBER3sstsY/Y45MbQYorj1LyY5NKaQn1LifQT8unhPIqtouWzpwgzz +WNdHPs51tgBEJ6t8PQdy6mQjI4gUY0y6dyyuIoZ6JuUnqJl3ctMiZUz4iUJotGOz +LeTR9PWwfCdzj8MuHBYb46ZR8Y5WOFH7aphS1WCHy7LuxeGypY4n2rVsqTqlzMuf +q2yiJaUdHC6rhR2F5djMxgoPA9XMfg9y7TwbVkM= +-----END CERTIFICATE REQUEST----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.key b/dashcaddy-api/generated-certs/dns1.sami/server.key new file mode 100644 index 0000000..fc9fe56 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqZdeuVPgvZujX +1uwleybP7pBXCjNuLq1Qhu6Gulc/mtLOrCErLjzBs6VhSoGcw83CWwZL4ET+bQ4y +HQx1O0T3V19JeaghXictnx33aJa3rhrCwHkao7pWfbDI5kHgYEni20Bmmc8xfYRT +rPF/1LU3dUeC3I77Lx8bTd5Op8HwdWJsGnAKFklJhSp0yYkHLIZiiBMJduS4pAT6 +zNxdoae7pyRFB5PPAeezeHYnHyWHKD0AxS1ZDx34JPQ9KhtLG0EQyUIm8Rbfpt0n +gGjZ8P6HSagB2/E6fmnXbOGpimAgazPzL76JHQKQs/+o4sUhxqo0PmLHH4WhBvkA +Wx7gkDkjAgMBAAECggEADZEAMNDZCN621ebQDe5JJi+5oQRrf2+kVg+RuOGBhf6r +0mj7I90rJZNJxn42sll9VBnVKkzxXheNFGMie8iIcomrr3jjiiYp4ZFg9f+HIgA9 +mmXVy0/GMfhEQl2t8jd+vaeniZB5yFhUi1wiLVBGtHyk53+4VO1PThtozxBYk4e6 +E91Hkb0DQ6hoUttjVpb2VREwGqgASsYW+Zf2/zZJ9hDKNLfzwmg72mXyvvOvRVcX +O4o+QuVc6Kk19iuOOnxK5lrP0PyXLwkPNZmQfuQx+h85M3te0taiM79vFcttsLzp +cJXEzN4WbFWA23cxmZdwBklQQmTl/v9Ifukf/ttmOQKBgQDes2oGQG4JYU+QTyNB +BscMQ30VGbtjU4f+QzyML4PrLGPD5XQa/bQ5BkoRqYrG5+QiyFtU7pbEPsq6z1hM +yfr1rdmQHnMdSnbBCKypzPTsQdVTaeE6Mh/GvS3x5eQby8UggUsR9VSyqtc2E5Hi +4DHnLJ9vqry5FC/YY0V0/Kk3pwKBgQDD4F0IwpzY6TJp+Vmisz69qOJKc+IFtXSl +YdMRjDRioTP1GNlh9NDBb8Em6WsMPBAY3+F657ds6AtooNvYUKBjb0tvPTbz+oAj +kLQHA2m2r1T7qsMSktyFQOzaVQvmuvHR+wN8fnIVq0j4rN+YM5OkdSHVEcBNrcb4 +qr4hXnEiJQKBgQCB7RSPPymzaUV9AN6lgmnAeuNP2ypbQZGWwu1hMBt7qfMuiACb ++qYZmtS9xzdC4mlT7aZzP5tQNP4bzPpMGo7CpMHIditczGPKLOBnVD8UUzg9KQmQ +5UtqrFZQyXmyychhNW1xtbrLXiae0v4K6hfTMlJ2WJswM1nSmeAc54dmAwKBgHZP +scIV0qlOCa5q91JH1DC4rp5r6myqUp+GO/gQaJ/eYMS9UqhRODpupws61/bW3J45 +tDcJeQhmDGYRK1k4Mfh9g+HX5rZtazKQN704uYSn2Lv+Q7+XR22Rbr7duceOyXuc +k1mCAqTGBdh2isOi+53NRjctdqs0uMcAUsFTCyClAoGBAI2xTpsyFgBKXUYzXiLs +b9AsdVqZgNB3Lv852Ahm7vRNMAwwQ558mQZFM8AUgdEqSpyaMOFNSkajZa9bmLSO +mL2mwruEy8UPJYPHInPf5nM5xICIhXbzdlTiNj2mHBaBn8fdsWqMhthNdasOrfCP +FqETebA6LaAGiYD+HzbTPtWh +-----END PRIVATE KEY----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.pem b/dashcaddy-api/generated-certs/dns1.sami/server.pem new file mode 100644 index 0000000..bd58f8f --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/server.pem @@ -0,0 +1,58 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqZdeuVPgvZujX +1uwleybP7pBXCjNuLq1Qhu6Gulc/mtLOrCErLjzBs6VhSoGcw83CWwZL4ET+bQ4y +HQx1O0T3V19JeaghXictnx33aJa3rhrCwHkao7pWfbDI5kHgYEni20Bmmc8xfYRT +rPF/1LU3dUeC3I77Lx8bTd5Op8HwdWJsGnAKFklJhSp0yYkHLIZiiBMJduS4pAT6 +zNxdoae7pyRFB5PPAeezeHYnHyWHKD0AxS1ZDx34JPQ9KhtLG0EQyUIm8Rbfpt0n +gGjZ8P6HSagB2/E6fmnXbOGpimAgazPzL76JHQKQs/+o4sUhxqo0PmLHH4WhBvkA +Wx7gkDkjAgMBAAECggEADZEAMNDZCN621ebQDe5JJi+5oQRrf2+kVg+RuOGBhf6r +0mj7I90rJZNJxn42sll9VBnVKkzxXheNFGMie8iIcomrr3jjiiYp4ZFg9f+HIgA9 +mmXVy0/GMfhEQl2t8jd+vaeniZB5yFhUi1wiLVBGtHyk53+4VO1PThtozxBYk4e6 +E91Hkb0DQ6hoUttjVpb2VREwGqgASsYW+Zf2/zZJ9hDKNLfzwmg72mXyvvOvRVcX +O4o+QuVc6Kk19iuOOnxK5lrP0PyXLwkPNZmQfuQx+h85M3te0taiM79vFcttsLzp +cJXEzN4WbFWA23cxmZdwBklQQmTl/v9Ifukf/ttmOQKBgQDes2oGQG4JYU+QTyNB +BscMQ30VGbtjU4f+QzyML4PrLGPD5XQa/bQ5BkoRqYrG5+QiyFtU7pbEPsq6z1hM +yfr1rdmQHnMdSnbBCKypzPTsQdVTaeE6Mh/GvS3x5eQby8UggUsR9VSyqtc2E5Hi +4DHnLJ9vqry5FC/YY0V0/Kk3pwKBgQDD4F0IwpzY6TJp+Vmisz69qOJKc+IFtXSl +YdMRjDRioTP1GNlh9NDBb8Em6WsMPBAY3+F657ds6AtooNvYUKBjb0tvPTbz+oAj +kLQHA2m2r1T7qsMSktyFQOzaVQvmuvHR+wN8fnIVq0j4rN+YM5OkdSHVEcBNrcb4 +qr4hXnEiJQKBgQCB7RSPPymzaUV9AN6lgmnAeuNP2ypbQZGWwu1hMBt7qfMuiACb ++qYZmtS9xzdC4mlT7aZzP5tQNP4bzPpMGo7CpMHIditczGPKLOBnVD8UUzg9KQmQ +5UtqrFZQyXmyychhNW1xtbrLXiae0v4K6hfTMlJ2WJswM1nSmeAc54dmAwKBgHZP +scIV0qlOCa5q91JH1DC4rp5r6myqUp+GO/gQaJ/eYMS9UqhRODpupws61/bW3J45 +tDcJeQhmDGYRK1k4Mfh9g+HX5rZtazKQN704uYSn2Lv+Q7+XR22Rbr7duceOyXuc +k1mCAqTGBdh2isOi+53NRjctdqs0uMcAUsFTCyClAoGBAI2xTpsyFgBKXUYzXiLs +b9AsdVqZgNB3Lv852Ahm7vRNMAwwQ558mQZFM8AUgdEqSpyaMOFNSkajZa9bmLSO +mL2mwruEy8UPJYPHInPf5nM5xICIhXbzdlTiNj2mHBaBn8fdsWqMhthNdasOrfCP +FqETebA6LaAGiYD+HzbTPtWh +-----END PRIVATE KEY----- + +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUE7qwapq8K0BzFtEARnhBBNwe06AwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzcwMloXDTI3MDIxMTIxMzcwMlowFDESMBAGA1UEAwwJZG5zMS5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqmXXrlT4L2bo19bs +JXsmz+6QVwozbi6tUIbuhrpXP5rSzqwhKy48wbOlYUqBnMPNwlsGS+BE/m0OMh0M +dTtE91dfSXmoIV4nLZ8d92iWt64awsB5GqO6Vn2wyOZB4GBJ4ttAZpnPMX2EU6zx +f9S1N3VHgtyO+y8fG03eTqfB8HVibBpwChZJSYUqdMmJByyGYogTCXbkuKQE+szc +XaGnu6ckRQeTzwHns3h2Jx8lhyg9AMUtWQ8d+CT0PSobSxtBEMlCJvEW36bdJ4Bo +2fD+h0moAdvxOn5p12zhqYpgIGsz8y++iR0CkLP/qOLFIcaqND5ixx+FoQb5AFse +4JA5IwIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMxLnNhbWmCCyouZG5zMS5zYW1pMB0GA1UdDgQWBBQ6 +tqY38Y7OJqK5CPbHWMURSgjOdTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiAoxRh+u2DzgFumKTqlS6BwDXZmb4c5ANTb +mCj6CYulKQIhAP2dC0WpaCkSkEM56GhbiQAcdaS9x0Lb004Pi7IGwS6t +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.pfx b/dashcaddy-api/generated-certs/dns1.sami/server.pfx new file mode 100644 index 0000000..fde3fcc Binary files /dev/null and b/dashcaddy-api/generated-certs/dns1.sami/server.pfx differ diff --git a/dashcaddy-api/generated-certs/dns2.sami/ca.srl b/dashcaddy-api/generated-certs/dns2.sami/ca.srl new file mode 100644 index 0000000..1a1afe3 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/ca.srl @@ -0,0 +1 @@ +04751A03AABA191E9D2760D5CB20ED183D9178E8 diff --git a/dashcaddy-api/generated-certs/dns2.sami/fullchain.pem b/dashcaddy-api/generated-certs/dns2.sami/fullchain.pem new file mode 100644 index 0000000..1d25c75 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/fullchain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIClzCCAjygAwIBAgIUBHUaA6q6GR6dJ2DVyyDtGD2ReOgwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxNDExNVoXDTI3MDIxMTIxNDExNVowFDESMBAGA1UEAwwJZG5zMi5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnxohNydjwNtMkAJr +BnNQHgA2McFkylJgq2hbOymPQLivezm3q0t+3wstVcp5l+91UQ+0mrIlDBrAu5Vf +UcaG/YUCgePSKxvdzBRsZsoa16VMoKBh58OTv5wiebAx0zALuNYjx/OVFYnxxmQl +B7eEham79Qz/8pKMUEoyB6IfzpsA/69kwmONgJeynW8hsYlEZlBm3VbZ80gWKG1g +nm2m1kg1FJnnljQF2WXPnpw/0TZIw4PX65fNpue09uShr68RFgpVxvI4LsQy+K4M +as334HPJBSzAUxBnOuItYnmcAOkejvTAygVSa0bEWNr4DADJ03+OqtpeJqh0h8t8 +w35OowIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMyLnNhbWmCCyouZG5zMi5zYW1pMB0GA1UdDgQWBBTr +68PJ406T+VqAoNNxIL7KBHkXCTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNJADBGAiEAoAYtlylMbBdRINOUmxGY3c9gqsQWpqBh +NIwNbHCnZMACIQCJ6Qy0dCOiW5R95C8U3zEjssUSDuoSIdm0nf6clE30cw== +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/openssl.cnf b/dashcaddy-api/generated-certs/dns2.sami/openssl.cnf new file mode 100644 index 0000000..8b3a355 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/openssl.cnf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = dns2.sami + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment, digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = dns2.sami +DNS.2 = *.dns2.sami \ No newline at end of file diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.crt b/dashcaddy-api/generated-certs/dns2.sami/server.crt new file mode 100644 index 0000000..e169ddf --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/server.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIIClzCCAjygAwIBAgIUBHUaA6q6GR6dJ2DVyyDtGD2ReOgwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxNDExNVoXDTI3MDIxMTIxNDExNVowFDESMBAGA1UEAwwJZG5zMi5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnxohNydjwNtMkAJr +BnNQHgA2McFkylJgq2hbOymPQLivezm3q0t+3wstVcp5l+91UQ+0mrIlDBrAu5Vf +UcaG/YUCgePSKxvdzBRsZsoa16VMoKBh58OTv5wiebAx0zALuNYjx/OVFYnxxmQl +B7eEham79Qz/8pKMUEoyB6IfzpsA/69kwmONgJeynW8hsYlEZlBm3VbZ80gWKG1g +nm2m1kg1FJnnljQF2WXPnpw/0TZIw4PX65fNpue09uShr68RFgpVxvI4LsQy+K4M +as334HPJBSzAUxBnOuItYnmcAOkejvTAygVSa0bEWNr4DADJ03+OqtpeJqh0h8t8 +w35OowIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMyLnNhbWmCCyouZG5zMi5zYW1pMB0GA1UdDgQWBBTr +68PJ406T+VqAoNNxIL7KBHkXCTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNJADBGAiEAoAYtlylMbBdRINOUmxGY3c9gqsQWpqBh +NIwNbHCnZMACIQCJ6Qy0dCOiW5R95C8U3zEjssUSDuoSIdm0nf6clE30cw== +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.csr b/dashcaddy-api/generated-certs/dns2.sami/server.csr new file mode 100644 index 0000000..2eab83d --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/server.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICWTCCAUECAQAwFDESMBAGA1UEAwwJZG5zMi5zYW1pMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAnxohNydjwNtMkAJrBnNQHgA2McFkylJgq2hbOymP +QLivezm3q0t+3wstVcp5l+91UQ+0mrIlDBrAu5VfUcaG/YUCgePSKxvdzBRsZsoa +16VMoKBh58OTv5wiebAx0zALuNYjx/OVFYnxxmQlB7eEham79Qz/8pKMUEoyB6If +zpsA/69kwmONgJeynW8hsYlEZlBm3VbZ80gWKG1gnm2m1kg1FJnnljQF2WXPnpw/ +0TZIw4PX65fNpue09uShr68RFgpVxvI4LsQy+K4Mas334HPJBSzAUxBnOuItYnmc +AOkejvTAygVSa0bEWNr4DADJ03+OqtpeJqh0h8t8w35OowIDAQABoAAwDQYJKoZI +hvcNAQELBQADggEBAHX+ZBbKH5ZEMrjZwFGjay2KWY7/5XeMOwXPUOLR1kvCcRG4 +SqijZWrFnSCD3zhtMmgkCfTk4cWt8kInrQOYIGIg+sReQH2PKMci8JZnzQNfTuh/ +4l6ZdEk6OLoZnDjDdqq0rdyLUdBIvgQZ79Rz5exDwKR4ASYZ4djvyh3/WifXk6Qk +CybM7Gnr2ExzcUslYyx2Ml30j49TdORZJ14vfTPy5I2PAr0gfrkmNbhsAoq/FsdC +R651/B7naFtaOQcSsp6V+BTnSpfCnL//Qn8AgnAYgJSjSy95p9g5N1ywApKVNmhq +RjVHjKqoCyNzhV505y+GScDZsGbUKIrI2u3B0sE= +-----END CERTIFICATE REQUEST----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.key b/dashcaddy-api/generated-certs/dns2.sami/server.key new file mode 100644 index 0000000..641e5cd --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfGiE3J2PA20yQ +AmsGc1AeADYxwWTKUmCraFs7KY9AuK97OberS37fCy1VynmX73VRD7SasiUMGsC7 +lV9Rxob9hQKB49IrG93MFGxmyhrXpUygoGHnw5O/nCJ5sDHTMAu41iPH85UVifHG +ZCUHt4SFqbv1DP/ykoxQSjIHoh/OmwD/r2TCY42Al7KdbyGxiURmUGbdVtnzSBYo +bWCebabWSDUUmeeWNAXZZc+enD/RNkjDg9frl82m57T25KGvrxEWClXG8jguxDL4 +rgxqzffgc8kFLMBTEGc64i1ieZwA6R6O9MDKBVJrRsRY2vgMAMnTf46q2l4mqHSH +y3zDfk6jAgMBAAECggEALy90L2POuVkwrTsSW7DiT2t3pyz4g+qHgf5qpDKwqhy7 +6ntVJge3YlXxxZJNmoppjDdwtDpmM19RzrA/u0R6L4D9m+EhqShUENzzw05oRJFh +FvhJ4Q8HaBnZvaZMOJp0t4ZGyVvL2L33BwQXWWELfAq5VDVdSSc+VueSS+JAtaiZ +zNDEtn95EOCB37gFsGsZR/CYxaQQv/b8uuMFjXwNmEi0eG1n2S75Hv5QIq/luQrK +5sUfeztmo7Vkt9VwsZVce0VnU3PsQtEQx8Gf6Cir9+FX0NCbmPJgJKYYWrAOvEhi +1FjhxEyL1TgFeyCcXvTwrp+k/DHj7N3sa7yId4gMXQKBgQDZLPil5eZ84MuV9zeP +oRrO+egN3SBPTySs5rOfw1TpYe5cLtbKdjzjXVuUNJAXdBgNiLhX8kg6rW8ysJQG +tiJHTNvUBnG7NLYdUAx9S5cNP4zOjSDPFoGPIv+6HpyPP9I5molECv8n5G30yeAt +W7/L9B1zwLzE5Rgv3LhxaojWNwKBgQC7i2ylBB4vpOTAcSpO+K4huHxHgaklC6dH +EPTQNphIBD3BrNTsjiaIkUrat/lN72IGE67bJWcSsyL7vEr5cAXywmgaTt1wvNg6 +CiUBI+9xH94V180wlrv3lBdpQ7aSTFAZ/d+6L90C5DSftLoP+xF0k4Ueerqzr+ZZ +yTeQqhsU9QKBgBne6JPiqi1QHhB0TbMXbvke/gzAvbuU4vqR1O4AN9pBZ0Kl9cJS +iXhQQ7uI821H0CG0mrknTIFo7aktLcUK51R1DG7agavaYKNnSMWYPps+acilOTZS +KQbjFXGXefD6mlFwXk+zu6eF569UaRceKd/i6atDV3lhDRuOgI5KMZjLAoGAYID2 +79a1nbiQyQGyTp6iI9HliXoLync7pVLxVm6xX2VnTdCcY/kllOKGjRNb4qGKZCwe +rr+JUVMCblzOi5n8RZWJsffg9JEIBp2Puw19uU03ny/DcvwtTtFSVqU0PgWstiMv +y4OzizhYZ9G8aFq9+amrNyraBXo+4vaqc/NEKj0CgYEAqQCsnZuf/ButQH9L15qd +0jaRkB/AaZ5iPDyjLGXPeq/WGrAXoqiqnLVdgk9tcxpvke7KimWPqVMzfS1A1y8Q +RQ852Sw1mtQCbhIHfOBFXIaLkPPR+m34nvbBQqiUuafg6fxSp1L0p7w4DchPD6mU +eU9fTRaIZrhBnaJD4uEGPlc= +-----END PRIVATE KEY----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.pem b/dashcaddy-api/generated-certs/dns2.sami/server.pem new file mode 100644 index 0000000..62fa6d7 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/server.pem @@ -0,0 +1,58 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfGiE3J2PA20yQ +AmsGc1AeADYxwWTKUmCraFs7KY9AuK97OberS37fCy1VynmX73VRD7SasiUMGsC7 +lV9Rxob9hQKB49IrG93MFGxmyhrXpUygoGHnw5O/nCJ5sDHTMAu41iPH85UVifHG +ZCUHt4SFqbv1DP/ykoxQSjIHoh/OmwD/r2TCY42Al7KdbyGxiURmUGbdVtnzSBYo +bWCebabWSDUUmeeWNAXZZc+enD/RNkjDg9frl82m57T25KGvrxEWClXG8jguxDL4 +rgxqzffgc8kFLMBTEGc64i1ieZwA6R6O9MDKBVJrRsRY2vgMAMnTf46q2l4mqHSH +y3zDfk6jAgMBAAECggEALy90L2POuVkwrTsSW7DiT2t3pyz4g+qHgf5qpDKwqhy7 +6ntVJge3YlXxxZJNmoppjDdwtDpmM19RzrA/u0R6L4D9m+EhqShUENzzw05oRJFh +FvhJ4Q8HaBnZvaZMOJp0t4ZGyVvL2L33BwQXWWELfAq5VDVdSSc+VueSS+JAtaiZ +zNDEtn95EOCB37gFsGsZR/CYxaQQv/b8uuMFjXwNmEi0eG1n2S75Hv5QIq/luQrK +5sUfeztmo7Vkt9VwsZVce0VnU3PsQtEQx8Gf6Cir9+FX0NCbmPJgJKYYWrAOvEhi +1FjhxEyL1TgFeyCcXvTwrp+k/DHj7N3sa7yId4gMXQKBgQDZLPil5eZ84MuV9zeP +oRrO+egN3SBPTySs5rOfw1TpYe5cLtbKdjzjXVuUNJAXdBgNiLhX8kg6rW8ysJQG +tiJHTNvUBnG7NLYdUAx9S5cNP4zOjSDPFoGPIv+6HpyPP9I5molECv8n5G30yeAt +W7/L9B1zwLzE5Rgv3LhxaojWNwKBgQC7i2ylBB4vpOTAcSpO+K4huHxHgaklC6dH +EPTQNphIBD3BrNTsjiaIkUrat/lN72IGE67bJWcSsyL7vEr5cAXywmgaTt1wvNg6 +CiUBI+9xH94V180wlrv3lBdpQ7aSTFAZ/d+6L90C5DSftLoP+xF0k4Ueerqzr+ZZ +yTeQqhsU9QKBgBne6JPiqi1QHhB0TbMXbvke/gzAvbuU4vqR1O4AN9pBZ0Kl9cJS +iXhQQ7uI821H0CG0mrknTIFo7aktLcUK51R1DG7agavaYKNnSMWYPps+acilOTZS +KQbjFXGXefD6mlFwXk+zu6eF569UaRceKd/i6atDV3lhDRuOgI5KMZjLAoGAYID2 +79a1nbiQyQGyTp6iI9HliXoLync7pVLxVm6xX2VnTdCcY/kllOKGjRNb4qGKZCwe +rr+JUVMCblzOi5n8RZWJsffg9JEIBp2Puw19uU03ny/DcvwtTtFSVqU0PgWstiMv +y4OzizhYZ9G8aFq9+amrNyraBXo+4vaqc/NEKj0CgYEAqQCsnZuf/ButQH9L15qd +0jaRkB/AaZ5iPDyjLGXPeq/WGrAXoqiqnLVdgk9tcxpvke7KimWPqVMzfS1A1y8Q +RQ852Sw1mtQCbhIHfOBFXIaLkPPR+m34nvbBQqiUuafg6fxSp1L0p7w4DchPD6mU +eU9fTRaIZrhBnaJD4uEGPlc= +-----END PRIVATE KEY----- + +-----BEGIN CERTIFICATE----- +MIIClzCCAjygAwIBAgIUBHUaA6q6GR6dJ2DVyyDtGD2ReOgwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxNDExNVoXDTI3MDIxMTIxNDExNVowFDESMBAGA1UEAwwJZG5zMi5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnxohNydjwNtMkAJr +BnNQHgA2McFkylJgq2hbOymPQLivezm3q0t+3wstVcp5l+91UQ+0mrIlDBrAu5Vf +UcaG/YUCgePSKxvdzBRsZsoa16VMoKBh58OTv5wiebAx0zALuNYjx/OVFYnxxmQl +B7eEham79Qz/8pKMUEoyB6IfzpsA/69kwmONgJeynW8hsYlEZlBm3VbZ80gWKG1g +nm2m1kg1FJnnljQF2WXPnpw/0TZIw4PX65fNpue09uShr68RFgpVxvI4LsQy+K4M +as334HPJBSzAUxBnOuItYnmcAOkejvTAygVSa0bEWNr4DADJ03+OqtpeJqh0h8t8 +w35OowIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMyLnNhbWmCCyouZG5zMi5zYW1pMB0GA1UdDgQWBBTr +68PJ406T+VqAoNNxIL7KBHkXCTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNJADBGAiEAoAYtlylMbBdRINOUmxGY3c9gqsQWpqBh +NIwNbHCnZMACIQCJ6Qy0dCOiW5R95C8U3zEjssUSDuoSIdm0nf6clE30cw== +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.pfx b/dashcaddy-api/generated-certs/dns2.sami/server.pfx new file mode 100644 index 0000000..48fc39e Binary files /dev/null and b/dashcaddy-api/generated-certs/dns2.sami/server.pfx differ diff --git a/dashcaddy-api/generated-certs/dns3.sami/ca.srl b/dashcaddy-api/generated-certs/dns3.sami/ca.srl new file mode 100644 index 0000000..c7b1bcc --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/ca.srl @@ -0,0 +1 @@ +276D2F2E212A363897488BE6A0B941BCE836BAD3 diff --git a/dashcaddy-api/generated-certs/dns3.sami/fullchain.pem b/dashcaddy-api/generated-certs/dns3.sami/fullchain.pem new file mode 100644 index 0000000..fd47d9c --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/fullchain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUJ20vLiEqNjiXSIvmoLlBvOg2utMwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzgwNloXDTI3MDIxMTIxMzgwNlowFDESMBAGA1UEAwwJZG5zMy5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4CmJnD34danEr45b +JayyM87UgvRjbsJJHYRWgp5sRjmNKr174VtzQ+1deXOe0kWyfjnSqI/Em2mJgGDR +4S2bhBEX5MiXqEHQATws5oHoXPuGmbnm0Y0B9ePU1Xz+ix0tz3vOHc6z62XWZquF +HE7TEhzM9OlMj/5sMPKxR4FHSDLWIXF6NUknBM6wX7uyJEcoXnf2vYm5ygqd/1vu +ncWBeybKFS4hsnEIsp1wcAAw1GH9x7fTyOn/7SLzTk7S6FkLt+upHCXqkYPUqNfG +sRFcmuBZr1RXRjxilgFJ9VQ4NEAvHRdXQmaCsGh3Ah6JioOjlpLJxcAPgyYeAoPz +xEVn6QIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMzLnNhbWmCCyouZG5zMy5zYW1pMB0GA1UdDgQWBBTC +Nw7MUUA4YxBFua8YzXZ2ciBmbzAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiBdq9UEw1H+/sidEXaf6D6d3z/YymJLNycN +xP4afkkExgIhAOV44DZsuGBckiIOJfNWpwbcA92GTXg/XlcALEcOtX5g +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/openssl.cnf b/dashcaddy-api/generated-certs/dns3.sami/openssl.cnf new file mode 100644 index 0000000..c72dc31 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/openssl.cnf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = dns3.sami + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment, digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = dns3.sami +DNS.2 = *.dns3.sami \ No newline at end of file diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.crt b/dashcaddy-api/generated-certs/dns3.sami/server.crt new file mode 100644 index 0000000..1369ebd --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/server.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUJ20vLiEqNjiXSIvmoLlBvOg2utMwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzgwNloXDTI3MDIxMTIxMzgwNlowFDESMBAGA1UEAwwJZG5zMy5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4CmJnD34danEr45b +JayyM87UgvRjbsJJHYRWgp5sRjmNKr174VtzQ+1deXOe0kWyfjnSqI/Em2mJgGDR +4S2bhBEX5MiXqEHQATws5oHoXPuGmbnm0Y0B9ePU1Xz+ix0tz3vOHc6z62XWZquF +HE7TEhzM9OlMj/5sMPKxR4FHSDLWIXF6NUknBM6wX7uyJEcoXnf2vYm5ygqd/1vu +ncWBeybKFS4hsnEIsp1wcAAw1GH9x7fTyOn/7SLzTk7S6FkLt+upHCXqkYPUqNfG +sRFcmuBZr1RXRjxilgFJ9VQ4NEAvHRdXQmaCsGh3Ah6JioOjlpLJxcAPgyYeAoPz +xEVn6QIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMzLnNhbWmCCyouZG5zMy5zYW1pMB0GA1UdDgQWBBTC +Nw7MUUA4YxBFua8YzXZ2ciBmbzAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiBdq9UEw1H+/sidEXaf6D6d3z/YymJLNycN +xP4afkkExgIhAOV44DZsuGBckiIOJfNWpwbcA92GTXg/XlcALEcOtX5g +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.csr b/dashcaddy-api/generated-certs/dns3.sami/server.csr new file mode 100644 index 0000000..b294090 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/server.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICWTCCAUECAQAwFDESMBAGA1UEAwwJZG5zMy5zYW1pMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA4CmJnD34danEr45bJayyM87UgvRjbsJJHYRWgp5s +RjmNKr174VtzQ+1deXOe0kWyfjnSqI/Em2mJgGDR4S2bhBEX5MiXqEHQATws5oHo +XPuGmbnm0Y0B9ePU1Xz+ix0tz3vOHc6z62XWZquFHE7TEhzM9OlMj/5sMPKxR4FH +SDLWIXF6NUknBM6wX7uyJEcoXnf2vYm5ygqd/1vuncWBeybKFS4hsnEIsp1wcAAw +1GH9x7fTyOn/7SLzTk7S6FkLt+upHCXqkYPUqNfGsRFcmuBZr1RXRjxilgFJ9VQ4 +NEAvHRdXQmaCsGh3Ah6JioOjlpLJxcAPgyYeAoPzxEVn6QIDAQABoAAwDQYJKoZI +hvcNAQELBQADggEBAIPDdVB4pt4JVg1O+IYoFLUASN0sTYtSIPqQCg9f3afxTNkQ +NNqgcegoZC+kfcOqhW1EGENjjzP2+U2A5clsMJlIhgumVSs/huuhLGs+2bY5ERuN +l9oc8zWAQvDl7NLGX/HEXw9OjBE7EV/aKmdogp7Z9SJXUJk6i4bC5BfnYm+Eabw/ +iM3nm+nBFSnYhm6nQQaddYvLUqPS2WrFTSTbHJZRx5FSR6u6ShTfqy1vZg7QB51j +NtPLUJx3dansUPEGUOtolk2AeRHvRmLI5MmtcoUt3TFM/XCzDDFM5r8MYXwJfTPI +Bm8hl2Ob+rxZhfSB2tMJbEIAiPwIsWmVfSesVW4= +-----END CERTIFICATE REQUEST----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.key b/dashcaddy-api/generated-certs/dns3.sami/server.key new file mode 100644 index 0000000..b4dcaa4 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDgKYmcPfh1qcSv +jlslrLIzztSC9GNuwkkdhFaCnmxGOY0qvXvhW3ND7V15c57SRbJ+OdKoj8SbaYmA +YNHhLZuEERfkyJeoQdABPCzmgehc+4aZuebRjQH149TVfP6LHS3Pe84dzrPrZdZm +q4UcTtMSHMz06UyP/mww8rFHgUdIMtYhcXo1SScEzrBfu7IkRyhed/a9ibnKCp3/ +W+6dxYF7JsoVLiGycQiynXBwADDUYf3Ht9PI6f/tIvNOTtLoWQu366kcJeqRg9So +18axEVya4FmvVFdGPGKWAUn1VDg0QC8dF1dCZoKwaHcCHomKg6OWksnFwA+DJh4C +g/PERWfpAgMBAAECggEAArQf+WOsrne2ZmNaee0L+005ybGlSW22q5YuEpHuIXGr +QUBNiWyD/QFO2c8Dcdd4THU7I8Ubpqih6l0nXsK0f7tSrtTMGr2U81jNYM3swpPp +3W26SI0R/5qxwQ3ZFQDJgCEm+KSMcpYur4aoOEQ7Dj2FYgqi+M6pWkY0xe24vQ3n +0//wWpUW5WokTfyQFe+/XtFK3XzD4LKfqeVDXf2/kaR7zEdqbiKdGuEW8dR0J/wQ +U0/Aj44FSMTr/BIK3zUcuMxmYdQ30x8PFQjOXfoAFqn7NRlcdD0RWQS5QY2c/oHq +xtYxhxGz27eDryVibvOXvj9rr8lE9T99iQfS5lo0+wKBgQD0ufl1IKUN3+uwhroW +0sldJRI2m1pJIyPXzt5NkUZTgm9vw1qgsH0dBszP5kuctFJ7M4s20re5gxkh5Shk +HKZqDvRmZYD7FF+IpMNogrWxRXTV3yk0WoP7ul2o56ZrgM/le2sgkC2Wh9IDR1B9 +W1wp2yuu3UouZLbcd9tvZADX3wKBgQDqfQ1fHpsrVqH5BQCaFPoJQ3pa8j1dsqsV +p3VVmk4LbmgRZyqx2zdot4YsnktUgnIrp7G4mi+WyZUFDQtastVm2PtfiFFml7th +Yr1qgStn/S6SHG137xbgFc9ISDqaqcBRtbufL9vPBseR9Gl1x6J/exsGz2Y3GdLq +mGKN3kXZNwKBgFYwPUvj8F1flFk6ScWJ/QMB8FUtB3IknxX9NEurM1Jr6KU67usS +3S1g3LcHi2+oDjh3obrwIWaDZlGKrFv9vxoxJB//9Zn3xeGQ7YUcK7NAG/LKwm8+ +xabdLukylGjeF9nhSoxQWs3eDbe74PwVNfNDzjGqm9qU+9XPnIexd56PAoGBAI8Z +r95btEB3hzOMPPjLUmfy2SnFaXPUBJqbbnzGRViukS4FssWFzcPHNEfodnplfT1t +AoVw+xfff2TsuAAq4Rb51jZP37VnZcAh3QyxNYcpuJEMbjXiICiyxqQLCFC4xZhX ++t9D+rXWIlbNPMrXATEhNLYsPenkhZYLVyHTAvdzAoGBAPPHGBN9DW7gFoHrxLf6 +J1kLcjFOxwNDXnMk9LMzyFYmvn7S/YXOIzkgGzH+dwnwigwCqi0hxkOfoGHF1cXM +k9N5tB6G4nnHPFi/MOVmyiRw3RiF2J1CU1u2xUN+ddIRvgpsvYI1dl3PjyKuStVA +45AxheULl0CfRqo7YhUG5E0l +-----END PRIVATE KEY----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.pem b/dashcaddy-api/generated-certs/dns3.sami/server.pem new file mode 100644 index 0000000..c4dd8bd --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/server.pem @@ -0,0 +1,58 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDgKYmcPfh1qcSv +jlslrLIzztSC9GNuwkkdhFaCnmxGOY0qvXvhW3ND7V15c57SRbJ+OdKoj8SbaYmA +YNHhLZuEERfkyJeoQdABPCzmgehc+4aZuebRjQH149TVfP6LHS3Pe84dzrPrZdZm +q4UcTtMSHMz06UyP/mww8rFHgUdIMtYhcXo1SScEzrBfu7IkRyhed/a9ibnKCp3/ +W+6dxYF7JsoVLiGycQiynXBwADDUYf3Ht9PI6f/tIvNOTtLoWQu366kcJeqRg9So +18axEVya4FmvVFdGPGKWAUn1VDg0QC8dF1dCZoKwaHcCHomKg6OWksnFwA+DJh4C +g/PERWfpAgMBAAECggEAArQf+WOsrne2ZmNaee0L+005ybGlSW22q5YuEpHuIXGr +QUBNiWyD/QFO2c8Dcdd4THU7I8Ubpqih6l0nXsK0f7tSrtTMGr2U81jNYM3swpPp +3W26SI0R/5qxwQ3ZFQDJgCEm+KSMcpYur4aoOEQ7Dj2FYgqi+M6pWkY0xe24vQ3n +0//wWpUW5WokTfyQFe+/XtFK3XzD4LKfqeVDXf2/kaR7zEdqbiKdGuEW8dR0J/wQ +U0/Aj44FSMTr/BIK3zUcuMxmYdQ30x8PFQjOXfoAFqn7NRlcdD0RWQS5QY2c/oHq +xtYxhxGz27eDryVibvOXvj9rr8lE9T99iQfS5lo0+wKBgQD0ufl1IKUN3+uwhroW +0sldJRI2m1pJIyPXzt5NkUZTgm9vw1qgsH0dBszP5kuctFJ7M4s20re5gxkh5Shk +HKZqDvRmZYD7FF+IpMNogrWxRXTV3yk0WoP7ul2o56ZrgM/le2sgkC2Wh9IDR1B9 +W1wp2yuu3UouZLbcd9tvZADX3wKBgQDqfQ1fHpsrVqH5BQCaFPoJQ3pa8j1dsqsV +p3VVmk4LbmgRZyqx2zdot4YsnktUgnIrp7G4mi+WyZUFDQtastVm2PtfiFFml7th +Yr1qgStn/S6SHG137xbgFc9ISDqaqcBRtbufL9vPBseR9Gl1x6J/exsGz2Y3GdLq +mGKN3kXZNwKBgFYwPUvj8F1flFk6ScWJ/QMB8FUtB3IknxX9NEurM1Jr6KU67usS +3S1g3LcHi2+oDjh3obrwIWaDZlGKrFv9vxoxJB//9Zn3xeGQ7YUcK7NAG/LKwm8+ +xabdLukylGjeF9nhSoxQWs3eDbe74PwVNfNDzjGqm9qU+9XPnIexd56PAoGBAI8Z +r95btEB3hzOMPPjLUmfy2SnFaXPUBJqbbnzGRViukS4FssWFzcPHNEfodnplfT1t +AoVw+xfff2TsuAAq4Rb51jZP37VnZcAh3QyxNYcpuJEMbjXiICiyxqQLCFC4xZhX ++t9D+rXWIlbNPMrXATEhNLYsPenkhZYLVyHTAvdzAoGBAPPHGBN9DW7gFoHrxLf6 +J1kLcjFOxwNDXnMk9LMzyFYmvn7S/YXOIzkgGzH+dwnwigwCqi0hxkOfoGHF1cXM +k9N5tB6G4nnHPFi/MOVmyiRw3RiF2J1CU1u2xUN+ddIRvgpsvYI1dl3PjyKuStVA +45AxheULl0CfRqo7YhUG5E0l +-----END PRIVATE KEY----- + +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUJ20vLiEqNjiXSIvmoLlBvOg2utMwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzgwNloXDTI3MDIxMTIxMzgwNlowFDESMBAGA1UEAwwJZG5zMy5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4CmJnD34danEr45b +JayyM87UgvRjbsJJHYRWgp5sRjmNKr174VtzQ+1deXOe0kWyfjnSqI/Em2mJgGDR +4S2bhBEX5MiXqEHQATws5oHoXPuGmbnm0Y0B9ePU1Xz+ix0tz3vOHc6z62XWZquF +HE7TEhzM9OlMj/5sMPKxR4FHSDLWIXF6NUknBM6wX7uyJEcoXnf2vYm5ygqd/1vu +ncWBeybKFS4hsnEIsp1wcAAw1GH9x7fTyOn/7SLzTk7S6FkLt+upHCXqkYPUqNfG +sRFcmuBZr1RXRjxilgFJ9VQ4NEAvHRdXQmaCsGh3Ah6JioOjlpLJxcAPgyYeAoPz +xEVn6QIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMzLnNhbWmCCyouZG5zMy5zYW1pMB0GA1UdDgQWBBTC +Nw7MUUA4YxBFua8YzXZ2ciBmbzAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiBdq9UEw1H+/sidEXaf6D6d3z/YymJLNycN +xP4afkkExgIhAOV44DZsuGBckiIOJfNWpwbcA92GTXg/XlcALEcOtX5g +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.pfx b/dashcaddy-api/generated-certs/dns3.sami/server.pfx new file mode 100644 index 0000000..8858fe9 Binary files /dev/null and b/dashcaddy-api/generated-certs/dns3.sami/server.pfx differ diff --git a/dashcaddy-api/health-checker.js b/dashcaddy-api/health-checker.js index 4762754..6327d26 100644 --- a/dashcaddy-api/health-checker.js +++ b/dashcaddy-api/health-checker.js @@ -111,7 +111,7 @@ class HealthChecker extends EventEmitter { responseTime, statusCode: result.statusCode, message: result.message, - details: result.details, + details: result.details }; // Track consecutive failures for exponential backoff @@ -136,7 +136,7 @@ class HealthChecker extends EventEmitter { timestamp: new Date().toISOString(), status: 'down', responseTime, - error: error.message, + error: error.message }; this.recordStatus(serviceId, status); @@ -170,7 +170,7 @@ class HealthChecker extends EventEmitter { method, timeout: config.timeout || 20000, headers: config.headers || {}, - rejectUnauthorized: false, // Trust internal CA certs (.sami TLD) + rejectUnauthorized: false // Trust internal CA certs (.sami TLD) }; const req = protocol.request(options, (res) => { @@ -189,8 +189,8 @@ class HealthChecker extends EventEmitter { message: healthy ? 'Service is healthy' : 'Service check failed', details: { headers: res.headers, - bodyLength: data.length, - }, + bodyLength: data.length + } }); }); }); @@ -306,7 +306,7 @@ class HealthChecker extends EventEmitter { const existing = this.incidents.find(i => i.serviceId === serviceId && i.type === type && - i.status === 'open', + i.status === 'open' ); if (existing) { @@ -327,7 +327,7 @@ class HealthChecker extends EventEmitter { createdAt: status.timestamp, lastOccurrence: status.timestamp, occurrences: 1, - details: status, + details: status }; this.incidents.push(incident); @@ -343,7 +343,7 @@ class HealthChecker extends EventEmitter { const incident = this.incidents.find(i => i.serviceId === serviceId && i.type === type && - i.status === 'open', + i.status === 'open' ); if (incident) { @@ -402,7 +402,7 @@ class HealthChecker extends EventEmitter { const history = this.history[serviceId] || []; return history.filter(h => - new Date(h.timestamp).getTime() > cutoffTime, + new Date(h.timestamp).getTime() > cutoffTime ); } @@ -423,10 +423,10 @@ class HealthChecker extends EventEmitter { name: config?.name || serviceId, uptime: { '24h': uptime24h, - '7d': uptime7d, + '7d': uptime7d }, avgResponseTime, - sla: config?.sla, + sla: config?.sla }; } @@ -456,8 +456,8 @@ class HealthChecker extends EventEmitter { min: Math.min(...responseTimes), max: Math.max(...responseTimes), p95: this.calculatePercentile(responseTimes, 95), - p99: this.calculatePercentile(responseTimes, 99), - }, + p99: this.calculatePercentile(responseTimes, 99) + } }; } @@ -504,7 +504,7 @@ class HealthChecker extends EventEmitter { slowResponseThreshold: config.slowResponseThreshold || 5000, sla: config.sla, headers: config.headers || {}, - body: config.body, + body: config.body }; this.saveConfig(); @@ -531,7 +531,7 @@ class HealthChecker extends EventEmitter { for (const serviceId in this.history) { this.history[serviceId] = this.history[serviceId].filter(h => - new Date(h.timestamp).getTime() > cutoffTime, + new Date(h.timestamp).getTime() > cutoffTime ); } } diff --git a/dashcaddy-api/input-validator.js b/dashcaddy-api/input-validator.js index 0d89d91..f1309ce 100644 --- a/dashcaddy-api/input-validator.js +++ b/dashcaddy-api/input-validator.js @@ -30,7 +30,7 @@ function validateDNSRecord(data) { if (!subdomainRegex.test(data.subdomain)) { errors.push({ field: 'subdomain', - message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)', + message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)' }); } @@ -80,7 +80,7 @@ function validateDNSRecord(data) { subdomain: data.subdomain.toLowerCase().trim(), domain: data.domain ? data.domain.toLowerCase().trim() : null, ip: data.ip.trim(), - ttl: data.ttl ? parseInt(data.ttl, 10) : 3600, + ttl: data.ttl ? parseInt(data.ttl, 10) : 3600 }; } @@ -99,7 +99,7 @@ function validateDockerDeployment(data) { if (!nameRegex.test(data.name)) { errors.push({ field: 'name', - message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens', + message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens' }); } @@ -119,7 +119,7 @@ function validateDockerDeployment(data) { if (!imageRegex.test(data.image)) { errors.push({ field: 'image', - message: 'Invalid Docker image format', + message: 'Invalid Docker image format' }); } @@ -146,7 +146,7 @@ function validateDockerDeployment(data) { if (!portRegex.test(port)) { errors.push({ field: `ports[${index}]`, - message: 'Invalid port format. Use "host:container" or "host:container/protocol"', + message: 'Invalid port format. Use "host:container" or "host:container/protocol"' }); } else { const [, hostPort, containerPort] = port.match(portRegex); @@ -193,7 +193,7 @@ function validateDockerDeployment(data) { if (!envKeyRegex.test(key)) { errors.push({ field: `environment.${key}`, - message: 'Invalid environment variable name', + message: 'Invalid environment variable name' }); } @@ -201,7 +201,7 @@ function validateDockerDeployment(data) { if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { errors.push({ field: `environment.${key}`, - message: 'Environment variable value must be string, number, or boolean', + message: 'Environment variable value must be string, number, or boolean' }); } }); @@ -219,7 +219,7 @@ function validateDockerDeployment(data) { image: data.image.trim(), ports: data.ports || [], volumes: data.volumes || [], - environment: data.environment || {}, + environment: data.environment || {} }; } @@ -248,7 +248,7 @@ function validateFilePath(filePath, allowedBasePaths = []) { 'C:\\Windows', 'C:\\Program Files', '/var/run', - '/var/lib/docker', + '/var/lib/docker' ]; const lowerPath = normalized.toLowerCase(); @@ -284,7 +284,7 @@ function validateVolumePath(volume, index) { if (!match) { errors.push({ field: `volumes[${index}]`, - message: 'Invalid volume format. Use "host:container" or "host:container:mode"', + message: 'Invalid volume format. Use "host:container" or "host:container:mode"' }); return errors; } @@ -297,7 +297,7 @@ function validateVolumePath(volume, index) { } catch (error) { errors.push({ field: `volumes[${index}].hostPath`, - message: `Invalid host path: ${error.message}`, + message: `Invalid host path: ${error.message}` }); } @@ -305,7 +305,7 @@ function validateVolumePath(volume, index) { if (containerPath.includes('..') || !path.isAbsolute(containerPath)) { errors.push({ field: `volumes[${index}].containerPath`, - message: 'Container path must be absolute and not contain ..', + message: 'Container path must be absolute and not contain ..' }); } @@ -313,7 +313,7 @@ function validateVolumePath(volume, index) { if (mode && !['ro', 'rw', 'z', 'Z'].includes(mode)) { errors.push({ field: `volumes[${index}].mode`, - message: 'Invalid volume mode. Use ro, rw, z, or Z', + message: 'Invalid volume mode. Use ro, rw, z, or Z' }); } @@ -333,7 +333,7 @@ function validateURL(url, options = {}) { require_protocol: options.requireProtocol !== false, require_valid_protocol: true, allow_underscores: false, - ...options, + ...options }; if (!validator.isURL(url, validatorOptions)) { @@ -451,7 +451,7 @@ function isPrivateIP(ip) { /^169\.254\./, /^::1$/, /^fc00:/, - /^fe80:/, + /^fe80:/ ]; return privateRanges.some(range => range.test(ip)); @@ -496,7 +496,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul auditLogger.logSecurityEvent('path_traversal_blocked', { requestedPath, reason: 'null_byte_detected', - severity: 'high', + severity: 'high' }); } throw new ValidationError('Invalid path - null byte detected', 'path'); @@ -510,7 +510,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul /\.\%2f/i, // .%2F (encoded ./) /%2e\./i, // %2E. /\.\\/, // .\ (Windows) - /%5c/i, // URL encoded backslash + /%5c/i // URL encoded backslash ]; if (suspiciousPatterns.some(pattern => pattern.test(requestedPath)) || @@ -520,7 +520,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul requestedPath, decodedPath, reason: 'traversal_sequence_detected', - severity: 'high', + severity: 'high' }); } throw new ValidationError('Path traversal detected', 'path'); @@ -581,7 +581,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul realPath, allowedRoots, reason: 'outside_allowed_roots', - severity: 'critical', + severity: 'critical' }); } throw new ValidationError('Access denied - path is outside allowed directories', 'path'); @@ -602,5 +602,5 @@ module.exports = { sanitizeString, isValidPort, isPrivateIP, - validateSecurePath, + validateSecurePath }; diff --git a/dashcaddy-api/jest.config.js b/dashcaddy-api/jest.config.js index 8482b6f..41fbdf5 100644 --- a/dashcaddy-api/jest.config.js +++ b/dashcaddy-api/jest.config.js @@ -11,17 +11,17 @@ module.exports = { 'update-manager.js', 'resource-monitor.js', 'credential-manager.js', - 'app-templates.js', + 'app-templates.js' ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, - statements: 80, - }, + statements: 80 + } }, setupFilesAfterEnv: ['/__tests__/jest.setup.js'], restoreMocks: true, - clearMocks: true, + clearMocks: true }; diff --git a/dashcaddy-api/keychain-manager.js b/dashcaddy-api/keychain-manager.js index 1082581..66f5908 100644 --- a/dashcaddy-api/keychain-manager.js +++ b/dashcaddy-api/keychain-manager.js @@ -182,7 +182,7 @@ class KeychainManager { try { execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], { input: value, - stdio: ['pipe', 'ignore', 'ignore'], + stdio: ['pipe', 'ignore', 'ignore'] }); return true; } catch { diff --git a/dashcaddy-api/license-keygen.js b/dashcaddy-api/license-keygen.js index 7578f5e..24761c9 100644 --- a/dashcaddy-api/license-keygen.js +++ b/dashcaddy-api/license-keygen.js @@ -177,7 +177,7 @@ function verifyCode(secret, code) { codeId, createdAt: createdDate.toISOString(), expiresAt: isLifetime ? null : expiresDate.toISOString(), - expired: isLifetime ? false : Date.now() > expiresDate.getTime(), + expired: isLifetime ? false : Date.now() > expiresDate.getTime() }; } catch (error) { return { valid: false, reason: error.message }; @@ -230,7 +230,7 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days const isLifetime = result.durationDays === 0; console.log('Code is VALID'); console.log(` Version: ${result.version}`); - console.log(` Duration: ${isLifetime ? 'LIFETIME' : `${result.durationDays } days`}`); + console.log(` Duration: ${isLifetime ? 'LIFETIME' : result.durationDays + ' days'}`); console.log(` Code ID: ${result.codeId}`); console.log(` Created: ${result.createdAt}`); console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`); @@ -293,16 +293,16 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days console.log(output); } } else { - const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : `${c.durationDays } days`}, ID: ${c.codeId})`); + const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : c.durationDays + ' days'}, ID: ${c.codeId})`); if (outputIndex !== -1) { - fs.writeFileSync(args[outputIndex + 1], `${codes.map(c => c.code).join('\n') }\n`); + fs.writeFileSync(args[outputIndex + 1], codes.map(c => c.code).join('\n') + '\n'); console.log(`${count} code(s) written to ${args[outputIndex + 1]}`); } else { lines.forEach(l => console.log(l)); } } - console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : `${duration } days`}. Next ID: ${startId + count}`); + console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`); } // Also export for use by license-manager.js diff --git a/dashcaddy-api/license-manager.js b/dashcaddy-api/license-manager.js index 47aad84..cda3568 100644 --- a/dashcaddy-api/license-manager.js +++ b/dashcaddy-api/license-manager.js @@ -23,7 +23,7 @@ const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when l const PREMIUM_FEATURES = { sso: { name: 'Auto-Login SSO', description: 'Automatic single sign-on for deployed apps' }, recipes: { name: 'Recipes', description: 'Multi-container stack deployment' }, - swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' }, + swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' } }; class LicenseManager { @@ -48,13 +48,13 @@ class LicenseManager { if (this.isExpired()) { this.log.info?.('license', 'License has expired', { code: this._maskCode(this.activation.code), - expiredAt: this.activation.expiresAt, + expiredAt: this.activation.expiresAt }); } else { this.log.info?.('license', 'License loaded', { code: this._maskCode(this.activation.code), expiresAt: this.activation.expiresAt, - daysRemaining: this.daysRemaining(), + daysRemaining: this.daysRemaining() }); } } else { @@ -96,7 +96,7 @@ class LicenseManager { os.hostname(), os.platform(), os.arch(), - os.cpus()[0]?.model || 'unknown', + os.cpus()[0]?.model || 'unknown' ]; // Get primary MAC address const interfaces = os.networkInterfaces(); @@ -132,7 +132,7 @@ class LicenseManager { return { success: true, message: 'This code is already activated', - activation: this.getStatus(), + activation: this.getStatus() }; } @@ -170,7 +170,7 @@ class LicenseManager { expiresAt: expiresAt.toISOString(), machineId, validationMethod: 'offline', - features: Object.keys(PREMIUM_FEATURES), + features: Object.keys(PREMIUM_FEATURES) }; } else { // Online validation succeeded — use server response @@ -182,7 +182,7 @@ class LicenseManager { try { await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), { activatedAt: this.activation.activatedAt, - expiresAt: this.activation.expiresAt, + expiresAt: this.activation.expiresAt }); } catch (error) { this.log.error?.('license', 'Failed to store activation', { error: error.message }); @@ -196,14 +196,14 @@ class LicenseManager { code: this._maskCode(code), durationDays: this.activation.durationDays, expiresAt: this.activation.expiresAt, - method: this.activation.validationMethod, + method: this.activation.validationMethod }); const durationLabel = this.activation.lifetime ? 'lifetime' : `${this.activation.durationDays} days`; return { success: true, message: `License activated for ${durationLabel}`, - activation: this.getStatus(), + activation: this.getStatus() }; } @@ -247,7 +247,7 @@ class LicenseManager { active: false, tier: 'free', features: [], - premiumFeatures: PREMIUM_FEATURES, + premiumFeatures: PREMIUM_FEATURES }; } @@ -267,7 +267,7 @@ class LicenseManager { expired, features: expired ? [] : (this.activation.features || Object.keys(PREMIUM_FEATURES)), premiumFeatures: PREMIUM_FEATURES, - validationMethod: this.activation.validationMethod, + validationMethod: this.activation.validationMethod }; } @@ -320,7 +320,7 @@ class LicenseManager { featureName: featureInfo.name, featureDescription: featureInfo.description, currentTier: this.isExpired() ? 'free' : 'expired', - upgradeUrl: '/settings#license', + upgradeUrl: '/settings#license' }); }; } @@ -359,7 +359,7 @@ class LicenseManager { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, machineId }), - signal: AbortSignal.timeout(10000), // 10s timeout + signal: AbortSignal.timeout(10000) // 10s timeout }); if (!response.ok) { @@ -379,8 +379,8 @@ class LicenseManager { expiresAt: data.expiresAt, machineId, features: data.features || Object.keys(PREMIUM_FEATURES), - serverToken: data.token, - }, + serverToken: data.token + } }; } @@ -388,7 +388,7 @@ class LicenseManager { } catch (error) { // Server unreachable — return null to fallback to offline this.log.warn?.('license', 'License server unreachable, falling back to offline validation', { - error: error.message, + error: error.message }); return null; } @@ -405,9 +405,9 @@ class LicenseManager { body: JSON.stringify({ code: this.activation.code, machineId: this.activation.machineId, - serverToken: this.activation.serverToken, + serverToken: this.activation.serverToken }), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); } @@ -431,7 +431,7 @@ class LicenseManager { tier: 'premium', expiresAt: this.activation.expiresAt, daysRemaining: this.daysRemaining(), - features: this.activation.features || Object.keys(PREMIUM_FEATURES), + features: this.activation.features || Object.keys(PREMIUM_FEATURES) }; } else { config.license = { active: false, tier: 'free' }; diff --git a/dashcaddy-api/log-digest.js b/dashcaddy-api/log-digest.js index 1605a2c..7242ba7 100644 --- a/dashcaddy-api/log-digest.js +++ b/dashcaddy-api/log-digest.js @@ -18,12 +18,12 @@ const ERROR_PATTERNS = [ /\berror\b/i, /\bfailed\b/i, /\bfatal\b/i, /\bpanic\b/i, /\bcrash(ed)?\b/i, /\bexception\b/i, /\btimeout\b/i, /\bOOM\b/, /\bout of memory\b/i, /\bkilled\b/i, - /\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i, + /\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i ]; const WARNING_PATTERNS = [ /\bwarn(ing)?\b/i, /\bdeprecated\b/i, /\bretry(ing)?\b/i, - /\bslow\b/i, /\blatency\b/i, + /\bslow\b/i, /\blatency\b/i ]; const EVENT_PATTERNS = [ @@ -31,7 +31,7 @@ const EVENT_PATTERNS = [ { pattern: /\b(stop(ped|ping)?|shutdown|exit(ed|ing)?|terminat(ed|ing)?)\b/i, type: 'shutdown' }, { pattern: /\b(restart(ed|ing)?|reload(ed|ing)?)\b/i, type: 'restart' }, { pattern: /\bhealth.?check.*(fail|unhealthy)\b/i, type: 'health_failure' }, - { pattern: /\b(update|upgrade|migration)\b/i, type: 'update' }, + { pattern: /\b(update|upgrade|migration)\b/i, type: 'update' } ]; class LogDigest extends EventEmitter { @@ -63,7 +63,7 @@ class LogDigest extends EventEmitter { // Collect logs every hour this.collectInterval = setInterval(() => { this._collectHourlyLogs().catch(e => - console.error('[LogDigest] Hourly collection failed:', e.message), + console.error('[LogDigest] Hourly collection failed:', e.message) ); }, DOCKER.DIGEST.COLLECT_INTERVAL); @@ -102,7 +102,7 @@ class LogDigest extends EventEmitter { const hourSummary = { hour: hourKey, timestamp: now.toISOString(), - services: {}, + services: {} }; try { @@ -123,7 +123,7 @@ class LogDigest extends EventEmitter { events: [], errorCount: 0, warningCount: 0, - totalLines: 0, + totalLines: 0 }; if (isRunning) { @@ -134,7 +134,7 @@ class LogDigest extends EventEmitter { stderr: true, since: sinceTimestamp, tail: DOCKER.DIGEST.LOG_TAIL, - timestamps: true, + timestamps: true }); const lines = this._parseDockerLogs(logBuffer); @@ -147,7 +147,7 @@ class LogDigest extends EventEmitter { if (serviceSummary.errors.length < 10) { serviceSummary.errors.push({ time: line.timestamp || hourKey, - text: line.text.slice(0, 500), + text: line.text.slice(0, 500) }); } continue; @@ -159,7 +159,7 @@ class LogDigest extends EventEmitter { if (serviceSummary.warnings.length < 5) { serviceSummary.warnings.push({ time: line.timestamp || hourKey, - text: line.text.slice(0, 300), + text: line.text.slice(0, 300) }); } continue; @@ -171,7 +171,7 @@ class LogDigest extends EventEmitter { serviceSummary.events.push({ type, time: line.timestamp || hourKey, - text: line.text.slice(0, 300), + text: line.text.slice(0, 300) }); break; } @@ -180,7 +180,7 @@ class LogDigest extends EventEmitter { } catch (logErr) { serviceSummary.errors.push({ time: now.toISOString(), - text: `Failed to fetch logs: ${logErr.message}`, + text: `Failed to fetch logs: ${logErr.message}` }); serviceSummary.errorCount++; } @@ -188,7 +188,7 @@ class LogDigest extends EventEmitter { serviceSummary.events.push({ type: 'not_running', time: now.toISOString(), - text: `Container is ${containerInfo.State}`, + text: `Container is ${containerInfo.State}` }); } @@ -237,7 +237,7 @@ class LogDigest extends EventEmitter { lines.push({ stream: streamType === 2 ? 'stderr' : 'stdout', text: message, - timestamp, + timestamp }); } offset += 8 + size; @@ -258,7 +258,7 @@ class LogDigest extends EventEmitter { const delay = next.getTime() - now.getTime(); this.digestTimeout = setTimeout(() => { this.generateDailyDigest().catch(e => - console.error('[LogDigest] Daily digest generation failed:', e.message), + console.error('[LogDigest] Daily digest generation failed:', e.message) ); // Reschedule for tomorrow if (this.running) this._scheduleDailyDigest(); @@ -288,7 +288,7 @@ class LogDigest extends EventEmitter { totalLines: 0, lastState: svc.state, topErrors: [], - events: [], + events: [] }; } const agg = serviceAgg[appId]; @@ -332,8 +332,8 @@ class LogDigest extends EventEmitter { totalServices: Object.keys(serviceAgg).length, servicesWithErrors: Object.values(serviceAgg).filter(s => s.totalErrors > 0).length, totalErrors: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalErrors, 0), - totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0), - }, + totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0) + } }; // Write formatted digest file @@ -369,7 +369,7 @@ class LogDigest extends EventEmitter { lines.push(''); // Service summary table - lines.push(`-- Service Summary ${ '-'.repeat(36)}`); + lines.push('-- Service Summary ' + '-'.repeat(36)); const services = Object.values(digest.services); if (services.length === 0) { lines.push(' No managed services found.'); @@ -387,14 +387,14 @@ class LogDigest extends EventEmitter { // Notable events const events = digest.notableEvents; if (events.length > 0) { - lines.push(`-- Notable Events ${ '-'.repeat(37)}`); + lines.push('-- Notable Events ' + '-'.repeat(37)); for (const evt of events) { const time = (evt.time || '').slice(11, 16) || '??:??'; lines.push(` [${time}] ${evt.service}: ${evt.text.slice(0, 80)}`); // Add guidance for where to look further const containerName = `${DOCKER.CONTAINER_PREFIX}${evt.appId}`; if (evt.type === 'health_failure' || evt.type === 'restart') { - const sinceDate = `${digest.date }T${ (evt.time || '').slice(11, 13) }:00:00`; + const sinceDate = digest.date + 'T' + (evt.time || '').slice(11, 13) + ':00:00'; lines.push(` See: docker logs ${containerName} --since ${sinceDate}`); } } @@ -404,7 +404,7 @@ class LogDigest extends EventEmitter { // Top errors per service const errServices = services.filter(s => s.totalErrors > 0); if (errServices.length > 0) { - lines.push(`-- Error Details ${ '-'.repeat(38)}`); + lines.push('-- Error Details ' + '-'.repeat(38)); for (const svc of errServices) { lines.push(` ${svc.name} (${svc.totalErrors} errors):`); for (const err of svc.topErrors) { @@ -419,7 +419,7 @@ class LogDigest extends EventEmitter { // Docker disk usage if (digest.diskUsage) { - lines.push(`-- Docker Disk Usage ${ '-'.repeat(34)}`); + lines.push('-- Docker Disk Usage ' + '-'.repeat(34)); const du = digest.diskUsage; lines.push(` Images: ${formatBytes(du.images.sizeBytes)} (${du.images.count} images)`); lines.push(` Containers: ${formatBytes(du.containers.sizeBytes)}`); @@ -439,7 +439,7 @@ class LogDigest extends EventEmitter { lines.push(` Hours collected: ${digest.hoursCollected}/24`); lines.push(hr); - return `${lines.join('\n') }\n`; + return lines.join('\n') + '\n'; } /** @@ -551,7 +551,7 @@ class LogDigest extends EventEmitter { date: today, hoursCollected: todayHours.length, lastCollect: this.lastCollect, - services: serviceAgg, + services: serviceAgg }; } @@ -560,7 +560,7 @@ class LogDigest extends EventEmitter { running: this.running, lastCollect: this.lastCollect, hourlySummaries: this.hourlySummaries.length, - digestDir: this.digestDir, + digestDir: this.digestDir }; } } @@ -569,7 +569,7 @@ function formatBytes(bytes) { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1) } ${ units[i]}`; + return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]; } module.exports = new LogDigest(); diff --git a/dashcaddy-api/logger-utils.js b/dashcaddy-api/logger-utils.js deleted file mode 100644 index 0ce7e7f..0000000 --- a/dashcaddy-api/logger-utils.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Logger Utilities - Sanitize sensitive data before logging - * Created: 2026-03-21 - * Purpose: Prevent credential/token/password leakage in logs - */ - -/** - * List of sensitive field names that should be redacted - */ -const SENSITIVE_FIELDS = [ - 'password', - 'passwd', - 'pwd', - 'secret', - 'token', - 'apiKey', - 'api_key', - 'apikey', - 'auth', - 'authorization', - 'bearer', - 'credential', - 'credentials', - 'key', - 'privateKey', - 'private_key', - 'accessToken', - 'access_token', - 'refreshToken', - 'refresh_token', - 'sessionId', - 'session_id', - 'cookie', - 'cookies', - 'cert', - 'certificate', - 'masterKey', - 'master_key', - 'encryptionKey', - 'encryption_key', -]; - -/** - * Recursively sanitize an object by redacting sensitive fields - * @param {any} data - Data to sanitize - * @param {Array} additionalSensitiveKeys - Additional field names to redact - * @returns {any} Sanitized copy of the data - */ -function sanitizeForLog(data, additionalSensitiveKeys = []) { - // Handle null/undefined - if (data === null || data === undefined) { - return data; - } - - // Handle primitives - if (typeof data !== 'object') { - return data; - } - - // Handle arrays - if (Array.isArray(data)) { - return data.map(item => sanitizeForLog(item, additionalSensitiveKeys)); - } - - // Handle objects - const sensitiveKeys = [...SENSITIVE_FIELDS, ...additionalSensitiveKeys]; - const sanitized = {}; - - for (const [key, value] of Object.entries(data)) { - const lowerKey = key.toLowerCase(); - const isSensitive = sensitiveKeys.some(sk => lowerKey.includes(sk.toLowerCase())); - - if (isSensitive) { - // Redact sensitive fields - sanitized[key] = '[REDACTED]'; - } else if (value && typeof value === 'object') { - // Recursively sanitize nested objects - sanitized[key] = sanitizeForLog(value, additionalSensitiveKeys); - } else { - sanitized[key] = value; - } - } - - return sanitized; -} - -/** - * Redact a credential value for logging (show first/last 4 chars only) - * @param {string} value - Credential value - * @returns {string} Partially redacted value (e.g., "abcd****xyz") - */ -function redactCredential(value) { - if (!value || typeof value !== 'string') { - return '[REDACTED]'; - } - - if (value.length <= 8) { - return '[REDACTED]'; - } - - const start = value.slice(0, 4); - const end = value.slice(-4); - const middle = '*'.repeat(Math.min(value.length - 8, 10)); - - return `${start}${middle}${end}`; -} - -/** - * Create a safe log message object (strips sensitive data) - * @param {string} message - Log message - * @param {object} data - Data to log - * @param {Array} additionalSensitiveKeys - Additional field names to redact - * @returns {object} Safe log object - */ -function safeLog(message, data = {}, additionalSensitiveKeys = []) { - return { - message, - data: sanitizeForLog(data, additionalSensitiveKeys), - timestamp: new Date().toISOString(), - }; -} - -module.exports = { - sanitizeForLog, - redactCredential, - safeLog, - SENSITIVE_FIELDS, -}; diff --git a/dashcaddy-api/metrics.js b/dashcaddy-api/metrics.js index 149564c..09b196d 100644 --- a/dashcaddy-api/metrics.js +++ b/dashcaddy-api/metrics.js @@ -11,11 +11,11 @@ class Metrics { total: 0, byStatus: {}, byMethod: {}, - byPath: {}, + byPath: {} }; this.errors = { total: 0, - byType: {}, + byType: {} }; this.business = { containersDeployed: 0, @@ -26,7 +26,7 @@ class Metrics { totpLogins: 0, siteAdded: 0, siteRemoved: 0, - credentialRotations: 0, + credentialRotations: 0 }; } @@ -78,19 +78,19 @@ class Metrics { perSecond: uptimeSec > 0 ? +(this.requests.total / uptimeSec).toFixed(2) : 0, byStatus: this.requests.byStatus, byMethod: this.requests.byMethod, - topEndpoints, + topEndpoints }, errors: { total: this.errors.total, rate: this.requests.total > 0 ? +((this.errors.total / this.requests.total) * 100).toFixed(2) : 0, - byType: this.errors.byType, + byType: this.errors.byType }, business: this.business, process: { memory: process.memoryUsage(), pid: process.pid, - nodeVersion: process.version, - }, + nodeVersion: process.version + } }; } diff --git a/dashcaddy-api/middleware.js b/dashcaddy-api/middleware.js index bbb1916..653818f 100644 --- a/dashcaddy-api/middleware.js +++ b/dashcaddy-api/middleware.js @@ -27,7 +27,7 @@ const { CACHE_CONFIGS, createCache } = require('./cache-config'); module.exports = function configureMiddleware(app, { siteConfig, totpConfig, tailscaleConfig, metrics, auditLogger, authManager, log, cryptoUtils, - isValidContainerId, isTailscaleIP, getTailscaleStatus, + isValidContainerId, isTailscaleIP, getTailscaleStatus }) { // ── Container ID param validation ── @@ -44,7 +44,7 @@ module.exports = function configureMiddleware(app, { app.use(cors({ origin: corsOrigins, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - credentials: true, + credentials: true })); // ── Security headers with Helmet ── @@ -54,16 +54,16 @@ module.exports = function configureMiddleware(app, { defaultSrc: ["'self'"], styleSrc: ["'self'"], scriptSrc: ["'self'"], - imgSrc: ["'self'", 'data:', 'https:'], + imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'"], - fontSrc: ["'self'", 'data:'], + fontSrc: ["'self'", "data:"], objectSrc: ["'none'"], mediaSrc: ["'self'"], - frameSrc: ["'none'"], - }, + frameSrc: ["'none'"] + } }, crossOriginEmbedderPolicy: false, - crossOriginResourcePolicy: { policy: 'cross-origin' }, + crossOriginResourcePolicy: { policy: "cross-origin" } })); // ── Trust proxy (one hop — Caddy) ── @@ -95,7 +95,7 @@ module.exports = function configureMiddleware(app, { if (req.path !== '/health' && req.path !== '/api/health') { const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'debug'; log[level]('http', `${req.method} ${req.path} ${res.statusCode}`, { - ms: duration, ip: req.ip, id: req.id, + ms: duration, ip: req.ip, id: req.id }); } }); @@ -128,7 +128,7 @@ module.exports = function configureMiddleware(app, { success: false, error: '[DC-120] Access denied. This dashboard requires Tailscale connection.', requiresTailscale: true, - clientIP: clientIP, + clientIP: clientIP }); } @@ -151,7 +151,7 @@ module.exports = function configureMiddleware(app, { success: false, error: '[DC-121] Access denied. Device not in allowed tailnet.', requiresTailscale: true, - clientIP, + clientIP }); } } @@ -178,7 +178,7 @@ module.exports = function configureMiddleware(app, { '8h': 8 * 60 * 60 * 1000, '12h': 12 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, - 'never': null, + 'never': null }; // IP-based session store (solves cross-domain cookie issues with .sami TLD) @@ -222,7 +222,7 @@ module.exports = function configureMiddleware(app, { const key = cryptoUtils.loadOrCreateKey(); const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url'); res.setHeader('Set-Cookie', - `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`, + `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` ); } @@ -254,7 +254,7 @@ module.exports = function configureMiddleware(app, { function clearSessionCookie(res) { res.setHeader('Set-Cookie', - `${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`, + `${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax` ); } @@ -324,7 +324,7 @@ module.exports = function configureMiddleware(app, { if (req.totpSessionValid || isSessionValid(req)) { req.auth = { type: 'session', - scope: ['admin'], + scope: ['admin'] }; return next(); } @@ -340,7 +340,7 @@ module.exports = function configureMiddleware(app, { req.auth = { type: 'jwt', userId: jwtPayload.userId, - scope: jwtPayload.scope || [], + scope: jwtPayload.scope || [] }; return next(); } @@ -355,7 +355,7 @@ module.exports = function configureMiddleware(app, { type: 'apikey', keyId: keyData.keyId, name: keyData.name, - scope: keyData.scopes || [], + scope: keyData.scopes || [] }; return next(); } @@ -364,7 +364,7 @@ module.exports = function configureMiddleware(app, { if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') { req.auth = { type: 'none', - scope: ['admin'], + scope: ['admin'] }; return next(); } @@ -372,7 +372,7 @@ module.exports = function configureMiddleware(app, { return res.status(401).json({ success: false, error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', - requiresTotp: totpConfig.enabled, + requiresTotp: totpConfig.enabled }); }; @@ -385,7 +385,7 @@ module.exports = function configureMiddleware(app, { standardHeaders: true, legacyHeaders: false, skip: (req) => isTest || req.path === '/health' || req.path === '/api/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/auth/gate/') || req.path === '/api/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs', - message: { success: false, error: 'Too many requests, please try again later' }, + message: { success: false, error: 'Too many requests, please try again later' } }); const strictLimiter = rateLimit({ @@ -393,7 +393,7 @@ module.exports = function configureMiddleware(app, { standardHeaders: true, legacyHeaders: false, skip: () => isTest, - message: { success: false, error: 'Too many requests to this endpoint, please try again later' }, + message: { success: false, error: 'Too many requests to this endpoint, please try again later' } }); app.use(generalLimiter); @@ -407,7 +407,7 @@ module.exports = function configureMiddleware(app, { ...RATE_LIMITS.TOTP, standardHeaders: true, legacyHeaders: false, - message: { success: false, error: 'Too many TOTP attempts, please try again later' }, + message: { success: false, error: 'Too many TOTP attempts, please try again later' } }); app.use('/api/totp/verify', totpLimiter); app.use('/api/totp/verify-setup', totpLimiter); @@ -425,6 +425,6 @@ module.exports = function configureMiddleware(app, { clearIPSession, clearSessionCookie, isSessionValid, - ipSessions, + ipSessions }; }; diff --git a/dashcaddy-api/pki/intermediate.crt b/dashcaddy-api/pki/intermediate.crt new file mode 100644 index 0000000..d992ac2 --- /dev/null +++ b/dashcaddy-api/pki/intermediate.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/pki/intermediate.key b/dashcaddy-api/pki/intermediate.key new file mode 100644 index 0000000..c0ff910 --- /dev/null +++ b/dashcaddy-api/pki/intermediate.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIOKHTAa6a6M/FCVOQ+sUzRe0uzHWCmlHx8pIzOtg2YZxoAoGCCqGSM49 +AwEHoUQDQgAEvdcwdLxtsaAewaiM9YiAOocfrmfGb/K9+sD/Dmtd7VA1nShB9d2F +Q4O67WhON1CIZI7Gnkk3v6vvcMgiMOg7VA== +-----END EC PRIVATE KEY----- diff --git a/dashcaddy-api/pki/root.crt b/dashcaddy-api/pki/root.crt new file mode 100644 index 0000000..97a4ce4 --- /dev/null +++ b/dashcaddy-api/pki/root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/pki/root.key b/dashcaddy-api/pki/root.key new file mode 100644 index 0000000..45aca90 --- /dev/null +++ b/dashcaddy-api/pki/root.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDbA5DxGcb/sbvBD+Hoijk2+90EQurCJSXbJwvh7h+P3oAoGCCqGSM49 +AwEHoUQDQgAE7PCuYb4e6gu+5HRYJMMiLukr8xLayuNepS5EJAvhO/cSMBkSrGe0 +/4HftAZNDTFGQcS8MOBdkZf5Ld3hruPa0g== +-----END EC PRIVATE KEY----- diff --git a/dashcaddy-api/platform-paths.js b/dashcaddy-api/platform-paths.js index 858ea88..9ab658c 100644 --- a/dashcaddy-api/platform-paths.js +++ b/dashcaddy-api/platform-paths.js @@ -47,17 +47,17 @@ const paths = { // Log paths (for allowed log file access) allowedLogPaths: isWindows ? [ - process.env.LOCALAPPDATA || 'C:\\Users', - process.env.APPDATA || 'C:\\Users', - 'C:\\ProgramData', - '/var/log', - '/opt', - ] + process.env.LOCALAPPDATA || 'C:\\Users', + process.env.APPDATA || 'C:\\Users', + 'C:\\ProgramData', + '/var/log', + '/opt' + ] : [ - '/var/log', - '/opt', - '/home', - ], + '/var/log', + '/opt', + '/home' + ], // Platform detection helpers isWindows, diff --git a/dashcaddy-api/port-lock-manager.js b/dashcaddy-api/port-lock-manager.js index ad08d78..29528e4 100644 --- a/dashcaddy-api/port-lock-manager.js +++ b/dashcaddy-api/port-lock-manager.js @@ -16,10 +16,10 @@ const LOCK_RETRY_OPTIONS = { retries: 10, minTimeout: 100, maxTimeout: 1000, - randomize: true, + randomize: true }, stale: LOCK_STALE_THRESHOLD, - realpath: false, + realpath: false }; class PortLockManager { @@ -72,7 +72,7 @@ class PortLockManager { if (!fs.existsSync(lockFilePath)) { fs.writeFileSync(lockFilePath, JSON.stringify({ created: new Date().toISOString(), - port, + port })); } @@ -89,7 +89,7 @@ class PortLockManager { this.activeLocks.set(lockId, { ports: sortedPorts, releases: releaseFunctions, - timestamp: Date.now(), + timestamp: Date.now() }); console.log(`[PortLockManager] Successfully acquired all locks (ID: ${lockId})`); @@ -97,13 +97,13 @@ class PortLockManager { } catch (error) { // Release any locks we managed to acquire - console.error('[PortLockManager] Failed to acquire all locks:', error.message); + console.error(`[PortLockManager] Failed to acquire all locks:`, error.message); for (const release of releaseFunctions) { try { await release(); } catch (releaseError) { - console.error('[PortLockManager] Error releasing lock during cleanup:', releaseError.message); + console.error(`[PortLockManager] Error releasing lock during cleanup:`, releaseError.message); } } @@ -132,7 +132,7 @@ class PortLockManager { await release(); } catch (error) { errors.push(error.message); - console.error('[PortLockManager] Error releasing lock:', error.message); + console.error(`[PortLockManager] Error releasing lock:`, error.message); } } @@ -198,13 +198,13 @@ class PortLockManager { lockId, ports: info.ports, age: Date.now() - info.timestamp, - timestamp: new Date(info.timestamp).toISOString(), + timestamp: new Date(info.timestamp).toISOString() })); return { activeLocks: activeLocks.length, locks: activeLocks, - lockDirectory: LOCK_DIR, + lockDirectory: LOCK_DIR }; } diff --git a/dashcaddy-api/recipe-templates.js b/dashcaddy-api/recipe-templates.js index 2f5f9a0..25fad76 100644 --- a/dashcaddy-api/recipe-templates.js +++ b/dashcaddy-api/recipe-templates.js @@ -4,336 +4,336 @@ const RECIPE_TEMPLATES = { // === MEDIA & ENTERTAINMENT === - 'htpc-suite': { - name: 'HTPC Suite', - description: 'Complete media automation: find, download, organize, and stream', - icon: '\uD83C\uDFAC', - category: 'Media', - type: 'recipe', - difficulty: 'Intermediate', + "htpc-suite": { + name: "HTPC Suite", + description: "Complete media automation: find, download, organize, and stream", + icon: "\uD83C\uDFAC", + category: "Media", + type: "recipe", + difficulty: "Intermediate", popularity: 98, components: [ { - id: 'prowlarr', - role: 'Indexer Manager', - templateRef: 'prowlarr', + id: "prowlarr", + role: "Indexer Manager", + templateRef: "prowlarr", required: true, - order: 1, + order: 1 }, { - id: 'qbittorrent', - role: 'Download Client', - templateRef: 'qbittorrent', + id: "qbittorrent", + role: "Download Client", + templateRef: "qbittorrent", required: true, - order: 2, + order: 2 }, { - id: 'sonarr', - role: 'TV Show Manager', - templateRef: 'sonarr', + id: "sonarr", + role: "TV Show Manager", + templateRef: "sonarr", required: true, - order: 3, + order: 3 }, { - id: 'radarr', - role: 'Movie Manager', - templateRef: 'radarr', + id: "radarr", + role: "Movie Manager", + templateRef: "radarr", required: true, - order: 4, + order: 4 }, { - id: 'lidarr', - role: 'Music Manager', - templateRef: 'lidarr', + id: "lidarr", + role: "Music Manager", + templateRef: "lidarr", required: false, - order: 5, + order: 5 }, { - id: 'overseerr', - role: 'Request Manager', - templateRef: 'seerr', + id: "overseerr", + role: "Request Manager", + templateRef: "seerr", required: false, - order: 6, - }, + order: 6 + } ], sharedVolumes: { media: { - label: 'Media Library', - description: 'Root folder for all media (movies, TV, music)', - defaultPath: '/media', - usedBy: ['sonarr', 'radarr', 'lidarr', 'qbittorrent'], + label: "Media Library", + description: "Root folder for all media (movies, TV, music)", + defaultPath: "/media", + usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"] }, downloads: { - label: 'Downloads', - description: 'Shared downloads folder for all download clients', - defaultPath: '/downloads', - usedBy: ['sonarr', 'radarr', 'lidarr', 'qbittorrent'], - }, + label: "Downloads", + description: "Shared downloads folder for all download clients", + defaultPath: "/downloads", + usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"] + } }, autoConnect: { enabled: true, - description: 'Automatically connects Sonarr/Radarr to Prowlarr and qBittorrent', + description: "Automatically connects Sonarr/Radarr to Prowlarr and qBittorrent", steps: [ - { action: 'configureProwlarrApps', targets: ['sonarr', 'radarr', 'lidarr'] }, - { action: 'configureDownloadClient', client: 'qbittorrent', targets: ['sonarr', 'radarr', 'lidarr'] }, - ], + { action: "configureProwlarrApps", targets: ["sonarr", "radarr", "lidarr"] }, + { action: "configureDownloadClient", client: "qbittorrent", targets: ["sonarr", "radarr", "lidarr"] } + ] }, setupInstructions: [ - 'All services share the same media and downloads folders', - 'Prowlarr is pre-connected to Sonarr, Radarr, and Lidarr', - 'Add indexers in Prowlarr \u2014 they sync automatically to all *arr apps', - 'Add your media library root folders in Sonarr and Radarr', - 'qBittorrent is pre-configured as the download client', - ], + "All services share the same media and downloads folders", + "Prowlarr is pre-connected to Sonarr, Radarr, and Lidarr", + "Add indexers in Prowlarr \u2014 they sync automatically to all *arr apps", + "Add your media library root folders in Sonarr and Radarr", + "qBittorrent is pre-configured as the download client" + ] }, // === PRODUCTIVITY === - 'nextcloud-complete': { - name: 'Nextcloud Complete', - description: 'Full productivity suite: cloud storage, office editing, and collaboration', - icon: '\u2601\uFE0F', - category: 'Productivity', - type: 'recipe', - difficulty: 'Intermediate', + "nextcloud-complete": { + name: "Nextcloud Complete", + description: "Full productivity suite: cloud storage, office editing, and collaboration", + icon: "\u2601\uFE0F", + category: "Productivity", + type: "recipe", + difficulty: "Intermediate", popularity: 90, components: [ { - id: 'nextcloud-db', - role: 'Database', + id: "nextcloud-db", + role: "Database", required: true, order: 0, docker: { - image: 'mariadb:11', + image: "mariadb:11", ports: [], - volumes: ['/opt/nextcloud-db/data:/var/lib/mysql'], + volumes: ["/opt/nextcloud-db/data:/var/lib/mysql"], environment: { - 'MYSQL_ROOT_PASSWORD': '{{GENERATED_PASSWORD}}', - 'MYSQL_DATABASE': 'nextcloud', - 'MYSQL_USER': 'nextcloud', - 'MYSQL_PASSWORD': '{{GENERATED_PASSWORD}}', - }, + "MYSQL_ROOT_PASSWORD": "{{GENERATED_PASSWORD}}", + "MYSQL_DATABASE": "nextcloud", + "MYSQL_USER": "nextcloud", + "MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}" + } }, - internal: true, + internal: true }, { - id: 'nextcloud-redis', - role: 'Cache', + id: "nextcloud-redis", + role: "Cache", required: true, order: 0, docker: { - image: 'redis:7-alpine', + image: "redis:7-alpine", ports: [], - volumes: ['/opt/nextcloud-redis/data:/data'], - environment: {}, + volumes: ["/opt/nextcloud-redis/data:/data"], + environment: {} }, - internal: true, + internal: true }, { - id: 'nextcloud', - role: 'Cloud Platform', - templateRef: 'nextcloud', + id: "nextcloud", + role: "Cloud Platform", + templateRef: "nextcloud", required: true, order: 1, envOverrides: { - 'MYSQL_HOST': 'dashcaddy-nextcloud-db', - 'MYSQL_DATABASE': 'nextcloud', - 'MYSQL_USER': 'nextcloud', - 'MYSQL_PASSWORD': '{{GENERATED_PASSWORD}}', - 'REDIS_HOST': 'dashcaddy-nextcloud-redis', - }, + "MYSQL_HOST": "dashcaddy-nextcloud-db", + "MYSQL_DATABASE": "nextcloud", + "MYSQL_USER": "nextcloud", + "MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}", + "REDIS_HOST": "dashcaddy-nextcloud-redis" + } }, { - id: 'collabora', - role: 'Office Suite', + id: "collabora", + role: "Office Suite", required: false, order: 2, docker: { - image: 'collabora/code:latest', - ports: ['{{PORT}}:9980'], + image: "collabora/code:latest", + ports: ["{{PORT}}:9980"], volumes: [], environment: { - 'aliasgroup1': 'https://{{NEXTCLOUD_DOMAIN}}', - 'extra_params': '--o:ssl.enable=false --o:ssl.termination=true', - }, + "aliasgroup1": "https://{{NEXTCLOUD_DOMAIN}}", + "extra_params": "--o:ssl.enable=false --o:ssl.termination=true" + } }, - subdomain: 'office', + subdomain: "office", defaultPort: 9980, - healthCheck: '/', - }, + healthCheck: "/" + } ], network: { - name: 'dashcaddy-nextcloud', - driver: 'bridge', + name: "dashcaddy-nextcloud", + driver: "bridge" }, sharedVolumes: { data: { - label: 'Cloud Storage', - description: 'Nextcloud data directory for user files', - defaultPath: '/opt/nextcloud/data', - usedBy: ['nextcloud'], - }, + label: "Cloud Storage", + description: "Nextcloud data directory for user files", + defaultPath: "/opt/nextcloud/data", + usedBy: ["nextcloud"] + } }, setupInstructions: [ - 'Complete the Nextcloud initial setup wizard in the browser', - 'MariaDB and Redis are pre-configured and connected', - 'If Collabora is enabled, configure it in Nextcloud: Settings \u2192 Nextcloud Office', - 'Point Nextcloud Office to your Collabora URL (e.g., https://office.sami)', - 'Configure email, 2FA, and other settings in Nextcloud admin panel', - ], + "Complete the Nextcloud initial setup wizard in the browser", + "MariaDB and Redis are pre-configured and connected", + "If Collabora is enabled, configure it in Nextcloud: Settings \u2192 Nextcloud Office", + "Point Nextcloud Office to your Collabora URL (e.g., https://office.sami)", + "Configure email, 2FA, and other settings in Nextcloud admin panel" + ] }, // === DEVELOPMENT === - 'dev-environment': { - name: 'Dev Environment', - description: 'Self-hosted development workflow: Git, CI/CD, IDE, and database', - icon: '\uD83D\uDCBB', - category: 'Development', - type: 'recipe', - difficulty: 'Advanced', + "dev-environment": { + name: "Dev Environment", + description: "Self-hosted development workflow: Git, CI/CD, IDE, and database", + icon: "\uD83D\uDCBB", + category: "Development", + type: "recipe", + difficulty: "Advanced", popularity: 82, components: [ { - id: 'dev-postgres', - role: 'Database', + id: "dev-postgres", + role: "Database", required: true, order: 0, docker: { - image: 'postgres:16-alpine', + image: "postgres:16-alpine", ports: [], - volumes: ['/opt/dev-postgres/data:/var/lib/postgresql/data'], + volumes: ["/opt/dev-postgres/data:/var/lib/postgresql/data"], environment: { - 'POSTGRES_DB': 'gitea', - 'POSTGRES_USER': 'gitea', - 'POSTGRES_PASSWORD': '{{GENERATED_PASSWORD}}', - }, + "POSTGRES_DB": "gitea", + "POSTGRES_USER": "gitea", + "POSTGRES_PASSWORD": "{{GENERATED_PASSWORD}}" + } }, - internal: true, + internal: true }, { - id: 'gitea', - role: 'Git Server', - templateRef: 'gitea', + id: "gitea", + role: "Git Server", + templateRef: "gitea", required: true, order: 1, envOverrides: { - 'GITEA__database__DB_TYPE': 'postgres', - 'GITEA__database__HOST': 'dashcaddy-dev-postgres:5432', - 'GITEA__database__NAME': 'gitea', - 'GITEA__database__USER': 'gitea', - 'GITEA__database__PASSWD': '{{GENERATED_PASSWORD}}', - }, + "GITEA__database__DB_TYPE": "postgres", + "GITEA__database__HOST": "dashcaddy-dev-postgres:5432", + "GITEA__database__NAME": "gitea", + "GITEA__database__USER": "gitea", + "GITEA__database__PASSWD": "{{GENERATED_PASSWORD}}" + } }, { - id: 'drone', - role: 'CI/CD Pipeline', - templateRef: 'drone', + id: "drone", + role: "CI/CD Pipeline", + templateRef: "drone", required: false, - order: 2, + order: 2 }, { - id: 'vscode-server', - role: 'Web IDE', - templateRef: 'vscode-server', + id: "vscode-server", + role: "Web IDE", + templateRef: "vscode-server", required: false, - order: 3, - }, + order: 3 + } ], network: { - name: 'dashcaddy-dev', - driver: 'bridge', + name: "dashcaddy-dev", + driver: "bridge" }, setupInstructions: [ - 'Gitea is pre-configured with PostgreSQL database', - 'Complete the Gitea initial setup wizard in the browser', - 'If Drone CI is enabled, connect it to Gitea via OAuth application', - 'VS Code Server provides a full IDE in your browser', - 'All development services share a Docker network for inter-service communication', - ], + "Gitea is pre-configured with PostgreSQL database", + "Complete the Gitea initial setup wizard in the browser", + "If Drone CI is enabled, connect it to Gitea via OAuth application", + "VS Code Server provides a full IDE in your browser", + "All development services share a Docker network for inter-service communication" + ] }, // === HOME AUTOMATION === - 'smart-home': { - name: 'Smart Home Hub', - description: 'Home automation: control, automate, and monitor IoT devices', - icon: '\uD83C\uDFE0', - category: 'Home Automation', - type: 'recipe', - difficulty: 'Intermediate', + "smart-home": { + name: "Smart Home Hub", + description: "Home automation: control, automate, and monitor IoT devices", + icon: "\uD83C\uDFE0", + category: "Home Automation", + type: "recipe", + difficulty: "Intermediate", popularity: 88, components: [ { - id: 'mosquitto', - role: 'MQTT Broker', + id: "mosquitto", + role: "MQTT Broker", required: true, order: 0, docker: { - image: 'eclipse-mosquitto:2', - ports: ['1883:1883', '9001:9001'], + image: "eclipse-mosquitto:2", + ports: ["1883:1883", "9001:9001"], volumes: [ - '/opt/mosquitto/config:/mosquitto/config', - '/opt/mosquitto/data:/mosquitto/data', - '/opt/mosquitto/log:/mosquitto/log', + "/opt/mosquitto/config:/mosquitto/config", + "/opt/mosquitto/data:/mosquitto/data", + "/opt/mosquitto/log:/mosquitto/log" ], - environment: {}, + environment: {} }, - subdomain: 'mqtt', + subdomain: "mqtt", defaultPort: 1883, internal: false, - setupNote: 'MQTT broker for IoT device communication', + setupNote: "MQTT broker for IoT device communication" }, { - id: 'homeassistant', - role: 'Automation Hub', - templateRef: 'homeassistant', + id: "homeassistant", + role: "Automation Hub", + templateRef: "homeassistant", required: true, - order: 1, + order: 1 }, { - id: 'nodered', - role: 'Flow Automation', - templateRef: 'nodered', + id: "nodered", + role: "Flow Automation", + templateRef: "nodered", required: true, - order: 2, + order: 2 }, { - id: 'zigbee2mqtt', - role: 'Zigbee Bridge', + id: "zigbee2mqtt", + role: "Zigbee Bridge", required: false, order: 3, docker: { - image: 'koenkk/zigbee2mqtt:latest', - ports: ['{{PORT}}:8080'], - volumes: ['/opt/zigbee2mqtt/data:/app/data'], + image: "koenkk/zigbee2mqtt:latest", + ports: ["{{PORT}}:8080"], + volumes: ["/opt/zigbee2mqtt/data:/app/data"], environment: { - 'TZ': '{{TIMEZONE}}', - }, + "TZ": "{{TIMEZONE}}" + } }, - subdomain: 'zigbee', + subdomain: "zigbee", defaultPort: 8080, - healthCheck: '/', - note: 'Requires a Zigbee USB adapter (e.g., Sonoff Zigbee 3.0 USB Dongle Plus)', - }, + healthCheck: "/", + note: "Requires a Zigbee USB adapter (e.g., Sonoff Zigbee 3.0 USB Dongle Plus)" + } ], network: { - name: 'dashcaddy-smarthome', - driver: 'bridge', + name: "dashcaddy-smarthome", + driver: "bridge" }, setupInstructions: [ - 'Mosquitto MQTT broker is ready for IoT device connections on port 1883', - 'Complete the Home Assistant onboarding wizard in the browser', - 'Connect Home Assistant to MQTT: Settings \u2192 Integrations \u2192 MQTT', - 'Node-RED provides visual flow automation \u2014 connect it to MQTT for device control', - 'If Zigbee2MQTT is enabled, it requires a physical Zigbee USB adapter', - ], - }, + "Mosquitto MQTT broker is ready for IoT device connections on port 1883", + "Complete the Home Assistant onboarding wizard in the browser", + "Connect Home Assistant to MQTT: Settings \u2192 Integrations \u2192 MQTT", + "Node-RED provides visual flow automation \u2014 connect it to MQTT for device control", + "If Zigbee2MQTT is enabled, it requires a physical Zigbee USB adapter" + ] + } }; // Recipe category metadata (separate from app categories) const RECIPE_CATEGORIES = { - 'Media': { icon: '\uD83C\uDFAC', color: '#e74c3c', description: 'Media streaming and automation stacks' }, - 'Productivity': { icon: '\u2601\uFE0F', color: '#3498db', description: 'Cloud storage and office suites' }, - 'Development': { icon: '\uD83D\uDCBB', color: '#9b59b6', description: 'Self-hosted development environments' }, - 'Home Automation': { icon: '\uD83C\uDFE0', color: '#27ae60', description: 'IoT and smart home control' }, + "Media": { icon: "\uD83C\uDFAC", color: "#e74c3c", description: "Media streaming and automation stacks" }, + "Productivity": { icon: "\u2601\uFE0F", color: "#3498db", description: "Cloud storage and office suites" }, + "Development": { icon: "\uD83D\uDCBB", color: "#9b59b6", description: "Self-hosted development environments" }, + "Home Automation": { icon: "\uD83C\uDFE0", color: "#27ae60", description: "IoT and smart home control" } }; module.exports = { RECIPE_TEMPLATES, RECIPE_CATEGORIES }; diff --git a/dashcaddy-api/resource-monitor.js b/dashcaddy-api/resource-monitor.js index 5bc34f3..5c5cf0d 100644 --- a/dashcaddy-api/resource-monitor.js +++ b/dashcaddy-api/resource-monitor.js @@ -144,28 +144,28 @@ class ResourceMonitor extends EventEmitter { timestamp: new Date().toISOString(), cpu: { percent: Math.round(cpuPercent * 100) / 100, - usage: stats.cpu_stats.cpu_usage.total_usage, + usage: stats.cpu_stats.cpu_usage.total_usage }, memory: { usage: memoryUsage, limit: memoryLimit, percent: Math.round(memoryPercent * 100) / 100, usageMB: Math.round(memoryUsage / 1024 / 1024), - limitMB: Math.round(memoryLimit / 1024 / 1024), + limitMB: Math.round(memoryLimit / 1024 / 1024) }, network: { rxBytes: networkRx, txBytes: networkTx, rxMB: Math.round(networkRx / 1024 / 1024 * 100) / 100, - txMB: Math.round(networkTx / 1024 / 1024 * 100) / 100, + txMB: Math.round(networkTx / 1024 / 1024 * 100) / 100 }, disk: { readBytes: blockRead, writeBytes: blockWrite, readMB: Math.round(blockRead / 1024 / 1024 * 100) / 100, - writeMB: Math.round(blockWrite / 1024 / 1024 * 100) / 100, + writeMB: Math.round(blockWrite / 1024 / 1024 * 100) / 100 }, - pids: stats.pids_stats?.current || 0, + pids: stats.pids_stats?.current || 0 }); }); }); @@ -178,7 +178,7 @@ class ResourceMonitor extends EventEmitter { if (!this.stats.has(containerId)) { this.stats.set(containerId, { name: containerName, - history: [], + history: [] }); } @@ -189,7 +189,7 @@ class ResourceMonitor extends EventEmitter { // Keep only recent stats (based on retention policy) const cutoffTime = Date.now() - (STATS_RETENTION_HOURS * 60 * 60 * 1000); containerStats.history = containerStats.history.filter(s => - new Date(s.timestamp).getTime() > cutoffTime, + new Date(s.timestamp).getTime() > cutoffTime ); } @@ -216,7 +216,7 @@ class ResourceMonitor extends EventEmitter { severity: 'warning', message: `CPU usage ${stats.cpu.percent.toFixed(1)}% exceeds threshold ${alertConfig.cpuThreshold}%`, value: stats.cpu.percent, - threshold: alertConfig.cpuThreshold, + threshold: alertConfig.cpuThreshold }); } @@ -227,7 +227,7 @@ class ResourceMonitor extends EventEmitter { severity: 'warning', message: `Memory usage ${stats.memory.percent.toFixed(1)}% exceeds threshold ${alertConfig.memoryThreshold}%`, value: stats.memory.percent, - threshold: alertConfig.memoryThreshold, + threshold: alertConfig.memoryThreshold }); } @@ -240,7 +240,7 @@ class ResourceMonitor extends EventEmitter { severity: 'warning', message: `Disk I/O ${diskIO.toFixed(1)} MB/s exceeds threshold ${alertConfig.diskIOThreshold} MB/s`, value: diskIO, - threshold: alertConfig.diskIOThreshold, + threshold: alertConfig.diskIOThreshold }); } } @@ -254,7 +254,7 @@ class ResourceMonitor extends EventEmitter { timestamp: new Date().toISOString(), alerts, stats, - config: alertConfig, + config: alertConfig }); // Auto-restart if configured @@ -278,7 +278,7 @@ class ResourceMonitor extends EventEmitter { containerId, containerName, timestamp: new Date().toISOString(), - reason: alerts, + reason: alerts }); } catch (error) { console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message); @@ -306,7 +306,7 @@ class ResourceMonitor extends EventEmitter { const cutoffTime = Date.now() - (hours * 60 * 60 * 1000); return containerStats.history.filter(s => - new Date(s.timestamp).getTime() > cutoffTime, + new Date(s.timestamp).getTime() > cutoffTime ); } @@ -325,16 +325,16 @@ class ResourceMonitor extends EventEmitter { current: cpuValues[cpuValues.length - 1], avg: cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length, max: Math.max(...cpuValues), - min: Math.min(...cpuValues), + min: Math.min(...cpuValues) }, memory: { current: memoryValues[memoryValues.length - 1], avg: memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length, max: Math.max(...memoryValues), - min: Math.min(...memoryValues), + min: Math.min(...memoryValues) }, dataPoints: history.length, - timeRange: hours, + timeRange: hours }; } @@ -352,7 +352,7 @@ class ResourceMonitor extends EventEmitter { name: data.name, current, aggregated, - alertConfig: this.alerts.get(containerId), + alertConfig: this.alerts.get(containerId) }; } @@ -370,7 +370,7 @@ class ResourceMonitor extends EventEmitter { diskIOThreshold: config.diskIOThreshold || null, cooldownMinutes: config.cooldownMinutes || 15, autoRestart: config.autoRestart || false, - notificationChannels: config.notificationChannels || [], + notificationChannels: config.notificationChannels || [] }); this.saveAlertConfig(); @@ -400,7 +400,7 @@ class ResourceMonitor extends EventEmitter { for (const [containerId, data] of this.stats.entries()) { data.history = data.history.filter(s => - new Date(s.timestamp).getTime() > cutoffTime, + new Date(s.timestamp).getTime() > cutoffTime ); // Remove container stats if no recent data @@ -471,7 +471,7 @@ class ResourceMonitor extends EventEmitter { return { stats: Object.fromEntries(this.stats), alerts: Object.fromEntries(this.alerts), - exportedAt: new Date().toISOString(), + exportedAt: new Date().toISOString() }; } diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index fc2f651..8026771 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -62,7 +62,7 @@ module.exports = function(ctx, helpers) { ctx.log.info('deploy', 'DashCA: Using existing index.html'); } - ctx.log.info('deploy', `DashCA: For full features, copy certificate files to ${ destPath}`); + ctx.log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath); ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully'); } catch (error) { ctx.log.error('deploy', 'DashCA deployment error', { error: error.message }); @@ -121,14 +121,14 @@ module.exports = function(ctx, helpers) { PortBindings: {}, Binds: translatedVolumes, RestartPolicy: { Name: 'unless-stopped' }, - LogConfig: DOCKER.LOG_CONFIG, + LogConfig: DOCKER.LOG_CONFIG }, Env: Object.entries(processedTemplate.docker.environment || {}).map(([k, v]) => `${k}=${v}`), Labels: { 'sami.managed': 'true', 'sami.app': appId, 'sami.subdomain': userConfig.subdomain, - 'sami.deployed': new Date().toISOString(), - }, + 'sami.deployed': new Date().toISOString() + } }; processedTemplate.docker.ports.forEach(portMapping => { @@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) { try { const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); + ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); } } catch (pruneErr) { ctx.log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message }); @@ -324,7 +324,7 @@ module.exports = function(ctx, helpers) { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [], customVolumes: config.customVolumes || undefined, - useExisting: false, + useExisting: false }, container: template.isStaticSite ? null : { image: processedTemplate.docker.image, @@ -340,14 +340,14 @@ module.exports = function(ctx, helpers) { } return env; })(), - capabilities: processedTemplate.docker.capabilities || undefined, + capabilities: processedTemplate.docker.capabilities || undefined }, caddy: { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [], subpathSupport: template.subpathSupport || 'strip', - routingMode: ctx.siteConfig.routingMode, - }, + routingMode: ctx.siteConfig.routingMode + } }; await ctx.addServiceToConfig({ @@ -358,7 +358,7 @@ module.exports = function(ctx, helpers) { tailscaleOnly: config.tailscaleOnly || false, routingMode: ctx.siteConfig.routingMode, deployedAt: new Date().toISOString(), - deploymentManifest, + deploymentManifest }); ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); @@ -366,7 +366,7 @@ module.exports = function(ctx, helpers) { success: true, containerId, usedExisting, url: serviceUrl, message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`, - setupInstructions: template.setupInstructions || [], + setupInstructions: template.setupInstructions || [] }; if (dnsWarning) response.warning = dnsWarning; diff --git a/dashcaddy-api/routes/apps/helpers.js b/dashcaddy-api/routes/apps/helpers.js index d674bb3..6e9d76b 100644 --- a/dashcaddy-api/routes/apps/helpers.js +++ b/dashcaddy-api/routes/apps/helpers.js @@ -38,16 +38,16 @@ module.exports = function(ctx) { const templateImage = template.docker.image.split(':')[0]; for (const container of containers) { const containerImage = container.Image.split(':')[0]; - if (containerImage === templateImage || containerImage.endsWith(`/${ templateImage}`)) { + if (containerImage === templateImage || containerImage.endsWith('/' + templateImage)) { const ports = container.Ports.filter(p => p.PublicPort).map(p => ({ - hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type, + hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type })); return { id: container.Id, shortId: container.Id.slice(0, 12), name: container.Names[0]?.replace(/^\//, '') || 'unknown', image: container.Image, status: container.Status, state: container.State, ports, primaryPort: ports.length > 0 ? ports[0].hostPort : null, - labels: container.Labels || {}, + labels: container.Labels || {} }; } } @@ -72,7 +72,7 @@ module.exports = function(ctx) { '{{PORT}}': config.port || template.defaultPort, '{{MEDIA_PATH}}': mediaPaths[0] || '/media', '{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC', - '{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex'), + '{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex') }; function replaceInObject(obj) { @@ -117,7 +117,7 @@ module.exports = function(ctx) { const basePath = `/${config.subdomain}`; // Some apps need the full URL, not just the path if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) { - processed.docker.environment[template.urlBaseEnv] = `${ctx.buildServiceUrl(config.subdomain) }/`; + processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/'; } else { processed.docker.environment[template.urlBaseEnv] = basePath; } @@ -137,7 +137,7 @@ module.exports = function(ctx) { config.mediaPath.split(',').map(p => p.trim()).filter(Boolean).forEach(p => allowedRoots.push(path.resolve(p))); } const isAllowed = allowedRoots.some(root => - normalizedHost === root || normalizedHost.startsWith(root + path.sep), + normalizedHost === root || normalizedHost.startsWith(root + path.sep) ); if (!isAllowed) { ctx.log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots }); @@ -162,76 +162,76 @@ module.exports = function(ctx) { c += ` root * ${sitePath}\n\n`; if (tailscaleOnly) { - c += ' @blocked not remote_ip 100.64.0.0/10\n'; - c += ' respond @blocked "Access denied. Tailscale connection required." 403\n\n'; + c += ` @blocked not remote_ip 100.64.0.0/10\n`; + c += ` respond @blocked "Access denied. Tailscale connection required." 403\n\n`; } if (apiProxy) { - c += ' handle /api/* {\n'; + c += ` handle /api/* {\n`; c += ` reverse_proxy ${apiProxy}\n`; - c += ' }\n\n'; + c += ` }\n\n`; } - c += ' @crt path *.crt\n'; - c += ' handle @crt {\n'; - c += ' header Content-Type application/x-x509-ca-cert\n'; - c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; - c += ' header Cache-Control "public, max-age=86400"\n'; - c += ' file_server\n'; - c += ' }\n\n'; - c += ' @der path *.der\n'; - c += ' handle @der {\n'; - c += ' header Content-Type application/x-x509-ca-cert\n'; - c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; - c += ' header Cache-Control "public, max-age=86400"\n'; - c += ' file_server\n'; - c += ' }\n\n'; - c += ' @mobileconfig path *.mobileconfig\n'; - c += ' handle @mobileconfig {\n'; - c += ' header Content-Type application/x-apple-aspen-config\n'; - c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; - c += ' header Cache-Control "public, max-age=86400"\n'; - c += ' file_server\n'; - c += ' }\n\n'; - c += ' @ps1 path *.ps1\n'; - c += ' handle @ps1 {\n'; - c += ' header Content-Type text/plain\n'; - c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; - c += ' file_server\n'; - c += ' }\n\n'; - c += ' @sh path *.sh\n'; - c += ' handle @sh {\n'; - c += ' header Content-Type text/x-shellscript\n'; - c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; - c += ' file_server\n'; - c += ' }\n\n'; - c += ' # Static site with SPA fallback\n'; - c += ' handle {\n'; - c += ' @notFile not file {path}\n'; - c += ' rewrite @notFile /index.html\n'; - c += ' file_server\n'; - c += ' }\n\n'; - c += ' # No cache for HTML\n'; - c += ' @htmlfiles {\n'; - c += ' path *.html\n'; - c += ' path /\n'; - c += ' }\n'; - c += ' header @htmlfiles Cache-Control "no-store"\n'; + c += ` @crt path *.crt\n`; + c += ` handle @crt {\n`; + c += ` header Content-Type application/x-x509-ca-cert\n`; + c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; + c += ` header Cache-Control "public, max-age=86400"\n`; + c += ` file_server\n`; + c += ` }\n\n`; + c += ` @der path *.der\n`; + c += ` handle @der {\n`; + c += ` header Content-Type application/x-x509-ca-cert\n`; + c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; + c += ` header Cache-Control "public, max-age=86400"\n`; + c += ` file_server\n`; + c += ` }\n\n`; + c += ` @mobileconfig path *.mobileconfig\n`; + c += ` handle @mobileconfig {\n`; + c += ` header Content-Type application/x-apple-aspen-config\n`; + c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; + c += ` header Cache-Control "public, max-age=86400"\n`; + c += ` file_server\n`; + c += ` }\n\n`; + c += ` @ps1 path *.ps1\n`; + c += ` handle @ps1 {\n`; + c += ` header Content-Type text/plain\n`; + c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; + c += ` file_server\n`; + c += ` }\n\n`; + c += ` @sh path *.sh\n`; + c += ` handle @sh {\n`; + c += ` header Content-Type text/x-shellscript\n`; + c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; + c += ` file_server\n`; + c += ` }\n\n`; + c += ` # Static site with SPA fallback\n`; + c += ` handle {\n`; + c += ` @notFile not file {path}\n`; + c += ` rewrite @notFile /index.html\n`; + c += ` file_server\n`; + c += ` }\n\n`; + c += ` # No cache for HTML\n`; + c += ` @htmlfiles {\n`; + c += ` path *.html\n`; + c += ` path /\n`; + c += ` }\n`; + c += ` header @htmlfiles Cache-Control "no-store"\n`; return c; } // HTTPS block let config = `${domain} {\n`; - config += ' tls internal\n\n'; + config += ` tls internal\n\n`; config += siteBlockContent(); - config += '}'; + config += `}`; // HTTP companion block for devices that haven't trusted the CA yet if (httpAccess) { - config += '\n\n# HTTP access for first-time certificate installation\n'; + config += `\n\n# HTTP access for first-time certificate installation\n`; config += `http://${domain} {\n`; config += siteBlockContent(); - config += '}'; + config += `}`; } return config; @@ -254,7 +254,7 @@ module.exports = function(ctx) { } else if (healthPath && port && httpCheckFailed < 5) { try { const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, { - signal: AbortSignal.timeout(3000), redirect: 'manual', + signal: AbortSignal.timeout(3000), redirect: 'manual' }); if (response.ok || (response.status >= 300 && response.status < 400)) { ctx.log.info('docker', 'Health check passed', { containerId, status: response.status }); @@ -290,7 +290,7 @@ module.exports = function(ctx) { await ctx.caddy.reload(existing); return; } - const result = await ctx.caddy.modify(c => `${c }\n${config}\n`); + const result = await ctx.caddy.modify(c => c + `\n${config}\n`); if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`); await ctx.caddy.verifySite(domain); } @@ -405,6 +405,6 @@ module.exports = function(ctx) { removeSubpathConfig, ensureMainDomainBlock, RESERVED_SUBPATHS, - generateStaticSiteConfig, + generateStaticSiteConfig }; }; diff --git a/dashcaddy-api/routes/apps/removal.js b/dashcaddy-api/routes/apps/removal.js index 1045403..2e14356 100644 --- a/dashcaddy-api/routes/apps/removal.js +++ b/dashcaddy-api/routes/apps/removal.js @@ -26,7 +26,7 @@ module.exports = function(ctx, helpers) { try { const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); + ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); } } catch (pruneErr) { ctx.log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message }); @@ -42,7 +42,7 @@ module.exports = function(ctx, helpers) { try { const domain = ctx.buildDomain(subdomain); const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', { - token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true', + token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true' }); let recordIp = ip || 'localhost'; if (getResult.status === 'ok' && getResult.response?.records) { @@ -50,7 +50,7 @@ module.exports = function(ctx, helpers) { if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress; } const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { - token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp, + token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp }); results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed'); ctx.log.info('dns', 'DNS record removal', { result: results.dns }); diff --git a/dashcaddy-api/routes/apps/restore.js b/dashcaddy-api/routes/apps/restore.js index a9fd416..91158a0 100644 --- a/dashcaddy-api/routes/apps/restore.js +++ b/dashcaddy-api/routes/apps/restore.js @@ -37,7 +37,7 @@ module.exports = function(ctx, helpers) { return res.json({ success: true, message: 'No services have deployment manifests to restore', - results: [], + results: [] }); } @@ -51,7 +51,7 @@ module.exports = function(ctx, helpers) { id: service.id, name: service.name, status: 'failed', - error: error.message, + error: error.message }); } } @@ -63,7 +63,7 @@ module.exports = function(ctx, helpers) { res.json({ success: true, message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`, - results, + results }); }, 'apps-restore-all')); @@ -81,7 +81,7 @@ module.exports = function(ctx, helpers) { hasManifest: !!service.deploymentManifest, templateId: service.deploymentManifest?.templateId || service.appTemplate || null, deployedAt: service.deployedAt || null, - containerRunning: false, + containerRunning: false }; // Check if container is currently running @@ -125,7 +125,7 @@ module.exports = function(ctx, helpers) { name: service.name, status: 'restored', type: 'static', - message: `Static site "${service.name}" config preserved`, + message: `Static site "${service.name}" config preserved` }; } @@ -140,7 +140,7 @@ module.exports = function(ctx, helpers) { id: service.id, name: service.name, status: 'skipped', - message: 'Container already running', + message: 'Container already running' }; } } catch (e) { @@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) { id: service.id, name: service.name, status: 'skipped', - message: 'Container already running (found by name)', + message: 'Container already running (found by name)' }; } // Exists but not running — remove stale container @@ -178,7 +178,7 @@ module.exports = function(ctx, helpers) { id: service.id, name: service.name, status: 'failed', - error: 'No container configuration in manifest', + error: 'No container configuration in manifest' }; } @@ -189,7 +189,7 @@ module.exports = function(ctx, helpers) { } catch (e) { // Check if image exists locally const images = await ctx.docker.client.listImages({ - filters: { reference: [manifest.container.image] }, + filters: { reference: [manifest.container.image] } }); if (images.length === 0) { throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`); @@ -206,7 +206,7 @@ module.exports = function(ctx, helpers) { PortBindings: {}, Binds: manifest.container.volumes || [], RestartPolicy: { Name: 'unless-stopped' }, - LogConfig: DOCKER.LOG_CONFIG, + LogConfig: DOCKER.LOG_CONFIG }, Env: Object.entries(manifest.container.environment || {}).map(([k, v]) => `${k}=${v}`), Labels: { @@ -214,8 +214,8 @@ module.exports = function(ctx, helpers) { 'sami.app': manifest.templateId, 'sami.subdomain': manifest.config.subdomain, 'sami.deployed': new Date().toISOString(), - 'sami.restored': 'true', - }, + 'sami.restored': 'true' + } }; // Set up port bindings @@ -287,7 +287,7 @@ module.exports = function(ctx, helpers) { status: 'restored', type: 'container', containerId: container.id, - message: `${service.name} restored successfully`, + message: `${service.name} restored successfully` }; } diff --git a/dashcaddy-api/routes/apps/templates.js b/dashcaddy-api/routes/apps/templates.js index 796cb3d..d1cef07 100644 --- a/dashcaddy-api/routes/apps/templates.js +++ b/dashcaddy-api/routes/apps/templates.js @@ -11,7 +11,7 @@ module.exports = function(ctx, helpers) { success: true, templates: ctx.APP_TEMPLATES, categories: ctx.TEMPLATE_CATEGORIES, - difficultyLevels: ctx.DIFFICULTY_LEVELS, + difficultyLevels: ctx.DIFFICULTY_LEVELS }); }, 'apps-templates')); @@ -71,7 +71,7 @@ module.exports = function(ctx, helpers) { try { const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain); const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { - token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost', + token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost' }); results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage; ctx.log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); @@ -139,7 +139,7 @@ module.exports = function(ctx, helpers) { success: true, message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`, newUrl: `https://${ctx.buildDomain(newSubdomain)}`, - results, + results }); }, 'update-subdomain')); diff --git a/dashcaddy-api/routes/arr/config.js b/dashcaddy-api/routes/arr/config.js index 31b3ca1..e3a8723 100644 --- a/dashcaddy-api/routes/arr/config.js +++ b/dashcaddy-api/routes/arr/config.js @@ -11,12 +11,12 @@ module.exports = function(ctx, helpers) { const results = { radarr: null, sonarr: null }; // Step 1: Authenticate with Overseerr via Plex token - const overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`; + let overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`; const overseerrSession = await helpers.getOverseerrSession(); if (!overseerrSession) { return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', { - hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.', + hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.' }); } @@ -30,8 +30,8 @@ module.exports = function(ctx, helpers) { headers: { 'Content-Type': 'application/json', 'Cookie': overseerrSession.cookie, - ...options.headers, - }, + ...options.headers + } }); return response; }; @@ -41,12 +41,12 @@ module.exports = function(ctx, helpers) { const statusRes = await overseerrFetch('/api/v1/status'); if (!statusRes.ok) { return ctx.errorResponse(res, 502, 'Cannot connect to Overseerr', { - hint: 'Make sure Overseerr is running on port 5055', + hint: 'Make sure Overseerr is running on port 5055' }); } } catch (e) { return ctx.errorResponse(res, 502, `Cannot reach Overseerr: ${e.message}`, { - hint: 'Check if Overseerr container is running', + hint: 'Check if Overseerr container is running' }); } @@ -59,14 +59,14 @@ module.exports = function(ctx, helpers) { // Fetch quality profiles from Radarr const profilesRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/qualityprofile`, { - headers: { 'X-Api-Key': radarr.apiKey }, + headers: { 'X-Api-Key': radarr.apiKey } }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders from Radarr const rootFoldersRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, { - headers: { 'X-Api-Key': radarr.apiKey }, + headers: { 'X-Api-Key': radarr.apiKey } }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/movies'; @@ -87,12 +87,12 @@ module.exports = function(ctx, helpers) { minimumAvailability: 'released', isDefault: true, externalUrl: radarr.url, - tags: [], + tags: [] }; const radarrRes = await overseerrFetch('/api/v1/settings/radarr', { method: 'POST', - body: JSON.stringify(radarrConfig), + body: JSON.stringify(radarrConfig) }); if (radarrRes.ok) { @@ -115,14 +115,14 @@ module.exports = function(ctx, helpers) { // Fetch quality profiles from Sonarr const profilesRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/qualityprofile`, { - headers: { 'X-Api-Key': sonarr.apiKey }, + headers: { 'X-Api-Key': sonarr.apiKey } }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders from Sonarr const rootFoldersRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/rootfolder`, { - headers: { 'X-Api-Key': sonarr.apiKey }, + headers: { 'X-Api-Key': sonarr.apiKey } }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; @@ -131,7 +131,7 @@ module.exports = function(ctx, helpers) { let languageProfileId = 1; try { const langRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, { - headers: { 'X-Api-Key': sonarr.apiKey }, + headers: { 'X-Api-Key': sonarr.apiKey } }); if (langRes.ok) { const langProfiles = await langRes.json(); @@ -158,12 +158,12 @@ module.exports = function(ctx, helpers) { isDefault: true, enableSeasonFolders: true, externalUrl: sonarr.url, - tags: [], + tags: [] }; const sonarrRes = await overseerrFetch('/api/v1/settings/sonarr', { method: 'POST', - body: JSON.stringify(sonarrConfig), + body: JSON.stringify(sonarrConfig) }); if (sonarrRes.ok) { @@ -182,7 +182,7 @@ module.exports = function(ctx, helpers) { res.json({ success: anyConfigured, message: anyConfigured ? 'Services configured in Overseerr' : 'Configuration failed', - results, + results }); }, 'arr-configure-overseerr')); @@ -210,7 +210,7 @@ module.exports = function(ctx, helpers) { } // Normalize URL - remove trailing slash - const baseUrl = url.replace(/\/+$/, ''); + let baseUrl = url.replace(/\/+$/, ''); // Build the API endpoint let apiEndpoint; @@ -233,7 +233,7 @@ module.exports = function(ctx, helpers) { const response = await ctx.fetchT(apiEndpoint, { method: 'GET', headers, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); if (response.ok) { @@ -244,7 +244,7 @@ module.exports = function(ctx, helpers) { return res.json({ success: true, version, - appName, + appName }); } else if (response.status === 401) { return ctx.errorResponse(res, 401, 'Invalid API key'); @@ -288,7 +288,7 @@ module.exports = function(ctx, helpers) { containerName: container.Names[0]?.replace(/^\//, ''), port: exposedPort, url: `http://host.docker.internal:${exposedPort}`, - localUrl: `http://localhost:${exposedPort}`, + localUrl: `http://localhost:${exposedPort}` }; // Extract API key for arr services @@ -305,7 +305,7 @@ module.exports = function(ctx, helpers) { radarrFound: !!detected.radarr?.apiKey, sonarrFound: !!detected.sonarr?.apiKey, lidarrFound: !!detected.lidarr?.apiKey, - prowlarrFound: !!detected.prowlarr?.apiKey, + prowlarrFound: !!detected.prowlarr?.apiKey }; ctx.log.info('arr', 'Detected services', summary); @@ -313,14 +313,14 @@ module.exports = function(ctx, helpers) { if (!summary.overseerrFound) { return ctx.errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', { detected, - summary, + summary }); } if (!summary.radarrFound && !summary.sonarrFound) { return ctx.errorResponse(res, 400, 'No Radarr or Sonarr found with valid API keys. Deploy at least one first.', { detected, - summary, + summary }); } @@ -331,7 +331,7 @@ module.exports = function(ctx, helpers) { return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', { setupUrl: detected.overseerr.localUrl, detected, - summary, + summary }); } @@ -344,8 +344,8 @@ module.exports = function(ctx, helpers) { headers: { 'Content-Type': 'application/json', 'Cookie': overseerrSession.cookie, - ...options.headers, - }, + ...options.headers + } }); }; @@ -356,14 +356,14 @@ module.exports = function(ctx, helpers) { try { // Fetch quality profiles from Radarr const profilesRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/qualityprofile`, { - headers: { 'X-Api-Key': detected.radarr.apiKey }, + headers: { 'X-Api-Key': detected.radarr.apiKey } }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders from Radarr const rootFoldersRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/rootfolder`, { - headers: { 'X-Api-Key': detected.radarr.apiKey }, + headers: { 'X-Api-Key': detected.radarr.apiKey } }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/movies'; @@ -384,12 +384,12 @@ module.exports = function(ctx, helpers) { minimumAvailability: 'released', isDefault: true, externalUrl: detected.radarr.localUrl, - tags: [], + tags: [] }; const resp = await overseerrFetch('/api/v1/settings/radarr', { method: 'POST', - body: JSON.stringify(radarrConfig), + body: JSON.stringify(radarrConfig) }); configResults.radarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`; @@ -403,14 +403,14 @@ module.exports = function(ctx, helpers) { try { // Fetch quality profiles from Sonarr const profilesRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/qualityprofile`, { - headers: { 'X-Api-Key': detected.sonarr.apiKey }, + headers: { 'X-Api-Key': detected.sonarr.apiKey } }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders from Sonarr const rootFoldersRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/rootfolder`, { - headers: { 'X-Api-Key': detected.sonarr.apiKey }, + headers: { 'X-Api-Key': detected.sonarr.apiKey } }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; @@ -419,7 +419,7 @@ module.exports = function(ctx, helpers) { let languageProfileId = 1; try { const langRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, { - headers: { 'X-Api-Key': detected.sonarr.apiKey }, + headers: { 'X-Api-Key': detected.sonarr.apiKey } }); if (langRes.ok) { const langProfiles = await langRes.json(); @@ -444,12 +444,12 @@ module.exports = function(ctx, helpers) { isDefault: true, enableSeasonFolders: true, externalUrl: detected.sonarr.localUrl, - tags: [], + tags: [] }; const resp = await overseerrFetch('/api/v1/settings/sonarr', { method: 'POST', - body: JSON.stringify(sonarrConfig), + body: JSON.stringify(sonarrConfig) }); configResults.sonarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`; @@ -466,7 +466,7 @@ module.exports = function(ctx, helpers) { 'deploymentSuccess', 'Arr Stack Auto-Connected', `Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`, - 'success', + 'success' ); } @@ -475,7 +475,7 @@ module.exports = function(ctx, helpers) { message: anyConfigured ? 'Auto-setup completed successfully!' : 'Configuration failed', detected, configResults, - summary, + summary }); }, 'arr-auto-setup')); diff --git a/dashcaddy-api/routes/arr/credentials.js b/dashcaddy-api/routes/arr/credentials.js index f652e1b..6d52f9b 100644 --- a/dashcaddy-api/routes/arr/credentials.js +++ b/dashcaddy-api/routes/arr/credentials.js @@ -39,7 +39,7 @@ module.exports = function(ctx, helpers) { service, source: url ? 'external' : 'local', url: url || null, - storedAt: new Date().toISOString(), + storedAt: new Date().toISOString() }; // Test connection if URL is known @@ -77,7 +77,7 @@ module.exports = function(ctx, helpers) { return ctx.errorResponse(res, 400, 'Invalid seedbox base URL'); } await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, { - storedAt: new Date().toISOString(), + storedAt: new Date().toISOString() }); } @@ -87,7 +87,7 @@ module.exports = function(ctx, helpers) { success: true, message: `${service} API key stored`, connectionTest, - url: resolvedUrl, + url: resolvedUrl }); }, 'arr-credentials-store')); @@ -106,7 +106,7 @@ module.exports = function(ctx, helpers) { url: metadata?.url || null, lastVerified: metadata?.lastVerified || null, version: metadata?.version || null, - source: metadata?.source || null, + source: metadata?.source || null }; } diff --git a/dashcaddy-api/routes/arr/detect.js b/dashcaddy-api/routes/arr/detect.js index d0c70a8..5af17ce 100644 --- a/dashcaddy-api/routes/arr/detect.js +++ b/dashcaddy-api/routes/arr/detect.js @@ -13,7 +13,7 @@ module.exports = function(ctx, helpers) { sonarr: null, overseerr: null, lidarr: null, - prowlarr: null, + prowlarr: null }; // Service detection patterns @@ -35,7 +35,7 @@ module.exports = function(ctx, helpers) { image: container.Image, port: exposedPort, status: container.State, - url: helpers.getServiceUrl(containerName, exposedPort), + url: helpers.getServiceUrl(containerName, exposedPort) }; // Get API key for arr services (not Plex or Overseerr) @@ -58,8 +58,8 @@ module.exports = function(ctx, helpers) { plexReady: !!(detected.plex?.token), radarrReady: !!(detected.radarr?.apiKey), sonarrReady: !!(detected.sonarr?.apiKey), - overseerrRunning: !!detected.overseerr, - }, + overseerrRunning: !!detected.overseerr + } }); }, 'arr-detect')); @@ -86,7 +86,7 @@ module.exports = function(ctx, helpers) { containerId: container.Id, containerName: container.Names[0]?.replace(/^\//, ''), port: portInfo?.PublicPort || config.port, - status: container.State, + status: container.State }; } } @@ -122,7 +122,7 @@ module.exports = function(ctx, helpers) { hasToken: false, containerId: null, containerName: null, - version: null, + version: null }; // Check Docker first @@ -143,7 +143,7 @@ module.exports = function(ctx, helpers) { // Store for later use await ctx.credentialManager.store('arr.plex.token', token, { service: 'plex', source: 'local', url: entry.url, - lastVerified: new Date().toISOString(), + lastVerified: new Date().toISOString() }); } else { entry.status = 'needs_key'; @@ -160,7 +160,7 @@ module.exports = function(ctx, helpers) { try { const radarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, { headers: { 'Cookie': session.cookie }, - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(5000) }); if (radarrCheck.ok) { const radarrSettings = await radarrCheck.json(); @@ -170,7 +170,7 @@ module.exports = function(ctx, helpers) { try { const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, { headers: { 'Cookie': session.cookie }, - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(5000) }); if (sonarrCheck.ok) { const sonarrSettings = await sonarrCheck.json(); @@ -180,7 +180,7 @@ module.exports = function(ctx, helpers) { try { const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, { headers: { 'Cookie': session.cookie }, - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(5000) }); if (plexCheck.ok) { const plexSettings = await plexCheck.json(); @@ -273,7 +273,7 @@ module.exports = function(ctx, helpers) { fullyConnected: statuses.filter(s => s.status === 'connected').length, needsApiKey: statuses.filter(s => s.status === 'needs_key').length, errors: statuses.filter(s => s.status === 'error').length, - readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2, + readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2 }; res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary }); diff --git a/dashcaddy-api/routes/arr/helpers.js b/dashcaddy-api/routes/arr/helpers.js index 93c3468..2936f51 100644 --- a/dashcaddy-api/routes/arr/helpers.js +++ b/dashcaddy-api/routes/arr/helpers.js @@ -12,7 +12,7 @@ module.exports = function(ctx) { const exec = await dockerContainer.exec({ Cmd: ['cat', '/config/config.xml'], AttachStdout: true, - AttachStderr: true, + AttachStderr: true }); const stream = await exec.start(); @@ -38,7 +38,7 @@ module.exports = function(ctx) { try { const containers = await ctx.docker.client.listContainers({ all: false }); const container = containers.find(c => - c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')), + c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')) ); if (!container) return null; @@ -47,7 +47,7 @@ module.exports = function(ctx) { const exec = await dockerContainer.exec({ Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'], AttachStdout: true, - AttachStderr: true, + AttachStderr: true }); const stream = await exec.start(); @@ -97,7 +97,7 @@ module.exports = function(ctx) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ authToken: plexToken }), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); if (!authRes.ok) { @@ -125,7 +125,7 @@ module.exports = function(ctx) { // 1. Get Plex server identity (for return info) const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); if (!identityRes.ok) throw new Error('Cannot reach Plex server'); const identity = await identityRes.json(); @@ -136,16 +136,16 @@ module.exports = function(ctx) { const plexConfig = { ip: 'host.docker.internal', port: APP_PORTS.plex, - useSsl: false, + useSsl: false }; const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Cookie': sessionCookie, + 'Cookie': sessionCookie }, - body: JSON.stringify(plexConfig), + body: JSON.stringify(plexConfig) }); if (!configRes.ok) { @@ -157,7 +157,7 @@ module.exports = function(ctx) { await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, { method: 'POST', headers: { 'Cookie': sessionCookie }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); } catch (e) { ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message }); @@ -168,7 +168,7 @@ module.exports = function(ctx) { try { const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { headers: { 'Cookie': sessionCookie }, - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(5000) }); if (libRes.ok) { const plexSettings = await libRes.json(); @@ -188,7 +188,7 @@ module.exports = function(ctx) { try { const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { headers: { 'X-Api-Key': prowlarrApiKey }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); existingApps = existingRes.ok ? await existingRes.json() : []; } catch (e) { @@ -217,8 +217,8 @@ module.exports = function(ctx) { { name: 'prowlarrUrl', value: prowlarrUrl }, { name: 'baseUrl', value: config.url }, { name: 'apiKey', value: config.apiKey }, - { name: 'syncCategories', value: syncCategories }, - ], + { name: 'syncCategories', value: syncCategories } + ] }; try { @@ -226,10 +226,10 @@ module.exports = function(ctx) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Api-Key': prowlarrApiKey, + 'X-Api-Key': prowlarrApiKey }, body: JSON.stringify(payload), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`; } catch (e) { @@ -262,7 +262,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(apiEndpoint, { method: 'GET', headers, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(15000) }); if (response.ok) { @@ -297,6 +297,6 @@ module.exports = function(ctx) { getOverseerrApiKey, connectPlexToOverseerr, configureProwlarrApps, - testServiceConnection, + testServiceConnection }; }; diff --git a/dashcaddy-api/routes/arr/plex.js b/dashcaddy-api/routes/arr/plex.js index 92b99e4..d351d23 100644 --- a/dashcaddy-api/routes/arr/plex.js +++ b/dashcaddy-api/routes/arr/plex.js @@ -14,7 +14,7 @@ module.exports = function(ctx, helpers) { if (!plexToken) { return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', { - hint: 'Deploy Plex with a claim token or manually configure it.', + hint: 'Deploy Plex with a claim token or manually configure it.' }); } @@ -32,7 +32,7 @@ module.exports = function(ctx, helpers) { // Fetch libraries const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, { headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); if (!libRes.ok) { @@ -45,7 +45,7 @@ module.exports = function(ctx, helpers) { title: dir.title, type: dir.type, count: parseInt(dir.count) || 0, - scannedAt: dir.scannedAt, + scannedAt: dir.scannedAt })); // Get server name @@ -54,7 +54,7 @@ module.exports = function(ctx, helpers) { try { const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(5000) }); if (identityRes.ok) { const identity = await identityRes.json(); @@ -66,7 +66,7 @@ module.exports = function(ctx, helpers) { // Store token for future use await ctx.credentialManager.store('arr.plex.token', plexToken, { service: 'plex', source: 'local', url: plexUrl, - lastVerified: new Date().toISOString(), + lastVerified: new Date().toISOString() }); res.json({ success: true, serverName, version, libraries }); diff --git a/dashcaddy-api/routes/arr/smart-connect.js b/dashcaddy-api/routes/arr/smart-connect.js index 9b8b933..ce6b3fb 100644 --- a/dashcaddy-api/routes/arr/smart-connect.js +++ b/dashcaddy-api/routes/arr/smart-connect.js @@ -44,7 +44,7 @@ module.exports = function(ctx, helpers) { steps.push({ step: `Test ${svc.charAt(0).toUpperCase() + svc.slice(1)} connection`, status: test.success ? 'success' : 'failed', - details: test.success ? `v${test.version}` : test.error, + details: test.success ? `v${test.version}` : test.error }); if (test.success) { @@ -55,12 +55,12 @@ module.exports = function(ctx, helpers) { const stored = await ctx.credentialManager.store(`arr.${svc}.apikey`, apiKey, { service: svc, source: 'external', url, lastVerified: new Date().toISOString(), - version: test.version, + version: test.version }); steps.push({ step: `Save ${svc} credentials`, status: stored ? 'success' : 'failed', - details: stored ? 'Encrypted and saved' : 'Storage failed', + details: stored ? 'Encrypted and saved' : 'Storage failed' }); } } @@ -94,7 +94,7 @@ module.exports = function(ctx, helpers) { steps.push({ step: 'Get Overseerr API key', status: 'failed', - details: 'Could not authenticate with Overseerr (Plex not running or not linked)', + details: 'Could not authenticate with Overseerr (Plex not running or not linked)' }); } else { steps.push({ step: 'Get Overseerr API key', status: 'success', details: 'Extracted from container' }); @@ -110,7 +110,7 @@ module.exports = function(ctx, helpers) { // Fetch quality profiles const profilesRes = await ctx.fetchT(`${radarrUrl}/api/v3/qualityprofile`, { headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; @@ -118,7 +118,7 @@ module.exports = function(ctx, helpers) { // Fetch root folders const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, { headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/movies'; @@ -141,20 +141,20 @@ module.exports = function(ctx, helpers) { minimumAvailability: 'released', isDefault: true, externalUrl: connectedServices.radarr.url, - tags: [], + tags: [] }; const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, body: JSON.stringify(radarrConfig), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); steps.push({ step: 'Configure Radarr in Overseerr', status: radarrRes.ok ? 'success' : 'failed', - details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text(), + details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text() }); } catch (e) { steps.push({ step: 'Configure Radarr in Overseerr', status: 'failed', details: e.message }); @@ -170,14 +170,14 @@ module.exports = function(ctx, helpers) { const profilesRes = await ctx.fetchT(`${sonarrUrl}/api/v3/qualityprofile`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; @@ -186,7 +186,7 @@ module.exports = function(ctx, helpers) { try { const langRes = await ctx.fetchT(`${sonarrUrl}/api/v3/languageprofile`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(5000) }); if (langRes.ok) { const langProfiles = await langRes.json(); @@ -212,20 +212,20 @@ module.exports = function(ctx, helpers) { isDefault: true, enableSeasonFolders: true, externalUrl: connectedServices.sonarr.url, - tags: [], + tags: [] }; const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, body: JSON.stringify(sonarrConfig), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) }); steps.push({ step: 'Configure Sonarr in Overseerr', status: sonarrRes.ok ? 'success' : 'failed', - details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text(), + details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text() }); } catch (e) { steps.push({ step: 'Configure Sonarr in Overseerr', status: 'failed', details: e.message }); @@ -239,7 +239,7 @@ module.exports = function(ctx, helpers) { steps.push({ step: 'Connect Plex to Overseerr', status: 'success', - details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced`, + details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced` }); } catch (e) { steps.push({ step: 'Connect Plex to Overseerr', status: 'failed', details: e.message }); @@ -259,13 +259,13 @@ module.exports = function(ctx, helpers) { const prowlarrResults = await helpers.configureProwlarrApps( connectedServices.prowlarr.url.replace(/\/+$/, ''), connectedServices.prowlarr.apiKey, - appsToConnect, + appsToConnect ); for (const [app, status] of Object.entries(prowlarrResults)) { steps.push({ step: `Add ${app.charAt(0).toUpperCase() + app.slice(1)} to Prowlarr`, status: status === 'configured' || status === 'already_configured' ? 'success' : 'failed', - details: status, + details: status }); } } catch (e) { @@ -283,14 +283,14 @@ module.exports = function(ctx, helpers) { 'deploymentSuccess', 'Smart Arr Connect Complete', `${succeeded}/${steps.length} steps completed successfully`, - 'success', + 'success' ); } res.json({ success: succeeded > 0, steps, - summary: { totalSteps: steps.length, succeeded, failed }, + summary: { totalSteps: steps.length, succeeded, failed } }); }, 'smart-connect')); diff --git a/dashcaddy-api/routes/auth/keys.js b/dashcaddy-api/routes/auth/keys.js index 0d91352..d1fa933 100644 --- a/dashcaddy-api/routes/auth/keys.js +++ b/dashcaddy-api/routes/auth/keys.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, - y: 365 * 24 * 60 * 60 * 1000, + y: 365 * 24 * 60 * 60 * 1000 }; return value * (multipliers[unit] || multipliers.h); @@ -54,7 +54,7 @@ module.exports = function(ctx) { const keyData = await ctx.authManager.generateAPIKey( name.trim(), - scopes || ['read', 'write'], + scopes || ['read', 'write'] ); res.json({ @@ -64,7 +64,7 @@ module.exports = function(ctx) { name: keyData.name, scopes: keyData.scopes, createdAt: keyData.createdAt, - warning: 'Save this key securely - it will not be shown again', + warning: 'Save this key securely - it will not be shown again' }); }, 'auth-keys-generate')); @@ -109,9 +109,9 @@ module.exports = function(ctx) { const token = await ctx.authManager.generateJWT( { sub: userId || 'dashcaddy-admin', - scope: ['admin'], // Session-generated JWTs have admin scope + scope: ['admin'] // Session-generated JWTs have admin scope }, - expiresIn || '24h', + expiresIn || '24h' ); // Calculate expiration timestamp @@ -122,7 +122,7 @@ module.exports = function(ctx) { success: true, token, expiresAt, - usage: 'Include in Authorization header as: Bearer ', + usage: 'Include in Authorization header as: Bearer ' }); }, 'auth-jwt-generate')); diff --git a/dashcaddy-api/routes/auth/session-handlers.js b/dashcaddy-api/routes/auth/session-handlers.js index aaca704..534b55b 100644 --- a/dashcaddy-api/routes/auth/session-handlers.js +++ b/dashcaddy-api/routes/auth/session-handlers.js @@ -29,7 +29,7 @@ module.exports = function(ctx) { const { spawnSync } = require('child_process'); const proc = spawnSync('wget', [ '-q', '-S', `--post-data=${routerBody}`, '-O', '/dev/null', - `${baseUrl}/cgi-bin/login.ha`, + `${baseUrl}/cgi-bin/login.ha` ], { timeout: 5000, encoding: 'utf8' }); const result = (proc.stderr || '').split('\n').slice(0, 2).join('\n'); const locationMatch = result.match(/Location:\s*(.+)/); diff --git a/dashcaddy-api/routes/auth/totp.js b/dashcaddy-api/routes/auth/totp.js index a593c8a..d899577 100644 --- a/dashcaddy-api/routes/auth/totp.js +++ b/dashcaddy-api/routes/auth/totp.js @@ -10,8 +10,8 @@ module.exports = function(ctx) { config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, - isSetUp: ctx.totpConfig.isSetUp, - }, + isSetUp: ctx.totpConfig.isSetUp + } }); }, 'totp-config-get')); @@ -35,7 +35,7 @@ module.exports = function(ctx) { const otpauth = authenticator.keyuri('user', 'DashCaddy', secret); const qrDataUrl = await QRCode.toDataURL(otpauth, { width: 256, margin: 2, - color: { dark: '#ffffff', light: '#00000000' }, + color: { dark: '#ffffff', light: '#00000000' } }); res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret }); @@ -166,7 +166,7 @@ module.exports = function(ctx) { if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) { return ctx.errorResponse(res, 400, 'Invalid session duration', { - validOptions: Object.keys(ctx.session.durations), + validOptions: Object.keys(ctx.session.durations) }); } @@ -180,7 +180,7 @@ module.exports = function(ctx) { await ctx.saveTotpConfig(); res.json({ success: true, - config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }, + config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp } }); }, 'totp-config')); diff --git a/dashcaddy-api/routes/backups.js b/dashcaddy-api/routes/backups.js index fc640d7..e766b1d 100644 --- a/dashcaddy-api/routes/backups.js +++ b/dashcaddy-api/routes/backups.js @@ -1,32 +1,36 @@ const express = require('express'); -const asyncHandler = require('../src/utils/async-handler'); -module.exports = function({ backupManager }) { +module.exports = function(ctx) { const router = express.Router(); - router.get('/backups/config', asyncHandler(async (req, res) => { - const config = backupManager.getConfig(); + // Get backup configuration + router.get('/backups/config', ctx.asyncHandler(async (req, res) => { + const config = ctx.backupManager.getConfig(); res.json({ success: true, config }); }, 'backups-config-get')); - router.post('/backups/config', asyncHandler(async (req, res) => { - backupManager.updateConfig(req.body); + // Update backup configuration + router.post('/backups/config', ctx.asyncHandler(async (req, res) => { + ctx.backupManager.updateConfig(req.body); res.json({ success: true, message: 'Backup configuration updated' }); }, 'backups-config-update')); - router.post('/backups/execute', asyncHandler(async (req, res) => { - const backup = await backupManager.executeBackup('manual', req.body); + // Execute manual backup + router.post('/backups/execute', ctx.asyncHandler(async (req, res) => { + const backup = await ctx.backupManager.executeBackup('manual', req.body); res.json({ success: true, backup }); }, 'backups-execute')); - router.get('/backups/history', asyncHandler(async (req, res) => { + // Get backup history + router.get('/backups/history', ctx.asyncHandler(async (req, res) => { const limit = parseInt(req.query.limit) || 50; - const history = backupManager.getHistory(limit); + const history = ctx.backupManager.getHistory(limit); res.json({ success: true, history }); }, 'backups-history')); - router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => { - const result = await backupManager.restoreBackup(req.params.backupId, req.body); + // Restore from backup + router.post('/backups/restore/:backupId', ctx.asyncHandler(async (req, res) => { + const result = await ctx.backupManager.restoreBackup(req.params.backupId, req.body); res.json({ success: true, result }); }, 'backups-restore')); diff --git a/dashcaddy-api/routes/browse.js b/dashcaddy-api/routes/browse.js index 49a911f..8223b0d 100644 --- a/dashcaddy-api/routes/browse.js +++ b/dashcaddy-api/routes/browse.js @@ -24,7 +24,7 @@ module.exports = function(ctx) { const allRoots = BROWSE_ROOTS.map(r => ({ name: r.hostPath, path: r.hostPath, - containerPath: r.containerPath, + containerPath: r.containerPath })); const roots = []; @@ -45,7 +45,7 @@ module.exports = function(ctx) { const allRoots = BROWSE_ROOTS.map(r => ({ name: r.hostPath, path: r.hostPath, - type: 'drive', + type: 'drive' })); const roots = []; for (const r of allRoots) { @@ -58,12 +58,12 @@ module.exports = function(ctx) { } const matchingRoot = BROWSE_ROOTS.find(r => - requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, ''), + requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, '') ); if (!matchingRoot) { return ctx.errorResponse(res, 400, 'Path not in browseable roots', { - availableRoots: BROWSE_ROOTS.map(r => r.hostPath), + availableRoots: BROWSE_ROOTS.map(r => r.hostPath) }); } @@ -80,7 +80,7 @@ module.exports = function(ctx) { requestedPath, containerFullPath, allowedRoots, error: error.message, ip: req.ip, - userAgent: req.get('user-agent'), + userAgent: req.get('user-agent') }); return ctx.errorResponse(res, 403, 'Access denied - path traversal detected'); } @@ -108,7 +108,7 @@ module.exports = function(ctx) { .map(entry => ({ name: entry.name, path: path.join(requestedPath, entry.name).replace(/\\/g, '/'), - type: 'folder', + type: 'folder' })) .sort((a, b) => a.name.localeCompare(b.name)); @@ -119,7 +119,7 @@ module.exports = function(ctx) { path: requestedPath, parent: path.dirname(requestedPath).replace(/\\/g, '/') || null, items: result.data, - ...(result.pagination && { pagination: result.pagination }), + ...(result.pagination && { pagination: result.pagination }) }); }, 'browse-dir')); @@ -128,12 +128,12 @@ module.exports = function(ctx) { const mediaServerPatterns = [ 'plex', 'jellyfin', 'emby', 'kodi', 'navidrome', 'airsonic', 'subsonic', 'funkwhale', 'beets', 'lidarr', 'sonarr', 'radarr', - 'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli', + 'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli' ]; const excludePatterns = [ '/config', '/cache', '/transcode', '/data/config', '/app', - '/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile', + '/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile' ]; const containers = await ctx.docker.client.listContainers({ all: false }); @@ -155,7 +155,7 @@ module.exports = function(ctx) { let hostPath, containerPath; if (parts[0].length === 1 && /[A-Za-z]/.test(parts[0])) { - hostPath = `${parts[0] }:${ parts[1]}`; + hostPath = parts[0] + ':' + parts[1]; containerPath = parts[2] || ''; } else { hostPath = parts[0]; @@ -164,7 +164,7 @@ module.exports = function(ctx) { const isExcluded = excludePatterns.some(p => containerPath.toLowerCase().includes(p.toLowerCase()) || - hostPath.toLowerCase().includes(p.toLowerCase()), + hostPath.toLowerCase().includes(p.toLowerCase()) ); if (isExcluded) continue; if (seenPaths.has(hostPath)) continue; @@ -175,7 +175,7 @@ module.exports = function(ctx) { detectedMounts.push({ hostPath, containerPath, folderName, sourceContainer: containerInfo.Names[0]?.replace('/', '') || containerInfo.Id.slice(0, 12), - sourceImage: containerInfo.Image.split('/').pop().split(':')[0], + sourceImage: containerInfo.Image.split('/').pop().split(':')[0] }); } } @@ -185,7 +185,7 @@ module.exports = function(ctx) { mounts: detectedMounts, message: detectedMounts.length > 0 ? `Found ${detectedMounts.length} media mount(s) from existing containers` - : 'No existing media mounts detected', + : 'No existing media mounts detected' }); }, 'detect-media-mounts')); diff --git a/dashcaddy-api/routes/ca.js b/dashcaddy-api/routes/ca.js index 987e1f0..0597c35 100644 --- a/dashcaddy-api/routes/ca.js +++ b/dashcaddy-api/routes/ca.js @@ -25,22 +25,22 @@ module.exports = function(ctx) { } const certInfo = JSON.parse(await fsp.readFile(certInfoFile, 'utf8')); - const expirationDate = new Date(certInfo.validUntil); - const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); + const expirationDate = new Date(certInfo.validUntil); + const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); - res.json({ - success: true, - certificate: { - name: certInfo.name, - fingerprint: certInfo.fingerprint, - validFrom: certInfo.validFrom, - validUntil: certInfo.validUntil, - daysUntilExpiration, - algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256', - serialNumber: certInfo.serialNumber, - downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt`, - }, - }); + res.json({ + success: true, + certificate: { + name: certInfo.name, + fingerprint: certInfo.fingerprint, + validFrom: certInfo.validFrom, + validUntil: certInfo.validUntil, + daysUntilExpiration, + algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256', + serialNumber: certInfo.serialNumber, + downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt` + } + }); }, 'ca-info')); // Serve root CA certificate directly (works even without DashCA deployed) @@ -99,7 +99,7 @@ module.exports = function(ctx) { // Look for template in multiple locations (packaged app vs dev) const templatePaths = [ path.join(__dirname, '..', 'scripts', templateName), - path.join('/app', 'scripts', templateName), + path.join('/app', 'scripts', templateName) ]; let templateContent; @@ -208,12 +208,12 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`; const serverCertContent = await fsp.readFile(certFile, 'utf8'); const intermediateCertContent = await fsp.readFile(intermediateCert, 'utf8'); const rootCertContent = await fsp.readFile(rootCert, 'utf8'); - await fsp.writeFile(fullChainFile, `${serverCertContent }\n${ intermediateCertContent }\n${ rootCertContent}`); + await fsp.writeFile(fullChainFile, serverCertContent + '\n' + intermediateCertContent + '\n' + rootCertContent); execSync(`openssl pkcs12 -export -out "${pfxFile}" -inkey "${keyFile}" -in "${certFile}" -certfile "${intermediateCert}" -password "pass:${password}"`, { stdio: 'pipe' }); const keyContent = await fsp.readFile(keyFile, 'utf8'); - await fsp.writeFile(pemFile, `${keyContent }\n${ serverCertContent }\n${ intermediateCertContent}`); + await fsp.writeFile(pemFile, keyContent + '\n' + serverCertContent + '\n' + intermediateCertContent); } if (format === 'pfx') { @@ -260,26 +260,26 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`; const certFile = path.join(certsDir, domain, 'server.crt'); if (!await exists(certFile)) return null; - try { - const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString(); - const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain; - const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : ''; - const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : ''; - const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : ''; + try { + const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString(); + const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain; + const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : ''; + const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : ''; + const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : ''; - const expirationDate = new Date(notAfter); - const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); + const expirationDate = new Date(notAfter); + const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); - return { - domain, subject, - validFrom: notBefore, validUntil: notAfter, - daysUntilExpiration, fingerprint, - status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid', - }; - } catch { - return null; - } - }))).filter(Boolean); + return { + domain, subject, + validFrom: notBefore, validUntil: notAfter, + daysUntilExpiration, fingerprint, + status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid' + }; + } catch { + return null; + } + }))).filter(Boolean); res.json({ success: true, certificates }); }, 'ca-certs')); diff --git a/dashcaddy-api/routes/config/assets.js b/dashcaddy-api/routes/config/assets.js index 8e76676..db71aa8 100644 --- a/dashcaddy-api/routes/config/assets.js +++ b/dashcaddy-api/routes/config/assets.js @@ -56,7 +56,7 @@ module.exports = function(ctx) { res.json({ success: true, path: `/assets/${safeFilename}`, - message: `Logo saved to ${filePath}`, + message: `Logo saved to ${filePath}` }); }, 'assets-upload')); @@ -75,7 +75,7 @@ module.exports = function(ctx) { customLogo: config.customLogo || config.customLogoDark || null, position: config.logoPosition || 'left', dashboardTitle: config.dashboardTitle || 'DashCaddy', - isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo, + isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo }); }, 'logo-get')); @@ -153,7 +153,7 @@ module.exports = function(ctx) { path: pathDark || pathLight, position: config.logoPosition || 'left', dashboardTitle: config.dashboardTitle || 'DashCaddy', - message: 'Branding settings saved', + message: 'Branding settings saved' }); }, 'logo-upload')); @@ -186,7 +186,7 @@ module.exports = function(ctx) { res.json({ success: true, - message: 'Branding reset to defaults', + message: 'Branding reset to defaults' }); }, 'logo-delete')); @@ -199,7 +199,7 @@ module.exports = function(ctx) { res.json({ success: true, customFavicon: config.customFavicon || null, - isDefault: !config.customFavicon, + isDefault: !config.customFavicon }); }, 'favicon-get')); @@ -237,8 +237,8 @@ module.exports = function(ctx) { sharp(buffer) .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .png() - .toBuffer(), - ), + .toBuffer() + ) ); // Convert to ICO @@ -261,7 +261,7 @@ module.exports = function(ctx) { res.json({ success: true, path: '/assets/favicon.ico', - message: 'Favicon created successfully', + message: 'Favicon created successfully' }); }, 'favicon')); @@ -285,7 +285,7 @@ module.exports = function(ctx) { res.json({ success: true, - message: 'Favicon reset to default', + message: 'Favicon reset to default' }); }, 'favicon-delete')); diff --git a/dashcaddy-api/routes/config/backup.js b/dashcaddy-api/routes/config/backup.js index c6acd64..b742e3e 100644 --- a/dashcaddy-api/routes/config/backup.js +++ b/dashcaddy-api/routes/config/backup.js @@ -34,7 +34,7 @@ module.exports = function(ctx) { dashcaddyVersion: '1.0.0', files: {}, themes: {}, - assets: {}, + assets: {} }; // Collect all configuration files (encryption key now included for self-contained restore) @@ -48,7 +48,7 @@ module.exports = function(ctx) { { key: 'encryptionKey', path: ENCRYPTION_KEY_FILE, required: false }, { key: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false }, { key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false }, - { key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false }, + { key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false } ]; for (const file of filesToBackup) { @@ -59,12 +59,12 @@ module.exports = function(ctx) { try { backup.files[file.key] = { type: 'json', - data: JSON.parse(content), + data: JSON.parse(content) }; } catch { backup.files[file.key] = { type: 'text', - data: content, + data: content }; } } else if (file.required) { @@ -85,7 +85,7 @@ module.exports = function(ctx) { const otpauth = authenticator.keyuri('user', 'DashCaddy', secret); const qrDataUrl = await QRCode.toDataURL(otpauth, { width: 256, margin: 2, - color: { dark: '#000000', light: '#ffffff' }, + color: { dark: '#000000', light: '#ffffff' } }); backup.totp = { qrCode: qrDataUrl, issuer: 'DashCaddy' }; } @@ -140,7 +140,7 @@ module.exports = function(ctx) { valid: true, version: backup.version, exportedAt: backup.exportedAt, - files: {}, + files: {} }; // Check each file in the backup @@ -154,7 +154,7 @@ module.exports = function(ctx) { encryptionKey: { path: ENCRYPTION_KEY_FILE, description: 'Encryption key (for credentials)' }, totpConfig: { path: ctx.TOTP_CONFIG_FILE, description: 'TOTP authentication config' }, tailscaleConfig: { path: ctx.TAILSCALE_CONFIG_FILE, description: 'Tailscale config' }, - notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' }, + notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' } }; for (const [key, value] of Object.entries(backup.files)) { @@ -167,7 +167,7 @@ module.exports = function(ctx) { inBackup: true, currentExists, action: currentExists ? 'overwrite' : 'create', - type: value.type, + type: value.type }; } } @@ -204,7 +204,7 @@ module.exports = function(ctx) { // Require TOTP verification for restores that include security-sensitive files const sensitiveKeys = ['credentials', 'totpConfig', 'encryptionKey']; const restoresSensitive = sensitiveKeys.some(key => - backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key), + backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key) ); if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) { if (!totpCode || !/^\d{6}$/.test(totpCode)) { @@ -223,7 +223,7 @@ module.exports = function(ctx) { const results = { restored: [], skipped: [], - errors: [], + errors: [] }; const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key'); @@ -236,7 +236,7 @@ module.exports = function(ctx) { encryptionKey: ENCRYPTION_KEY_FILE, totpConfig: ctx.TOTP_CONFIG_FILE, tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE, - notifications: ctx.NOTIFICATIONS_FILE, + notifications: ctx.NOTIFICATIONS_FILE }; // Restore each file @@ -286,7 +286,7 @@ module.exports = function(ctx) { const loadResponse = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, { method: 'POST', headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: caddyContent, + body: caddyContent }); if (loadResponse.ok) { @@ -345,7 +345,7 @@ module.exports = function(ctx) { if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true }); for (const [slug, data] of Object.entries(backup.themes)) { if (/^[a-z0-9-]+$/.test(slug)) { - fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(data, null, 2), 'utf8'); + fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(data, null, 2), 'utf8'); } } results.restored.push(`themes:${Object.keys(backup.themes).length}`); @@ -376,7 +376,7 @@ module.exports = function(ctx) { message: success ? `Restored ${results.restored.length} file(s) successfully` : `Restore completed with ${results.errors.length} error(s)`, - results, + results }); ctx.log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length }); diff --git a/dashcaddy-api/routes/containers.js b/dashcaddy-api/routes/containers.js index 9a586c5..9bde8e0 100644 --- a/dashcaddy-api/routes/containers.js +++ b/dashcaddy-api/routes/containers.js @@ -1,21 +1,14 @@ -/** - * Container management routes - * Refactored to use explicit dependencies instead of ctx god object - */ - const express = require('express'); const { DOCKER } = require('../constants'); const { paginate, parsePaginationParams } = require('../pagination'); const { NotFoundError } = require('../errors'); -const asyncHandler = require('../src/utils/async-handler'); -const log = require('../src/utils/logger'); -module.exports = function({ docker }) { +module.exports = function(ctx) { const router = express.Router(); // Helper: verify container exists before operating on it async function getVerifiedContainer(id) { - const container = docker.client.getContainer(id); + const container = ctx.docker.client.getContainer(id); try { await container.inspect(); } catch (err) { @@ -28,143 +21,138 @@ module.exports = function({ docker }) { } // Start container - router.post('/:id/start', asyncHandler(async (req, res) => { + router.post('/:id/start', ctx.asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.start(); res.json({ success: true, message: 'Container started' }); }, 'container-start')); // Stop container - router.post('/:id/stop', asyncHandler(async (req, res) => { + router.post('/:id/stop', ctx.asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.stop(); res.json({ success: true, message: 'Container stopped' }); }, 'container-stop')); // Restart container - router.post('/:id/restart', asyncHandler(async (req, res) => { + router.post('/:id/restart', ctx.asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.restart(); res.json({ success: true, message: 'Container restarted' }); }, 'container-restart')); // Update container to latest image version - router.post('/:id/update', asyncHandler(async (req, res) => { + router.post('/:id/update', ctx.asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); - // Get container info - const containerInfo = await container.inspect(); - const imageName = containerInfo.Config.Image; - const containerName = containerInfo.Name.replace(/^\//, ''); + // Get container info + const containerInfo = await container.inspect(); + const imageName = containerInfo.Config.Image; + const containerName = containerInfo.Name.replace(/^\//, ''); - log.info('docker', 'Updating container', { containerName, imageName }); + ctx.log.info('docker', 'Updating container', { containerName, imageName }); - // Pull the latest image - log.info('docker', `Pulling latest image: ${imageName}`); - await docker.pull(imageName); + // Pull the latest image + ctx.log.info('docker', `Pulling latest image: ${imageName}`); + await ctx.docker.pull(imageName); - // Get current container config for recreation - const hostConfig = containerInfo.HostConfig; - const config = { - Image: imageName, - name: containerName, - Env: containerInfo.Config.Env, - ExposedPorts: containerInfo.Config.ExposedPorts, - Labels: containerInfo.Config.Labels, - HostConfig: { - Binds: hostConfig.Binds, - PortBindings: hostConfig.PortBindings, - RestartPolicy: hostConfig.RestartPolicy, - NetworkMode: hostConfig.NetworkMode, - ExtraHosts: hostConfig.ExtraHosts, - Privileged: hostConfig.Privileged, - CapAdd: hostConfig.CapAdd, - CapDrop: hostConfig.CapDrop, - Devices: hostConfig.Devices, - LogConfig: DOCKER.LOG_CONFIG, - }, - NetworkingConfig: {}, - }; - - // Get network settings if using a custom network - if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { - const networkName = hostConfig.NetworkMode; - config.NetworkingConfig.EndpointsConfig = { - [networkName]: containerInfo.NetworkSettings.Networks[networkName], + // Get current container config for recreation + const hostConfig = containerInfo.HostConfig; + const config = { + Image: imageName, + name: containerName, + Env: containerInfo.Config.Env, + ExposedPorts: containerInfo.Config.ExposedPorts, + Labels: containerInfo.Config.Labels, + HostConfig: { + Binds: hostConfig.Binds, + PortBindings: hostConfig.PortBindings, + RestartPolicy: hostConfig.RestartPolicy, + NetworkMode: hostConfig.NetworkMode, + ExtraHosts: hostConfig.ExtraHosts, + Privileged: hostConfig.Privileged, + CapAdd: hostConfig.CapAdd, + CapDrop: hostConfig.CapDrop, + Devices: hostConfig.Devices, + LogConfig: DOCKER.LOG_CONFIG // Ensure log rotation on updated containers + }, + NetworkingConfig: {} }; - } - // Stop and remove old container - log.info('docker', 'Stopping container', { containerName }); - await container.stop().catch(() => {}); // Ignore if already stopped - log.info('docker', 'Removing container', { containerName }); - await container.remove(); + // Get network settings if using a custom network + if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { + const networkName = hostConfig.NetworkMode; + config.NetworkingConfig.EndpointsConfig = { + [networkName]: containerInfo.NetworkSettings.Networks[networkName] + }; + } - // Wait for port release - await new Promise((r) => setTimeout(r, 3000)); + // Stop and remove old container + ctx.log.info('docker', 'Stopping container', { containerName }); + await container.stop().catch(() => {}); // Ignore if already stopped + ctx.log.info('docker', 'Removing container', { containerName }); + await container.remove(); - // Create and start new container - log.info('docker', 'Creating new container', { containerName }); - let newContainer; - try { - newContainer = await docker.client.createContainer(config); - log.info('docker', 'Starting container', { containerName }); - await newContainer.start(); - } catch (startError) { - log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); - if (newContainer) { - try { - await newContainer.remove({ force: true }); - } catch (e) { - /* already gone */ + // Wait for port release (Windows/Docker Desktop can be slow to free ports) + await new Promise(r => setTimeout(r, 3000)); + + // Create and start new container + ctx.log.info('docker', 'Creating new container', { containerName }); + let newContainer; + try { + newContainer = await ctx.docker.client.createContainer(config); + ctx.log.info('docker', 'Starting container', { containerName }); + await newContainer.start(); + } catch (startError) { + // Clean up the failed container so it doesn't block future attempts + ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); + if (newContainer) { + try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } } + throw startError; } - throw startError; - } - const newContainerInfo = await newContainer.inspect(); + const newContainerInfo = await newContainer.inspect(); - // Prune dangling images - try { - const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); - if (pruneResult.SpaceReclaimed > 0) { - log.info('docker', 'Pruned dangling images after update', { - spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024)}MB`, - }); + // Prune dangling images after update + try { + const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + if (pruneResult.SpaceReclaimed > 0) { + ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); + } + } catch (pruneErr) { + ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); } - } catch (pruneErr) { - log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); - } - res.json({ - success: true, - message: `Container ${containerName} updated successfully`, - newContainerId: newContainerInfo.Id, - }); + res.json({ + success: true, + message: `Container ${containerName} updated successfully`, + newContainerId: newContainerInfo.Id + }); }, 'container-update')); - // Check for available updates - router.get('/:id/check-update', asyncHandler(async (req, res) => { + // Check for available updates (compares local and remote image digests) + router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); const containerInfo = await container.inspect(); const imageName = containerInfo.Config.Image; - const localImage = docker.client.getImage(containerInfo.Image); + const localImage = ctx.docker.client.getImage(containerInfo.Image); const localImageInfo = await localImage.inspect(); const localDigest = localImageInfo.RepoDigests?.[0] || null; let updateAvailable = false; try { - const pullStream = await docker.pull(imageName); + const pullStream = await ctx.docker.pull(imageName); - const downloadedLayers = pullStream.filter( - (e) => e.status === 'Downloading' || e.status === 'Download complete', + const downloadedLayers = pullStream.filter(e => + e.status === 'Downloading' || e.status === 'Download complete' ); updateAvailable = downloadedLayers.length > 0; - const newImage = docker.client.getImage(imageName); + const newImage = ctx.docker.client.getImage(imageName); const newImageInfo = await newImage.inspect(); const newDigest = newImageInfo.RepoDigests?.[0] || null; @@ -172,44 +160,44 @@ module.exports = function({ docker }) { updateAvailable = true; } } catch (pullError) { - log.debug('docker', 'Could not check for updates', { error: pullError.message }); + ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message }); } res.json({ success: true, imageName, updateAvailable, - currentDigest: localDigest, + currentDigest: localDigest }); }, 'container-check-update')); // Get container logs - router.get('/:id/logs', asyncHandler(async (req, res) => { + router.get('/:id/logs', ctx.asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); const logs = await container.logs({ stdout: true, stderr: true, tail: 100, - timestamps: true, + timestamps: true }); res.json({ success: true, logs: logs.toString() }); }, 'container-logs')); // Delete container - router.delete('/:id', asyncHandler(async (req, res) => { + router.delete('/:id', ctx.asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.remove({ force: true }); res.json({ success: true, message: 'Container removed' }); }, 'container-delete')); // Discover running containers - router.get('/discover', asyncHandler(async (req, res) => { - const containers = await docker.client.listContainers({ all: true }); - const samiContainers = containers.filter( - (container) => container.Labels && container.Labels['sami.managed'] === 'true', + router.get('/discover', ctx.asyncHandler(async (req, res) => { + const containers = await ctx.docker.client.listContainers({ all: true }); + const samiContainers = containers.filter(container => + container.Labels && container.Labels['sami.managed'] === 'true' ); - const discoveredContainers = samiContainers.map((container) => ({ + const discoveredContainers = samiContainers.map(container => ({ id: container.Id, name: container.Names[0].replace('/', ''), image: container.Image, @@ -217,16 +205,12 @@ module.exports = function({ docker }) { status: container.Status, appTemplate: container.Labels['sami.app'], subdomain: container.Labels['sami.subdomain'], - ports: container.Ports, + ports: container.Ports })); const paginationParams = parsePaginationParams(req.query); const result = paginate(discoveredContainers, paginationParams); - res.json({ - success: true, - containers: result.data, - ...(result.pagination && { pagination: result.pagination }), - }); + res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'containers-discover')); return router; diff --git a/dashcaddy-api/routes/containers.old.js b/dashcaddy-api/routes/containers.old.js deleted file mode 100644 index 9a6f136..0000000 --- a/dashcaddy-api/routes/containers.old.js +++ /dev/null @@ -1,217 +0,0 @@ -const express = require('express'); -const { DOCKER } = require('../constants'); -const { paginate, parsePaginationParams } = require('../pagination'); -const { NotFoundError } = require('../errors'); - -module.exports = function(ctx) { - const router = express.Router(); - - // Helper: verify container exists before operating on it - async function getVerifiedContainer(id) { - const container = ctx.docker.client.getContainer(id); - try { - await container.inspect(); - } catch (err) { - if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) { - throw new NotFoundError(`Container ${id}`); - } - throw err; - } - return container; - } - - // Start container - router.post('/:id/start', ctx.asyncHandler(async (req, res) => { - const container = await getVerifiedContainer(req.params.id); - await container.start(); - res.json({ success: true, message: 'Container started' }); - }, 'container-start')); - - // Stop container - router.post('/:id/stop', ctx.asyncHandler(async (req, res) => { - const container = await getVerifiedContainer(req.params.id); - await container.stop(); - res.json({ success: true, message: 'Container stopped' }); - }, 'container-stop')); - - // Restart container - router.post('/:id/restart', ctx.asyncHandler(async (req, res) => { - const container = await getVerifiedContainer(req.params.id); - await container.restart(); - res.json({ success: true, message: 'Container restarted' }); - }, 'container-restart')); - - // Update container to latest image version - router.post('/:id/update', ctx.asyncHandler(async (req, res) => { - const containerId = req.params.id; - const container = await getVerifiedContainer(containerId); - - // Get container info - const containerInfo = await container.inspect(); - const imageName = containerInfo.Config.Image; - const containerName = containerInfo.Name.replace(/^\//, ''); - - ctx.log.info('docker', 'Updating container', { containerName, imageName }); - - // Pull the latest image - ctx.log.info('docker', `Pulling latest image: ${imageName}`); - await ctx.docker.pull(imageName); - - // Get current container config for recreation - const hostConfig = containerInfo.HostConfig; - const config = { - Image: imageName, - name: containerName, - Env: containerInfo.Config.Env, - ExposedPorts: containerInfo.Config.ExposedPorts, - Labels: containerInfo.Config.Labels, - HostConfig: { - Binds: hostConfig.Binds, - PortBindings: hostConfig.PortBindings, - RestartPolicy: hostConfig.RestartPolicy, - NetworkMode: hostConfig.NetworkMode, - ExtraHosts: hostConfig.ExtraHosts, - Privileged: hostConfig.Privileged, - CapAdd: hostConfig.CapAdd, - CapDrop: hostConfig.CapDrop, - Devices: hostConfig.Devices, - LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers - }, - NetworkingConfig: {}, - }; - - // Get network settings if using a custom network - if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { - const networkName = hostConfig.NetworkMode; - config.NetworkingConfig.EndpointsConfig = { - [networkName]: containerInfo.NetworkSettings.Networks[networkName], - }; - } - - // Stop and remove old container - ctx.log.info('docker', 'Stopping container', { containerName }); - await container.stop().catch(() => {}); // Ignore if already stopped - ctx.log.info('docker', 'Removing container', { containerName }); - await container.remove(); - - // Wait for port release (Windows/Docker Desktop can be slow to free ports) - await new Promise(r => setTimeout(r, 3000)); - - // Create and start new container - ctx.log.info('docker', 'Creating new container', { containerName }); - let newContainer; - try { - newContainer = await ctx.docker.client.createContainer(config); - ctx.log.info('docker', 'Starting container', { containerName }); - await newContainer.start(); - } catch (startError) { - // Clean up the failed container so it doesn't block future attempts - ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); - if (newContainer) { - try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } - } - throw startError; - } - - const newContainerInfo = await newContainer.inspect(); - - // Prune dangling images after update - try { - const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); - if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); - } - } catch (pruneErr) { - ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); - } - - res.json({ - success: true, - message: `Container ${containerName} updated successfully`, - newContainerId: newContainerInfo.Id, - }); - }, 'container-update')); - - // Check for available updates (compares local and remote image digests) - router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => { - const containerId = req.params.id; - const container = await getVerifiedContainer(containerId); - const containerInfo = await container.inspect(); - const imageName = containerInfo.Config.Image; - - const localImage = ctx.docker.client.getImage(containerInfo.Image); - const localImageInfo = await localImage.inspect(); - const localDigest = localImageInfo.RepoDigests?.[0] || null; - - let updateAvailable = false; - try { - const pullStream = await ctx.docker.pull(imageName); - - const downloadedLayers = pullStream.filter(e => - e.status === 'Downloading' || e.status === 'Download complete', - ); - updateAvailable = downloadedLayers.length > 0; - - const newImage = ctx.docker.client.getImage(imageName); - const newImageInfo = await newImage.inspect(); - const newDigest = newImageInfo.RepoDigests?.[0] || null; - - if (localDigest && newDigest && localDigest !== newDigest) { - updateAvailable = true; - } - } catch (pullError) { - ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message }); - } - - res.json({ - success: true, - imageName, - updateAvailable, - currentDigest: localDigest, - }); - }, 'container-check-update')); - - // Get container logs - router.get('/:id/logs', ctx.asyncHandler(async (req, res) => { - const container = await getVerifiedContainer(req.params.id); - const logs = await container.logs({ - stdout: true, - stderr: true, - tail: 100, - timestamps: true, - }); - res.json({ success: true, logs: logs.toString() }); - }, 'container-logs')); - - // Delete container - router.delete('/:id', ctx.asyncHandler(async (req, res) => { - const container = await getVerifiedContainer(req.params.id); - await container.remove({ force: true }); - res.json({ success: true, message: 'Container removed' }); - }, 'container-delete')); - - // Discover running containers - router.get('/discover', ctx.asyncHandler(async (req, res) => { - const containers = await ctx.docker.client.listContainers({ all: true }); - const samiContainers = containers.filter(container => - container.Labels && container.Labels['sami.managed'] === 'true', - ); - - const discoveredContainers = samiContainers.map(container => ({ - id: container.Id, - name: container.Names[0].replace('/', ''), - image: container.Image, - state: container.State, - status: container.Status, - appTemplate: container.Labels['sami.app'], - subdomain: container.Labels['sami.subdomain'], - ports: container.Ports, - })); - - const paginationParams = parsePaginationParams(req.query); - const result = paginate(discoveredContainers, paginationParams); - res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); - }, 'containers-discover')); - - return router; -}; diff --git a/dashcaddy-api/routes/credentials.js b/dashcaddy-api/routes/credentials.js index 56011ff..11d95d4 100644 --- a/dashcaddy-api/routes/credentials.js +++ b/dashcaddy-api/routes/credentials.js @@ -1,21 +1,21 @@ const express = require('express'); -const asyncHandler = require('../src/utils/async-handler'); -const { errorResponse } = require('../src/utils/responses'); -module.exports = function({ credentialManager }) { +module.exports = function(ctx) { const router = express.Router(); - router.get('/credentials/list', asyncHandler(async (req, res) => { - const keys = await credentialManager.list(); + // List all stored credentials (keys only, no values) + router.get('/credentials/list', ctx.asyncHandler(async (req, res) => { + const keys = await ctx.credentialManager.list(); res.json({ success: true, credentials: keys, count: keys.length }); }, 'credentials-list')); - router.post('/credentials/rotate-key', asyncHandler(async (req, res) => { - const success = await credentialManager.rotateEncryptionKey(); + // Rotate encryption key — re-encrypts all stored credentials + router.post('/credentials/rotate-key', ctx.asyncHandler(async (req, res) => { + const success = await ctx.credentialManager.rotateEncryptionKey(); if (success) { res.json({ success: true, message: 'Encryption key rotated, all credentials re-encrypted' }); } else { - errorResponse(res, 500, 'Key rotation failed'); + ctx.errorResponse(res, 500, 'Key rotation failed'); } }, 'credentials-rotate')); diff --git a/dashcaddy-api/routes/dns.js b/dashcaddy-api/routes/dns.js index a4c8795..498c7d5 100644 --- a/dashcaddy-api/routes/dns.js +++ b/dashcaddy-api/routes/dns.js @@ -113,7 +113,7 @@ module.exports = function(ctx) { const zone = parts.slice(1).join('.') || ctx.siteConfig.tld.replace(/^\./, ''); const result = await ctx.dns.call(dnsServer, '/api/zones/records/add', { - token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true', + token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true' }); if (result.status === 'ok') { @@ -151,7 +151,7 @@ module.exports = function(ctx) { try { const result = await ctx.dns.call(dnsServer, '/api/zones/records/get', { - token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true', + token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true' }); if (result.status === 'ok' && result.response && result.response.records) { @@ -218,7 +218,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(technitiumUrl, { method: 'GET', headers: { 'Accept': 'text/plain' }, - timeout: 10000, + timeout: 10000 }); if (!response.ok) { @@ -232,7 +232,7 @@ module.exports = function(ctx) { server: server, count: 0, logs: [], - message: 'No logs available for this server', + message: 'No logs available for this server' }); } return ctx.errorResponse(res, response.status, ctx.safeErrorMessage(errorJson.errorMessage || errorText)); @@ -255,7 +255,7 @@ module.exports = function(ctx) { server: server, count: 0, logs: [], - message: 'No logs available for this server', + message: 'No logs available for this server' }); } // Invalidate cached token on auth errors so next request re-authenticates @@ -287,7 +287,7 @@ module.exports = function(ctx) { class: match[6].trim(), rcode: match[7].trim(), answer: match[8].trim() || null, - raw: line, + raw: line }; } return { raw: line, parsed: false }; @@ -299,7 +299,7 @@ module.exports = function(ctx) { server: server, logFile: logFileName, count: parsedLogs.length, - logs: parsedLogs, + logs: parsedLogs }); } catch (error) { @@ -319,7 +319,7 @@ module.exports = function(ctx) { hasCredentials, hasToken, tokenExpiry: ctx.dns.getTokenExpiry(), - isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null, + isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null }); }, 'dns-token-status')); @@ -394,7 +394,7 @@ module.exports = function(ctx) { return res.json({ success: anySuccess, message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed', - results, + results }); } @@ -430,7 +430,7 @@ module.exports = function(ctx) { res.json({ success: true, message: 'DNS credentials saved and verified (encrypted)', - tokenExpiry: ctx.dns.getTokenExpiry(), + tokenExpiry: ctx.dns.getTokenExpiry() }); }, 'dns-credentials')); @@ -495,7 +495,7 @@ module.exports = function(ctx) { res.json({ success: true, message: 'Token refreshed successfully', - tokenExpiry: ctx.dns.getTokenExpiry(), + tokenExpiry: ctx.dns.getTokenExpiry() }); } else { ctx.errorResponse(res, 401, result.error); @@ -529,8 +529,8 @@ module.exports = function(ctx) { method: 'GET', headers: { 'Accept': 'application/json', - 'User-Agent': APP.USER_AGENTS.API, - }, + 'User-Agent': APP.USER_AGENTS.API + } }); const text = await response.text(); @@ -550,7 +550,7 @@ module.exports = function(ctx) { updateTitle: result.response.updateTitle || null, updateMessage: result.response.updateMessage || null, downloadLink: result.response.downloadLink || null, - instructionsLink: result.response.instructionsLink || null, + instructionsLink: result.response.instructionsLink || null }); } else { ctx.errorResponse(res, 500, result.errorMessage || 'Check failed'); @@ -586,7 +586,7 @@ module.exports = function(ctx) { // Check if update is available const checkResponse = await ctx.fetchT( `http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`, - { method: 'GET', headers: { 'Accept': 'application/json' } }, + { method: 'GET', headers: { 'Accept': 'application/json' } } ); const checkText = await checkResponse.text(); @@ -604,7 +604,7 @@ module.exports = function(ctx) { success: true, message: 'Already up to date', currentVersion: checkResult.response.currentVersion, - updated: false, + updated: false }); } @@ -620,7 +620,7 @@ module.exports = function(ctx) { downloadLink: checkResult.response.downloadLink || null, instructionsLink: checkResult.response.instructionsLink || null, updated: false, - manualUpdateRequired: true, + manualUpdateRequired: true }); } catch (error) { ctx.log.error('dns', 'DNS update error', { error: error.message }); diff --git a/dashcaddy-api/routes/errorlogs.js b/dashcaddy-api/routes/errorlogs.js index 328dc7e..fe4ebcc 100644 --- a/dashcaddy-api/routes/errorlogs.js +++ b/dashcaddy-api/routes/errorlogs.js @@ -14,22 +14,22 @@ module.exports = function(ctx) { } const logContent = await fsp.readFile(ctx.ERROR_LOG_FILE, 'utf8'); - const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim()); + const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim()); - const logs = logEntries.map(entry => { - const lines = entry.trim().split('\n'); - const firstLine = lines[0] || ''; - const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/); + const logs = logEntries.map(entry => { + const lines = entry.trim().split('\n'); + const firstLine = lines[0] || ''; + const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/); - if (match) { - return { - timestamp: match[1], - context: match[2], - error: match[3], - }; - } - return null; - }).filter(Boolean); + if (match) { + return { + timestamp: match[1], + context: match[2], + error: match[3] + }; + } + return null; + }).filter(Boolean); res.json({ success: true, logs: logs.slice(-50).reverse() }); }, 'error-logs-get')); diff --git a/dashcaddy-api/routes/health.js b/dashcaddy-api/routes/health.js index 0e7a5ee..ac1cbe5 100644 --- a/dashcaddy-api/routes/health.js +++ b/dashcaddy-api/routes/health.js @@ -34,7 +34,7 @@ module.exports = function(ctx) { try { let url = null; - const checkType = 'http'; + let checkType = 'http'; // Determine URL to check url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); @@ -52,7 +52,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(url, { method: 'HEAD', signal: controller.signal, - redirect: 'follow', + redirect: 'follow' }); clearTimeout(timeout); @@ -60,7 +60,7 @@ module.exports = function(ctx) { status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', statusCode: response.status, url, - checkedAt: new Date().toISOString(), + checkedAt: new Date().toISOString() }; } catch (fetchError) { clearTimeout(timeout); @@ -73,7 +73,7 @@ module.exports = function(ctx) { const getResponse = await ctx.fetchT(url, { method: 'GET', signal: getController.signal, - redirect: 'follow', + redirect: 'follow' }); clearTimeout(getTimeout); @@ -81,14 +81,14 @@ module.exports = function(ctx) { status: getResponse.ok || getResponse.status < 500 ? 'healthy' : 'unhealthy', statusCode: getResponse.status, url, - checkedAt: new Date().toISOString(), + checkedAt: new Date().toISOString() }; } catch (e) { health[serviceId] = { status: 'unhealthy', reason: e.name === 'AbortError' ? 'Timeout' : e.message, url, - checkedAt: new Date().toISOString(), + checkedAt: new Date().toISOString() }; } } @@ -96,7 +96,7 @@ module.exports = function(ctx) { health[serviceId] = { status: 'error', reason: e.message, - checkedAt: new Date().toISOString(), + checkedAt: new Date().toISOString() }; } })); @@ -113,7 +113,7 @@ module.exports = function(ctx) { success: true, health: paginatedHealth, checkedAt: lastHealthCheck, - ...(result.pagination && { pagination: result.pagination }), + ...(result.pagination && { pagination: result.pagination }) }); }, 'health-services')); @@ -123,7 +123,7 @@ module.exports = function(ctx) { success: true, health: serviceHealthCache, lastCheck: lastHealthCheck, - cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null, + cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null }); }, 'health-cached')); @@ -157,7 +157,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(url, { method: 'GET', signal: controller.signal, - redirect: 'follow', + redirect: 'follow' }); clearTimeout(timeout); @@ -168,8 +168,8 @@ module.exports = function(ctx) { status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', statusCode: response.status, url, - checkedAt: new Date().toISOString(), - }, + checkedAt: new Date().toISOString() + } }); } catch (e) { clearTimeout(timeout); @@ -180,8 +180,8 @@ module.exports = function(ctx) { status: 'unhealthy', reason: e.name === 'AbortError' ? 'Timeout' : e.message, url, - checkedAt: new Date().toISOString(), - }, + checkedAt: new Date().toISOString() + } }); } }, 'health-service')); @@ -201,7 +201,7 @@ module.exports = function(ctx) { return res.json({ status: 'error', message: 'Root CA certificate not found', - daysUntilExpiration: null, + daysUntilExpiration: null }); } @@ -232,14 +232,14 @@ module.exports = function(ctx) { status: status, message: message, daysUntilExpiration: daysUntilExpiration, - expiresAt: notAfter, + expiresAt: notAfter }); } catch (error) { await ctx.logError('GET /api/health/ca', error); res.json({ status: 'error', message: error.message, - daysUntilExpiration: null, + daysUntilExpiration: null }); } }, 'health-ca')); diff --git a/dashcaddy-api/routes/license.js b/dashcaddy-api/routes/license.js index 41cce63..11656f2 100644 --- a/dashcaddy-api/routes/license.js +++ b/dashcaddy-api/routes/license.js @@ -1,44 +1,50 @@ const express = require('express'); -const asyncHandler = require('../src/utils/async-handler'); -const { errorResponse } = require('../src/utils/responses'); -module.exports = function({ licenseManager }) { +module.exports = function(ctx) { const router = express.Router(); - router.post('/activate', asyncHandler(async (req, res) => { + // Activate a license code + router.post('/activate', ctx.asyncHandler(async (req, res) => { const { code } = req.body; if (!code) { - return errorResponse(res, 400, 'License code is required'); + return ctx.errorResponse(res, 400, 'License code is required'); } - const result = await licenseManager.activate(code); + const result = await ctx.licenseManager.activate(code); if (result.success) { - res.json({ success: true, message: result.message, license: result.activation }); + res.json({ + success: true, + message: result.message, + license: result.activation + }); } else { - errorResponse(res, 400, result.message); + ctx.errorResponse(res, 400, result.message); } }, 'license-activate')); - router.get('/status', asyncHandler(async (req, res) => { - const status = licenseManager.getStatus(); + // Get current license status + router.get('/status', ctx.asyncHandler(async (req, res) => { + const status = ctx.licenseManager.getStatus(); res.json({ success: true, license: status }); }, 'license-status')); - router.post('/deactivate', asyncHandler(async (req, res) => { - const result = await licenseManager.deactivate(); + // Deactivate current license + router.post('/deactivate', ctx.asyncHandler(async (req, res) => { + const result = await ctx.licenseManager.deactivate(); if (result.success) { res.json({ success: true, message: result.message }); } else { - errorResponse(res, 400, result.message); + ctx.errorResponse(res, 400, result.message); } }, 'license-deactivate')); - router.get('/feature/:feature', asyncHandler(async (req, res) => { + // Check if a specific feature is available (lightweight check for frontend) + router.get('/feature/:feature', ctx.asyncHandler(async (req, res) => { const { feature } = req.params; - const available = licenseManager.hasFeature(feature); - const status = licenseManager.getStatus(); + const available = ctx.licenseManager.hasFeature(feature); + const status = ctx.licenseManager.getStatus(); res.json({ success: true, @@ -47,8 +53,8 @@ module.exports = function({ licenseManager }) { tier: status.tier, ...(available ? {} : { upgradeUrl: '/settings#license', - message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium`, - }), + message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium` + }) }); }, 'license-feature-check')); diff --git a/dashcaddy-api/routes/logs.js b/dashcaddy-api/routes/logs.js index 3670a93..e59e13d 100644 --- a/dashcaddy-api/routes/logs.js +++ b/dashcaddy-api/routes/logs.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { name: c.Names[0]?.replace(/^\//, '') || 'unknown', image: c.Image, status: c.State, - created: c.Created, + created: c.Created })); const paginationParams = parsePaginationParams(req.query); @@ -46,7 +46,7 @@ module.exports = function(ctx) { const logs = await container.logs({ stdout: true, stderr: true, - tail, since, timestamps, + tail, since, timestamps }); // Parse Docker log stream (demultiplex stdout/stderr) @@ -65,7 +65,7 @@ module.exports = function(ctx) { if (line) { lines.push({ stream: streamType === 2 ? 'stderr' : 'stdout', - text: line, + text: line }); } offset += 8 + size; @@ -75,7 +75,7 @@ module.exports = function(ctx) { success: true, containerId, containerName, logs: lines, - count: lines.length, + count: lines.length }); }, 'logs-container')); @@ -100,7 +100,7 @@ module.exports = function(ctx) { const logStream = await container.logs({ stdout: true, stderr: true, - follow: true, tail: 50, timestamps: true, + follow: true, tail: 50, timestamps: true }); let buffer = Buffer.alloc(0); @@ -119,7 +119,7 @@ module.exports = function(ctx) { const data = JSON.stringify({ stream: streamType === 2 ? 'stderr' : 'stdout', text: line, - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }); res.write(`data: ${data}\n\n`); } @@ -248,7 +248,7 @@ module.exports = function(ctx) { const logs = tailLines.map(line => ({ stream: 'stdout', text: line, - timestamp: extractTimestamp(line), + timestamp: extractTimestamp(line) })); res.json({ @@ -256,7 +256,7 @@ module.exports = function(ctx) { logPath: normalizedPath, logs, count: logs.length, - totalLines: lines.length, + totalLines: lines.length }); }, 'logs-file')); diff --git a/dashcaddy-api/routes/monitoring.js b/dashcaddy-api/routes/monitoring.js index d1e8860..699624b 100644 --- a/dashcaddy-api/routes/monitoring.js +++ b/dashcaddy-api/routes/monitoring.js @@ -1,23 +1,19 @@ -/** - * Monitoring and stats routes - * Refactored to use explicit dependencies - */ - const express = require('express'); -const asyncHandler = require('../src/utils/async-handler'); -module.exports = function({ docker, resourceMonitor }) { +module.exports = function(ctx) { const router = express.Router(); // ===== RESOURCE MONITORING ENDPOINTS ===== - router.get('/monitoring/stats', asyncHandler(async (req, res) => { - const stats = resourceMonitor.getAllStats(); + // Get all container stats (from resource monitor module) + router.get('/monitoring/stats', ctx.asyncHandler(async (req, res) => { + const stats = ctx.resourceMonitor.getAllStats(); res.json({ success: true, stats }); }, 'monitoring-stats')); - router.get('/monitoring/stats/:containerId', asyncHandler(async (req, res) => { - const stats = resourceMonitor.getCurrentStats(req.params.containerId); + // Get stats for specific container + router.get('/monitoring/stats/:containerId', ctx.asyncHandler(async (req, res) => { + const stats = ctx.resourceMonitor.getCurrentStats(req.params.containerId); if (!stats) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Container'); @@ -25,15 +21,17 @@ module.exports = function({ docker, resourceMonitor }) { res.json({ success: true, stats }); }, 'monitoring-stats-container')); - router.get('/monitoring/history/:containerId', asyncHandler(async (req, res) => { + // Get historical stats + router.get('/monitoring/history/:containerId', ctx.asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; - const history = resourceMonitor.getHistoricalStats(req.params.containerId, hours); + const history = ctx.resourceMonitor.getHistoricalStats(req.params.containerId, hours); res.json({ success: true, history, hours }); }, 'monitoring-history')); - router.get('/monitoring/aggregated/:containerId', asyncHandler(async (req, res) => { + // Get aggregated stats + router.get('/monitoring/aggregated/:containerId', ctx.asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; - const aggregated = resourceMonitor.getAggregatedStats(req.params.containerId, hours); + const aggregated = ctx.resourceMonitor.getAggregatedStats(req.params.containerId, hours); if (!aggregated) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Monitoring data'); @@ -41,42 +39,49 @@ module.exports = function({ docker, resourceMonitor }) { res.json({ success: true, aggregated, hours }); }, 'monitoring-aggregated')); - router.post('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { - resourceMonitor.setAlertConfig(req.params.containerId, req.body); + // Configure alerts + router.post('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { + ctx.resourceMonitor.setAlertConfig(req.params.containerId, req.body); res.json({ success: true, message: 'Alert configuration saved' }); }, 'monitoring-alerts-set')); - router.get('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { - const config = resourceMonitor.getAlertConfig(req.params.containerId); + // Get alert configuration + router.get('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { + const config = ctx.resourceMonitor.getAlertConfig(req.params.containerId); res.json({ success: true, config: config || {} }); }, 'monitoring-alerts-get')); - router.delete('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { - resourceMonitor.removeAlertConfig(req.params.containerId); + // Delete alert configuration + router.delete('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { + ctx.resourceMonitor.removeAlertConfig(req.params.containerId); res.json({ success: true, message: 'Alert configuration removed' }); }, 'monitoring-alerts-delete')); // ===== CONTAINER STATS ENDPOINTS (legacy /stats/) ===== - router.get('/stats/containers', asyncHandler(async (req, res) => { - const containers = await docker.client.listContainers({ all: false }); + // Get all container stats (live Docker stats) + router.get('/stats/containers', ctx.asyncHandler(async (req, res) => { + const containers = await ctx.docker.client.listContainers({ all: false }); const stats = []; for (const containerInfo of containers) { try { - const container = docker.client.getContainer(containerInfo.Id); + const container = ctx.docker.client.getContainer(containerInfo.Id); const containerStats = await container.stats({ stream: false }); + // Calculate CPU percentage const cpuDelta = containerStats.cpu_stats.cpu_usage.total_usage - (containerStats.precpu_stats.cpu_usage?.total_usage || 0); const systemDelta = containerStats.cpu_stats.system_cpu_usage - (containerStats.precpu_stats.system_cpu_usage || 0); const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * (containerStats.cpu_stats.online_cpus || 1) : 0; + // Calculate memory usage const memUsage = containerStats.memory_stats.usage || 0; const memLimit = containerStats.memory_stats.limit || 1; const memPercent = (memUsage / memLimit) * 100; + // Network stats let netRx = 0, netTx = 0; if (containerStats.networks) { for (const net of Object.values(containerStats.networks)) { @@ -90,15 +95,21 @@ module.exports = function({ docker, resourceMonitor }) { name: containerInfo.Names[0]?.replace(/^\//, '') || 'unknown', image: containerInfo.Image, status: containerInfo.State, - cpu: { percent: Math.round(cpuPercent * 100) / 100 }, + cpu: { + percent: Math.round(cpuPercent * 100) / 100 + }, memory: { used: memUsage, limit: memLimit, - percent: Math.round(memPercent * 100) / 100, + percent: Math.round(memPercent * 100) / 100 }, - network: { rx: netRx, tx: netTx }, + network: { + rx: netRx, + tx: netTx + } }); } catch (e) { + // Skip containers we can't get stats for console.log(`Could not get stats for ${containerInfo.Names[0]}:`, e.message); } } @@ -106,20 +117,24 @@ module.exports = function({ docker, resourceMonitor }) { res.json({ success: true, stats, timestamp: new Date().toISOString() }); }, 'stats-containers')); - router.get('/stats/container/:id', asyncHandler(async (req, res) => { - const container = docker.client.getContainer(req.params.id); + // Get single container stats + router.get('/stats/container/:id', ctx.asyncHandler(async (req, res) => { + const container = ctx.docker.client.getContainer(req.params.id); const containerStats = await container.stats({ stream: false }); const info = await container.inspect(); + // Calculate CPU percentage const cpuDelta = containerStats.cpu_stats.cpu_usage.total_usage - (containerStats.precpu_stats.cpu_usage?.total_usage || 0); const systemDelta = containerStats.cpu_stats.system_cpu_usage - (containerStats.precpu_stats.system_cpu_usage || 0); const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * (containerStats.cpu_stats.online_cpus || 1) : 0; + // Memory const memUsage = containerStats.memory_stats.usage || 0; const memLimit = containerStats.memory_stats.limit || 1; + // Network let netRx = 0, netTx = 0; if (containerStats.networks) { for (const net of Object.values(containerStats.networks)) { @@ -135,14 +150,16 @@ module.exports = function({ docker, resourceMonitor }) { image: info.Config.Image, status: info.State.Status, started: info.State.StartedAt, - cpu: { percent: Math.round(cpuPercent * 100) / 100 }, + cpu: { + percent: Math.round(cpuPercent * 100) / 100 + }, memory: { used: memUsage, limit: memLimit, - percent: Math.round((memUsage / memLimit) * 100 * 100) / 100, + percent: Math.round((memUsage / memLimit) * 100 * 100) / 100 }, - network: { rx: netRx, tx: netTx }, - }, + network: { rx: netRx, tx: netTx } + } }); }, 'stats-container')); diff --git a/dashcaddy-api/routes/notifications.js b/dashcaddy-api/routes/notifications.js index f005d21..d8b77ad 100644 --- a/dashcaddy-api/routes/notifications.js +++ b/dashcaddy-api/routes/notifications.js @@ -7,116 +7,116 @@ module.exports = function(ctx) { // GET /config — Get notification configuration (sensitive data redacted) router.get('/config', ctx.asyncHandler(async (req, res) => { - const notificationConfig = ctx.notification.getConfig(); - // Return config without sensitive data - const safeConfig = { - enabled: notificationConfig.enabled, - providers: { - discord: { - enabled: notificationConfig.providers.discord?.enabled || false, - configured: !!notificationConfig.providers.discord?.webhookUrl, + const notificationConfig = ctx.notification.getConfig(); + // Return config without sensitive data + const safeConfig = { + enabled: notificationConfig.enabled, + providers: { + discord: { + enabled: notificationConfig.providers.discord?.enabled || false, + configured: !!notificationConfig.providers.discord?.webhookUrl + }, + telegram: { + enabled: notificationConfig.providers.telegram?.enabled || false, + configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId) + }, + ntfy: { + enabled: notificationConfig.providers.ntfy?.enabled || false, + configured: !!notificationConfig.providers.ntfy?.topic, + serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh' + } }, - telegram: { - enabled: notificationConfig.providers.telegram?.enabled || false, - configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId), - }, - ntfy: { - enabled: notificationConfig.providers.ntfy?.enabled || false, - configured: !!notificationConfig.providers.ntfy?.topic, - serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh', - }, - }, - events: notificationConfig.events, - healthCheck: notificationConfig.healthCheck, - }; - res.json({ success: true, config: safeConfig }); + events: notificationConfig.events, + healthCheck: notificationConfig.healthCheck + }; + res.json({ success: true, config: safeConfig }); }, 'notifications-config-get')); // POST /config — Update notification configuration router.post('/config', ctx.asyncHandler(async (req, res) => { - const { enabled, providers, events, healthCheck } = req.body; - const notificationConfig = ctx.notification.getConfig(); + const { enabled, providers, events, healthCheck } = req.body; + const notificationConfig = ctx.notification.getConfig(); - // Validate provider webhook URLs and tokens - if (providers) { - if (providers.discord?.webhookUrl) { - try { - validateURL(providers.discord.webhookUrl); - } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL'); + // Validate provider webhook URLs and tokens + if (providers) { + if (providers.discord?.webhookUrl) { + try { + validateURL(providers.discord.webhookUrl); + } catch (validationErr) { + return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL'); + } + } + if (providers.telegram?.botToken) { + try { + validateToken(providers.telegram.botToken); + } catch (validationErr) { + return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format'); + } + } + if (providers.ntfy?.serverUrl) { + try { + validateURL(providers.ntfy.serverUrl); + } catch (validationErr) { + return ctx.errorResponse(res, 400, 'Invalid ntfy server URL'); + } + } + if (providers.ntfy?.topic) { + const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/; + if (!topicRegex.test(providers.ntfy.topic)) { + return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)'); + } } } - if (providers.telegram?.botToken) { - try { - validateToken(providers.telegram.botToken); - } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format'); + + // Update enabled state + if (typeof enabled === 'boolean') { + notificationConfig.enabled = enabled; + } + + // Update providers (only update provided fields) + if (providers) { + if (providers.discord) { + notificationConfig.providers.discord = { + ...notificationConfig.providers.discord, + ...providers.discord + }; + } + if (providers.telegram) { + notificationConfig.providers.telegram = { + ...notificationConfig.providers.telegram, + ...providers.telegram + }; + } + if (providers.ntfy) { + notificationConfig.providers.ntfy = { + ...notificationConfig.providers.ntfy, + ...providers.ntfy + }; } } - if (providers.ntfy?.serverUrl) { - try { - validateURL(providers.ntfy.serverUrl); - } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid ntfy server URL'); + + // Update events + if (events) { + notificationConfig.events = { ...notificationConfig.events, ...events }; + } + + // Update health check settings + if (healthCheck) { + const wasEnabled = notificationConfig.healthCheck?.enabled; + notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck }; + + // Restart daemon if settings changed + if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) { + if (notificationConfig.healthCheck.enabled) { + ctx.notification.startHealthDaemon(); + } else { + ctx.notification.stopHealthDaemon(); + } } } - if (providers.ntfy?.topic) { - const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/; - if (!topicRegex.test(providers.ntfy.topic)) { - return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)'); - } - } - } - // Update enabled state - if (typeof enabled === 'boolean') { - notificationConfig.enabled = enabled; - } - - // Update providers (only update provided fields) - if (providers) { - if (providers.discord) { - notificationConfig.providers.discord = { - ...notificationConfig.providers.discord, - ...providers.discord, - }; - } - if (providers.telegram) { - notificationConfig.providers.telegram = { - ...notificationConfig.providers.telegram, - ...providers.telegram, - }; - } - if (providers.ntfy) { - notificationConfig.providers.ntfy = { - ...notificationConfig.providers.ntfy, - ...providers.ntfy, - }; - } - } - - // Update events - if (events) { - notificationConfig.events = { ...notificationConfig.events, ...events }; - } - - // Update health check settings - if (healthCheck) { - const wasEnabled = notificationConfig.healthCheck?.enabled; - notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck }; - - // Restart daemon if settings changed - if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) { - if (notificationConfig.healthCheck.enabled) { - ctx.notification.startHealthDaemon(); - } else { - ctx.notification.stopHealthDaemon(); - } - } - } - - await ctx.notification.saveConfig(); - res.json({ success: true, message: 'Notification config updated' }); + await ctx.notification.saveConfig(); + res.json({ success: true, message: 'Notification config updated' }); }, 'notifications-config-update')); // POST /test — Test notification delivery @@ -159,7 +159,7 @@ module.exports = function(ctx) { res.json({ success: true, history: notificationHistory.slice(0, limit), - total: notificationHistory.length, + total: notificationHistory.length }); } }, 'notifications-history')); @@ -177,7 +177,7 @@ module.exports = function(ctx) { res.json({ success: true, lastCheck: notificationConfig.healthCheck.lastCheck, - containersMonitored: Object.keys(ctx.notification.getHealthState()).length, + containersMonitored: Object.keys(ctx.notification.getHealthState()).length }); }, 'notifications-health-check')); diff --git a/dashcaddy-api/routes/recipes/deploy.js b/dashcaddy-api/routes/recipes/deploy.js index 44d437d..2111d90 100644 --- a/dashcaddy-api/routes/recipes/deploy.js +++ b/dashcaddy-api/routes/recipes/deploy.js @@ -42,7 +42,7 @@ module.exports = function(ctx) { await ctx.docker.client.createNetwork({ Name: networkName, Driver: recipe.network.driver || 'bridge', - Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId }, + Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId } }); ctx.log.info('recipe', 'Created Docker network', { networkName }); } catch (e) { @@ -62,18 +62,18 @@ module.exports = function(ctx) { try { ctx.log.info('recipe', `Deploying component: ${component.id}`, { role: component.role, - internal: component.internal || false, + internal: component.internal || false }); const result = await deployComponent(component, recipe, config, generatedPasswords, networkName); deployedComponents.push(result); ctx.log.info('recipe', `Component deployed: ${component.id}`, { - containerId: result.containerId?.substring(0, 12), + containerId: result.containerId?.substring(0, 12) }); } catch (componentError) { ctx.log.error('recipe', `Component failed: ${component.id}`, { - error: componentError.message, + error: componentError.message }); errors.push({ componentId: component.id, role: component.role, error: componentError.message }); // Continue deploying other components — partial success is better than total failure @@ -96,7 +96,7 @@ module.exports = function(ctx) { recipeId: recipeId, recipeRole: deployed.role, tailscaleOnly: config.sharedConfig?.tailscaleOnly || false, - deployedAt: new Date().toISOString(), + deployedAt: new Date().toISOString() }); } } @@ -119,18 +119,18 @@ module.exports = function(ctx) { role: c.role, containerId: c.containerId?.substring(0, 12), url: c.url, - internal: c.internal, + internal: c.internal })), errors: errors.length > 0 ? errors : undefined, message: errors.length > 0 ? `${recipe.name} partially deployed (${deployedComponents.length}/${componentsToDeploy.length} components)` : `${recipe.name} deployed successfully!`, - setupInstructions: recipe.setupInstructions, + setupInstructions: recipe.setupInstructions }; ctx.notification.send('deploymentSuccess', 'Recipe Deployed', `**${recipe.name}** recipe deployed (${deployedComponents.length} components).`, - 'success', + 'success' ); res.json(response); @@ -146,7 +146,7 @@ module.exports = function(ctx) { } } catch (cleanupError) { ctx.log.warn('recipe', 'Cleanup failed for component', { - componentId: deployed.id, error: cleanupError.message, + componentId: deployed.id, error: cleanupError.message }); } } @@ -162,7 +162,7 @@ module.exports = function(ctx) { } ctx.notification.send('deploymentFailed', 'Recipe Failed', - `Failed to deploy **${recipe.name}**: ${error.message}`, 'error', + `Failed to deploy **${recipe.name}**: ${error.message}`, 'error' ); ctx.errorResponse(res, 500, error.message); @@ -254,7 +254,7 @@ module.exports = function(ctx) { HostConfig: { PortBindings: {}, Binds: dockerConfig.volumes || [], - RestartPolicy: { Name: 'unless-stopped' }, + RestartPolicy: { Name: 'unless-stopped' } }, Env: Object.entries(dockerConfig.environment || {}).map(([k, v]) => `${k}=${v}`), Labels: { @@ -264,8 +264,8 @@ module.exports = function(ctx) { 'sami.recipe.component': component.id, 'sami.recipe.role': component.role, 'sami.subdomain': subdomain, - 'sami.deployed': new Date().toISOString(), - }, + 'sami.deployed': new Date().toISOString() + } }; // Configure ports @@ -288,7 +288,7 @@ module.exports = function(ctx) { } catch (e) { ctx.log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`); const images = await ctx.docker.client.listImages({ - filters: { reference: [dockerConfig.image] }, + filters: { reference: [dockerConfig.image] } }); if (images.length === 0) throw new Error(`Image not found: ${dockerConfig.image}`); } @@ -324,7 +324,7 @@ module.exports = function(ctx) { const primaryPort = port || dockerConfig.ports[0].split(/[:/]/)[0]; const caddyConfig = ctx.caddy.generateConfig( subdomain, hostIp, primaryPort, - { tailscaleOnly: sharedConfig.tailscaleOnly || false }, + { tailscaleOnly: sharedConfig.tailscaleOnly || false } ); try { const helpers = require('../apps/helpers')(ctx); @@ -344,7 +344,7 @@ module.exports = function(ctx) { internal: component.internal || false, templateRef: component.templateRef, logo, - url, + url }; } diff --git a/dashcaddy-api/routes/recipes/index.js b/dashcaddy-api/routes/recipes/index.js index 33baba6..ed8b415 100644 --- a/dashcaddy-api/routes/recipes/index.js +++ b/dashcaddy-api/routes/recipes/index.js @@ -29,9 +29,9 @@ module.exports = function(ctx) { required: c.required, internal: c.internal || false, templateRef: c.templateRef || null, - note: c.note || null, + note: c.note || null })), - setupInstructions: recipe.setupInstructions, + setupInstructions: recipe.setupInstructions })); res.json({ success: true, templates, categories: RECIPE_CATEGORIES }); diff --git a/dashcaddy-api/routes/recipes/manage.js b/dashcaddy-api/routes/recipes/manage.js index 6e1e5f8..135da68 100644 --- a/dashcaddy-api/routes/recipes/manage.js +++ b/dashcaddy-api/routes/recipes/manage.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { if (!recipeGroups[service.recipeId]) { recipeGroups[service.recipeId] = { recipeId: service.recipeId, - components: [], + components: [] }; } recipeGroups[service.recipeId].components.push({ @@ -25,7 +25,7 @@ module.exports = function(ctx) { logo: service.logo, containerId: service.containerId, recipeRole: service.recipeRole, - deployedAt: service.deployedAt, + deployedAt: service.deployedAt }); } @@ -48,7 +48,7 @@ module.exports = function(ctx) { // Check if this container is already listed (by containerId) const existing = recipeGroups[recipeId].components.find( - c => c.containerId === containerInfo.Id, + c => c.containerId === containerInfo.Id ); if (existing) continue; @@ -59,7 +59,7 @@ module.exports = function(ctx) { recipeRole: labels['sami.recipe.role'] || 'Unknown', internal: true, state: containerInfo.State, - status: containerInfo.Status, + status: containerInfo.Status }); } } catch (e) { @@ -242,7 +242,7 @@ module.exports = function(ctx) { ctx.notification.send('recipeRemoved', 'Recipe Removed', `Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`, - 'info', + 'info' ); ctx.log.info('recipe', 'Recipe removed', { recipeId, results }); @@ -271,7 +271,7 @@ module.exports = function(ctx) { Id: c.Id, component: c.Labels['sami.recipe.component'] || c.Names[0]?.replace('/', ''), role: c.Labels['sami.recipe.role'] || 'Unknown', - state: c.State, + state: c.State })); } @@ -293,7 +293,7 @@ module.exports = function(ctx) { */ async function removeCaddyBlock(subdomain) { const domain = ctx.buildDomain(subdomain); - const content = await ctx.caddy.read(); + let content = await ctx.caddy.read(); // Find and remove the block for this domain const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index 29b9ea4..d06836d 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -99,7 +99,7 @@ module.exports = function(ctx) { isUp: false, statusCode: 502, responseTime, - error: error.message, + error: error.message }; } @@ -108,7 +108,7 @@ module.exports = function(ctx) { isUp: isServiceUp(statusCode), statusCode, responseTime, - url, + url }; } @@ -169,7 +169,7 @@ module.exports = function(ctx) { success: true, hasApiKey: !!(arrKey || svcKey), hasBasicAuth: !!username, - username: username || null, + username: username || null }); } catch (error) { res.json({ success: true, hasApiKey: false, hasBasicAuth: false }); @@ -249,7 +249,7 @@ module.exports = function(ctx) { services.forEach(service => addId(service.id)); const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) => - probeServiceStatus(id, serviceMap.get(id)), + probeServiceStatus(id, serviceMap.get(id)) ); const statuses = {}; @@ -261,7 +261,7 @@ module.exports = function(ctx) { res.json({ success: true, checkedAt: new Date().toISOString(), - statuses, + statuses }); }, 'services-status')); @@ -343,7 +343,7 @@ module.exports = function(ctx) { res.json({ success: true, message: `Successfully imported ${services.length} services`, - count: services.length, + count: services.length }); }, 'services-import')); @@ -396,12 +396,12 @@ module.exports = function(ctx) { const oldDomain = ctx.buildDomain(oldSubdomain); const newDomain = ctx.buildDomain(newSubdomain); - const content = await ctx.caddy.read(); + let content = await ctx.caddy.read(); const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp( `${escapedOldDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, - 's', + 's' ); const oldBlockMatch = content.match(siteBlockRegex); @@ -414,7 +414,7 @@ module.exports = function(ctx) { const finalPort = port || existingPort; const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, { - tailscaleOnly: tailscaleOnly || false, + tailscaleOnly: tailscaleOnly || false }); const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig)); @@ -445,7 +445,7 @@ module.exports = function(ctx) { id: newSubdomain, port: port || services[serviceIndex].port, ip: ip || services[serviceIndex].ip, - tailscaleOnly: tailscaleOnly || false, + tailscaleOnly: tailscaleOnly || false }; results.services = 'updated'; } else { @@ -459,7 +459,7 @@ module.exports = function(ctx) { res.json({ success: true, message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`, - results, + results }); }, 'services-update')); diff --git a/dashcaddy-api/routes/sites.js b/dashcaddy-api/routes/sites.js index 03f3b4a..65762dd 100644 --- a/dashcaddy-api/routes/sites.js +++ b/dashcaddy-api/routes/sites.js @@ -25,7 +25,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, { method: 'POST', headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: caddyfileContent, + body: caddyfileContent }); if (!response.ok) { @@ -39,80 +39,80 @@ module.exports = function(ctx) { // Get Certificate Authorities from Caddyfile router.get('/caddy/cas', ctx.asyncHandler(async (req, res) => { - const content = await ctx.caddy.read(); - const cas = []; + const content = await ctx.caddy.read(); + const cas = []; - const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs; - let pkiMatch; - while ((pkiMatch = pkiRegex.exec(content)) !== null) { - const pkiBlock = pkiMatch[1]; - let caMatch; - const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs; - while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) { - const caName = caMatch[1]; - const caBlock = caMatch[2]; - const ca = { id: caName, name: caName, root: {}, intermediate: {} }; + const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs; + let pkiMatch; + while ((pkiMatch = pkiRegex.exec(content)) !== null) { + const pkiBlock = pkiMatch[1]; + let caMatch; + const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs; + while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) { + const caName = caMatch[1]; + const caBlock = caMatch[2]; + const ca = { id: caName, name: caName, root: {}, intermediate: {} }; - const nameMatch = /name\s+"([^"]+)"/.exec(caBlock); - if (nameMatch) ca.name = nameMatch[1]; + const nameMatch = /name\s+"([^"]+)"/.exec(caBlock); + if (nameMatch) ca.name = nameMatch[1]; - const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock); - const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock); - if (rootCnMatch) ca.root_cn = rootCnMatch[1]; - if (intCnMatch) ca.intermediate_cn = intCnMatch[1]; + const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock); + const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock); + if (rootCnMatch) ca.root_cn = rootCnMatch[1]; + if (intCnMatch) ca.intermediate_cn = intCnMatch[1]; - const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock); - if (rootMatch) { - const rootBlock = rootMatch[1]; - const certMatch = /cert\s+(\S+)/.exec(rootBlock); - const keyMatch = /key\s+(\S+)/.exec(rootBlock); - if (certMatch) ca.root.cert = certMatch[1]; - if (keyMatch) ca.root.key = keyMatch[1]; + const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock); + if (rootMatch) { + const rootBlock = rootMatch[1]; + const certMatch = /cert\s+(\S+)/.exec(rootBlock); + const keyMatch = /key\s+(\S+)/.exec(rootBlock); + if (certMatch) ca.root.cert = certMatch[1]; + if (keyMatch) ca.root.key = keyMatch[1]; + } + + const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock); + if (intMatch) { + const intBlock = intMatch[1]; + const certMatch = /cert\s+(\S+)/.exec(intBlock); + const keyMatch = /key\s+(\S+)/.exec(intBlock); + if (certMatch) ca.intermediate.cert = certMatch[1]; + if (keyMatch) ca.intermediate.key = keyMatch[1]; + } + + cas.push(ca); } + } - const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock); - if (intMatch) { - const intBlock = intMatch[1]; - const certMatch = /cert\s+(\S+)/.exec(intBlock); - const keyMatch = /key\s+(\S+)/.exec(intBlock); - if (certMatch) ca.intermediate.cert = certMatch[1]; - if (keyMatch) ca.intermediate.key = keyMatch[1]; + const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g; + let tlsMatch; + while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) { + cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' }); + } + + const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || []; + const tlsInternalCAs = new Set(); + for (const block of siteBlocks) { + const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block); + if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]); + if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) { + tlsInternalCAs.add('local'); } - - cas.push(ca); } - } - - const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g; - let tlsMatch; - while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) { - cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' }); - } - - const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || []; - const tlsInternalCAs = new Set(); - for (const block of siteBlocks) { - const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block); - if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]); - if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) { - tlsInternalCAs.add('local'); + for (const caName of tlsInternalCAs) { + if (!cas.find(c => c.name === caName)) { + cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' }); + } } - } - for (const caName of tlsInternalCAs) { - if (!cas.find(c => c.name === caName)) { - cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' }); + if (cas.length === 0 && /tls\s+internal/.test(content)) { + cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' }); } - } - if (cas.length === 0 && /tls\s+internal/.test(content)) { - cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' }); - } - const caList = cas.map(ca => ({ - id: ca.id || ca.name, - name: ca.name, - displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name, - })); - res.json({ status: 'success', data: { cas: caList } }); + const caList = cas.map(ca => ({ + id: ca.id || ca.name, + name: ca.name, + displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name + })); + res.json({ status: 'success', data: { cas: caList } }); }, 'caddy-get-cas')); // Remove a site from Caddyfile @@ -123,7 +123,7 @@ module.exports = function(ctx) { const result = await ctx.caddy.modify((content) => { const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp( - `\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g', + `\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g' ); const modified = content.replace(siteBlockRegex, '\n'); if (modified.length === content.length) return null; @@ -149,7 +149,7 @@ module.exports = function(ctx) { const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i; if (!upstreamRegex.test(upstream)) return ctx.errorResponse(res, 400, 'Invalid upstream format. Use host:port'); - const content = await ctx.caddy.read(); + let content = await ctx.caddy.read(); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g'); if (siteBlockRegex.test(content)) { @@ -200,7 +200,7 @@ module.exports = function(ctx) { } const sslConfig = sslType === 'letsencrypt' ? '' : 'tls internal'; - const hostHeader = preserveHost ? '\n header_up Host {upstream_hostport}' : ''; + const hostHeader = preserveHost ? `\n header_up Host {upstream_hostport}` : ''; const urlObj = new URL(externalUrl); @@ -238,7 +238,7 @@ module.exports = function(ctx) { await ctx.addServiceToConfig({ id: subdomain, name: serviceName, logo, isExternal: true, externalUrl, - deployedAt: new Date().toISOString(), + deployedAt: new Date().toISOString() }); ctx.log.info('deploy', 'Service added to dashboard', { subdomain }); } catch (serviceError) { @@ -248,7 +248,7 @@ module.exports = function(ctx) { const response = { success: true, - message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`, + message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}` }; if (dnsWarning) response.warning = dnsWarning; res.json(response); diff --git a/dashcaddy-api/routes/tailscale.js b/dashcaddy-api/routes/tailscale.js index a322736..07b807b 100644 --- a/dashcaddy-api/routes/tailscale.js +++ b/dashcaddy-api/routes/tailscale.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { success: true, installed: false, connected: false, - message: 'Tailscale not available or not running', + message: 'Tailscale not available or not running' }); } @@ -30,7 +30,7 @@ module.exports = function(ctx) { os: peer.OS, online: peer.Online, lastSeen: peer.LastSeen, - user: peer.UserID, + user: peer.UserID }); } } @@ -44,11 +44,11 @@ module.exports = function(ctx) { hostname: status.Self?.HostName, ip: localIP, tailnetName: status.MagicDNSSuffix, - online: status.Self?.Online, + online: status.Self?.Online }, config: ctx.tailscale.config, devices, - deviceCount: devices.length, + deviceCount: devices.length }); }, 'tailscale-status')); @@ -65,7 +65,7 @@ module.exports = function(ctx) { res.json({ success: true, message: 'Tailscale configuration updated', - config: ctx.tailscale.config, + config: ctx.tailscale.config }); }, 'tailscale-config')); @@ -83,7 +83,7 @@ module.exports = function(ctx) { isTailscale, clientIP, forwardedFor: forwardedFor || null, - realIP: realIP || null, + realIP: realIP || null }); }, 'tailscale-check')); @@ -102,7 +102,7 @@ module.exports = function(ctx) { hostname: peer.HostName, ip: peer.TailscaleIPs?.[0], os: peer.OS, - user: peer.UserID, + user: peer.UserID }); } } @@ -114,7 +114,7 @@ module.exports = function(ctx) { ip: status.Self.TailscaleIPs?.[0], os: status.Self.OS, user: status.Self.UserID, - isSelf: true, + isSelf: true }); } @@ -129,7 +129,7 @@ module.exports = function(ctx) { return ctx.errorResponse(res, 400, 'subdomain is required'); } - const content = await ctx.caddy.read(); + let content = await ctx.caddy.read(); const domain = ctx.buildDomain(subdomain); const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's'); @@ -149,7 +149,7 @@ module.exports = function(ctx) { const newConfig = ctx.caddy.generateConfig(subdomain, ip, port || '80', { tailscaleOnly: tailscaleOnly !== false, - allowedIPs: allowedIPs || [], + allowedIPs: allowedIPs || [] }); const caddyResult = await ctx.caddy.modify(c => c.replace(blockRegex, newConfig)); @@ -170,7 +170,7 @@ module.exports = function(ctx) { res.json({ success: true, message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`, - tailscaleOnly: tailscaleOnly !== false, + tailscaleOnly: tailscaleOnly !== false }); }, 'tailscale-protect')); @@ -188,7 +188,7 @@ module.exports = function(ctx) { const tokenRes = await fetch(TAILSCALE.OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`, + body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials` }); if (!tokenRes.ok) { @@ -199,7 +199,7 @@ module.exports = function(ctx) { // Test with the device list to verify scopes const testRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, { - headers: { Authorization: `Bearer ${tokenData.access_token}` }, + headers: { Authorization: `Bearer ${tokenData.access_token}` } }); if (!testRes.ok) { @@ -259,7 +259,7 @@ module.exports = function(ctx) { res.json({ success: true, devices: ctx.tailscale.config.devices || [], - lastSync: ctx.tailscale.config.lastSync, + lastSync: ctx.tailscale.config.lastSync }); }, 'tailscale-api-devices')); @@ -274,7 +274,7 @@ module.exports = function(ctx) { res.json({ success: true, devices: devices || [], - lastSync: ctx.tailscale.config.lastSync, + lastSync: ctx.tailscale.config.lastSync }); }, 'tailscale-sync')); @@ -287,7 +287,7 @@ module.exports = function(ctx) { } const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, { - headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }); if (!aclRes.ok) { return ctx.errorResponse(res, aclRes.status, `ACL fetch failed: HTTP ${aclRes.status}`); @@ -299,7 +299,7 @@ module.exports = function(ctx) { groups: Object.keys(acl.groups || {}), tagOwners: Object.keys(acl.tagOwners || {}), aclRuleCount: (acl.acls || []).length, - sshRuleCount: (acl.ssh || []).length, + sshRuleCount: (acl.ssh || []).length }; res.json({ success: true, acl, summary }); diff --git a/dashcaddy-api/routes/themes.js b/dashcaddy-api/routes/themes.js index fe92822..db80d67 100644 --- a/dashcaddy-api/routes/themes.js +++ b/dashcaddy-api/routes/themes.js @@ -46,15 +46,15 @@ module.exports = function(ctx) { const themeData = { name, ...colors }; if (lightBg) themeData.lightBg = true; - fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(themeData, null, 2), 'utf8'); + fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(themeData, null, 2), 'utf8'); - res.json({ success: true, message: `${name } theme saved` }); + res.json({ success: true, message: name + ' theme saved' }); }); // Delete a theme router.delete('/themes/:slug', (req, res) => { const { slug } = req.params; - const filePath = path.join(THEMES_DIR, `${slug }.json`); + const filePath = path.join(THEMES_DIR, slug + '.json'); if (!fs.existsSync(filePath)) { return res.status(404).json({ success: false, error: 'Theme not found' }); @@ -64,7 +64,7 @@ module.exports = function(ctx) { const name = data.name || slug; fs.unlinkSync(filePath); - res.json({ success: true, message: `${name } theme deleted` }); + res.json({ success: true, message: name + ' theme deleted' }); }); return router; diff --git a/dashcaddy-api/scripts/build-release.sh b/dashcaddy-api/scripts/build-release.sh deleted file mode 100644 index 1c0eda3..0000000 --- a/dashcaddy-api/scripts/build-release.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -# DashCaddy Release Builder -# Triggered by Gitea webhook on push to main. -# Clones repo, builds tarball, writes version.json, deploys to web root. - -set -euo pipefail - -readonly REPO_URL="http://sami7777:2728bb667201841b08cb35ac101ffe52838f7d11@100.98.123.59:3000/sami7777/dashcaddy.git" -readonly RELEASE_DIR="/var/www/get.dashcaddy.net/release" -readonly BUILD_DIR="/tmp/dashcaddy-build-$$" -readonly MIRROR_HOST="root@100.98.123.59" # Contabo DE -readonly BRANCH="main" - -log() { echo "[build-release] $(date '+%Y-%m-%d %H:%M:%S') $*"; } - -cleanup() { rm -rf "$BUILD_DIR"; } -trap cleanup EXIT - -main() { - log "=== Starting release build ===" - - # 1. Clone latest - mkdir -p "$BUILD_DIR" - log "Cloning ${BRANCH}..." - git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$BUILD_DIR/repo" 2>&1 - cd "$BUILD_DIR/repo" - - local commit - commit=$(git rev-parse --short HEAD) - log "Commit: ${commit}" - - # 2. Read version from package.json - local version - version=$(python3 -c "import json; print(json.load(open('dashcaddy-api/package.json'))['version'])") - log "Version: ${version}" - - # 3. Build changelog (last 10 commits, one-liner) - local changelog - changelog=$(git log --oneline -10 --no-decorate 2>/dev/null || echo "${commit} (no log)") - - # 4. Assemble tarball contents - local staging="$BUILD_DIR/dashcaddy" - mkdir -p "$staging/dashcaddy-api/routes" "$staging/status" "$staging/scripts" - - # API files - cp -f dashcaddy-api/*.js "$staging/dashcaddy-api/" 2>/dev/null || true - cp -rf dashcaddy-api/routes/* "$staging/dashcaddy-api/routes/" 2>/dev/null || true - cp -f dashcaddy-api/package.json "$staging/dashcaddy-api/" - cp -f dashcaddy-api/package-lock.json "$staging/dashcaddy-api/" 2>/dev/null || true - cp -f dashcaddy-api/Dockerfile "$staging/dashcaddy-api/" - cp -f dashcaddy-api/openapi.yaml "$staging/dashcaddy-api/" 2>/dev/null || true - - # Dashboard files - cp -f status/index.html "$staging/status/" - cp -f status/sw.js "$staging/status/" 2>/dev/null || true - for dir in css js dist vendor assets; do - [ -d "status/${dir}" ] && cp -rf "status/${dir}" "$staging/status/" - done - - # Updater scripts - cp -f dashcaddy-api/scripts/dashcaddy-update.sh "$staging/scripts/" 2>/dev/null || true - cp -f dashcaddy-api/scripts/dashcaddy-updater.path "$staging/scripts/" 2>/dev/null || true - cp -f dashcaddy-api/scripts/dashcaddy-updater.service "$staging/scripts/" 2>/dev/null || true - - # 5. Create tarball - local tarball="dashcaddy-${version}.tar.gz" - cd "$BUILD_DIR" - tar czf "$tarball" dashcaddy/ - log "Tarball: ${tarball} ($(du -h "$tarball" | cut -f1))" - - # 6. Compute SHA-256 - local sha256 - sha256=$(sha256sum "$tarball" | cut -d' ' -f1) - log "SHA-256: ${sha256}" - - # 7. Write version.json - cat > version.json </dev/null; then - log "Syncing to mirror..." - rsync -az --timeout=30 "$RELEASE_DIR/" "$MIRROR_HOST:/var/www/get2.dashcaddy.net/release/" 2>&1 || { - log "WARNING: Mirror sync failed (non-fatal)" - } - log "Mirror synced" - else - log "WARNING: Mirror host unreachable, skipping sync" - fi - - log "=== Release build complete: v${version} (${commit}) ===" -} - -main "$@" diff --git a/dashcaddy-api/scripts/dashcaddy-update.sh b/dashcaddy-api/scripts/dashcaddy-update.sh deleted file mode 100644 index 29ab64f..0000000 --- a/dashcaddy-api/scripts/dashcaddy-update.sh +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env bash -# DashCaddy Host-Side Updater -# Triggered by systemd path unit when the API container writes trigger.json. -# Handles API container rebuild + restart with automatic rollback on failure. - -set -euo pipefail - -readonly UPDATES_DIR="/opt/dashcaddy/updates" -readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json" -readonly RESULT_FILE="${UPDATES_DIR}/result.json" -readonly BACKUP_BASE="${UPDATES_DIR}/backups" -readonly HEALTH_URL="http://localhost:3001/health" -readonly HEALTH_TIMEOUT=60 -readonly MAX_BACKUPS=3 - -log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; } - -write_result() { - local success="$1" message="$2" version="$3" duration="$4" - cat > "$RESULT_FILE" </dev/null || true - cp -rf "${api_dir}/routes/"* "$backup_dir/routes/" 2>/dev/null || true - cp -f "${api_dir}/package.json" "$backup_dir/" 2>/dev/null || true - cp -f "${api_dir}/package-lock.json" "$backup_dir/" 2>/dev/null || true - cp -f "${api_dir}/Dockerfile" "$backup_dir/" 2>/dev/null || true - cp -f "${api_dir}/openapi.yaml" "$backup_dir/" 2>/dev/null || true - - log "Backed up version ${version} to ${backup_dir}" -} - -restore_backup() { - local api_dir="$1" version="$2" - local backup_dir="${BACKUP_BASE}/${version}" - - if [ ! -d "$backup_dir" ]; then - log "ERROR: No backup found for version ${version}" - return 1 - fi - - cp -f "${backup_dir}"/*.js "$api_dir/" 2>/dev/null || true - cp -rf "${backup_dir}/routes/"* "${api_dir}/routes/" 2>/dev/null || true - cp -f "${backup_dir}/package.json" "$api_dir/" 2>/dev/null || true - cp -f "${backup_dir}/package-lock.json" "$api_dir/" 2>/dev/null || true - cp -f "${backup_dir}/Dockerfile" "$api_dir/" 2>/dev/null || true - cp -f "${backup_dir}/openapi.yaml" "$api_dir/" 2>/dev/null || true - - log "Restored version ${version} from ${backup_dir}" -} - -copy_new_files() { - local staging_dir="$1" api_dir="$2" - - cp -f "${staging_dir}"/*.js "$api_dir/" 2>/dev/null || true - [ -d "${staging_dir}/routes" ] && cp -rf "${staging_dir}/routes/"* "${api_dir}/routes/" 2>/dev/null || true - cp -f "${staging_dir}/package.json" "$api_dir/" 2>/dev/null || true - cp -f "${staging_dir}/package-lock.json" "$api_dir/" 2>/dev/null || true - cp -f "${staging_dir}/Dockerfile" "$api_dir/" 2>/dev/null || true - cp -f "${staging_dir}/openapi.yaml" "$api_dir/" 2>/dev/null || true - - log "Copied new files from ${staging_dir} to ${api_dir}" -} - -wait_for_health() { - local attempt=0 - local max_attempts=$((HEALTH_TIMEOUT / 2)) - - while (( attempt < max_attempts )); do - if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then - log "Health check passed (attempt $((attempt+1)))" - return 0 - fi - sleep 2 - attempt=$((attempt + 1)) - done - - log "Health check FAILED after ${HEALTH_TIMEOUT}s" - return 1 -} - -find_compose_dir() { - # Find the docker-compose.yml for dashcaddy-api - for dir in /etc/dashcaddy/sites/dashcaddy-api /etc/dashcaddy/sites/caddy-api; do - if [ -f "${dir}/docker-compose.yml" ] || [ -f "${dir}/docker-compose.yaml" ]; then - echo "$dir" - return 0 - fi - done - # Fallback: same as api source - echo "$1" -} - -cleanup_old_backups() { - if [ ! -d "$BACKUP_BASE" ]; then return; fi - local count - count=$(ls -1d "${BACKUP_BASE}"/*/ 2>/dev/null | wc -l) - if (( count > MAX_BACKUPS )); then - local to_remove=$((count - MAX_BACKUPS)) - ls -1d "${BACKUP_BASE}"/*/ 2>/dev/null | head -n "$to_remove" | while read -r dir; do - rm -rf "$dir" - log "Cleaned old backup: $dir" - done - fi -} - -main() { - if [ ! -f "$TRIGGER_FILE" ]; then - log "No trigger file found, exiting" - exit 0 - fi - - local start_time - start_time=$(date +%s) - - # Parse trigger file - local action version from_version staging_dir api_dir commit - action=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('action','update'))" 2>/dev/null || echo "update") - version=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('version','unknown'))" 2>/dev/null || echo "unknown") - from_version=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('fromVersion','unknown'))" 2>/dev/null || echo "unknown") - staging_dir=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('stagingDir',''))" 2>/dev/null || echo "") - api_dir=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('apiSourceDir','/opt/dashcaddy'))" 2>/dev/null || echo "/opt/dashcaddy") - commit=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('commit','unknown'))" 2>/dev/null || echo "unknown") - - log "=== DashCaddy ${action} started: ${from_version} → ${version} (${commit}) ===" - - if [ -z "$staging_dir" ] || [ ! -d "$staging_dir" ]; then - log "ERROR: Staging directory not found: ${staging_dir}" - write_result "false" "Staging directory not found" "$version" "0" - rm -f "$TRIGGER_FILE" - exit 1 - fi - - local compose_dir - compose_dir=$(find_compose_dir "$api_dir") - - # Step 1: Backup current version - log "Step 1: Backing up current version (${from_version})" - backup_current "$api_dir" "$from_version" - - # Step 2: Copy new files - log "Step 2: Copying new files" - copy_new_files "$staging_dir" "$api_dir" - - # Write commit hash to VERSION file - echo "$commit" > "${api_dir}/VERSION" - - # Step 3: Rebuild container - log "Step 3: Building new container image" - cd "$compose_dir" - if ! docker compose build --build-arg DASHCADDY_COMMIT="$commit" --quiet 2>&1; then - log "ERROR: docker compose build failed, rolling back" - restore_backup "$api_dir" "$from_version" - local elapsed=$(( $(date +%s) - start_time )) - write_result "false" "Build failed, rolled back to ${from_version}" "$version" "$((elapsed * 1000))" - rm -f "$TRIGGER_FILE" - exit 1 - fi - - # Step 4: Restart container - log "Step 4: Restarting container" - if ! docker compose up -d 2>&1; then - log "ERROR: docker compose up failed, rolling back" - restore_backup "$api_dir" "$from_version" - docker compose build --quiet 2>&1 || true - docker compose up -d 2>&1 || true - local elapsed=$(( $(date +%s) - start_time )) - write_result "false" "Container start failed, rolled back to ${from_version}" "$version" "$((elapsed * 1000))" - rm -f "$TRIGGER_FILE" - exit 1 - fi - - # Step 5: Health check - log "Step 5: Waiting for health check (${HEALTH_TIMEOUT}s timeout)" - if wait_for_health; then - local elapsed=$(( $(date +%s) - start_time )) - log "=== Update to ${version} SUCCESSFUL (${elapsed}s) ===" - write_result "true" "Update successful" "$version" "$((elapsed * 1000))" - else - log "Health check failed — ROLLING BACK to ${from_version}" - restore_backup "$api_dir" "$from_version" - cd "$compose_dir" - docker compose build --quiet 2>&1 || true - docker compose up -d 2>&1 || true - - if wait_for_health; then - local elapsed=$(( $(date +%s) - start_time )) - log "Rollback to ${from_version} succeeded" - write_result "false" "Health check failed after update. Rolled back to ${from_version}." "$version" "$((elapsed * 1000))" - else - local elapsed=$(( $(date +%s) - start_time )) - log "CRITICAL: Rollback also failed. Manual intervention required." - write_result "false" "CRITICAL: Both update and rollback failed. Manual intervention required." "$version" "$((elapsed * 1000))" - fi - fi - - # Cleanup - rm -f "$TRIGGER_FILE" - rm -rf "${UPDATES_DIR}/staging" - cleanup_old_backups - - log "Update process complete" -} - -main "$@" diff --git a/dashcaddy-api/scripts/dashcaddy-updater.path b/dashcaddy-api/scripts/dashcaddy-updater.path deleted file mode 100644 index e65498b..0000000 --- a/dashcaddy-api/scripts/dashcaddy-updater.path +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Watch for DashCaddy update trigger -Documentation=https://dashcaddy.net - -[Path] -PathChanged=/opt/dashcaddy/updates/trigger.json - -[Install] -WantedBy=multi-user.target diff --git a/dashcaddy-api/scripts/dashcaddy-updater.service b/dashcaddy-api/scripts/dashcaddy-updater.service deleted file mode 100644 index d554574..0000000 --- a/dashcaddy-api/scripts/dashcaddy-updater.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=DashCaddy auto-update handler -Documentation=https://dashcaddy.net -After=docker.service -Requires=docker.service - -[Service] -Type=oneshot -ExecStart=/opt/dashcaddy/scripts/dashcaddy-update.sh -TimeoutStartSec=300 -StandardOutput=journal -StandardError=journal -SyslogIdentifier=dashcaddy-update diff --git a/dashcaddy-api/scripts/install-ca.ps1.template b/dashcaddy-api/scripts/install-ca.ps1.template deleted file mode 100644 index bfb74ec..0000000 --- a/dashcaddy-api/scripts/install-ca.ps1.template +++ /dev/null @@ -1,120 +0,0 @@ -#Requires -RunAsAdministrator - -<# -.SYNOPSIS - Installs the DashCaddy Root CA certificate to the Trusted Root Certification Authorities store. - -.DESCRIPTION - This script downloads the root CA certificate from your DashCaddy instance, verifies its fingerprint, - and installs it to the local machine's trusted root store. -#> - -$ErrorActionPreference = "Stop" - -# ========================================== -# CONFIGURATION (Injected by DashCaddy API) -# ========================================== -$CertUrl = "{{CERT_URL}}" -$ExpectedFingerprint = "{{CERT_FINGERPRINT}}" -# ========================================== - -$TempFile = "$env:TEMP\dashcaddy-root-ca.crt" - -# Colors -$Red = [System.ConsoleColor]::Red -$Green = [System.ConsoleColor]::Green -$Cyan = [System.ConsoleColor]::Cyan -$Yellow = [System.ConsoleColor]::Yellow - -Write-Host "" -Write-Host "========================================" -ForegroundColor $Cyan -Write-Host " DashCaddy Certificate Installer" -ForegroundColor $Cyan -Write-Host "========================================" -ForegroundColor $Cyan -Write-Host "" - -# Step 1: Download certificate -Write-Host "[1/4] Downloading certificate..." -ForegroundColor $Cyan -try { - $ProgressPreference = 'SilentlyContinue' - - # Bypass SSL validation — the user doesn't trust the CA yet, that's the whole point - if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy').Type) { - Add-Type @" -using System.Net; -using System.Security.Cryptography.X509Certificates; -public class TrustAllCertsPolicy : ICertificatePolicy { - public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; } -} -"@ - } - [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy - - Invoke-WebRequest -Uri $CertUrl -OutFile $TempFile -UseBasicParsing -ErrorAction Stop - Write-Host " OK Certificate downloaded" -ForegroundColor $Green -} catch { - Write-Host " FAIL Failed to download certificate from $CertUrl" -ForegroundColor $Red - Write-Host " Error: $_" -ForegroundColor $Red - exit 1 -} - -# Step 2: Verify fingerprint -Write-Host "[2/4] Verifying certificate fingerprint..." -ForegroundColor $Cyan -try { - $Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($TempFile) - $Fingerprint = $Cert.Thumbprint - - $NormalizedExpected = $ExpectedFingerprint -replace '[:\s]', '' - $NormalizedActual = $Fingerprint -replace '[:\s]', '' - - if ($NormalizedActual -ne $NormalizedExpected) { - Write-Host " FAIL Fingerprint mismatch!" -ForegroundColor $Red - Write-Host " Expected: $ExpectedFingerprint" -ForegroundColor $Yellow - Write-Host " Got: $Fingerprint" -ForegroundColor $Red - Remove-Item $TempFile -Force - Write-Host "" - Write-Host "SECURITY WARNING: The downloaded certificate does not match the expected fingerprint." -ForegroundColor $Red - exit 1 - } - Write-Host " OK Fingerprint verified" -ForegroundColor $Green -} catch { - Write-Host " FAIL Failed to verify fingerprint: $_" -ForegroundColor $Red - Remove-Item $TempFile -Force -ErrorAction SilentlyContinue - exit 1 -} - -# Step 3: Check if already installed -Write-Host "[3/4] Checking for existing certificate..." -ForegroundColor $Cyan -$ExistingCert = Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Fingerprint } -if ($ExistingCert) { - Write-Host " INFO Certificate already installed" -ForegroundColor $Yellow - Remove-Item $TempFile -Force - Write-Host "" - Write-Host "The DashCaddy Root CA is already trusted on this system." -ForegroundColor $Green - Start-Sleep -Seconds 3 - exit 0 -} -Write-Host " OK Not yet installed, proceeding..." -ForegroundColor $Green - -# Step 4: Install certificate -Write-Host "[4/4] Installing to Trusted Root store..." -ForegroundColor $Cyan -try { - $ImportedCert = Import-Certificate -FilePath $TempFile -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction Stop - Write-Host " OK Certificate installed successfully" -ForegroundColor $Green -} catch { - Write-Host " FAIL Failed to install certificate. Ensure you are running as Administrator." -ForegroundColor $Red - Remove-Item $TempFile -Force -ErrorAction SilentlyContinue - exit 1 -} - -# Cleanup -Remove-Item $TempFile -Force -ErrorAction SilentlyContinue - -Write-Host "" -Write-Host "========================================" -ForegroundColor $Green -Write-Host " SUCCESS!" -ForegroundColor $Green -Write-Host "========================================" -ForegroundColor $Green -Write-Host "" -Write-Host "Your browser will now trust DashCaddy apps." -ForegroundColor $Green -Write-Host "You may need to restart your browser for changes to take effect." -ForegroundColor $Yellow -Write-Host "" -Start-Sleep -Seconds 3 diff --git a/dashcaddy-api/scripts/install-ca.sh.template b/dashcaddy-api/scripts/install-ca.sh.template deleted file mode 100644 index 06f2b92..0000000 --- a/dashcaddy-api/scripts/install-ca.sh.template +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ========================================== -# CONFIGURATION (Injected by DashCaddy API) -# ========================================== -CERT_URL="{{CERT_URL}}" -EXPECTED_FP="{{CERT_FINGERPRINT}}" -# ========================================== - -TMPFILE="$(mktemp /tmp/dashcaddy-root-ca.XXXXXX.crt)" - -RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'; NC='\033[0m' - -echo "" -echo -e "${CYAN}========================================" -echo " DashCaddy Certificate Installer" -echo -e "========================================${NC}" -echo "" - -# Step 1: Download certificate (skip TLS verification — we verify the fingerprint instead) -echo -e "${CYAN}[1/4] Downloading certificate...${NC}" -if command -v curl &>/dev/null; then - curl -fsSk -o "$TMPFILE" "$CERT_URL" -elif command -v wget &>/dev/null; then - wget -q --no-check-certificate -O "$TMPFILE" "$CERT_URL" -else - echo -e "${RED} FAIL Neither curl nor wget found${NC}" - exit 1 -fi -echo -e "${GREEN} OK Certificate downloaded${NC}" - -# Step 2: Verify fingerprint -echo -e "${CYAN}[2/4] Verifying certificate fingerprint...${NC}" -ACTUAL_FP=$(openssl x509 -in "$TMPFILE" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//; s/://g') -CLEAN_EXPECTED=$(echo "$EXPECTED_FP" | tr -d ': ') - -if [ "$ACTUAL_FP" != "$CLEAN_EXPECTED" ]; then - echo -e "${RED} FAIL Fingerprint mismatch!${NC}" - echo -e "${YELLOW} Expected: $EXPECTED_FP${NC}" - echo -e "${RED} Got: $ACTUAL_FP${NC}" - rm -f "$TMPFILE" - echo -e "${RED}SECURITY WARNING: Certificate does not match expected fingerprint.${NC}" - exit 1 -fi -echo -e "${GREEN} OK Fingerprint verified${NC}" - -# Step 3: Detect OS and install -echo -e "${CYAN}[3/4] Installing certificate...${NC}" - -install_debian() { - sudo cp "$TMPFILE" /usr/local/share/ca-certificates/dashcaddy-root-ca.crt - sudo update-ca-certificates -} - -install_redhat() { - sudo cp "$TMPFILE" /etc/pki/ca-trust/source/anchors/dashcaddy-root-ca.crt - sudo update-ca-trust extract -} - -install_arch() { - sudo cp "$TMPFILE" /etc/ca-certificates/trust-source/anchors/dashcaddy-root-ca.crt - sudo trust extract-compat -} - -install_macos() { - sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$TMPFILE" -} - -if [ "$(uname)" = "Darwin" ]; then - install_macos -elif [ -f /etc/debian_version ]; then - install_debian -elif [ -f /etc/redhat-release ]; then - install_redhat -elif [ -f /etc/arch-release ]; then - install_arch -elif command -v update-ca-certificates &>/dev/null; then - install_debian -elif command -v update-ca-trust &>/dev/null; then - install_redhat -else - echo -e "${RED} FAIL Could not detect package manager. Install manually:${NC}" - echo " Copy $TMPFILE to your system's CA trust store" - exit 1 -fi -echo -e "${GREEN} OK Certificate installed${NC}" - -# Step 4: Cleanup -rm -f "$TMPFILE" - -echo "" -echo -e "${GREEN}========================================" -echo " SUCCESS!" -echo -e "========================================${NC}" -echo "" -echo -e "${GREEN}Your system now trusts the DashCaddy Root CA.${NC}" -echo -e "${YELLOW}Restart your browser for changes to take effect.${NC}" -echo "" diff --git a/dashcaddy-api/scripts/webhook-handler.js b/dashcaddy-api/scripts/webhook-handler.js deleted file mode 100644 index 89a889f..0000000 --- a/dashcaddy-api/scripts/webhook-handler.js +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env node -/** - * DashCaddy Release Webhook Handler - * Receives push webhooks from Gitea, verifies HMAC signature, - * and triggers build-release.sh. - * - * Usage: node webhook-handler.js - * Env vars: - * WEBHOOK_SECRET — Gitea webhook secret (required) - * WEBHOOK_PORT — Listen port (default: 9090) - * BUILD_SCRIPT — Path to build script (default: /opt/dashcaddy-release/build-release.sh) - */ - -const http = require('http'); -const crypto = require('crypto'); -const { spawn } = require('child_process'); -const fs = require('fs'); - -const PORT = parseInt(process.env.WEBHOOK_PORT || '9090', 10); -const SECRET = process.env.WEBHOOK_SECRET; -const BUILD_SCRIPT = process.env.BUILD_SCRIPT || '/opt/dashcaddy-release/build-release.sh'; -const LOG_FILE = '/var/log/dashcaddy-release.log'; - -if (!SECRET) { - console.error('WEBHOOK_SECRET environment variable is required'); - process.exit(1); -} - -let buildRunning = false; - -function log(msg) { - const line = `[webhook] ${new Date().toISOString()} ${msg}`; - console.log(line); - fs.appendFileSync(LOG_FILE, `${line }\n`); -} - -function verifySignature(body, signature) { - if (!signature) return false; - const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex'); - return crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(hmac), - ); -} - -function triggerBuild() { - if (buildRunning) { - log('Build already in progress, skipping'); - return; - } - buildRunning = true; - log('Triggering build...'); - - const child = spawn('bash', [BUILD_SCRIPT], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, PATH: process.env.PATH }, - }); - - child.stdout.on('data', (data) => { - const lines = data.toString().trim().split('\n'); - lines.forEach(line => log(`[build] ${line}`)); - }); - - child.stderr.on('data', (data) => { - const lines = data.toString().trim().split('\n'); - lines.forEach(line => log(`[build:err] ${line}`)); - }); - - child.on('close', (code) => { - buildRunning = false; - if (code === 0) { - log('Build completed successfully'); - } else { - log(`Build FAILED with exit code ${code}`); - } - }); -} - -const server = http.createServer((req, res) => { - // Health check - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', buildRunning })); - return; - } - - // Only accept POST to /webhook - if (req.method !== 'POST' || req.url !== '/webhook') { - res.writeHead(404); - res.end('Not found'); - return; - } - - let body = ''; - req.on('data', chunk => { body += chunk; }); - req.on('end', () => { - // Verify Gitea HMAC signature - const sig = req.headers['x-gitea-signature'] || ''; - if (!verifySignature(body, sig)) { - log('Signature verification FAILED'); - res.writeHead(403); - res.end('Invalid signature'); - return; - } - - try { - const payload = JSON.parse(body); - const ref = payload.ref || ''; - const branch = ref.replace('refs/heads/', ''); - - if (branch !== 'main') { - log(`Ignoring push to ${branch} (not main)`); - res.writeHead(200); - res.end('Ignored (not main branch)'); - return; - } - - const pusher = payload.pusher?.login || 'unknown'; - const commits = payload.commits?.length || 0; - log(`Push to main by ${pusher}: ${commits} commit(s)`); - - triggerBuild(); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ accepted: true })); - } catch (e) { - log(`Failed to parse webhook payload: ${ e.message}`); - res.writeHead(400); - res.end('Invalid payload'); - } - }); -}); - -server.listen(PORT, '0.0.0.0', () => { - log(`Webhook handler listening on 127.0.0.1:${PORT}`); -}); diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index afa4266..c5ba1de 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -185,7 +185,7 @@ class SelfUpdater extends EventEmitter { const frontendSrc = this._findDir(stagingDir, 'status'); if (frontendSrc) { await this._copyDir(frontendSrc, this.config.frontendDir, [ - 'dist', 'css', 'assets', 'vendor', 'index.html', 'sw.js', + 'dist', 'css', 'assets', 'vendor', 'index.html', 'sw.js' ]); this.emit('update-progress', { step: 'frontend-updated', version: remoteInfo.version }); } @@ -209,7 +209,7 @@ class SelfUpdater extends EventEmitter { }; await fsp.writeFile( path.join(this.config.updatesDir, 'trigger.json'), - JSON.stringify(trigger, null, 2), + JSON.stringify(trigger, null, 2) ); // The host-side systemd service will handle the rest. @@ -312,7 +312,7 @@ class SelfUpdater extends EventEmitter { this.status = 'waiting'; await fsp.writeFile( path.join(this.config.updatesDir, 'trigger.json'), - JSON.stringify(trigger, null, 2), + JSON.stringify(trigger, null, 2) ); this._addToHistory({ @@ -412,12 +412,12 @@ class SelfUpdater extends EventEmitter { try { resolve(JSON.parse(data)); } catch (e) { - reject(new Error(`Invalid JSON from ${ url}`)); + reject(new Error('Invalid JSON from ' + url)); } }); }); req.on('error', reject); - req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout fetching ${ url}`)); }); + req.on('timeout', () => { req.destroy(); reject(new Error('Timeout fetching ' + url)); }); }); } @@ -459,7 +459,7 @@ class SelfUpdater extends EventEmitter { try { execSync(`tar xzf "${tarballPath}" -C "${destDir}" --strip-components=1`, { stdio: 'pipe' }); } catch (e) { - throw new Error(`Failed to extract tarball: ${ e.message}`); + throw new Error('Failed to extract tarball: ' + e.message); } } diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 10db30e..400e1cd 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -10,7 +10,7 @@ const { execSync } = require('child_process'); const path = require('path'); const { ValidationError, validateFilePath, validateURL, validateToken, - validateServiceConfig, sanitizeString, isValidPort, validateSecurePath, + validateServiceConfig, sanitizeString, isValidPort, validateSecurePath } = require('./input-validator'); const validatorLib = require('validator'); const credentialManager = require('./credential-manager'); @@ -128,7 +128,7 @@ licenseManager.loadSecret(LICENSE_SECRET_FILE); // ===== Site configuration loaded from config.json (#5) ===== // These are read at startup and refreshed on config save. // All code should use these instead of hardcoded values. -const siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' }; +let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' }; function loadSiteConfig() { try { @@ -147,7 +147,7 @@ function loadSiteConfig() { } siteConfig.tld = raw.tld || '.home'; - if (!siteConfig.tld.startsWith('.')) siteConfig.tld = `.${ siteConfig.tld}`; + if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld; siteConfig.caName = raw.caName || ''; siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || ''; siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT; @@ -199,7 +199,7 @@ async function callDns(server, apiPath, params) { const response = await fetchT(url, { method: 'GET', headers: { 'Accept': 'application/json' }, - agent: httpsAgent, + agent: httpsAgent }, TIMEOUTS.HTTP_LONG); return response.json(); } @@ -323,7 +323,7 @@ async function getServiceById(serviceId) { async function findContainerByName(name, opts = { all: false }) { const containers = await docker.listContainers(opts); const match = containers.find(c => - c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())), + c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())) ); return match || null; } @@ -348,7 +348,7 @@ async function requireDnsToken(providedToken) { if (providedToken) return providedToken; const result = await ensureValidDnsToken(); if (result.success) return result.token; - const err = new Error(`No valid DNS token available. ${ result.error}`); + const err = new Error('No valid DNS token available. ' + result.error); err.statusCode = 401; throw err; } @@ -430,9 +430,9 @@ async function logError(context, error, additionalInfo = {}) { error: { message: error.message || error, stack: error.stack, - code: error.code, + code: error.code }, - ...additionalInfo, + ...additionalInfo }; // Format log line with request context @@ -446,7 +446,7 @@ async function logError(context, error, additionalInfo = {}) { try { const stats = await fsp.stat(ERROR_LOG_FILE); if (stats.size > MAX_ERROR_LOG_SIZE) { - const rotated = `${ERROR_LOG_FILE }.1`; + const rotated = ERROR_LOG_FILE + '.1'; if (await exists(rotated)) await fsp.unlink(rotated); await fsp.rename(ERROR_LOG_FILE, rotated); } @@ -519,7 +519,7 @@ let tailscaleConfig = { oauthConfigured: false, // true when OAuth credentials are stored tailnet: null, // tailnet name for API calls (e.g., "example.com" or "-") syncInterval: 300, // seconds between API syncs (default 5 min) - lastSync: null, // ISO timestamp of last successful sync + lastSync: null // ISO timestamp of last successful sync }; // Load Tailscale config from file @@ -605,7 +605,7 @@ async function getTailscaleAccessToken() { const res = await fetch(TAILSCALE.OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`, + body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials` }); if (!res.ok) { @@ -617,7 +617,7 @@ async function getTailscaleAccessToken() { const data = await res.json(); _tsTokenCache = { token: data.access_token, - expiresAt: Date.now() + (data.expires_in || 3600) * 1000, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000 }; return data.access_token; } @@ -629,7 +629,7 @@ async function syncFromTailscaleAPI() { if (!token || !tailnet) return null; const res = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, { - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`Tailscale API: HTTP ${res.status}`); @@ -647,7 +647,7 @@ async function syncFromTailscaleAPI() { tags: d.tags || [], lastSeen: d.lastSeen, clientVersion: d.clientVersion, - isExternal: d.isExternal || false, + isExternal: d.isExternal || false })); tailscaleConfig.devices = devices; @@ -670,7 +670,7 @@ function startTailscaleSyncTimer() { log.warn('tailscale', 'API sync failed', { error: error.message }); } }, interval); - log.info('tailscale', 'API sync enabled', { interval: `${interval / 1000 }s` }); + log.info('tailscale', 'API sync enabled', { interval: interval / 1000 + 's' }); } function stopTailscaleSyncTimer() { @@ -681,10 +681,10 @@ function stopTailscaleSyncTimer() { } // TOTP authentication configuration -const totpConfig = { +let totpConfig = { enabled: false, sessionDuration: 'never', // 'never' = disabled, or '15m','30m','1h','2h','4h','8h','12h','24h' - isSetUp: false, // true once a secret has been verified + isSetUp: false // true once a secret has been verified }; async function loadTotpConfig() { @@ -725,20 +725,20 @@ let notificationConfig = { providers: { discord: { enabled: false, webhookUrl: '' }, telegram: { enabled: false, botToken: '', chatId: '' }, - ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' }, + ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' } }, events: { containerDown: true, containerUp: true, deploymentSuccess: true, deploymentFailed: true, - serviceError: true, + serviceError: true }, healthCheck: { enabled: false, intervalMinutes: 5, - lastCheck: null, - }, + lastCheck: null + } }; // Notification history (in-memory, last 100 entries) @@ -801,7 +801,7 @@ async function saveNotificationConfig() { function addNotificationToHistory(notification) { notificationHistory.unshift({ ...notification, - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }); if (notificationHistory.length > MAX_NOTIFICATION_HISTORY) { notificationHistory = notificationHistory.slice(0, MAX_NOTIFICATION_HISTORY); @@ -817,7 +817,7 @@ async function sendDiscordNotification(title, message, type = 'info') { success: 0x00ff00, // Green error: 0xff0000, // Red warning: 0xffff00, // Yellow - info: 0x0099ff, // Blue + info: 0x0099ff // Blue }; const payload = { @@ -826,15 +826,15 @@ async function sendDiscordNotification(title, message, type = 'info') { description: message, color: colors[type] || colors.info, timestamp: new Date().toISOString(), - footer: { text: 'DashCaddy Notifications' }, - }], + footer: { text: 'DashCaddy Notifications' } + }] }; try { const response = await fetchT(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: JSON.stringify(payload) }); if (!response.ok) { @@ -857,7 +857,7 @@ async function sendTelegramNotification(title, message, type = 'info') { success: '✅', error: '❌', warning: '⚠️', - info: 'ℹ️', + info: 'ℹ️' }; const text = `${emoji[type] || emoji.info} *DashCaddy: ${title}*\n\n${message}`; @@ -869,8 +869,8 @@ async function sendTelegramNotification(title, message, type = 'info') { body: JSON.stringify({ chat_id: chatId, text: text, - parse_mode: 'Markdown', - }), + parse_mode: 'Markdown' + }) }); const result = await response.json(); @@ -894,14 +894,14 @@ async function sendNtfyNotification(title, message, type = 'info') { success: 3, // default error: 5, // max warning: 4, // high - info: 3, // default + info: 3 // default }; const tags = { success: 'white_check_mark', error: 'x', warning: 'warning', - info: 'information_source', + info: 'information_source' }; try { @@ -910,9 +910,9 @@ async function sendNtfyNotification(title, message, type = 'info') { headers: { 'Title': `DashCaddy: ${title}`, 'Priority': String(priority[type] || 3), - 'Tags': tags[type] || 'information_source', + 'Tags': tags[type] || 'information_source' }, - body: message, + body: message }); if (!response.ok) { @@ -958,14 +958,14 @@ async function sendNotification(event, title, message, type = 'info') { title, message, type, - results, + results }); return { sent: true, results }; } // Container health monitoring state -const containerHealthState = {}; +let containerHealthState = {}; let healthCheckInterval = null; // Check container health and send notifications @@ -1003,7 +1003,7 @@ async function checkContainerHealth() { 'containerUp', 'Container Recovered', `**${serviceName}** is now running again.`, - 'success', + 'success' ); } else { // Container went down @@ -1011,7 +1011,7 @@ async function checkContainerHealth() { 'containerDown', 'Container Down', `**${serviceName}** has stopped running.\nStatus: ${container.Status}`, - 'error', + 'error' ); } } @@ -1082,13 +1082,13 @@ const middlewareResult = configureMiddleware(app, { siteConfig, totpConfig, tailscaleConfig, metrics, auditLogger, authManager, log, cryptoUtils, isValidContainerId, isTailscaleIP, getTailscaleStatus, - RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache, + RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache }); const { strictLimiter, SESSION_DURATIONS, ipSessions, getClientIP, createIPSession, setSessionCookie, - clearIPSession, clearSessionCookie, isSessionValid, + clearIPSession, clearSessionCookie, isSessionValid } = middlewareResult; // ── Populate route context and mount extracted route modules ── @@ -1280,7 +1280,7 @@ app.get('/probe/:id', asyncHandler(async (req, res) => { const fReq = fLib.request({ hostname: fp.hostname, port: 443, path: '/', method: 'GET', timeout: 5000, agent: httpsAgent, - headers: { 'User-Agent': APP.USER_AGENTS.PROBE }, + headers: { 'User-Agent': APP.USER_AGENTS.PROBE } }, (fRes) => { fRes.resume(); resolve(fRes.statusCode); }); fReq.on('error', reject); fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); }); @@ -1305,7 +1305,7 @@ app.get('/api/network/ips', (req, res) => { localhost: '127.0.0.1', lan: envLan || null, tailscale: envTailscale || null, - all: [], + all: [] }; // If env vars not set, try to detect from network interfaces @@ -1364,7 +1364,7 @@ async function refreshDnsToken(username, password, server) { const params = new URLSearchParams({ user: username, pass: password, - includeInfo: 'false', + includeInfo: 'false' }); const response = await fetchT( @@ -1373,10 +1373,10 @@ async function refreshDnsToken(username, password, server) { method: 'POST', headers: { 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Type': 'application/x-www-form-urlencoded' }, - timeout: 10000, - }, + timeout: 10000 + } ); const result = await response.json(); @@ -1436,7 +1436,7 @@ async function ensureValidDnsToken() { return { success: false, - error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials', + error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials' }; } @@ -1466,7 +1466,7 @@ async function getTokenForServer(targetServer, role = 'readonly') { const params = new URLSearchParams({ user: username, pass: password, - includeInfo: 'false', + includeInfo: 'false' }); const response = await fetchT( @@ -1475,9 +1475,9 @@ async function getTokenForServer(targetServer, role = 'readonly') { method: 'POST', headers: { 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }, + 'Content-Type': 'application/x-www-form-urlencoded' + } + } ); const result = await response.json(); @@ -1485,7 +1485,7 @@ async function getTokenForServer(targetServer, role = 'readonly') { if (result.status === 'ok' && result.token) { dnsServerTokens.set(cacheKey, { token: result.token, - expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString(), + expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString() }); log.info('dns', 'DNS token obtained for server', { server: targetServer, role }); return { success: true, token: result.token }; @@ -1575,13 +1575,13 @@ function generateCaddyConfig(subdomain, ip, port, options = {}) { } if (tailscaleOnly) { - config += '\t\t@blocked not remote_ip 100.64.0.0/10'; + config += `\t\t@blocked not remote_ip 100.64.0.0/10`; if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; - config += '\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n'; + config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`; } config += `\t\treverse_proxy ${ip}:${port}\n`; - config += '\t}'; + config += `\t}`; return config; } @@ -1589,16 +1589,16 @@ function generateCaddyConfig(subdomain, ip, port, options = {}) { let config = `${buildDomain(subdomain)} {\n`; if (tailscaleOnly) { - config += ' @blocked not remote_ip 100.64.0.0/10'; + config += ` @blocked not remote_ip 100.64.0.0/10`; if (allowedIPs.length > 0) { config += ` ${allowedIPs.join(' ')}`; } - config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n'; + config += `\n respond @blocked "Access denied. Tailscale connection required." 403\n`; } config += ` reverse_proxy ${ip}:${port}\n`; - config += ' tls internal\n'; - config += '}'; + config += ` tls internal\n`; + config += `}`; return config; } @@ -1614,7 +1614,7 @@ async function reloadCaddy(content) { const response = await fetchT(`${CADDY_ADMIN_URL}/load`, { method: 'POST', headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: content, + body: content }); if (response.ok) { @@ -1648,7 +1648,7 @@ async function verifySiteAccessible(domain, maxAttempts = 5) { const response = await fetchT(`https://${domain}/`, { method: 'HEAD', agent: httpsAgent, // Ignore cert errors for internal CA - timeout: 5000, + timeout: 5000 }); // Any response (even 4xx) means Caddy is serving the site @@ -1782,14 +1782,14 @@ app.use((err, req, res, next) => { success: false, error: err.message, code: err.code, - ...(err.details ? { details: err.details } : {}), + ...(err.details ? { details: err.details } : {}) }); } if (err instanceof ValidationError) { return res.status(err.statusCode || 400).json({ success: false, error: err.message, - errors: err.errors || undefined, + errors: err.errors || undefined }); } // Catch-all: never leak stack traces or internal paths @@ -1803,150 +1803,150 @@ module.exports = app; if (require.main === module) { // Validate configuration and wait for async config loads before starting server +(async () => { +await Promise.all([_configsReady, _notificationsReady]); +await licenseManager.load(); +await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }); + +const server = app.listen(PORT, '0.0.0.0', () => { + log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE }); + if (BROWSE_ROOTS.length > 0) { + log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) }); + } + + // Start new feature modules + log.info('server', 'Starting DashCaddy feature modules'); + + // Clean up stale port locks (async () => { - await Promise.all([_configsReady, _notificationsReady]); - await licenseManager.load(); - await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }); - - const server = app.listen(PORT, '0.0.0.0', () => { - log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE }); - if (BROWSE_ROOTS.length > 0) { - log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) }); - } - - // Start new feature modules - log.info('server', 'Starting DashCaddy feature modules'); - - // Clean up stale port locks - (async () => { - try { - await portLockManager.cleanupStaleLocks(); - log.info('server', 'Port lock cleanup completed'); - } catch (error) { - log.error('server', 'Port lock cleanup failed', { error: error.message }); - } - })(); - - try { - resourceMonitor.start(); - log.info('server', 'Resource monitoring started'); - } catch (error) { - log.error('server', 'Resource monitoring failed to start', { error: error.message }); - } - - try { - backupManager.start(); - log.info('server', 'Backup manager started'); - } catch (error) { - log.error('server', 'Backup manager failed to start', { error: error.message }); - } - - (async () => { - try { - // Auto-configure health checker from services.json - await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }); - healthChecker.start(); - log.info('server', 'Health checker started'); - } catch (error) { - log.error('server', 'Health checker failed to start', { error: error.message }); - } - })(); - - try { - updateManager.start(); - log.info('server', 'Update manager started'); - } catch (error) { - log.error('server', 'Update manager failed to start', { error: error.message }); - } - - try { - selfUpdater.start(); - log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl }); - // Check for post-update result (did a previous update succeed or roll back?) - selfUpdater.checkPostUpdateResult().then(result => { - if (result) { - log.info('server', 'Post-update result', result); - if (typeof ctx.notification?.send === 'function') { - ctx.notification.send('system.update', - result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed', - result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`, - result.success ? 'info' : 'error', - ); - } - } - }).catch(() => {}); - } catch (error) { - log.error('server', 'Self-updater failed to start', { error: error.message }); - } - - if (dockerMaintenance) { - try { - dockerMaintenance.start(); - log.info('server', 'Docker maintenance started'); - dockerMaintenance.on('maintenance-complete', (result) => { - const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024); - if (saved > 0 || result.warnings.length > 0) { - log.info('maintenance', 'Docker maintenance completed', { - spaceReclaimedMB: saved, - pruned: result.pruned, - warnings: result.warnings.length, - }); - } - if (result.warnings.length > 0) { - for (const w of result.warnings) log.warn('maintenance', w); - } - }); - } catch (error) { - log.error('server', 'Docker maintenance failed to start', { error: error.message }); - } - } - - if (logDigest) { - try { - logDigest.start(platformPaths.digestDir); - log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); - logDigest.on('digest-generated', ({ date }) => { - log.info('digest', `Daily digest generated for ${date}`); - if (typeof ctx.notification?.send === 'function') { - ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info'); - } - }); - } catch (error) { - log.error('server', 'Log digest failed to start', { error: error.message }); - } - } - - // Tailscale API sync (if OAuth configured) - if (tailscaleConfig.oauthConfigured) { - startTailscaleSyncTimer(); - // Run initial sync - syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message })); - } - - log.info('server', 'All feature modules initialized'); - }); - - // Graceful shutdown — drain connections before exiting - function shutdown(signal) { - log.info('shutdown', `${signal} received, draining connections...`); - resourceMonitor.stop(); - backupManager.stop(); - if (dockerMaintenance) dockerMaintenance.stop(); - if (logDigest) logDigest.stop(); - healthChecker.stop(); - updateManager.stop(); - selfUpdater.stop(); - stopTailscaleSyncTimer(); - server.close(() => { - log.info('shutdown', 'HTTP server closed'); - process.exit(0); - }); - // Force exit after 5s if connections don't drain - setTimeout(() => process.exit(0), 5000).unref(); + try { + await portLockManager.cleanupStaleLocks(); + log.info('server', 'Port lock cleanup completed'); + } catch (error) { + log.error('server', 'Port lock cleanup failed', { error: error.message }); } - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - })(); // end async startup + })(); + + try { + resourceMonitor.start(); + log.info('server', 'Resource monitoring started'); + } catch (error) { + log.error('server', 'Resource monitoring failed to start', { error: error.message }); + } + + try { + backupManager.start(); + log.info('server', 'Backup manager started'); + } catch (error) { + log.error('server', 'Backup manager failed to start', { error: error.message }); + } + + (async () => { + try { + // Auto-configure health checker from services.json + await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }); + healthChecker.start(); + log.info('server', 'Health checker started'); + } catch (error) { + log.error('server', 'Health checker failed to start', { error: error.message }); + } + })(); + + try { + updateManager.start(); + log.info('server', 'Update manager started'); + } catch (error) { + log.error('server', 'Update manager failed to start', { error: error.message }); + } + + try { + selfUpdater.start(); + log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl }); + // Check for post-update result (did a previous update succeed or roll back?) + selfUpdater.checkPostUpdateResult().then(result => { + if (result) { + log.info('server', 'Post-update result', result); + if (typeof ctx.notification?.send === 'function') { + ctx.notification.send('system.update', + result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed', + result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`, + result.success ? 'info' : 'error' + ); + } + } + }).catch(() => {}); + } catch (error) { + log.error('server', 'Self-updater failed to start', { error: error.message }); + } + + if (dockerMaintenance) { + try { + dockerMaintenance.start(); + log.info('server', 'Docker maintenance started'); + dockerMaintenance.on('maintenance-complete', (result) => { + const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024); + if (saved > 0 || result.warnings.length > 0) { + log.info('maintenance', 'Docker maintenance completed', { + spaceReclaimedMB: saved, + pruned: result.pruned, + warnings: result.warnings.length + }); + } + if (result.warnings.length > 0) { + for (const w of result.warnings) log.warn('maintenance', w); + } + }); + } catch (error) { + log.error('server', 'Docker maintenance failed to start', { error: error.message }); + } + } + + if (logDigest) { + try { + logDigest.start(platformPaths.digestDir); + log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); + logDigest.on('digest-generated', ({ date }) => { + log.info('digest', `Daily digest generated for ${date}`); + if (typeof ctx.notification?.send === 'function') { + ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info'); + } + }); + } catch (error) { + log.error('server', 'Log digest failed to start', { error: error.message }); + } + } + + // Tailscale API sync (if OAuth configured) + if (tailscaleConfig.oauthConfigured) { + startTailscaleSyncTimer(); + // Run initial sync + syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message })); + } + + log.info('server', 'All feature modules initialized'); +}); + +// Graceful shutdown — drain connections before exiting +function shutdown(signal) { + log.info('shutdown', `${signal} received, draining connections...`); + resourceMonitor.stop(); + backupManager.stop(); + if (dockerMaintenance) dockerMaintenance.stop(); + if (logDigest) logDigest.stop(); + healthChecker.stop(); + updateManager.stop(); + selfUpdater.stop(); + stopTailscaleSyncTimer(); + server.close(() => { + log.info('shutdown', 'HTTP server closed'); + process.exit(0); + }); + // Force exit after 5s if connections don't drain + setTimeout(() => process.exit(0), 5000).unref(); +} +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); +})(); // end async startup } // end if (require.main === module) // #2: Catch unhandled errors so the process doesn't crash silently diff --git a/dashcaddy-api/server.old.js b/dashcaddy-api/server.old.js deleted file mode 100644 index 10db30e..0000000 --- a/dashcaddy-api/server.old.js +++ /dev/null @@ -1,1960 +0,0 @@ -const express = require('express'); -const crypto = require('crypto'); -const fs = require('fs'); -const fsp = require('fs').promises; -const { exists, readJsonFile, writeJsonFile, readTextFile, isAccessible } = require('./fs-helpers'); -const os = require('os'); -const http = require('http'); -const https = require('https'); -const { execSync } = require('child_process'); -const path = require('path'); -const { - ValidationError, validateFilePath, validateURL, validateToken, - validateServiceConfig, sanitizeString, isValidPort, validateSecurePath, -} = require('./input-validator'); -const validatorLib = require('validator'); -const credentialManager = require('./credential-manager'); -const { CACHE_CONFIGS, createCache } = require('./cache-config'); -const { AppError } = require('./errors'); -const { validateConfig } = require('./config-schema'); -const { resolveServiceUrl } = require('./url-resolver'); -const { - APP, APP_PORTS, ARR_SERVICES, TIMEOUTS, RETRIES, - SESSION_TTL, RATE_LIMITS, CADDY, PLEX, LIMITS, - REGEX, DNS_RECORD_TYPES, DOCKER, TAILSCALE, buildMediaAuth, -} = require('./constants'); -const platformPaths = require('./platform-paths'); - -// Image processing for favicon conversion -let sharp, pngToIco; -try { - sharp = require('sharp'); - pngToIco = require('png-to-ico'); -} catch (e) { - log.warn('server', 'Image processing libraries not available - favicon conversion disabled'); -} - -// Docker integration -const Docker = require('dockerode'); -const docker = new Docker(); - -// App templates -const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('./app-templates'); -const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('./recipe-templates'); - -// Crypto utilities for credential encryption -const cryptoUtils = require('./crypto-utils'); - -// New feature modules -const resourceMonitor = require('./resource-monitor'); -const backupManager = require('./backup-manager'); -const healthChecker = require('./health-checker'); -const updateManager = require('./update-manager'); -const selfUpdater = require('./self-updater'); -let dockerMaintenance; -try { dockerMaintenance = require('./docker-maintenance'); } catch (_) { console.warn('[WARN] docker-maintenance module not found, skipped'); } -let logDigest; -try { logDigest = require('./log-digest'); } catch (_) { console.warn('[WARN] log-digest module not found, skipped'); } -const StateManager = require('./state-manager'); -const auditLogger = require('./audit-logger'); -const portLockManager = require('./port-lock-manager'); -const dockerSecurity = require('./docker-security'); -const authManager = require('./auth-manager'); -const configureMiddleware = require('./middleware'); -const { validateStartupConfig, syncHealthCheckerServices } = require('./startup-validator'); -const { CSRF_HEADER_NAME } = require('./csrf-protection'); - -// Route modules -const ctx = require('./routes/context'); -const healthRoutes = require('./routes/health'); -const monitoringRoutes = require('./routes/monitoring'); -const updatesRoutes = require('./routes/updates'); -const authRoutes = require('./routes/auth'); -const configRoutes = require('./routes/config'); -const dnsRoutes = require('./routes/dns'); -const notificationRoutes = require('./routes/notifications'); -const containerRoutes = require('./routes/containers'); -const serviceRoutes = require('./routes/services'); -const tailscaleRoutes = require('./routes/tailscale'); -const sitesRoutes = require('./routes/sites'); -const credentialsRoutes = require('./routes/credentials'); -const arrRoutes = require('./routes/arr'); -const appsRoutes = require('./routes/apps'); -const logsRoutes = require('./routes/logs'); -const backupsRoutes = require('./routes/backups'); -const caRoutes = require('./routes/ca'); -const browseRoutes = require('./routes/browse'); -const errorLogsRoutes = require('./routes/errorlogs'); -const licenseRoutes = require('./routes/license'); -const recipesRoutes = require('./routes/recipes'); -const themesRoutes = require('./routes/themes'); -const { LicenseManager } = require('./license-manager'); -const metrics = require('./metrics'); - -const app = express(); -const PORT = APP.PORT; - -// Configuration from environment variables -const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile; -const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl; -const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile; -const SERVICES_DIR = path.dirname(SERVICES_FILE); -const CONFIG_FILE = process.env.CONFIG_FILE || path.join(SERVICES_DIR, 'config.json'); -const DNS_CREDENTIALS_FILE = process.env.DNS_CREDENTIALS_FILE || path.join(SERVICES_DIR, 'dns-credentials.json'); -const TAILSCALE_CONFIG_FILE = process.env.TAILSCALE_CONFIG_FILE || path.join(SERVICES_DIR, 'tailscale-config.json'); -const NOTIFICATIONS_FILE = process.env.NOTIFICATIONS_FILE || path.join(SERVICES_DIR, 'notifications.json'); -const TOTP_CONFIG_FILE = process.env.TOTP_CONFIG_FILE || path.join(SERVICES_DIR, 'totp-config.json'); -const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(__dirname, 'dashcaddy-errors.log'); -const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE; -const BROWSE_ROOTS = (process.env.MEDIA_BROWSE_ROOTS || '') - .split(',') - .filter(r => r.includes('=')) - .map(r => { - const eqIndex = r.indexOf('='); - const containerPath = r.slice(0, eqIndex).trim(); - const hostPath = r.slice(eqIndex + 1).trim(); - return { containerPath, hostPath }; - }); - -// State management with file locking (prevents data corruption) -const servicesStateManager = new StateManager(SERVICES_FILE); -const configStateManager = new StateManager(CONFIG_FILE); - -// License manager for premium feature gating -const licenseManager = new LicenseManager(credentialManager, CONFIG_FILE, console); -const LICENSE_SECRET_FILE = process.env.LICENSE_SECRET_FILE || path.join(__dirname, '.license-secret'); -licenseManager.loadSecret(LICENSE_SECRET_FILE); - -// ===== Site configuration loaded from config.json (#5) ===== -// These are read at startup and refreshed on config save. -// All code should use these instead of hardcoded values. -const siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' }; - -function loadSiteConfig() { - try { - if (fs.existsSync(CONFIG_FILE)) { - const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); - - // Validate config and log any issues (log.warn may not be assigned during initial load) - const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw); - if (log.warn) { - if (!valid) { - log.warn('config', 'Config validation errors', { errors: configErrors }); - } - for (const w of configWarnings) { - log.warn('config', w); - } - } - - siteConfig.tld = raw.tld || '.home'; - if (!siteConfig.tld.startsWith('.')) siteConfig.tld = `.${ siteConfig.tld}`; - siteConfig.caName = raw.caName || ''; - siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || ''; - siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT; - siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`; - siteConfig.timezone = raw.timezone || 'UTC'; - siteConfig.dnsServers = raw.dnsServers || {}; - siteConfig.configurationType = raw.configurationType || 'homelab'; - siteConfig.domain = raw.domain || ''; - siteConfig.routingMode = raw.routingMode || 'subdomain'; - } - } catch (e) { - // log.error may not be assigned yet during initial module load - if (log.error) { - log.error('config', 'Failed to load site config', { error: e.message }); - } - } -} -loadSiteConfig(); - -/** Build a domain from subdomain + configured TLD or public domain */ -function buildDomain(subdomain) { - if (siteConfig.configurationType === 'public' && siteConfig.domain) { - return `${subdomain}.${siteConfig.domain}`; - } - return `${subdomain}${siteConfig.tld}`; -} - -/** Build full service URL (protocol + host + path) for a given subdomain. - * Subdirectory mode: https://example.com/sonarr - * Subdomain mode: https://sonarr.example.com */ -function buildServiceUrl(subdomain) { - if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { - return `https://${siteConfig.domain}/${subdomain}`; - } - return `https://${buildDomain(subdomain)}`; -} - -/** Build full Technitium DNS API URL for any server (handles IP vs hostname, port) */ -function buildDnsUrl(server, apiPath, params) { - const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https'; - const port = protocol === 'http' ? `:${CADDY.DEFAULT_DNS_PORT}` : ''; - const qs = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString(); - return `${protocol}://${server}${port}${apiPath}?${qs}`; -} - -/** Call a Technitium DNS API endpoint and return parsed JSON */ -async function callDns(server, apiPath, params) { - const url = buildDnsUrl(server, apiPath, params); - const response = await fetchT(url, { - method: 'GET', - headers: { 'Accept': 'application/json' }, - agent: httpsAgent, - }, TIMEOUTS.HTTP_LONG); - return response.json(); -} - -// ===== Shared Helpers ===== - -/** Fetch with automatic timeout — adds AbortSignal if no signal is present. - * Drop-in replacement for fetch(); safely ignores calls that already have a signal. */ -function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { - // Caddy admin API rejects Node.js undici fetch due to Sec-Fetch-* headers triggering - // origin checking. Use raw http.request for Caddy admin calls to avoid this. - if (url.includes(':2019')) { - return _httpFetch(url, opts, timeoutMs); - } - if (!opts.signal) { - opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) }; - } - delete opts.timeout; - return fetch(url, opts); -} - -/** Raw http.request wrapper that returns a fetch-like Response for Caddy admin API */ -function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { - return new Promise((resolve, reject) => { - const parsed = new URL(url); - const options = { - hostname: parsed.hostname, - port: parsed.port || 2019, - path: parsed.pathname + parsed.search, - method: (opts.method || 'GET').toUpperCase(), - headers: { ...opts.headers }, - timeout: timeoutMs, - }; - if (opts.body) { - options.headers['Content-Length'] = Buffer.byteLength(opts.body); - } - const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB - const req = http.request(options, (res) => { - let data = ''; - let size = 0; - res.on('data', chunk => { - size += chunk.length; - if (size > MAX_RESPONSE_SIZE) { - res.destroy(); - reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`)); - return; - } - data += chunk; - }); - res.on('end', () => { - resolve({ - ok: res.statusCode >= 200 && res.statusCode < 300, - status: res.statusCode, - statusText: res.statusMessage, - json: () => Promise.resolve(JSON.parse(data)), - text: () => Promise.resolve(data), - headers: { get: (k) => res.headers[k.toLowerCase()] }, - }); - }); - }); - req.on('timeout', () => { req.destroy(); reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`)); }); - req.on('error', reject); - if (opts.body) req.write(opts.body); - req.end(); - }); -} - -/** Pull a Docker image with timeout protection */ -function dockerPull(imageName, timeoutMs = DOCKER.TIMEOUT) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Docker pull timed out after ${timeoutMs / 1000}s: ${imageName}`)), timeoutMs); - docker.pull(imageName, (err, stream) => { - if (err) { clearTimeout(timer); return reject(err); } - docker.modem.followProgress(stream, (err, output) => { - clearTimeout(timer); - if (err) return reject(err); - resolve(output); - }); - }); - }); -} - -// ===== Structured Logging ===== -const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; -const LOG_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL || 'info'] || 1; - -function log(level, context, message, data = {}) { - if (LOG_LEVELS[level] < LOG_LEVEL) return; - const entry = { - t: new Date().toISOString(), - level, - ctx: context, - msg: message, - }; - if (Object.keys(data).length) entry.data = data; - const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; - fn(JSON.stringify(entry)); -} -log.info = (ctx, msg, data) => log('info', ctx, msg, data); -log.warn = (ctx, msg, data) => log('warn', ctx, msg, data); -log.error = (ctx, msg, data) => log('error', ctx, msg, data); -log.debug = (ctx, msg, data) => log('debug', ctx, msg, data); - -/** Standard error response — always returns { success: false, error, ...extras } */ -function errorResponse(res, statusCode, message, extras = {}) { - return res.status(statusCode).json({ success: false, error: message, ...extras }); -} - -/** Standard success response — always returns { success: true, ...data } */ -function ok(res, data = {}) { - return res.json({ success: true, ...data }); -} - -/** Look up a single service by ID from services.json */ -async function getServiceById(serviceId) { - const services = await servicesStateManager.read(); - return services.find(s => s.id === serviceId) || null; -} - -/** Find a running Docker container by name substring */ -async function findContainerByName(name, opts = { all: false }) { - const containers = await docker.listContainers(opts); - const match = containers.find(c => - c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())), - ); - return match || null; -} - -/** Read config.json with fallback to empty object */ -async function readConfig() { - return readJsonFile(CONFIG_FILE, {}); -} - -/** Save config.json (merges with existing, atomic with locking) */ -async function saveConfig(updates) { - return await configStateManager.update(config => { - return Object.assign(config, updates); - }); -} - -/** - * Resolve a DNS token: use the provided one or auto-refresh. - * @returns {{ token: string }} or throws with 401-appropriate message - */ -async function requireDnsToken(providedToken) { - if (providedToken) return providedToken; - const result = await ensureValidDnsToken(); - if (result.success) return result.token; - const err = new Error(`No valid DNS token available. ${ result.error}`); - err.statusCode = 401; - throw err; -} - -/** Get all host ports currently in use by Docker containers */ -async function getUsedPorts() { - const containers = await docker.listContainers({ all: false }); - const ports = new Set(); - for (const c of containers) { - for (const p of (c.Ports || [])) { - if (p.PublicPort) ports.add(p.PublicPort); - } - } - return ports; -} - -/** - * Atomically read-modify-write the Caddyfile and reload Caddy. - * Uses a mutex to prevent concurrent modifications from clobbering each other. - * Rolls back on reload failure. - * @param {function} modifyFn - receives current content, returns modified content (or null to skip) - * @returns {{ success: boolean, error?: string }} - */ -let _caddyfileLock = Promise.resolve(); -async function modifyCaddyfile(modifyFn) { - let resolve; - const prev = _caddyfileLock; - _caddyfileLock = new Promise(r => { resolve = r; }); - await prev; // wait for any in-flight modification to finish - try { - const original = await readCaddyfile(); - const modified = await modifyFn(original); - if (modified === null || modified === original) { - return { success: false, error: 'No changes to apply' }; - } - await fsp.writeFile(CADDYFILE_PATH, modified, 'utf8'); - try { - await reloadCaddy(modified); - return { success: true }; - } catch (err) { - // Rollback - await fsp.writeFile(CADDYFILE_PATH, original, 'utf8'); - return { success: false, error: safeErrorMessage(err), rolledBack: true }; - } - } finally { - resolve(); - } -} - -/** Read the current Caddyfile content */ -async function readCaddyfile() { - return fsp.readFile(CADDYFILE_PATH, 'utf8'); -} - -// Error logging function with enhanced context tracking -async function logError(context, error, additionalInfo = {}) { - const timestamp = new Date().toISOString(); - - // Extract request context if a request object is provided - const requestContext = {}; - if (additionalInfo.req) { - const req = additionalInfo.req; - const clientIP = req.ip || req.socket?.remoteAddress || ''; - requestContext.requestId = req.id; - requestContext.ip = clientIP; - requestContext.userAgent = req.get('user-agent'); - requestContext.method = req.method; - requestContext.path = req.path; - // Check session validity using ipSessions cache - const session = ipSessions.get(clientIP); - requestContext.sessionValid = session && session.exp > Date.now(); - delete additionalInfo.req; // Remove req from additionalInfo to avoid circular refs - } - - const logEntry = { - timestamp, - context, - ...requestContext, - error: { - message: error.message || error, - stack: error.stack, - code: error.code, - }, - ...additionalInfo, - }; - - // Format log line with request context - const contextInfo = Object.keys(requestContext).length > 0 - ? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}` - : ''; - const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`; - - try { - // #7: Rotate log if it exceeds max size - try { - const stats = await fsp.stat(ERROR_LOG_FILE); - if (stats.size > MAX_ERROR_LOG_SIZE) { - const rotated = `${ERROR_LOG_FILE }.1`; - if (await exists(rotated)) await fsp.unlink(rotated); - await fsp.rename(ERROR_LOG_FILE, rotated); - } - } catch (_) { /* file may not exist yet */ } - await fsp.appendFile(ERROR_LOG_FILE, logLine); - } catch (e) { - log.error('errorlog', 'Failed to write to error log', { error: e.message }); - } -} - -/** #6: Return a safe error message to the client without leaking internals */ -function safeErrorMessage(error) { - const msg = error.message || String(error); - - // Detect port conflict errors from Docker - const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/); - if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) { - const port = portMatch ? portMatch[1] : 'requested'; - return `[DC-200] Port ${port} is already in use. Try a different port in Advanced Options, or stop the service using that port first.`; - } - - // Only expose messages that are clearly user-facing (short, no paths/stack frames) - if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) { - return msg; - } - return 'An internal error occurred'; -} - -/** Wrap async route handlers — catches unhandled errors, logs, and returns 500. - * Eliminates try/catch boilerplate from route definitions. - * @param {Function} fn - async (req, res, next) handler - * @param {string} [context] - label for logError (defaults to req.path) - */ -function asyncHandler(fn, context) { - return async (req, res, next) => { - try { - await fn(req, res, next); - } catch (error) { - // Let typed errors (AppError subclasses) propagate to the global error handler - if (error instanceof AppError) { - return next(error); - } - await logError(context || req.path, error); - if (!res.headersSent) { - errorResponse(res, 500, safeErrorMessage(error)); - } - } - }; -} - -/** #4: Validate Docker container IDs (hex SHA256 prefix or name) */ -const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,127}$/; -function isValidContainerId(id) { - return typeof id === 'string' && CONTAINER_ID_RE.test(id); -} - -// DNS token management - auto-refresh when expired -let dnsToken = process.env.DNS_ADMIN_TOKEN || ''; -let dnsTokenExpiry = null; - -// Per-server token cache for authenticating against specific DNS servers (e.g., for updates) -const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens); // LRU cache: serverIp -> { token, expiry } - -// Tailscale configuration cache -let tailscaleConfig = { - enabled: false, - requireAuth: false, // Require Tailscale for dashboard access - allowedTailnet: null, // Restrict to specific tailnet - devices: [], // Cache of known devices - oauthConfigured: false, // true when OAuth credentials are stored - tailnet: null, // tailnet name for API calls (e.g., "example.com" or "-") - syncInterval: 300, // seconds between API syncs (default 5 min) - lastSync: null, // ISO timestamp of last successful sync -}; - -// Load Tailscale config from file -async function loadTailscaleConfig() { - try { - if (await exists(TAILSCALE_CONFIG_FILE)) { - const data = await fsp.readFile(TAILSCALE_CONFIG_FILE, 'utf8'); - tailscaleConfig = { ...tailscaleConfig, ...JSON.parse(data) }; - log.info('config', 'Tailscale config loaded', { enabled: tailscaleConfig.enabled }); - } - } catch (e) { - await logError('loadTailscaleConfig', e); - log.warn('config', 'Could not load Tailscale config', { error: e.message }); - } -} -// Save Tailscale config to file -async function saveTailscaleConfig() { - try { - await writeJsonFile(TAILSCALE_CONFIG_FILE, tailscaleConfig); - } catch (e) { - log.error('config', 'Could not save Tailscale config', { error: e.message }); - } -} - -// Check if an IP is a Tailscale IP (100.x.x.x CGNAT range) -function isTailscaleIP(ip) { - if (!ip) return false; - // Tailscale uses 100.64.0.0/10 CGNAT range - const parts = ip.split('.'); - if (parts.length !== 4) return false; - const first = parseInt(parts[0]); - const second = parseInt(parts[1]); - return first === 100 && second >= 64 && second <= 127; -} - -// Get Tailscale status (cached for performance) -const tailscaleStatusCache = createCache(CACHE_CONFIGS.tailscaleStatus); - -async function getTailscaleStatus() { - const cached = tailscaleStatusCache.get('status'); - if (cached) { - return cached; - } - - try { - const output = execSync('tailscale status --json', { encoding: 'utf8', timeout: 5000 }); - const status = JSON.parse(output); - tailscaleStatusCache.set('status', status); - return status; - } catch (e) { - log.warn('config', 'Could not get Tailscale status', { error: e.message }); - return null; - } -} - -// Get the local Tailscale IP -async function getLocalTailscaleIP() { - try { - const status = await getTailscaleStatus(); - if (status && status.Self && status.Self.TailscaleIPs) { - // Return first IPv4 address - return status.Self.TailscaleIPs.find(ip => !ip.includes(':')); - } - } catch (e) { - log.warn('config', 'Could not get local Tailscale IP', { error: e.message }); - } - return null; -} - -// ── Tailscale OAuth 2.0 Client Credentials ── -let _tsTokenCache = { token: null, expiresAt: 0 }; - -async function getTailscaleAccessToken() { - // Return cached token if still valid (with 60s buffer) - if (_tsTokenCache.token && Date.now() < _tsTokenCache.expiresAt - 60000) { - return _tsTokenCache.token; - } - - const clientId = await credentialManager.retrieve('tailscale.oauth.client_id'); - const clientSecret = await credentialManager.retrieve('tailscale.oauth.client_secret'); - if (!clientId || !clientSecret) return null; - - const res = await fetch(TAILSCALE.OAUTH_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`, - }); - - if (!res.ok) { - log.error('tailscale', 'OAuth token exchange failed', { status: res.status }); - _tsTokenCache = { token: null, expiresAt: 0 }; - return null; - } - - const data = await res.json(); - _tsTokenCache = { - token: data.access_token, - expiresAt: Date.now() + (data.expires_in || 3600) * 1000, - }; - return data.access_token; -} - -// Sync device list from Tailscale API (richer than local CLI) -async function syncFromTailscaleAPI() { - const token = await getTailscaleAccessToken(); - const tailnet = tailscaleConfig.tailnet; - if (!token || !tailnet) return null; - - const res = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error(`Tailscale API: HTTP ${res.status}`); - - const data = await res.json(); - - const devices = (data.devices || []).map(d => ({ - id: d.id, - name: d.name, - hostname: d.hostname, - addresses: d.addresses || [], - ip: (d.addresses || []).find(a => !a.includes(':')) || null, - os: d.os, - user: d.user, - authorized: d.authorized, - tags: d.tags || [], - lastSeen: d.lastSeen, - clientVersion: d.clientVersion, - isExternal: d.isExternal || false, - })); - - tailscaleConfig.devices = devices; - tailscaleConfig.lastSync = new Date().toISOString(); - await saveTailscaleConfig(); - - return devices; -} - -let _tsSyncInterval = null; - -function startTailscaleSyncTimer() { - if (_tsSyncInterval) clearInterval(_tsSyncInterval); - const interval = (tailscaleConfig.syncInterval || 300) * 1000; - _tsSyncInterval = setInterval(async () => { - try { - await syncFromTailscaleAPI(); - log.debug('tailscale', 'API sync completed', { deviceCount: tailscaleConfig.devices.length }); - } catch (error) { - log.warn('tailscale', 'API sync failed', { error: error.message }); - } - }, interval); - log.info('tailscale', 'API sync enabled', { interval: `${interval / 1000 }s` }); -} - -function stopTailscaleSyncTimer() { - if (_tsSyncInterval) { - clearInterval(_tsSyncInterval); - _tsSyncInterval = null; - } -} - -// TOTP authentication configuration -const totpConfig = { - enabled: false, - sessionDuration: 'never', // 'never' = disabled, or '15m','30m','1h','2h','4h','8h','12h','24h' - isSetUp: false, // true once a secret has been verified -}; - -async function loadTotpConfig() { - try { - if (await exists(TOTP_CONFIG_FILE)) { - const data = await fsp.readFile(TOTP_CONFIG_FILE, 'utf8'); - const loaded = JSON.parse(data); - // Never load secret from file — it belongs only in credential-manager - delete loaded.secret; - Object.assign(totpConfig, loaded); - log.info('config', 'TOTP config loaded', { enabled: totpConfig.enabled }); - } - } catch (e) { - await logError('loadTotpConfig', e); - log.warn('config', 'Could not load TOTP config', { error: e.message }); - } -} - -async function saveTotpConfig() { - try { - await writeJsonFile(TOTP_CONFIG_FILE, totpConfig); - } catch (e) { - log.error('config', 'Could not save TOTP config', { error: e.message }); - } -} - -// Load config on startup (async — resolved before server starts listening) -const _configsReady = (async () => { - await loadTailscaleConfig(); - await loadTotpConfig(); -})(); - -// ===== NOTIFICATION SERVICE ===== - -// Notification configuration -let notificationConfig = { - enabled: false, - providers: { - discord: { enabled: false, webhookUrl: '' }, - telegram: { enabled: false, botToken: '', chatId: '' }, - ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' }, - }, - events: { - containerDown: true, - containerUp: true, - deploymentSuccess: true, - deploymentFailed: true, - serviceError: true, - }, - healthCheck: { - enabled: false, - intervalMinutes: 5, - lastCheck: null, - }, -}; - -// Notification history (in-memory, last 100 entries) -let notificationHistory = []; -const MAX_NOTIFICATION_HISTORY = 100; - -// Load notification config from file (with decryption of sensitive fields) -async function loadNotificationConfig() { - try { - if (await exists(NOTIFICATIONS_FILE)) { - const data = await fsp.readFile(NOTIFICATIONS_FILE, 'utf8'); - const loaded = JSON.parse(data); - - // Decrypt sensitive fields if encrypted - if (loaded._encrypted && loaded.providers) { - if (loaded.providers.discord?.webhookUrl && cryptoUtils.isEncrypted(loaded.providers.discord.webhookUrl)) { - loaded.providers.discord.webhookUrl = cryptoUtils.decrypt(loaded.providers.discord.webhookUrl); - } - if (loaded.providers.telegram?.botToken && cryptoUtils.isEncrypted(loaded.providers.telegram.botToken)) { - loaded.providers.telegram.botToken = cryptoUtils.decrypt(loaded.providers.telegram.botToken); - } - delete loaded._encrypted; - } - - notificationConfig = { ...notificationConfig, ...loaded }; - log.info('config', 'Notification config loaded', { enabled: notificationConfig.enabled }); - } - } catch (e) { - await logError('loadNotificationConfig', e); - log.warn('config', 'Could not load notification config', { error: e.message }); - } -} - -// Save notification config to file (with encryption of sensitive fields) -async function saveNotificationConfig() { - try { - // Create a copy for encryption - const toSave = JSON.parse(JSON.stringify(notificationConfig)); - - // Encrypt sensitive fields - if (toSave.providers) { - if (toSave.providers.discord?.webhookUrl) { - toSave.providers.discord.webhookUrl = cryptoUtils.encrypt(toSave.providers.discord.webhookUrl); - } - if (toSave.providers.telegram?.botToken) { - toSave.providers.telegram.botToken = cryptoUtils.encrypt(toSave.providers.telegram.botToken); - } - } - toSave._encrypted = true; - - await fsp.writeFile(NOTIFICATIONS_FILE, JSON.stringify(toSave, null, 2), 'utf8'); - log.info('config', 'Notification config saved (encrypted)'); - } catch (e) { - await logError('saveNotificationConfig', e); - log.error('config', 'Could not save notification config', { error: e.message }); - } -} - -// Add to notification history -function addNotificationToHistory(notification) { - notificationHistory.unshift({ - ...notification, - timestamp: new Date().toISOString(), - }); - if (notificationHistory.length > MAX_NOTIFICATION_HISTORY) { - notificationHistory = notificationHistory.slice(0, MAX_NOTIFICATION_HISTORY); - } -} - -// Send notification via Discord webhook -async function sendDiscordNotification(title, message, type = 'info') { - const { webhookUrl } = notificationConfig.providers.discord; - if (!webhookUrl) return { success: false, error: 'No webhook URL configured' }; - - const colors = { - success: 0x00ff00, // Green - error: 0xff0000, // Red - warning: 0xffff00, // Yellow - info: 0x0099ff, // Blue - }; - - const payload = { - embeds: [{ - title: `DashCaddy: ${title}`, - description: message, - color: colors[type] || colors.info, - timestamp: new Date().toISOString(), - footer: { text: 'DashCaddy Notifications' }, - }], - }; - - try { - const response = await fetchT(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error(`Discord API returned ${response.status}`); - } - - return { success: true }; - } catch (error) { - await logError('sendDiscordNotification', error); - return { success: false, error: error.message }; - } -} - -// Send notification via Telegram bot -async function sendTelegramNotification(title, message, type = 'info') { - const { botToken, chatId } = notificationConfig.providers.telegram; - if (!botToken || !chatId) return { success: false, error: 'Bot token or chat ID not configured' }; - - const emoji = { - success: '✅', - error: '❌', - warning: '⚠️', - info: 'ℹ️', - }; - - const text = `${emoji[type] || emoji.info} *DashCaddy: ${title}*\n\n${message}`; - - try { - const response = await fetchT(`https://api.telegram.org/bot${botToken}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chat_id: chatId, - text: text, - parse_mode: 'Markdown', - }), - }); - - const result = await response.json(); - if (!result.ok) { - throw new Error(result.description || 'Telegram API error'); - } - - return { success: true }; - } catch (error) { - await logError('sendTelegramNotification', error); - return { success: false, error: error.message }; - } -} - -// Send notification via ntfy.sh -async function sendNtfyNotification(title, message, type = 'info') { - const { serverUrl, topic } = notificationConfig.providers.ntfy; - if (!topic) return { success: false, error: 'No topic configured' }; - - const priority = { - success: 3, // default - error: 5, // max - warning: 4, // high - info: 3, // default - }; - - const tags = { - success: 'white_check_mark', - error: 'x', - warning: 'warning', - info: 'information_source', - }; - - try { - const response = await fetchT(`${serverUrl}/${topic}`, { - method: 'POST', - headers: { - 'Title': `DashCaddy: ${title}`, - 'Priority': String(priority[type] || 3), - 'Tags': tags[type] || 'information_source', - }, - body: message, - }); - - if (!response.ok) { - throw new Error(`ntfy returned ${response.status}`); - } - - return { success: true }; - } catch (error) { - await logError('sendNtfyNotification', error); - return { success: false, error: error.message }; - } -} - -// Send notification to all enabled providers -async function sendNotification(event, title, message, type = 'info') { - if (!notificationConfig.enabled) { - return { sent: false, reason: 'Notifications disabled' }; - } - - // Check if this event type is enabled - if (notificationConfig.events[event] === false) { - return { sent: false, reason: `Event type '${event}' is disabled` }; - } - - const results = {}; - const providers = notificationConfig.providers; - - if (providers.discord?.enabled) { - results.discord = await sendDiscordNotification(title, message, type); - } - - if (providers.telegram?.enabled) { - results.telegram = await sendTelegramNotification(title, message, type); - } - - if (providers.ntfy?.enabled) { - results.ntfy = await sendNtfyNotification(title, message, type); - } - - // Log to history - addNotificationToHistory({ - event, - title, - message, - type, - results, - }); - - return { sent: true, results }; -} - -// Container health monitoring state -const containerHealthState = {}; -let healthCheckInterval = null; - -// Check container health and send notifications -async function checkContainerHealth() { - if (!notificationConfig.enabled || !notificationConfig.healthCheck?.enabled) { - return; - } - - try { - const containers = await docker.listContainers({ all: true }); - const services = (await exists(SERVICES_FILE)) - ? await servicesStateManager.read() - : []; - - // Create a map of container IDs to service names - const serviceMap = {}; - for (const service of services) { - if (service.containerId) { - serviceMap[service.containerId] = service.name || service.id; - } - } - - for (const container of containers) { - const containerId = container.Id; - const containerName = container.Names?.[0]?.replace(/^\//, '') || containerId.slice(0, 12); - const serviceName = serviceMap[containerId] || containerName; - const isRunning = container.State === 'running'; - const previousState = containerHealthState[containerId]; - - // Detect state changes - if (previousState !== undefined && previousState !== isRunning) { - if (isRunning) { - // Container came back up - await sendNotification( - 'containerUp', - 'Container Recovered', - `**${serviceName}** is now running again.`, - 'success', - ); - } else { - // Container went down - await sendNotification( - 'containerDown', - 'Container Down', - `**${serviceName}** has stopped running.\nStatus: ${container.Status}`, - 'error', - ); - } - } - - containerHealthState[containerId] = isRunning; - } - - // Update last check time - notificationConfig.healthCheck.lastCheck = new Date().toISOString(); - } catch (error) { - await logError('checkContainerHealth', error); - } -} - -// Start health check daemon -function startHealthCheckDaemon() { - if (healthCheckInterval) { - clearInterval(healthCheckInterval); - } - - if (!notificationConfig.healthCheck?.enabled) { - log.info('health', 'Health check daemon disabled'); - return; - } - - const intervalMs = (notificationConfig.healthCheck.intervalMinutes || 5) * 60 * 1000; - log.info('health', 'Starting health check daemon', { intervalMinutes: notificationConfig.healthCheck.intervalMinutes }); - - // Initial check - checkContainerHealth(); - - // Periodic checks - healthCheckInterval = setInterval(checkContainerHealth, intervalMs); -} - -// Stop health check daemon -function stopHealthCheckDaemon() { - if (healthCheckInterval) { - clearInterval(healthCheckInterval); - healthCheckInterval = null; - log.info('health', 'Health check daemon stopped'); - } -} - -// Load notification config on startup (async — resolved before server starts listening) -const _notificationsReady = (async () => { - await loadNotificationConfig(); - // Start health check if enabled - if (notificationConfig.healthCheck?.enabled) { - startHealthCheckDaemon(); - } -})(); - -// HTTPS agent for internal Caddy CA — load cert if available, keep system CAs too -const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt'; -let httpsAgent; -try { - const caCert = fs.readFileSync(CA_CERT_PATH); - httpsAgent = new https.Agent({ ca: [...require('tls').rootCertificates, caCert] }); - log.info('server', 'HTTPS agent configured with CA certificate + system CAs', { path: CA_CERT_PATH }); -} catch { - httpsAgent = new https.Agent(); - log.error('server', 'CA cert not found — HTTPS calls to internal services may fail', { path: CA_CERT_PATH }); -} - -// ── Configure middleware stack (CORS, auth, rate limiting, etc.) ── -const middlewareResult = configureMiddleware(app, { - siteConfig, totpConfig, tailscaleConfig, - metrics, auditLogger, authManager, log, cryptoUtils, - isValidContainerId, isTailscaleIP, getTailscaleStatus, - RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache, -}); - -const { - strictLimiter, SESSION_DURATIONS, ipSessions, - getClientIP, createIPSession, setSessionCookie, - clearIPSession, clearSessionCookie, isSessionValid, -} = middlewareResult; - -// ── Populate route context and mount extracted route modules ── - -// Namespaced groups -Object.assign(ctx.docker, { - client: docker, - pull: dockerPull, - findContainer: findContainerByName, - getUsedPorts, - security: dockerSecurity, -}); -Object.assign(ctx.caddy, { - modify: modifyCaddyfile, - read: readCaddyfile, - reload: reloadCaddy, - generateConfig: generateCaddyConfig, - verifySite: verifySiteAccessible, - adminUrl: CADDY_ADMIN_URL, - filePath: CADDYFILE_PATH, -}); -Object.assign(ctx.dns, { - call: callDns, - buildUrl: buildDnsUrl, - requireToken: requireDnsToken, - ensureToken: ensureValidDnsToken, - createRecord: createDnsRecord, - getToken: () => dnsToken, - setToken: (t) => { dnsToken = t; }, - getTokenExpiry: () => dnsTokenExpiry, - setTokenExpiry: (e) => { dnsTokenExpiry = e; }, - getTokenForServer, - invalidateTokenForServer: (serverIp) => { dnsServerTokens.delete(`${serverIp}:readonly`); dnsServerTokens.delete(`${serverIp}:admin`); }, - refresh: refreshDnsToken, - credentialsFile: DNS_CREDENTIALS_FILE, -}); -Object.assign(ctx.session, { - ipSessions, - durations: SESSION_DURATIONS, - getClientIP, - create: createIPSession, - setCookie: setSessionCookie, - clear: clearIPSession, - clearCookie: clearSessionCookie, - isValid: isSessionValid, -}); -Object.assign(ctx.notification, { - getConfig: () => notificationConfig, - saveConfig: saveNotificationConfig, - send: sendNotification, - sendDiscord: sendDiscordNotification, - sendTelegram: sendTelegramNotification, - sendNtfy: sendNtfyNotification, - getHistory: () => notificationHistory, - clearHistory: () => { notificationHistory = []; }, - startHealthDaemon: startHealthCheckDaemon, - stopHealthDaemon: stopHealthCheckDaemon, - checkHealth: checkContainerHealth, - getHealthState: () => containerHealthState, -}); -Object.assign(ctx.tailscale, { - config: tailscaleConfig, - save: saveTailscaleConfig, - getStatus: getTailscaleStatus, - getLocalIP: getLocalTailscaleIP, - isTailscaleIP, - getAccessToken: getTailscaleAccessToken, - syncAPI: syncFromTailscaleAPI, - startSync: startTailscaleSyncTimer, - stopSync: stopTailscaleSyncTimer, -}); - -// Flat properties (shared across domains) -Object.assign(ctx, { - app, siteConfig, servicesStateManager, configStateManager, - credentialManager, authManager, licenseManager, - healthChecker, updateManager, backupManager, resourceMonitor, - auditLogger, portLockManager, selfUpdater, dockerMaintenance, logDigest, - APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES, - asyncHandler, errorResponse, ok, fetchT, log, logError, safeErrorMessage, - buildDomain, buildServiceUrl, getServiceById, readConfig, saveConfig, addServiceToConfig, - validateURL, strictLimiter, - totpConfig, saveTotpConfig, - loadSiteConfig, loadNotificationConfig, - loadDnsCredentials: () => {}, - SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE, - NOTIFICATIONS_FILE, ERROR_LOG_FILE, - resyncHealthChecker: () => syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }), -}); - -// Build versioned API router — all route modules attach here -const apiRouter = express.Router(); -apiRouter.use(authRoutes(ctx)); -apiRouter.use(configRoutes(ctx)); -apiRouter.use('/dns', dnsRoutes(ctx)); -apiRouter.use('/notifications', notificationRoutes(ctx)); -apiRouter.use('/containers', containerRoutes(ctx)); -apiRouter.use(serviceRoutes(ctx)); -apiRouter.use(healthRoutes(ctx)); -apiRouter.use(monitoringRoutes(ctx)); -apiRouter.use(updatesRoutes(ctx)); -apiRouter.use('/tailscale', tailscaleRoutes(ctx)); -apiRouter.use(sitesRoutes(ctx)); -apiRouter.use(credentialsRoutes(ctx)); -apiRouter.use(arrRoutes(ctx)); -apiRouter.use(appsRoutes(ctx)); -apiRouter.use(logsRoutes(ctx)); -apiRouter.use(backupsRoutes(ctx)); -apiRouter.use('/ca', caRoutes(ctx)); -apiRouter.use(browseRoutes(ctx)); -apiRouter.use(errorLogsRoutes(ctx)); -apiRouter.use('/license', licenseRoutes(ctx)); -apiRouter.use('/recipes', recipesRoutes(ctx)); -apiRouter.use(themesRoutes(ctx)); - -// Inline routes on the API router -apiRouter.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); -apiRouter.get('/csrf-token', (req, res) => { - res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME }); -}); -apiRouter.get('/metrics', (req, res) => { - res.json({ success: true, metrics: metrics.getSummary() }); -}); - -// Mount at /api/v1 (canonical) and /api (legacy compat for tests & external consumers) -app.use('/api/v1', apiRouter); -app.use('/api', apiRouter); - -// Root-level health check (no /api prefix) -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// Lightweight probe endpoint - performs real health checks for frontend status dots -app.get('/probe/:id', asyncHandler(async (req, res) => { - const id = req.params.id; - - try { - // Look up service in services.json - let service = null; - if (id !== 'internet' && await exists(SERVICES_FILE)) { - const data = await servicesStateManager.read(); - const services = Array.isArray(data) ? data : data.services || []; - service = services.find(s => s.id === id); - } - - const url = resolveServiceUrl(id, service, siteConfig, buildServiceUrl); - - const parsed = new URL(url); - const isHttps = parsed.protocol === 'https:'; - const lib = isHttps ? https : http; - - const options = { - hostname: parsed.hostname, - port: parsed.port || (isHttps ? 443 : 80), - path: parsed.pathname + parsed.search, - method: 'HEAD', - timeout: 5000, - agent: isHttps ? httpsAgent : undefined, - headers: { 'User-Agent': APP.USER_AGENTS.PROBE }, - }; - - const makeRequest = (method) => new Promise((resolve, reject) => { - const reqOpts = { ...options, method }; - const probeReq = lib.request(reqOpts, (response) => { - response.resume(); - resolve(response.statusCode); - }); - probeReq.on('error', reject); - probeReq.on('timeout', () => { probeReq.destroy(); reject(new Error('Timeout')); }); - probeReq.end(); - }); - - let statusCode; - try { - statusCode = await makeRequest('HEAD'); - // Fall back to GET if HEAD is not supported - if (statusCode === 501 || statusCode === 405) { - statusCode = await makeRequest('GET'); - } - } catch { - // Direct URL failed (e.g. HTTP/1.0 parse errors) — try via Caddy domain - const fallbackUrl = `https://${buildDomain(id)}`; - const fp = new URL(fallbackUrl); - const fLib = require('https'); - statusCode = await new Promise((resolve, reject) => { - const fReq = fLib.request({ - hostname: fp.hostname, port: 443, path: '/', method: 'GET', - timeout: 5000, agent: httpsAgent, - headers: { 'User-Agent': APP.USER_AGENTS.PROBE }, - }, (fRes) => { fRes.resume(); resolve(fRes.statusCode); }); - fReq.on('error', reject); - fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); }); - fReq.end(); - }); - } - - res.status(statusCode).send(); - } catch { - res.status(502).send(); - } -}, 'probe')); - -// Get network IPs (LAN, Tailscale) for quick selection -app.get('/api/network/ips', (req, res) => { - try { - // Prefer environment variables (set in docker-compose.yml) - const envLan = process.env.HOST_LAN_IP; - const envTailscale = process.env.HOST_TAILSCALE_IP; - - const result = { - localhost: '127.0.0.1', - lan: envLan || null, - tailscale: envTailscale || null, - all: [], - }; - - // If env vars not set, try to detect from network interfaces - if (!envLan || !envTailscale) { - const interfaces = os.networkInterfaces(); - - for (const [name, addrs] of Object.entries(interfaces)) { - for (const addr of addrs) { - // Skip internal and IPv6 - if (addr.internal || addr.family !== 'IPv4') continue; - - const ip = addr.address; - result.all.push({ name, ip }); - - // Detect Tailscale (100.x.x.x range) - if (!result.tailscale && ip.startsWith('100.')) { - result.tailscale = ip; - } - // Detect common LAN ranges - else if (!result.lan && (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./))) { - result.lan = ip; - } - } - } - } - - // Return null if not detected — let the frontend handle it - if (!result.lan) result.lan = null; - if (!result.tailscale) result.tailscale = null; - - res.json(result); - } catch (error) { - errorResponse(res, 500, safeErrorMessage(error)); - } -}); - - -// (TOTP/auth inline routes moved to routes/auth.js) - -// (SSO auth gate + getAppSession moved to routes/auth.js) - -// (Tailscale routes moved to routes/tailscale.js) - -// (Caddy/site routes moved to routes/sites.js) - -// (Assets, config, backup routes moved to routes/config.js) - - -// (Credential management routes moved to routes/credentials.js) - -// ===== DNS TOKEN AUTO-REFRESH FUNCTIONS ===== - -async function refreshDnsToken(username, password, server) { - try { - // Use /api/user/login to get a session token - const params = new URLSearchParams({ - user: username, - pass: password, - includeInfo: 'false', - }); - - const response = await fetchT( - `http://${server}:5380/api/user/login?${params.toString()}`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - timeout: 10000, - }, - ); - - const result = await response.json(); - - if (result.status === 'ok' && result.token) { - dnsToken = result.token; - // Technitium session tokens expire after inactivity, refresh every 6 hours to keep alive - dnsTokenExpiry = new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString(); - log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry }); - return { success: true, token: dnsToken }; - } - - return { success: false, error: result.errorMessage || 'Login failed' }; - } catch (error) { - log.error('dns', 'DNS token refresh error', { error: error.message }); - return { success: false, error: error.message }; - } -} - -async function ensureValidDnsToken() { - // Check if token is valid and not expired - if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) { - return { success: true, token: dnsToken }; - } - - // Try per-server admin credentials for the primary DNS server - const primaryIp = siteConfig.dnsServerIp; - if (primaryIp) { - const dnsId = dnsIpToDnsId(primaryIp); - if (dnsId) { - // Try admin credentials first (used for DNS record operations) - for (const role of ['admin', 'readonly']) { - try { - const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`); - const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`); - if (username && password) { - return await refreshDnsToken(username, password, primaryIp); - } - } catch (err) { - log.error('dns', `Per-server ${role} credential error for primary DNS`, { dnsId, error: err.message }); - } - } - } - } - - // Fall back to global credentials - try { - const username = await credentialManager.retrieve('dns.username'); - const password = await credentialManager.retrieve('dns.password'); - const server = await credentialManager.retrieve('dns.server'); - if (username && password) { - return await refreshDnsToken(username, password, server || primaryIp); - } - } catch (err) { - log.error('dns', 'Credential manager error', { error: err.message }); - } - - return { - success: false, - error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials', - }; -} - -// Map a DNS server IP to its dnsId (dns1, dns2, dns3) using config -function dnsIpToDnsId(serverIp) { - for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) { - if (info.ip === serverIp) return dnsId; - } - return null; -} - -// Get a valid token for a specific DNS server (authenticates directly against that server) -// role: 'readonly' for logs, 'admin' for records/restart/updates (defaults to 'readonly') -async function getTokenForServer(targetServer, role = 'readonly') { - const cacheKey = `${targetServer}:${role}`; - - // Check cached per-server token first - const cached = dnsServerTokens.get(cacheKey); - if (cached && cached.token && cached.expiry && new Date() < new Date(cached.expiry)) { - return { success: true, token: cached.token }; - } - - const serverPort = siteConfig.dnsServerPort || '5380'; - - // Helper to authenticate against a DNS server via login - async function authenticateToServer(username, password) { - const params = new URLSearchParams({ - user: username, - pass: password, - includeInfo: 'false', - }); - - const response = await fetchT( - `http://${targetServer}:${serverPort}/api/user/login?${params.toString()}`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }, - ); - - const result = await response.json(); - - if (result.status === 'ok' && result.token) { - dnsServerTokens.set(cacheKey, { - token: result.token, - expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString(), - }); - log.info('dns', 'DNS token obtained for server', { server: targetServer, role }); - return { success: true, token: result.token }; - } - - return { success: false, error: result.errorMessage || 'Login failed' }; - } - - const dnsId = dnsIpToDnsId(targetServer); - - // Try per-server credentials with the requested role first - if (dnsId) { - try { - const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`); - const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`); - if (username && password) { - return await authenticateToServer(username, password); - } - } catch (err) { - log.error('dns', `Per-server ${role} credential error`, { dnsId, server: targetServer, error: err.message }); - } - - // Fall back to the other role (readonly -> admin or admin -> readonly) - const fallbackRole = role === 'readonly' ? 'admin' : 'readonly'; - try { - const username = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.username`); - const password = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.password`); - if (username && password) { - return await authenticateToServer(username, password); - } - } catch (err) { - // ignore fallback errors - } - } - - // Fall back to global credentials - try { - const username = await credentialManager.retrieve('dns.username'); - const password = await credentialManager.retrieve('dns.password'); - if (username && password) { - return await authenticateToServer(username, password); - } - } catch (err) { - log.error('dns', 'Credential manager error', { server: targetServer, error: err.message }); - } - - return { success: false, error: 'No DNS credentials configured' }; -} - -// Load credentials and refresh token on startup -(async function initDnsToken() { - if (dnsToken) { - log.info('dns', 'Using DNS token from environment variable'); - return; - } - - // Get token using credential manager - const result = await ensureValidDnsToken(); - if (result.success) { - log.info('dns', 'DNS token obtained from stored credentials'); - } else if (await credentialManager.retrieve('dns.username')) { - log.warn('dns', 'Failed to get DNS token', { error: result.error }); - } else { - log.info('dns', 'No DNS credentials configured - DNS record management unavailable'); - } -})(); - -// (Arr stack routes moved to routes/arr.js) -// (App deployment routes moved to routes/apps.js) -// (Container management routes moved to routes/containers.js) -// (Docker helper functions moved to routes/apps.js) - -function generateCaddyConfig(subdomain, ip, port, options = {}) { - const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; - - // Subdirectory mode: generate handle/handle_path block (injected into main domain block) - if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { - let config = ''; - - // Native-support apps: use handle (preserve path prefix) - // Strip-mode apps: use handle_path (remove path prefix before proxying) - if (subpathSupport === 'native') { - config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; - config += `\thandle /${subdomain}/* {\n`; - } else { - config += `\thandle_path /${subdomain}/* {\n`; - } - - if (tailscaleOnly) { - config += '\t\t@blocked not remote_ip 100.64.0.0/10'; - if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; - config += '\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n'; - } - - config += `\t\treverse_proxy ${ip}:${port}\n`; - config += '\t}'; - return config; - } - - // Subdomain mode (default): standalone domain block - let config = `${buildDomain(subdomain)} {\n`; - - if (tailscaleOnly) { - config += ' @blocked not remote_ip 100.64.0.0/10'; - if (allowedIPs.length > 0) { - config += ` ${allowedIPs.join(' ')}`; - } - config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n'; - } - - config += ` reverse_proxy ${ip}:${port}\n`; - config += ' tls internal\n'; - config += '}'; - - return config; -} - -// (generateStaticSiteConfig, addCaddyConfig, pullImage moved to routes/apps.js) - -async function reloadCaddy(content) { - const maxRetries = RETRIES.CADDY_RELOAD; - let lastError = null; - - for (let i = 0; i < maxRetries; i++) { - try { - const response = await fetchT(`${CADDY_ADMIN_URL}/load`, { - method: 'POST', - headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: content, - }); - - if (response.ok) { - log.info('caddy', 'Caddy configuration reloaded successfully'); - // Wait a moment for Caddy to fully apply the config - await new Promise(resolve => setTimeout(resolve, 1000)); - return; - } - - lastError = await response.text(); - log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError }); - } catch (error) { - lastError = error.message; - log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError }); - } - - if (i < maxRetries - 1) { - await new Promise(resolve => setTimeout(resolve, 2000)); - } - } - - throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); -} - -async function verifySiteAccessible(domain, maxAttempts = 5) { - const delay = 2000; - - for (let i = 0; i < maxAttempts; i++) { - try { - // Try HTTPS first (internal CA) - const response = await fetchT(`https://${domain}/`, { - method: 'HEAD', - agent: httpsAgent, // Ignore cert errors for internal CA - timeout: 5000, - }); - - // Any response (even 4xx) means Caddy is serving the site - log.info('caddy', 'Site is accessible', { domain, status: response.status }); - return true; - } catch (error) { - log.debug('caddy', 'Site verification attempt', { domain, attempt: i + 1, maxAttempts, error: error.message }); - } - - if (i < maxAttempts - 1) { - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - - log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain }); - return false; -} - -async function createDnsRecord(subdomain, ip) { - // Ensure we have a valid token (auto-refresh if needed) - const tokenResult = await ensureValidDnsToken(); - if (!tokenResult.success) { - throw new Error(`DNS token not available: ${tokenResult.error}. Configure credentials via POST /api/dns/credentials`); - } - - const domain = buildDomain(subdomain); - const zone = siteConfig.tld.replace(/^\./, ''); - - const dnsParams = { token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: '300', overwrite: 'true' }; - const callDnsApi = () => callDns(siteConfig.dnsServerIp, '/api/zones/records/add', dnsParams); - - try { - log.info('dns', 'Creating DNS record', { domain, ip }); - const result = await callDnsApi(); - - if (result.status === 'ok') { - log.info('dns', 'DNS record created', { domain, ip }); - return { success: true }; - } - - // Check for token expired error - try to refresh once - if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) { - log.info('dns', 'Token appears expired, attempting auto-refresh'); - const refreshResult = await ensureValidDnsToken(); - if (!refreshResult.success) throw new Error(`Token refresh failed: ${refreshResult.error}`); - - const retryResult = await callDnsApi(); - if (retryResult.status === 'ok') { - log.info('dns', 'DNS record created after token refresh', { domain, ip }); - return { success: true }; - } - throw new Error(retryResult.errorMessage || 'Unknown error after token refresh'); - } - - throw new Error(result.errorMessage || 'Unknown error'); - } catch (error) { - throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`); - } -} - -async function addServiceToConfig(service) { - try { - await servicesStateManager.update(services => { - // Check if service already exists - const existingIndex = services.findIndex(s => s.id === service.id); - if (existingIndex >= 0) { - // Update existing service - services[existingIndex] = { ...services[existingIndex], ...service }; - } else { - // Add new service - services.push(service); - } - return services; - }); - log.info('deploy', 'Service added to config', { serviceId: service.id }); - // Sync health checker with updated services list - ctx.resyncHealthChecker?.().catch(() => {}); - } catch (error) { - log.error('deploy', 'Failed to add service to config', { serviceId: service.id, error: error.message }); - throw error; - } -} - -// (Notification routes moved to routes/notifications.js) -// (Stats routes moved to routes/monitoring.js) -// (Container logs routes moved to routes/logs.js) -// (Health service routes moved to routes/health.js) -// (Resource monitoring routes moved to routes/monitoring.js) -// (Backup routes moved to routes/backups.js) -// (CA routes moved to routes/ca.js) -// (Error log, audit log, browse/media routes moved to route modules) - -// API Documentation endpoint -app.get('/api/docs', (req, res) => { - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://unpkg.com;"); - res.send(` - - - - DashCaddy API Documentation - - - - -
- - - -`); -}); - -app.get('/api/docs/spec', asyncHandler(async (req, res) => { - const specPath = path.join(__dirname, 'openapi.yaml'); - if (await exists(specPath)) { - const yaml = await fsp.readFile(specPath, 'utf8'); - res.type('text/yaml').send(yaml); - } else { - errorResponse(res, 404, 'OpenAPI spec not found'); - } -}, 'api-docs-spec')); - -// JSON 404 catch-all for unmatched API routes -app.use('/api', (req, res) => { - res.status(404).json({ success: false, error: `Not found: ${req.method} ${req.path}` }); -}); - -// Global error handler for typed errors -app.use((err, req, res, next) => { - if (err instanceof AppError) { - return res.status(err.statusCode).json({ - success: false, - error: err.message, - code: err.code, - ...(err.details ? { details: err.details } : {}), - }); - } - if (err instanceof ValidationError) { - return res.status(err.statusCode || 400).json({ - success: false, - error: err.message, - errors: err.errors || undefined, - }); - } - // Catch-all: never leak stack traces or internal paths - const status = err.status || err.statusCode || 500; - log.error('server', 'Unhandled error', { error: err.message, path: req.path, method: req.method }); - res.status(status).json({ success: false, error: status === 413 ? 'Request payload too large' : 'An internal error occurred' }); -}); - -// Export app for testing -module.exports = app; - -if (require.main === module) { -// Validate configuration and wait for async config loads before starting server - (async () => { - await Promise.all([_configsReady, _notificationsReady]); - await licenseManager.load(); - await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }); - - const server = app.listen(PORT, '0.0.0.0', () => { - log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE }); - if (BROWSE_ROOTS.length > 0) { - log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) }); - } - - // Start new feature modules - log.info('server', 'Starting DashCaddy feature modules'); - - // Clean up stale port locks - (async () => { - try { - await portLockManager.cleanupStaleLocks(); - log.info('server', 'Port lock cleanup completed'); - } catch (error) { - log.error('server', 'Port lock cleanup failed', { error: error.message }); - } - })(); - - try { - resourceMonitor.start(); - log.info('server', 'Resource monitoring started'); - } catch (error) { - log.error('server', 'Resource monitoring failed to start', { error: error.message }); - } - - try { - backupManager.start(); - log.info('server', 'Backup manager started'); - } catch (error) { - log.error('server', 'Backup manager failed to start', { error: error.message }); - } - - (async () => { - try { - // Auto-configure health checker from services.json - await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }); - healthChecker.start(); - log.info('server', 'Health checker started'); - } catch (error) { - log.error('server', 'Health checker failed to start', { error: error.message }); - } - })(); - - try { - updateManager.start(); - log.info('server', 'Update manager started'); - } catch (error) { - log.error('server', 'Update manager failed to start', { error: error.message }); - } - - try { - selfUpdater.start(); - log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl }); - // Check for post-update result (did a previous update succeed or roll back?) - selfUpdater.checkPostUpdateResult().then(result => { - if (result) { - log.info('server', 'Post-update result', result); - if (typeof ctx.notification?.send === 'function') { - ctx.notification.send('system.update', - result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed', - result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`, - result.success ? 'info' : 'error', - ); - } - } - }).catch(() => {}); - } catch (error) { - log.error('server', 'Self-updater failed to start', { error: error.message }); - } - - if (dockerMaintenance) { - try { - dockerMaintenance.start(); - log.info('server', 'Docker maintenance started'); - dockerMaintenance.on('maintenance-complete', (result) => { - const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024); - if (saved > 0 || result.warnings.length > 0) { - log.info('maintenance', 'Docker maintenance completed', { - spaceReclaimedMB: saved, - pruned: result.pruned, - warnings: result.warnings.length, - }); - } - if (result.warnings.length > 0) { - for (const w of result.warnings) log.warn('maintenance', w); - } - }); - } catch (error) { - log.error('server', 'Docker maintenance failed to start', { error: error.message }); - } - } - - if (logDigest) { - try { - logDigest.start(platformPaths.digestDir); - log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); - logDigest.on('digest-generated', ({ date }) => { - log.info('digest', `Daily digest generated for ${date}`); - if (typeof ctx.notification?.send === 'function') { - ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info'); - } - }); - } catch (error) { - log.error('server', 'Log digest failed to start', { error: error.message }); - } - } - - // Tailscale API sync (if OAuth configured) - if (tailscaleConfig.oauthConfigured) { - startTailscaleSyncTimer(); - // Run initial sync - syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message })); - } - - log.info('server', 'All feature modules initialized'); - }); - - // Graceful shutdown — drain connections before exiting - function shutdown(signal) { - log.info('shutdown', `${signal} received, draining connections...`); - resourceMonitor.stop(); - backupManager.stop(); - if (dockerMaintenance) dockerMaintenance.stop(); - if (logDigest) logDigest.stop(); - healthChecker.stop(); - updateManager.stop(); - selfUpdater.stop(); - stopTailscaleSyncTimer(); - server.close(() => { - log.info('shutdown', 'HTTP server closed'); - process.exit(0); - }); - // Force exit after 5s if connections don't drain - setTimeout(() => process.exit(0), 5000).unref(); - } - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - })(); // end async startup -} // end if (require.main === module) - -// #2: Catch unhandled errors so the process doesn't crash silently -process.on('unhandledRejection', (reason) => { - logError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))); -}); -process.on('uncaughtException', (error) => { - logError('uncaughtException', error); - // Give the error log time to flush, then exit - setTimeout(() => process.exit(1), 1000).unref(); -}); diff --git a/dashcaddy-api/src/config/env.js b/dashcaddy-api/src/config/env.js deleted file mode 100644 index 4c4eca9..0000000 --- a/dashcaddy-api/src/config/env.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Environment variable loading and validation - * Central place for all process.env reads - */ - -const path = require('path'); -const platformPaths = require('../../platform-paths'); -const { APP, LIMITS, CADDY } = require('../../constants'); - -// Resolve services directory from SERVICES_FILE env var -const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile; -const SERVICES_DIR = path.dirname(SERVICES_FILE); - -/** - * Application configuration loaded from environment variables - */ -const config = { - // Server - port: APP.PORT, - - // Caddy paths - caddyfilePath: process.env.CADDYFILE_PATH || platformPaths.caddyfile, - caddyAdminUrl: process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl, - - // State files - servicesFile: SERVICES_FILE, - configFile: process.env.CONFIG_FILE || path.join(SERVICES_DIR, 'config.json'), - dnsCredentialsFile: process.env.DNS_CREDENTIALS_FILE || path.join(SERVICES_DIR, 'dns-credentials.json'), - tailscaleConfigFile: process.env.TAILSCALE_CONFIG_FILE || path.join(SERVICES_DIR, 'tailscale-config.json'), - notificationsFile: process.env.NOTIFICATIONS_FILE || path.join(SERVICES_DIR, 'notifications.json'), - totpConfigFile: process.env.TOTP_CONFIG_FILE || path.join(SERVICES_DIR, 'totp-config.json'), - errorLogFile: process.env.ERROR_LOG_FILE || path.join(__dirname, '../../dashcaddy-errors.log'), - licenseSecretFile: process.env.LICENSE_SECRET_FILE || path.join(__dirname, '../../.license-secret'), - - // Limits - maxErrorLogSize: LIMITS.ERROR_LOG_SIZE, - - // Media browse roots (optional feature) - browseRoots: (process.env.MEDIA_BROWSE_ROOTS || '') - .split(',') - .filter((r) => r.includes('=')) - .map((r) => { - const eqIndex = r.indexOf('='); - const containerPath = r.slice(0, eqIndex).trim(); - const hostPath = r.slice(eqIndex + 1).trim(); - return { containerPath, hostPath }; - }), -}; - -module.exports = config; diff --git a/dashcaddy-api/src/config/index.js b/dashcaddy-api/src/config/index.js deleted file mode 100644 index d2a3a32..0000000 --- a/dashcaddy-api/src/config/index.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Configuration module - * Central exports for all configuration loading - */ - -const envConfig = require('./env'); -const { - siteConfig, - loadSiteConfig, - buildDomain, - buildServiceUrl, -} = require('./site-config'); - -module.exports = { - // Environment config - ...envConfig, - - // Site config - siteConfig, - loadSiteConfig, - buildDomain, - buildServiceUrl, -}; diff --git a/dashcaddy-api/src/config/site-config.js b/dashcaddy-api/src/config/site-config.js deleted file mode 100644 index 001e9db..0000000 --- a/dashcaddy-api/src/config/site-config.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Site configuration management - * Loads and validates site-wide settings from config.json - */ - -const fs = require('fs'); -const { validateConfig } = require('../../config-schema'); -const { CADDY } = require('../../constants'); - -/** - * Site configuration state - * Modified by loadSiteConfig() - */ -const siteConfig = { - tld: '.home', - caName: '', - dnsServerIp: '', - dnsServerPort: CADDY.DEFAULT_DNS_PORT, - dashboardHost: '', - timezone: 'UTC', - dnsServers: {}, - configurationType: 'homelab', - domain: '', - routingMode: 'subdomain', -}; - -/** - * Load site configuration from config.json - * @param {string} configFilePath - Path to config.json - * @param {object} log - Logger instance (optional, may not be available at startup) - */ -function loadSiteConfig(configFilePath, log) { - try { - if (fs.existsSync(configFilePath)) { - const raw = JSON.parse(fs.readFileSync(configFilePath, 'utf8')); - - // Validate config and log any issues - const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw); - if (log && log.warn) { - if (!valid) { - log.warn('config', 'Config validation errors', { errors: configErrors }); - } - for (const w of configWarnings) { - log.warn('config', w); - } - } - - // Apply config values - siteConfig.tld = raw.tld || '.home'; - if (!siteConfig.tld.startsWith('.')) { - siteConfig.tld = `.${siteConfig.tld}`; - } - siteConfig.caName = raw.caName || ''; - siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || ''; - siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT; - siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`; - siteConfig.timezone = raw.timezone || 'UTC'; - siteConfig.dnsServers = raw.dnsServers || {}; - siteConfig.configurationType = raw.configurationType || 'homelab'; - siteConfig.domain = raw.domain || ''; - siteConfig.routingMode = raw.routingMode || 'subdomain'; - } - } catch (e) { - if (log && log.error) { - log.error('config', 'Failed to load site config', { error: e.message }); - } else { - console.error('[ERROR] Failed to load site config:', e.message); - } - } -} - -/** - * Build a domain from subdomain + configured TLD or public domain - * @param {string} subdomain - Service subdomain (e.g., 'sonarr') - * @returns {string} Full domain (e.g., 'sonarr.home' or 'sonarr.example.com') - */ -function buildDomain(subdomain) { - if (siteConfig.configurationType === 'public' && siteConfig.domain) { - return `${subdomain}.${siteConfig.domain}`; - } - return `${subdomain}${siteConfig.tld}`; -} - -/** - * Build full service URL (protocol + host + path) for a given subdomain - * Subdirectory mode: https://example.com/sonarr - * Subdomain mode: https://sonarr.example.com - * @param {string} subdomain - Service subdomain - * @returns {string} Full service URL - */ -function buildServiceUrl(subdomain) { - if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { - return `https://${siteConfig.domain}/${subdomain}`; - } - return `https://${buildDomain(subdomain)}`; -} - -module.exports = { - siteConfig, - loadSiteConfig, - buildDomain, - buildServiceUrl, -}; diff --git a/dashcaddy-api/src/context/caddy.js b/dashcaddy-api/src/context/caddy.js deleted file mode 100644 index 50e98c6..0000000 --- a/dashcaddy-api/src/context/caddy.js +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Caddy context - * Caddyfile manipulation, reload, and configuration generation - */ - -const fsp = require('fs').promises; -const { RETRIES, CADDY } = require('../../constants'); -const { safeErrorMessage } = require('../utils/safe-error'); - -// Mutex for atomic Caddyfile modifications -let _caddyfileLock = Promise.resolve(); - -/** - * Create Caddy context - * @param {object} deps - Dependencies { caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent } - */ -function createCaddyContext({ caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }) { - /** - * Read the current Caddyfile content - * @returns {Promise} Caddyfile content - */ - async function readCaddyfile() { - return fsp.readFile(caddyfilePath, 'utf8'); - } - - /** - * Reload Caddy configuration via admin API - * @param {string} content - New Caddyfile content - */ - async function reloadCaddy(content) { - const maxRetries = RETRIES.CADDY_RELOAD; - let lastError = null; - - for (let i = 0; i < maxRetries; i++) { - try { - const response = await fetchT(`${caddyAdminUrl}/load`, { - method: 'POST', - headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: content, - }); - - if (response.ok) { - log.info('caddy', 'Caddy configuration reloaded successfully'); - // Wait a moment for Caddy to fully apply the config - await new Promise((resolve) => setTimeout(resolve, 1000)); - return; - } - - lastError = await response.text(); - log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError }); - } catch (error) { - lastError = error.message; - log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError }); - } - - if (i < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - } - - throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); - } - - /** - * Atomically read-modify-write the Caddyfile and reload Caddy - * Uses a mutex to prevent concurrent modifications - * Rolls back on reload failure - * @param {function} modifyFn - Receives current content, returns modified content (or null to skip) - * @returns {Promise<{success: boolean, error?: string, rolledBack?: boolean}>} - */ - async function modifyCaddyfile(modifyFn) { - let resolve; - const prev = _caddyfileLock; - _caddyfileLock = new Promise((r) => { - resolve = r; - }); - - await prev; // wait for any in-flight modification to finish - - try { - const original = await readCaddyfile(); - const modified = await modifyFn(original); - - if (modified === null || modified === original) { - return { success: false, error: 'No changes to apply' }; - } - - await fsp.writeFile(caddyfilePath, modified, 'utf8'); - - try { - await reloadCaddy(modified); - return { success: true }; - } catch (err) { - // Rollback - await fsp.writeFile(caddyfilePath, original, 'utf8'); - return { success: false, error: safeErrorMessage(err), rolledBack: true }; - } - } finally { - resolve(); - } - } - - /** - * Generate Caddy configuration block for a service - * @param {string} subdomain - Service subdomain - * @param {string} ip - Target IP address - * @param {number} port - Target port - * @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport } - * @returns {string} Caddy configuration block - */ - function generateConfig(subdomain, ip, port, options = {}) { - const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; - - // Subdirectory mode: generate handle/handle_path block - if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { - let config = ''; - - if (subpathSupport === 'native') { - config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; - config += `\thandle /${subdomain}/* {\n`; - } else { - config += `\thandle_path /${subdomain}/* {\n`; - } - - if (tailscaleOnly) { - config += '\t\t@blocked not remote_ip 100.64.0.0/10'; - if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; - config += '\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n'; - } - - config += `\t\treverse_proxy ${ip}:${port}\n`; - config += '\t}'; - return config; - } - - // Subdomain mode (default): standalone domain block - let config = `${buildDomain(subdomain)} {\n`; - - if (tailscaleOnly) { - config += ' @blocked not remote_ip 100.64.0.0/10'; - if (allowedIPs.length > 0) { - config += ` ${allowedIPs.join(' ')}`; - } - config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n'; - } - - config += ` reverse_proxy ${ip}:${port}\n`; - config += ' tls internal\n'; - config += '}'; - - return config; - } - - /** - * Verify a site is accessible via HTTPS - * @param {string} domain - Domain to check - * @param {number} maxAttempts - Maximum retry attempts - * @returns {Promise} True if accessible - */ - async function verifySite(domain, maxAttempts = 5) { - const delay = 2000; - - for (let i = 0; i < maxAttempts; i++) { - try { - const response = await fetchT(`https://${domain}/`, { - method: 'HEAD', - agent: httpsAgent, - timeout: 5000, - }); - - log.info('caddy', 'Site is accessible', { domain, status: response.status }); - return true; - } catch (error) { - log.debug('caddy', 'Site verification attempt', { - domain, - attempt: i + 1, - maxAttempts, - error: error.message, - }); - } - - if (i < maxAttempts - 1) { - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - - log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain }); - return false; - } - - return { - modify: modifyCaddyfile, - read: readCaddyfile, - reload: reloadCaddy, - generateConfig, - verifySite, - adminUrl: caddyAdminUrl, - filePath: caddyfilePath, - }; -} - -module.exports = createCaddyContext; diff --git a/dashcaddy-api/src/context/dns.js b/dashcaddy-api/src/context/dns.js deleted file mode 100644 index 5ea1db8..0000000 --- a/dashcaddy-api/src/context/dns.js +++ /dev/null @@ -1,211 +0,0 @@ -/** - * DNS context - * Technitium DNS API wrapper with token management - */ - -const { CADDY } = require('../../constants'); - -/** - * Create DNS context - * @param {object} deps - Dependencies { siteConfig, fetchT, log, credentialManager, buildDomain, createCache, CACHE_CONFIGS } - */ -function createDnsContext({ siteConfig, fetchT, log, credentialManager, buildDomain, createCache, CACHE_CONFIGS }) { - // DNS token state - let dnsToken = process.env.DNS_ADMIN_TOKEN || ''; - let dnsTokenExpiry = null; - - // Per-server token cache - const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens); - - /** - * Build full Technitium DNS API URL - * @param {string} server - Server IP or hostname - * @param {string} apiPath - API path (e.g., '/api/zones/records/add') - * @param {object|URLSearchParams} params - Query parameters - * @returns {string} Full API URL - */ - function buildUrl(server, apiPath, params) { - const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https'; - const port = protocol === 'http' ? `:${CADDY.DEFAULT_DNS_PORT}` : ''; - const qs = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString(); - return `${protocol}://${server}${port}${apiPath}?${qs}`; - } - - /** - * Call DNS API endpoint - * @param {string} server - Server IP or hostname - * @param {string} apiPath - API path - * @param {object} params - Query parameters - * @returns {Promise} Parsed JSON response - */ - async function call(server, apiPath, params) { - const url = buildUrl(server, apiPath, params); - const response = await fetchT(url, { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - return response.json(); - } - - /** - * Refresh DNS token via login - * @param {string} username - DNS username - * @param {string} password - DNS password - * @param {string} server - Server IP - * @returns {Promise<{success: boolean, token?: string, error?: string}>} - */ - async function refreshToken(username, password, server) { - try { - const params = new URLSearchParams({ - user: username, - pass: password, - includeInfo: 'false', - }); - - const response = await fetchT(`http://${server}:5380/api/user/login?${params.toString()}`, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - timeout: 10000, - }); - - const result = await response.json(); - - if (result.status === 'ok' && result.token) { - dnsToken = result.token; - dnsTokenExpiry = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); // 6 hours - log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry }); - return { success: true, token: dnsToken }; - } - - return { success: false, error: result.errorMessage || 'Login failed' }; - } catch (error) { - log.error('dns', 'DNS token refresh error', { error: error.message }); - return { success: false, error: error.message }; - } - } - - /** - * Map DNS server IP to dnsId (dns1, dns2, dns3) - * @param {string} serverIp - Server IP address - * @returns {string|null} DNS ID or null - */ - function dnsIpToDnsId(serverIp) { - for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) { - if (info.ip === serverIp) return dnsId; - } - return null; - } - - /** - * Ensure a valid DNS token exists (auto-refresh if needed) - * @returns {Promise<{success: boolean, token?: string, error?: string}>} - */ - async function ensureToken() { - // Check if token is valid and not expired - if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) { - return { success: true, token: dnsToken }; - } - - // Try per-server admin credentials for the primary DNS server - const primaryIp = siteConfig.dnsServerIp; - if (primaryIp) { - const dnsId = dnsIpToDnsId(primaryIp); - if (dnsId) { - for (const role of ['admin', 'readonly']) { - try { - const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`); - const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`); - if (username && password) { - return await refreshToken(username, password, primaryIp); - } - } catch (err) { - log.error('dns', `Per-server ${role} credential error`, { dnsId, error: err.message }); - } - } - } - } - - // Fall back to global credentials - try { - const username = await credentialManager.retrieve('dns.username'); - const password = await credentialManager.retrieve('dns.password'); - const server = await credentialManager.retrieve('dns.server'); - if (username && password) { - return await refreshToken(username, password, server || primaryIp); - } - } catch (err) { - log.error('dns', 'Credential manager error', { error: err.message }); - } - - return { - success: false, - error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials', - }; - } - - /** - * Require a valid DNS token (auto-refresh if needed) - * @param {string} providedToken - Optional token provided by caller - * @returns {Promise} Valid token - * @throws {Error} If no valid token can be obtained - */ - async function requireToken(providedToken) { - if (providedToken) return providedToken; - const result = await ensureToken(); - if (result.success) return result.token; - const err = new Error(`No valid DNS token available. ${result.error}`); - err.statusCode = 401; - throw err; - } - - /** - * Create a DNS A record - * @param {string} subdomain - Service subdomain - * @param {string} ip - IP address - * @returns {Promise} - */ - async function createRecord(subdomain, ip) { - const tokenResult = await ensureToken(); - if (!tokenResult.success) { - throw new Error(`DNS token not available: ${tokenResult.error}`); - } - - const domain = buildDomain(subdomain); - const zone = siteConfig.tld.replace(/^\./, ''); - - const params = { - token: dnsToken, - domain, - zone, - type: 'A', - ipAddress: ip, - ttl: '300', - overwrite: 'true', - }; - - log.info('dns', 'Creating DNS record', { domain, ip }); - await call(siteConfig.dnsServerIp, '/api/zones/records/add', params); - } - - return { - call, - buildUrl, - requireToken, - ensureToken, - createRecord, - refresh: refreshToken, - getToken: () => dnsToken, - setToken: (t) => { - dnsToken = t; - }, - getTokenExpiry: () => dnsTokenExpiry, - setTokenExpiry: (e) => { - dnsTokenExpiry = e; - }, - }; -} - -module.exports = createDnsContext; diff --git a/dashcaddy-api/src/context/docker.js b/dashcaddy-api/src/context/docker.js deleted file mode 100644 index ebbf39d..0000000 --- a/dashcaddy-api/src/context/docker.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Docker context - * Docker API wrapper and container utilities - */ - -const Docker = require('dockerode'); -const { DOCKER } = require('../../constants'); - -// Docker client instance -const docker = new Docker(); - -/** - * Pull a Docker image with timeout protection - * @param {string} imageName - Image name (e.g., 'nginx:latest') - * @param {number} timeoutMs - Timeout in milliseconds - * @returns {Promise} Pull progress output - */ -function dockerPull(imageName, timeoutMs = DOCKER.TIMEOUT) { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`Docker pull timed out after ${timeoutMs / 1000}s: ${imageName}`)), - timeoutMs, - ); - - docker.pull(imageName, (err, stream) => { - if (err) { - clearTimeout(timer); - return reject(err); - } - - docker.modem.followProgress(stream, (err, output) => { - clearTimeout(timer); - if (err) return reject(err); - resolve(output); - }); - }); - }); -} - -/** - * Find a running Docker container by name substring - * @param {string} name - Container name or substring - * @param {object} opts - Options (e.g., { all: true } to include stopped containers) - * @returns {Promise} Container object or null if not found - */ -async function findContainerByName(name, opts = { all: false }) { - const containers = await docker.listContainers(opts); - const match = containers.find((c) => - c.Names.some((n) => n.toLowerCase().includes(name.toLowerCase())), - ); - return match || null; -} - -/** - * Get all host ports currently in use by Docker containers - * @returns {Promise>} Set of port numbers - */ -async function getUsedPorts() { - const containers = await docker.listContainers({ all: false }); - const ports = new Set(); - - for (const c of containers) { - for (const p of (c.Ports || [])) { - if (p.PublicPort) { - ports.add(p.PublicPort); - } - } - } - - return ports; -} - -module.exports = { - client: docker, - pull: dockerPull, - findContainer: findContainerByName, - getUsedPorts, -}; diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js deleted file mode 100644 index 5ad4c26..0000000 --- a/dashcaddy-api/src/context/index.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Context modules - * Domain-specific context factories - */ - -const docker = require('./docker'); -const createCaddyContext = require('./caddy'); -const createDnsContext = require('./dns'); - -module.exports = { - docker, - createCaddyContext, - createDnsContext, -}; diff --git a/dashcaddy-api/src/utils/async-handler.js b/dashcaddy-api/src/utils/async-handler.js deleted file mode 100644 index dcd2640..0000000 --- a/dashcaddy-api/src/utils/async-handler.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Async route handler wrapper - * Catches async errors and passes them to Express error middleware - */ - -const { AppError } = require('../../errors'); -const { safeErrorMessage } = require('./safe-error'); - -/** - * Wrap async Express route handlers to catch errors - * @param {Function} fn - async (req, res, next) handler - * @param {string} [context] - label for logError (defaults to req.path) - * @returns {Function} Express middleware - */ -function asyncHandler(fn, context) { - return async (req, res, next) => { - try { - await fn(req, res, next); - } catch (error) { - // Let typed errors (AppError subclasses) propagate to the global error handler - if (error instanceof AppError) { - return next(error); - } - - // Log error (requires logger to be injected) - if (req.app.locals.logError) { - await req.app.locals.logError(context || req.path, error); - } - - // Send error response if headers haven't been sent - if (!res.headersSent && req.app.locals.errorResponse) { - req.app.locals.errorResponse(res, 500, safeErrorMessage(error)); - } else if (!res.headersSent) { - // Fallback if errorResponse not available - res.status(500).json({ success: false, error: safeErrorMessage(error) }); - } - } - }; -} - -module.exports = asyncHandler; diff --git a/dashcaddy-api/src/utils/index.js b/dashcaddy-api/src/utils/index.js deleted file mode 100644 index 86ef7a5..0000000 --- a/dashcaddy-api/src/utils/index.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Utility functions - * Common helpers used across the API - */ - -const asyncHandler = require('./async-handler'); -const { errorResponse, ok } = require('./responses'); -const { safeErrorMessage } = require('./safe-error'); -const log = require('./logger'); - -module.exports = { - asyncHandler, - errorResponse, - ok, - safeErrorMessage, - log, -}; diff --git a/dashcaddy-api/src/utils/logger.js b/dashcaddy-api/src/utils/logger.js deleted file mode 100644 index bcfcb0d..0000000 --- a/dashcaddy-api/src/utils/logger.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Structured logging - * JSON-formatted logs with levels (debug, info, warn, error) - */ - -const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; -const LOG_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL || 'info'] || 1; - -/** - * Core log function - * @param {string} level - Log level (debug, info, warn, error) - * @param {string} context - Context label (e.g., 'server', 'docker', 'caddy') - * @param {string} message - Log message - * @param {object} data - Additional structured data - */ -function log(level, context, message, data = {}) { - if (LOG_LEVELS[level] < LOG_LEVEL) return; - - const entry = { - t: new Date().toISOString(), - level, - ctx: context, - msg: message, - }; - - if (Object.keys(data).length) { - entry.data = data; - } - - const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; - fn(JSON.stringify(entry)); -} - -// Convenience methods -log.info = (ctx, msg, data) => log('info', ctx, msg, data); -log.warn = (ctx, msg, data) => log('warn', ctx, msg, data); -log.error = (ctx, msg, data) => log('error', ctx, msg, data); -log.debug = (ctx, msg, data) => log('debug', ctx, msg, data); - -module.exports = log; diff --git a/dashcaddy-api/src/utils/responses.js b/dashcaddy-api/src/utils/responses.js deleted file mode 100644 index 768fb89..0000000 --- a/dashcaddy-api/src/utils/responses.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Standard HTTP response helpers - */ - -/** - * Standard error response — always returns { success: false, error, ...extras } - * @param {object} res - Express response object - * @param {number} statusCode - HTTP status code - * @param {string} message - Error message - * @param {object} extras - Additional fields to include - * @returns {object} Express response - */ -function errorResponse(res, statusCode, message, extras = {}) { - return res.status(statusCode).json({ success: false, error: message, ...extras }); -} - -/** - * Standard success response — always returns { success: true, ...data } - * @param {object} res - Express response object - * @param {object} data - Data to include in response - * @returns {object} Express response - */ -function ok(res, data = {}) { - return res.json({ success: true, ...data }); -} - -module.exports = { - errorResponse, - ok, -}; diff --git a/dashcaddy-api/src/utils/safe-error.js b/dashcaddy-api/src/utils/safe-error.js deleted file mode 100644 index 85001fb..0000000 --- a/dashcaddy-api/src/utils/safe-error.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Safe error message sanitization - * Prevents leaking internal paths, stack traces, etc. to clients - */ - -/** - * Return a safe error message to the client without leaking internals - * @param {Error|string} error - Error object or string - * @returns {string} Sanitized error message safe for client consumption - */ -function safeErrorMessage(error) { - const msg = error.message || String(error); - - // Detect port conflict errors from Docker - const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/); - if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) { - const port = portMatch ? portMatch[1] : 'requested'; - return `[DC-200] Port ${port} is already in use. Try a different port in Advanced Options, or stop the service using that port first.`; - } - - // Only expose messages that are clearly user-facing (short, no paths/stack frames) - if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) { - return msg; - } - - return 'An internal error occurred'; -} - -module.exports = { - safeErrorMessage, -}; diff --git a/dashcaddy-api/startup-validator.js b/dashcaddy-api/startup-validator.js index a2e72dd..11c3e57 100644 --- a/dashcaddy-api/startup-validator.js +++ b/dashcaddy-api/startup-validator.js @@ -108,7 +108,7 @@ async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFI port: urlObj.port, path: '/config/', method: 'GET', - timeout: 2000, + timeout: 2000 }, (res) => { resolve(res.statusCode >= 200 && res.statusCode < 500); }); diff --git a/dashcaddy-api/state-manager.js b/dashcaddy-api/state-manager.js index 28dc8e6..ed963ca 100644 --- a/dashcaddy-api/state-manager.js +++ b/dashcaddy-api/state-manager.js @@ -27,9 +27,9 @@ class StateManager { retries: { retries: options.lockRetries || 10, minTimeout: options.lockRetryInterval || 100, - maxTimeout: (options.lockRetryInterval || 100) * 3, + maxTimeout: (options.lockRetryInterval || 100) * 3 }, - stale: options.lockTimeout || 30000, // 30 seconds + stale: options.lockTimeout || 30000 // 30 seconds }; // Ensure file exists diff --git a/dashcaddy-api/test-security-fixes.js b/dashcaddy-api/test-security-fixes.js index ca5cb91..3186b5d 100644 --- a/dashcaddy-api/test-security-fixes.js +++ b/dashcaddy-api/test-security-fixes.js @@ -26,7 +26,7 @@ const colors = { red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m', - cyan: '\x1b[36m', + cyan: '\x1b[36m' }; function log(message, color = 'reset') { @@ -56,7 +56,7 @@ async function makeRequest(path, options = {}) { path: url.pathname + url.search, method: options.method || 'GET', headers: options.headers || {}, - ...options, + ...options }; const req = client.request(requestOptions, (res) => { @@ -67,7 +67,7 @@ async function makeRequest(path, options = {}) { statusCode: res.statusCode, headers: res.headers, body: data, - data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null, + data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null }); }); }); @@ -90,7 +90,7 @@ async function testPathTraversal() { { path: '/api/browse/directories?path=../../../../../../etc/passwd', desc: 'Unix path traversal' }, { path: '/api/browse/directories?path=..\\..\\..\\Windows\\System32', desc: 'Windows path traversal' }, { path: '/api/browse/directories?path=%2e%2e%2f%2e%2e%2fetc%2fpasswd', desc: 'URL-encoded traversal' }, - { path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' }, + { path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' } ]; for (const attack of attacks) { @@ -117,7 +117,7 @@ async function testRequestSizeLimits() { const response = await makeRequest('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(smallPayload), + body: JSON.stringify(smallPayload) }); logResult(true, 'Small payload accepted (100 bytes)'); } catch (error) { @@ -130,7 +130,7 @@ async function testRequestSizeLimits() { const response = await makeRequest('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(largePayload), + body: JSON.stringify(largePayload) }); if (response.statusCode === 413 || response.statusCode === 400) { logResult(true, 'Large payload rejected on general endpoint (2MB)'); @@ -151,7 +151,7 @@ async function testRequestSizeLimits() { const response = await makeRequest('/api/logo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ logo: largeImage }), + body: JSON.stringify({ logo: largeImage }) }); if (response.statusCode !== 413) { logResult(true, 'Large payload accepted on logo endpoint (5MB)'); diff --git a/dashcaddy-api/update-manager.js b/dashcaddy-api/update-manager.js index 50f4088..d8ac7bd 100644 --- a/dashcaddy-api/update-manager.js +++ b/dashcaddy-api/update-manager.js @@ -83,7 +83,7 @@ class UpdateManager extends EventEmitter { currentDigest: currentDigest.substring(0, 12), latestDigest: latestDigest.substring(0, 12), currentTag: this.extractTag(imageName), - detectedAt: new Date().toISOString(), + detectedAt: new Date().toISOString() }); this.emit('update-available', this.availableUpdates.get(containerInfo.Id)); @@ -137,8 +137,8 @@ class UpdateManager extends EventEmitter { path: `/v2/${repo}/manifests/${tag}`, method: 'GET', headers: { - 'Accept': 'application/vnd.docker.distribution.manifest.v2+json', - }, + 'Accept': 'application/vnd.docker.distribution.manifest.v2+json' + } }; const req = https.request(options, (res) => { @@ -206,8 +206,8 @@ class UpdateManager extends EventEmitter { ...originalOptions, headers: { ...originalOptions.headers, - 'Authorization': `Bearer ${token}`, - }, + 'Authorization': `Bearer ${token}` + } }; const req = https.request(options, (res) => { @@ -271,7 +271,7 @@ class UpdateManager extends EventEmitter { config: inspect.Config, hostConfig: inspect.HostConfig, networkSettings: inspect.NetworkSettings, - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }; // Pull latest image @@ -292,7 +292,7 @@ class UpdateManager extends EventEmitter { name: containerName, Image: imageName, ...backup.config, - HostConfig: backup.hostConfig, + HostConfig: backup.hostConfig }); // Start new container @@ -300,7 +300,7 @@ class UpdateManager extends EventEmitter { await newContainer.start(); // Extended verification with health checks and port accessibility - console.log('[UpdateManager] Performing extended verification...'); + console.log(`[UpdateManager] Performing extended verification...`); await this.verifyContainerExtended(newContainer, inspect, options.verifyTimeout || 60000); // Get new image ID @@ -313,7 +313,7 @@ class UpdateManager extends EventEmitter { console.log(`[UpdateManager] Removing old image: ${oldImageId.substring(0, 12)}`); const oldImage = docker.getImage(oldImageId); await oldImage.remove({ force: false }); - console.log('[UpdateManager] Old image removed successfully'); + console.log(`[UpdateManager] Old image removed successfully`); } catch (error) { console.warn(`[UpdateManager] Could not remove old image (may be in use): ${error.message}`); } @@ -330,7 +330,7 @@ class UpdateManager extends EventEmitter { timestamp: new Date().toISOString(), duration, status: 'success', - backup, + backup }; this.addToHistory(historyEntry); @@ -348,7 +348,7 @@ class UpdateManager extends EventEmitter { timestamp: new Date().toISOString(), duration, status: 'failed', - error: error.message, + error: error.message }; this.addToHistory(historyEntry); @@ -360,7 +360,7 @@ class UpdateManager extends EventEmitter { try { await this.rollbackUpdate(containerId); } catch (rollbackError) { - console.error('[UpdateManager] Rollback failed:', rollbackError.message); + console.error(`[UpdateManager] Rollback failed:`, rollbackError.message); } } @@ -448,7 +448,7 @@ class UpdateManager extends EventEmitter { // Step 2: Check Docker health check if available if (inspect.State.Health) { if (inspect.State.Health.Status === 'healthy') { - console.log('[UpdateManager] Container health check: healthy'); + console.log(`[UpdateManager] Container health check: healthy`); return true; } else if (inspect.State.Health.Status === 'unhealthy') { lastError = 'Container health check failed (unhealthy)'; @@ -468,7 +468,7 @@ class UpdateManager extends EventEmitter { try { const response = await fetch(testUrl, { signal: AbortSignal.timeout(3000), - redirect: 'manual', + redirect: 'manual' }); // Accept 2xx, 3xx, 4xx as "accessible" (server is responding) @@ -477,7 +477,7 @@ class UpdateManager extends EventEmitter { // Wait a bit more to ensure stability if (attempt >= 2) { - console.log('[UpdateManager] Container verified successfully'); + console.log(`[UpdateManager] Container verified successfully`); return true; } } @@ -488,7 +488,7 @@ class UpdateManager extends EventEmitter { } else { // No ports exposed - just verify it's running for a few cycles if (attempt >= 5) { - console.log('[UpdateManager] Container running without exposed ports (verified)'); + console.log(`[UpdateManager] Container running without exposed ports (verified)`); return true; } } @@ -529,7 +529,7 @@ class UpdateManager extends EventEmitter { ports.push({ containerPort: containerPort.split('/')[0], hostPort: binding.HostPort, - protocol: containerPort.split('/')[1] || 'tcp', + protocol: containerPort.split('/')[1] || 'tcp' }); } } @@ -572,7 +572,7 @@ class UpdateManager extends EventEmitter { name: backup.containerName, Image: backup.imageName, ...backup.config, - HostConfig: backup.hostConfig, + HostConfig: backup.hostConfig }); await newContainer.start(); @@ -582,7 +582,7 @@ class UpdateManager extends EventEmitter { return true; } catch (error) { - console.error('[UpdateManager] Rollback failed:', error.message); + console.error(`[UpdateManager] Rollback failed:`, error.message); throw error; } } @@ -599,7 +599,7 @@ class UpdateManager extends EventEmitter { setTimeout(() => { this.updateContainer(containerId).catch(error => { - console.error('[UpdateManager] Scheduled update failed:', error.message); + console.error(`[UpdateManager] Scheduled update failed:`, error.message); }); }, delay); @@ -663,20 +663,20 @@ class UpdateManager extends EventEmitter { shortDescription: repoInfo?.description?.substring(0, 200) || '', starCount: repoInfo?.star_count || 0, pullCount: repoInfo?.pull_count || 0, - lastUpdated: repoInfo?.last_updated || null, + lastUpdated: repoInfo?.last_updated || null }, tags: tags.slice(0, 10).map(t => ({ name: t.name, lastPushed: t.last_pushed || t.tag_last_pushed, digest: t.digest?.substring(0, 12) || 'unknown', - size: t.full_size || t.size || 0, + size: t.full_size || t.size || 0 })), urls: { dockerHub: hubUrl, tags: `${hubUrl}/tags`, - dockerfile: repoInfo?.dockerfile_url || null, + dockerfile: repoInfo?.dockerfile_url || null }, - changelog: this.formatChangelog(repoInfo, tags, imageTag), + changelog: this.formatChangelog(repoInfo, tags, imageTag) }; } catch (error) { console.error(`[UpdateManager] Error fetching changelog for ${imageName}:`, error.message); @@ -691,7 +691,7 @@ class UpdateManager extends EventEmitter { urls: { dockerHub: `https://hub.docker.com/r/${repoPath.replace('library/', '_/')}`, }, - changelog: 'Unable to fetch changelog. Visit Docker Hub for details.', + changelog: 'Unable to fetch changelog. Visit Docker Hub for details.' }; } } @@ -711,8 +711,8 @@ class UpdateManager extends EventEmitter { method: 'GET', headers: { 'Accept': 'application/json', - 'User-Agent': 'DashCaddy/1.0', - }, + 'User-Agent': 'DashCaddy/1.0' + } }; const req = https.request(options, (res) => { @@ -755,8 +755,8 @@ class UpdateManager extends EventEmitter { method: 'GET', headers: { 'Accept': 'application/json', - 'User-Agent': 'DashCaddy/1.0', - }, + 'User-Agent': 'DashCaddy/1.0' + } }; const req = https.request(options, (res) => { @@ -836,7 +836,7 @@ class UpdateManager extends EventEmitter { schedule: config.schedule || 'weekly', maintenanceWindow: config.maintenanceWindow, autoRollback: config.autoRollback !== false, - securityOnly: config.securityOnly || false, + securityOnly: config.securityOnly || false }; this.saveConfig();