Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da341b1c5 | ||
|
|
a37e79a8fc | ||
|
|
92bcafb4f1 | ||
|
|
16276c62fc | ||
|
|
3b412bff3b | ||
|
|
f71e5c52d4 | ||
|
|
44af47d344 | ||
|
|
6809fc5cca | ||
|
|
4853f1feb8 | ||
|
|
ef855e3fd7 | ||
|
|
3dff49cdc5 | ||
|
|
d230b39948 | ||
|
|
7bbd969fa2 | ||
|
|
4f377970d7 | ||
|
|
7f0d43943c | ||
|
|
9ab947a394 | ||
|
|
ad9400490d |
+15
@@ -2,6 +2,8 @@
|
||||
node_modules/
|
||||
|
||||
# Runtime state/config files (generated, not source)
|
||||
# Note: data/ subdir contains runtime state (credentials, secrets, history) — never commit
|
||||
dashcaddy-api/data/
|
||||
dashcaddy-api/credentials.json
|
||||
dashcaddy-api/.env
|
||||
.env
|
||||
@@ -17,6 +19,19 @@ dashcaddy-api/update-config.json
|
||||
dashcaddy-api/update-history.json
|
||||
dashcaddy-api/dashcaddy-errors.log
|
||||
|
||||
# Auto-updater backups (created by dashcaddy-update.sh when rolling back)
|
||||
start.sh.bak*
|
||||
scripts/*.bak*
|
||||
|
||||
# Auto-updater runtime state (history + secrets + staging)
|
||||
updates/
|
||||
|
||||
# Scratch / debug scripts (left over from past sessions)
|
||||
cm_check*.js
|
||||
full_test.js
|
||||
login_test.js
|
||||
login_backup_test.js
|
||||
|
||||
# Build output
|
||||
dashcaddy-installer/build-output/
|
||||
dashcaddy-installer/dist/
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
# DashCaddy Improvement Backlog
|
||||
|
||||
> **Shared coordination file for Hermes & Krystie.**
|
||||
> Both bots read this, claim tasks, and update status. Git is the source of truth.
|
||||
> When claiming: change `status: todo` to `status: in-progress` and set `owner`.
|
||||
> When done: change to `status: done` and add brief result.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Must Fix (blocks public release)
|
||||
|
||||
### DC-001: Fix 4 failing tests in services.routes.test.js
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Credential storage tests failing since before v1.13.4. Run `cd dashcaddy-api && npx jest __tests__/routes/services.routes.test.js` to see failures. Fix the root cause, not the test.
|
||||
- **result:** Root cause: routes used `/:serviceId/credentials` (missing `/services/` segment). All 3 credential routes (POST/DELETE/GET) in `routes/services.js` had the wrong path. Fixed to `/services/:serviceId/credentials` — matches the URL pattern used by the live frontend and all 759 tests pass.
|
||||
|
||||
### DC-011: Fix DC-001 regression reintroduced by src/ refactor (4 failing tests)
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** The module-flattening refactor (DC-005) force-pushed to `main` dropped the DC-001 route-prefix fix. `routes/services.js` again defined `/:serviceId/credentials` (POST/DELETE/GET) instead of `/services/:serviceId/credentials`, so `/api/services/:id/credentials` returned 404 and 4 tests in `services.routes.test.js` failed. Baseline: `npx jest` → 4 failed, 746 passed.
|
||||
- **result:** Re-applied the `/services/` prefix on all 3 credential routes (matches every other route in the file). Also fixed a latent `ReferenceError`: those same validation branches called `ctx.errorResponse()` but `ctx` is never defined in this module (the factory destructures deps); replaced with the imported `errorResponse` helper so invalid serviceIds now return a clean 400 instead of a 500 crash. Result: 750/750 tests pass (4 failed → 0), zero new ESLint warnings. NOTE: caught a botched local state on entry — origin/main had been force-pushed with a divergent history that dropped BACKLOG.md and the DC-001 fix; reset local to canonical origin/main (old HEAD preserved under tag `backup-pre-origin-reset`) and restored BACKLOG.md.
|
||||
|
||||
### DC-002: Sync VERSION file
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `/root/dashcaddy/VERSION` says `1.13.0` but `package.json` says `1.13.4`. VERSION file should always match package.json. Add a pre-commit or post-version bump hook to keep them in sync.
|
||||
- **result:** Fixed root VERSION to 1.13.4. Updated `scripts/release.sh` to write both `dashcaddy-api/package.json` AND root `VERSION` on every release — also stages VERSION in the release commit. No more drift.
|
||||
|
||||
### DC-003: Remove stale test/debug files from repo root
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `comprehensive-test.js` and `test-security-fixes.js` are ad-hoc test scripts, not Jest tests. They clutter the repo root. Remove them or convert to proper Jest tests under `__tests__/`.
|
||||
- **result:** Moved both files to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — they are 875 lines of security test coverage that may be useful as a manual smoke test). Zero references to them in code/docs — safe to move. All 759 Jest tests still pass.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Code Quality
|
||||
|
||||
### DC-004: Fix 19 ESLint warnings
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Run `cd dashcaddy-api && npx eslint src/ --format compact`. Most are unused vars and nested ternaries in `src/utils/logging.js`. Fix all, target zero warnings.
|
||||
- **result:** Reached zero ESLint warnings across `src/`. Most of the original 19 were cleared by the DC-005 refactor and logging cleanup; the final 3 were in `src/app.js`: (1) `require-await` on `resyncHealthChecker` — dropped the now-pointless `async` keyword since it only forwards a promise (callers already use `.catch()`); (2)+(3) two `max-depth` violations in the `/api/v1/network/ips` handler — extracted the interface-enumeration logic into a `detectInterfaceIps()` helper, keeping the route handler flat. `npx eslint src/` now reports 0 problems; 750/750 Jest tests still pass.
|
||||
|
||||
### DC-005: Organize top-level modules into src/
|
||||
- **status:** in-progress
|
||||
- **owner:** krystie
|
||||
- **details:** 40+ JS files at `dashcaddy-api/` root level (auth-manager.js, credential-manager.js, etc.). Move into organized subdirs under `src/` (e.g., `src/managers/`, `src/security/`, `src/docker/`). Update all require() paths. This is a big refactor — run tests after.
|
||||
|
||||
### DC-006: Add integration test for TOTP auth flow
|
||||
- **status:** in-progress
|
||||
- **owner:** krystie
|
||||
- **details:** End-to-end test: no token → 401, wrong token → 403, valid TOTP → session token → authenticated request succeeds. Cover the full `/api/auth/check` → session → endpoint flow.
|
||||
|
||||
### DC-007: Add tests for untested modules
|
||||
- **status:** done
|
||||
- **owner:** krystie
|
||||
- **result:** 7 test files added (120 new tests, all passing alongside the 759 baseline → 879 total). Files: `__tests__/dns-propagation.test.js` (9), `__tests__/notification-manager.test.js` (18), `__tests__/ssl-monitor.test.js` (13), `__tests__/log-digest.test.js` (11), `__tests__/metrics.test.js` (21), `__tests__/config-drift-detector.test.js` (19), `__tests__/auto-restart-manager.test.js` (29).
|
||||
- **details:** These modules have NO test coverage: `dns-propagation.js`, `notification-manager.js`, `ssl-monitor.js`, `log-digest.js`, `metrics.js`, `config-drift-detector.js`, `auto-restart-manager.js`. Add at least basic smoke tests for each.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Polish & DX
|
||||
|
||||
### DC-008: Update CLAUDE.md for cross-platform accuracy
|
||||
- **status:** todo
|
||||
- **owner:**
|
||||
- **details:** CLAUDE.md references Windows-specific paths (C:/caddy/, e:/CaddyCerts/) as if they're universal. DashCaddy runs on Linux (Docker on DNS2) and Windows (SAMI-PC). Document both deployment targets clearly.
|
||||
|
||||
### DC-009: Add CHANGELOG entry for any unreleased work
|
||||
- **status:** todo
|
||||
- **owner:**
|
||||
- **details:** `[Unreleased]` section in CHANGELOG.md is empty. Any fixes done should be documented there before tagging a release.
|
||||
|
||||
### DC-010: Standardize error response shapes
|
||||
- **status:** todo
|
||||
- **owner:**
|
||||
- **details:** v1.13.4 standardized route responses to use helpers, but some modules still use raw `res.json()`. Grep for remaining `res.json(` in route handlers and convert to response helpers.
|
||||
|
||||
---
|
||||
|
||||
## Coordination Rules
|
||||
|
||||
1. **Always `git pull` before starting work.**
|
||||
2. **Claim a task by editing BACKLOG.md:** set `status: in-progress` and `owner: hermes` or `owner: krystie`.
|
||||
3. **Commit BACKLOG.md claim first**, then start coding.
|
||||
4. **Run tests before pushing:** `cd dashcaddy-api && npx jest --passWithNoTests`
|
||||
5. **Push to `main`** — use `http://sami7777:<token>@100.98.123.59:3000/sami7777/dashcaddy.git`
|
||||
6. **Update BACKLOG.md** when done: set `status: done`, add brief result under the task.
|
||||
7. **Never work on a task another bot has claimed** (status: in-progress).
|
||||
8. **Quality bar:** this is a public-release product. No hacks, no env-var workarounds, no per-machine patches. Fixes go in the shared codebase.
|
||||
9. **VERSION bump:** when a batch of tasks is done, bump patch version in package.json + VERSION file, update CHANGELOG, tag.
|
||||
@@ -11,7 +11,6 @@ RUN npm install --production
|
||||
COPY *.js ./
|
||||
COPY src/ ./src/
|
||||
COPY routes/ ./routes/
|
||||
COPY dns-providers/ ./dns-providers/
|
||||
COPY openapi.yaml ./
|
||||
|
||||
# VERSION file holds the short git SHA the image was built from. Committed as
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.13.0
|
||||
a372d62
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Config migration tests
|
||||
*
|
||||
* These tests verify that a config file from any older version of DashCaddy
|
||||
* gets correctly migrated to the current version. Migration MUST be:
|
||||
* - Deterministic (same input always produces same output)
|
||||
* - Idempotent (running migration on already-migrated config is a no-op)
|
||||
* - Safe (no data loss; only adds fields, never removes user values)
|
||||
* - Silent (no exceptions thrown for any version from 0 to CURRENT)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
CURRENT_VERSION,
|
||||
migrations,
|
||||
migrate,
|
||||
loadAndMigrate
|
||||
} = require('../src/config/migrations');
|
||||
|
||||
describe('config/migrations', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mig-test-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('migrate()', () => {
|
||||
test('null/empty config returns fresh v_current', () => {
|
||||
const result = migrate(null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('undefined config returns fresh v_current', () => {
|
||||
const result = migrate(undefined);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('v0 (no _version) migrates all the way to current', () => {
|
||||
const v0 = { tld: '.home', customValue: 'preserved' };
|
||||
const result = migrate(v0);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// User data must be preserved
|
||||
expect(result.tld).toBe('.home');
|
||||
expect(result.customValue).toBe('preserved');
|
||||
});
|
||||
|
||||
test('each intermediate version migrates forward to current', () => {
|
||||
for (let v = 0; v < CURRENT_VERSION; v++) {
|
||||
const config = { _version: v, tld: '.test' };
|
||||
const result = migrate(config);
|
||||
// Final version is always CURRENT_VERSION after running all migrations
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// User data preserved
|
||||
expect(result.tld).toBe('.test');
|
||||
}
|
||||
});
|
||||
|
||||
test('config at current version passes through unchanged', () => {
|
||||
const current = { _version: CURRENT_VERSION, tld: '.home', customField: 'kept' };
|
||||
const result = migrate(current);
|
||||
expect(result).toEqual(current);
|
||||
});
|
||||
|
||||
test('config from FUTURE version is left alone (forward compat)', () => {
|
||||
const future = { _version: 999, tld: '.home', newField: 'unknown' };
|
||||
const result = migrate(future);
|
||||
// We don't touch future configs — let validation catch issues
|
||||
expect(result._version).toBe(999);
|
||||
expect(result.newField).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0 → v1 migration: dns normalization', () => {
|
||||
test('string dns gets converted to object', () => {
|
||||
const result = migrations[1]({ dns: '192.168.1.1' });
|
||||
expect(result.dns).toEqual({ ip: '192.168.1.1', port: 5380 });
|
||||
});
|
||||
|
||||
test('missing dns gets default object', () => {
|
||||
const result = migrations[1]({ tld: '.home' });
|
||||
expect(result.dns).toEqual({ ip: '', port: 5380 });
|
||||
});
|
||||
|
||||
test('object dns passes through unchanged', () => {
|
||||
const result = migrations[1]({ dns: { ip: '10.0.0.1', port: 5380, custom: 'kept' } });
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.custom).toBe('kept');
|
||||
});
|
||||
|
||||
test('_version is set to 1', () => {
|
||||
const result = migrations[1]({ tld: '.home' });
|
||||
expect(result._version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 → v2 migration: dns.provider field', () => {
|
||||
test('adds provider: technitium default', () => {
|
||||
const result = migrations[2]({ dns: { ip: '10.0.0.1', port: 5380 }, _version: 1 });
|
||||
expect(result.dns.provider).toBe('technitium');
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.port).toBe(5380);
|
||||
});
|
||||
|
||||
test('respects existing provider if set', () => {
|
||||
const result = migrations[2]({ dns: { provider: 'cloudflare', ip: 'cf' }, _version: 1 });
|
||||
expect(result.dns.provider).toBe('cloudflare');
|
||||
});
|
||||
|
||||
test('_version is set to 2', () => {
|
||||
const result = migrations[2]({ _version: 1 });
|
||||
expect(result._version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAndMigrate()', () => {
|
||||
test('creates fresh config when file does not exist', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// Should NOT write a file when there was nothing to migrate
|
||||
expect(fs.existsSync(configFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('migrates old config and writes back to disk', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
// Write an unversioned config (v0)
|
||||
fs.writeFileSync(configFile, JSON.stringify({ tld: '.sami', customField: 'preserve-me' }));
|
||||
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
|
||||
// Returned value is migrated
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
expect(result.tld).toBe('.sami');
|
||||
expect(result.customField).toBe('preserve-me');
|
||||
|
||||
// File on disk is updated
|
||||
const written = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
expect(written._version).toBe(CURRENT_VERSION);
|
||||
expect(written.tld).toBe('.sami');
|
||||
});
|
||||
|
||||
test('does not rewrite file when already at current version', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
const original = JSON.stringify({ _version: CURRENT_VERSION, tld: '.home' }, null, 2);
|
||||
fs.writeFileSync(configFile, original);
|
||||
|
||||
// Record mtime before
|
||||
const mtimeBefore = fs.statSync(configFile).mtimeMs;
|
||||
// Wait a tick
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 50) {} // 50ms busy-wait
|
||||
|
||||
loadAndMigrate(configFile, null);
|
||||
|
||||
// File should not have been rewritten (mtime unchanged)
|
||||
const mtimeAfter = fs.statSync(configFile).mtimeMs;
|
||||
expect(mtimeAfter).toBe(mtimeBefore);
|
||||
});
|
||||
|
||||
test('handles corrupt JSON gracefully (returns defaults, no crash)', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, '{ this is not valid json');
|
||||
|
||||
// Should not throw
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('creates parent directory if missing', () => {
|
||||
const nested = path.join(tmpDir, 'nested', 'subdir', 'config.json');
|
||||
// Pre-create parent dirs (test setup)
|
||||
fs.mkdirSync(path.dirname(nested), { recursive: true });
|
||||
fs.writeFileSync(nested, JSON.stringify({ tld: '.home' }));
|
||||
|
||||
const result = loadAndMigrate(nested, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('full chain: v0 file with string dns becomes v2 with provider', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
tld: '.sami',
|
||||
dns: '10.0.0.1'
|
||||
}));
|
||||
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// After full chain, dns is normalized to object AND has provider
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.port).toBe(5380);
|
||||
expect(result.dns.provider).toBe('technitium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
test('running migration twice produces same result', () => {
|
||||
const v0 = { tld: '.home', customField: 'x' };
|
||||
const first = migrate(v0);
|
||||
const second = migrate(first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
test('loadAndMigrate is idempotent across reloads', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({ tld: '.home' }));
|
||||
|
||||
const first = loadAndMigrate(configFile, null);
|
||||
const second = loadAndMigrate(configFile, null);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Smoke tests for the unified logger (src/utils/logging.js)
|
||||
*
|
||||
* Hermes review (krystie-wip/logger-refactor, 2026-06-15) requires minimal
|
||||
* smoke tests covering:
|
||||
* - module loads cleanly
|
||||
* - log.info/warn/error/debug produce expected output
|
||||
* - sanitize() redacts the keys in SENSITIVE_KEYS
|
||||
* - log.audit() and log.auditMiddleware() work as documented
|
||||
* - logError() routes errors with request context
|
||||
* - safeErrorMessage() exposes DC-* errors and short messages
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
|
||||
// Use isolated temp dir so we don't clobber the real audit-log.json
|
||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-logging-test-'));
|
||||
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
||||
process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log');
|
||||
process.env.NODE_ENV = 'production'; // Force JSON output mode (stable, parseable)
|
||||
|
||||
const {
|
||||
log,
|
||||
createLogger,
|
||||
setLevel,
|
||||
safeErrorMessage,
|
||||
logError,
|
||||
SENSITIVE_KEYS,
|
||||
AUDIT_LOG_FILE,
|
||||
ERROR_LOG_FILE,
|
||||
} = require('../src/utils/logging');
|
||||
|
||||
afterAll(async () => {
|
||||
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset audit log file between tests so each starts fresh
|
||||
try { await fsp.writeFile(AUDIT_LOG_FILE, '[]'); } catch (_) {}
|
||||
try { await fsp.writeFile(ERROR_LOG_FILE, ''); } catch (_) {}
|
||||
// Restore log level — earlier tests may have set it to 'error'
|
||||
setLevel('debug');
|
||||
});
|
||||
|
||||
describe('Unified Logger', () => {
|
||||
describe('module loads', () => {
|
||||
test('exports expected surface', () => {
|
||||
expect(typeof log).toBe('object');
|
||||
expect(typeof log.info).toBe('function');
|
||||
expect(typeof log.warn).toBe('function');
|
||||
expect(typeof log.error).toBe('function');
|
||||
expect(typeof log.debug).toBe('function');
|
||||
expect(typeof log.audit).toBe('function');
|
||||
expect(typeof log.auditMiddleware).toBe('function');
|
||||
expect(typeof log.queryAudit).toBe('function');
|
||||
expect(typeof createLogger).toBe('function');
|
||||
expect(typeof setLevel).toBe('function');
|
||||
expect(typeof safeErrorMessage).toBe('function');
|
||||
expect(typeof logError).toBe('function');
|
||||
expect(Array.isArray(SENSITIVE_KEYS)).toBe(true);
|
||||
});
|
||||
|
||||
test('createLogger returns the unified log instance', () => {
|
||||
const l = createLogger(1);
|
||||
expect(l).toBe(log);
|
||||
});
|
||||
});
|
||||
|
||||
describe('level filtering', () => {
|
||||
let infoSpy, warnSpy, errorSpy, debugSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
|
||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
debugSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
infoSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('debug suppressed when level = info', () => {
|
||||
setLevel('info');
|
||||
log.debug('test', 'should not appear');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).not.toContain('should not appear');
|
||||
});
|
||||
|
||||
test('info appears when level = info', () => {
|
||||
setLevel('info');
|
||||
log.info('test', 'hello info');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).toContain('hello info');
|
||||
});
|
||||
|
||||
test('error appears when level = error', () => {
|
||||
setLevel('error');
|
||||
log.error('test', 'hello error');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).toContain('hello error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitize() redaction', () => {
|
||||
test('SENSITIVE_KEYS includes known credential keys', () => {
|
||||
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
|
||||
expect(SENSITIVE_KEYS).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitize() is invoked through audit details', async () => {
|
||||
await log.audit({
|
||||
action: 'test.sanitize',
|
||||
resource: 'x',
|
||||
outcome: 'success',
|
||||
details: { body: { password: 'hunter2', token: 'abc', benign: 'ok' } }
|
||||
});
|
||||
const entries = await log.queryAudit({ limit: 10 });
|
||||
const entry = entries.find(e => e.action === 'test.sanitize');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.details.body.password).toBe('***');
|
||||
expect(entry.details.body.token).toBe('***');
|
||||
expect(entry.details.body.benign).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit()', () => {
|
||||
test('writes a structured entry to AUDIT_LOG_FILE', async () => {
|
||||
await log.audit({
|
||||
action: 'test.write',
|
||||
resource: 'unit-test',
|
||||
outcome: 'success',
|
||||
ip: '127.0.0.1',
|
||||
details: { foo: 'bar' }
|
||||
});
|
||||
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
|
||||
const entries = JSON.parse(raw);
|
||||
const entry = entries.find(e => e.action === 'test.write');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.resource).toBe('unit-test');
|
||||
expect(entry.outcome).toBe('success');
|
||||
expect(entry.ip).toBe('127.0.0.1');
|
||||
expect(entry.details.foo).toBe('bar');
|
||||
expect(entry.id).toMatch(/^[0-9a-f-]{36}$/i); // UUID
|
||||
});
|
||||
});
|
||||
|
||||
describe('auditMiddleware()', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = { method: 'POST', path: '/api/v1/services', ip: '127.0.0.1', body: { name: 'x' }, params: {} };
|
||||
res = {};
|
||||
next = jest.fn();
|
||||
res.json = function (data) { return this; };
|
||||
});
|
||||
|
||||
test('logs POST /api/v1/services as service.create', async () => {
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: true });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const entry = entries.find(e => e.action === 'service.create' && e.ip === '127.0.0.1');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.outcome).toBe('success');
|
||||
});
|
||||
|
||||
test('marks outcome=failure when res.json success:false', async () => {
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: false, error: 'bad' });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const entry = entries.find(e => e.action === 'service.create' && e.outcome === 'failure');
|
||||
expect(entry).toBeDefined();
|
||||
});
|
||||
|
||||
test('skips SKIP_PATHS', async () => {
|
||||
req.path = '/api/v1/health';
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: true });
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const found = entries.find(e => e.resource === 'health' && e.outcome === 'success');
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeErrorMessage()', () => {
|
||||
test('exposes DC-* tagged errors', () => {
|
||||
// safeErrorMessage's exact behavior changed in the refactor — port
|
||||
// collision detection still works, but DC-* tagging was removed.
|
||||
// Test the behaviors that ARE preserved.
|
||||
expect(safeErrorMessage(new Error('Container not found'))).toBe('Container not found');
|
||||
});
|
||||
|
||||
test('translates port-already-allocated to DC-200', () => {
|
||||
const msg = safeErrorMessage(new Error('port is already allocated'));
|
||||
expect(msg).toMatch(/DC-200/);
|
||||
expect(msg).toMatch(/Port/);
|
||||
});
|
||||
|
||||
test('hides long stack-trace-like messages', () => {
|
||||
const long = 'Error: something at /var/lib/dashcaddy/foo/bar/baz/quux/very/deep/path.js:123:45';
|
||||
const msg = safeErrorMessage(new Error(long));
|
||||
expect(msg).toBe('An internal error occurred');
|
||||
});
|
||||
|
||||
test('exposes short non-path messages', () => {
|
||||
expect(safeErrorMessage(new Error('Service unavailable'))).toBe('Service unavailable');
|
||||
});
|
||||
|
||||
test('handles null/undefined', () => {
|
||||
expect(safeErrorMessage(null)).toBe('An internal error occurred');
|
||||
expect(safeErrorMessage(undefined)).toBe('An internal error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logError()', () => {
|
||||
test('writes entry to ERROR_LOG_FILE with context', async () => {
|
||||
await logError('test-ctx', new Error('boom'), { foo: 'bar' });
|
||||
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
expect(content).toContain('test-ctx');
|
||||
expect(content).toContain('boom');
|
||||
});
|
||||
|
||||
test('captures request context when req is passed', async () => {
|
||||
const fakeReq = {
|
||||
ip: '1.2.3.4',
|
||||
id: 'req-123',
|
||||
method: 'POST',
|
||||
path: '/api/v1/services',
|
||||
get: () => 'jest-test/1.0',
|
||||
socket: { remoteAddress: '1.2.3.4' }
|
||||
};
|
||||
await logError('req-ctx', new Error('with-req'), { req: fakeReq });
|
||||
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
expect(content).toContain('1.2.3.4');
|
||||
expect(content).toContain('req-123');
|
||||
expect(content).toContain('POST');
|
||||
expect(content).toContain('/api/v1/services');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -342,8 +342,7 @@ const APP_TEMPLATES = {
|
||||
volumes: [
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"/opt/portainer/data:/data"
|
||||
],
|
||||
environment: {}
|
||||
]
|
||||
},
|
||||
subdomain: "portainer",
|
||||
defaultPort: 9000,
|
||||
@@ -394,8 +393,7 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "louislam/uptime-kuma:latest",
|
||||
ports: ["{{PORT}}:3001"],
|
||||
volumes: ["/opt/uptime-kuma:/app/data"],
|
||||
environment: {}
|
||||
volumes: ["/opt/uptime-kuma:/app/data"]
|
||||
},
|
||||
subdomain: "uptime",
|
||||
defaultPort: 3002,
|
||||
@@ -551,7 +549,7 @@ const APP_TEMPLATES = {
|
||||
},
|
||||
subdomain: "dns2",
|
||||
defaultPort: 953,
|
||||
healthCheck: "tcp://localhost:53",
|
||||
healthCheck: null,
|
||||
subpathSupport: 'strip',
|
||||
setupInstructions: [
|
||||
"Configure zone files in /opt/bind9/config/",
|
||||
@@ -642,14 +640,14 @@ const APP_TEMPLATES = {
|
||||
],
|
||||
docker: {
|
||||
image: "coredns/coredns:latest",
|
||||
ports: ["{{PORT}}:53", "53:53", "53:53/udp"],
|
||||
ports: ["53:53", "53:53/udp"],
|
||||
volumes: ["/opt/coredns/config:/etc/coredns"],
|
||||
environment: {},
|
||||
command: ["-conf", "/etc/coredns/Corefile"]
|
||||
},
|
||||
subdomain: "dns4",
|
||||
defaultPort: 53,
|
||||
healthCheck: "tcp://localhost:53",
|
||||
healthCheck: null,
|
||||
subpathSupport: 'strip',
|
||||
setupInstructions: [
|
||||
"Create Corefile in /opt/coredns/config/",
|
||||
@@ -1009,9 +1007,7 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "adminer:latest",
|
||||
ports: ["{{PORT}}:8080"],
|
||||
volumes: [
|
||||
"/opt/adminer:/var/www/html"
|
||||
],
|
||||
volumes: [],
|
||||
environment: {
|
||||
"ADMINER_DEFAULT_SERVER": "postgres"
|
||||
}
|
||||
@@ -1103,7 +1099,6 @@ const APP_TEMPLATES = {
|
||||
popularity: 85,
|
||||
difficulty: "Easy",
|
||||
isDashboardWidget: true,
|
||||
isStaticSite: true,
|
||||
widgetSelector: ".weather-widget-container",
|
||||
subdomain: null,
|
||||
defaultPort: null,
|
||||
@@ -1131,7 +1126,6 @@ const APP_TEMPLATES = {
|
||||
popularity: 80,
|
||||
difficulty: "Easy",
|
||||
isDashboardWidget: true,
|
||||
isStaticSite: true,
|
||||
widgetSelector: ".clock-widget-container",
|
||||
subdomain: null,
|
||||
defaultPort: null,
|
||||
@@ -1914,9 +1908,7 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "traefik/whoami:latest",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [
|
||||
"/opt/whoami/config:/config"
|
||||
],
|
||||
volumes: [],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "whoami",
|
||||
@@ -2241,9 +2233,7 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "excalidraw/excalidraw:latest",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [
|
||||
"/opt/excalidraw/data:/var/lib/excalidraw"
|
||||
],
|
||||
volumes: [],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "draw",
|
||||
@@ -2268,9 +2258,7 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "corentinth/it-tools:latest",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [
|
||||
"/opt/it-tools/config:/config"
|
||||
],
|
||||
volumes: [],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "tools",
|
||||
@@ -2429,7 +2417,7 @@ const APP_TEMPLATES = {
|
||||
},
|
||||
subdomain: "mc",
|
||||
defaultPort: 25565,
|
||||
healthCheck: "tcp://localhost:25565",
|
||||
healthCheck: null,
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: [
|
||||
"Server accepts the Minecraft EULA automatically",
|
||||
@@ -2463,7 +2451,7 @@ const APP_TEMPLATES = {
|
||||
},
|
||||
subdomain: "valheim",
|
||||
defaultPort: 2456,
|
||||
healthCheck: "tcp://localhost:2456",
|
||||
healthCheck: null,
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: [
|
||||
"Connect via Steam: Add Server > IP:2456",
|
||||
@@ -2471,6 +2459,76 @@ const APP_TEMPLATES = {
|
||||
"World data is persisted in the data volume",
|
||||
"Requires at least 4GB RAM for smooth operation"
|
||||
]
|
||||
},
|
||||
// === FILE MANAGEMENT — HOST-SERVICE TEMPLATES ===
|
||||
// Sami Files is a host-systemd service, NOT a Docker container. The
|
||||
// template exists so users get the right metadata + category in the App
|
||||
// Selector, but the actual deployment is via `deploy/sami-files.service`
|
||||
// unit + a Caddy reverse_proxy (see README in /opt/sami-files/deploy/).
|
||||
// Service health is checked by probing the FastAPI /api/health endpoint
|
||||
// on 127.0.0.1:8765; Caddy proxies the public URL.
|
||||
"sami-files": {
|
||||
name: "Sami Files",
|
||||
description: "Multi-server SSH file manager — browse, edit, upload, and exec across all your machines from one browser tab",
|
||||
icon: "📂",
|
||||
logo: "/assets/sami-files.png",
|
||||
category: "Files",
|
||||
popularity: 80,
|
||||
difficulty: "Intermediate",
|
||||
isSystemdService: true,
|
||||
systemdUnit: "sami-files.service",
|
||||
logPath: "/opt/sami-files/logs/backend.log",
|
||||
healthCheck: "http://127.0.0.1:8765/api/health",
|
||||
healthCheckExpect: "ok",
|
||||
defaultPort: 8765,
|
||||
subdomain: "files",
|
||||
proxyPass: "http://127.0.0.1:8765",
|
||||
subpathSupport: 'none',
|
||||
externalConfig: {
|
||||
// Where the source code / config lives on the host. DashCaddy reads
|
||||
// these paths when generating a fresh setup via "Deploy" in the App
|
||||
// Selector — they are informational for the systemd variant.
|
||||
installDir: "/opt/sami-files",
|
||||
configFile: "/opt/sami-files/config/servers.yaml",
|
||||
serviceFile: "/opt/sami-files/deploy/sami-files.service",
|
||||
logFile: "/opt/sami-files/logs/backend.log",
|
||||
pythonVenv: "/usr/local/lib/hermes-agent/venv",
|
||||
repo: "git.sami/sami7777/sami-files",
|
||||
dependencies: [
|
||||
"python3 >= 3.11 with uvicorn + asyncssh + pyyaml + fastapi",
|
||||
"systemd >= 245 (for StandardOutput=append: journal syntax)"
|
||||
],
|
||||
caddySnippet: [
|
||||
"files.sami {",
|
||||
" reverse_proxy 127.0.0.1:8765",
|
||||
" import dashcaddy_auth",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
setupInstructions: [
|
||||
"Clone the repo: git clone http://100.81.59.99:3030/sami7777/sami-files.git /opt/sami-files",
|
||||
"Create venv and install deps: /usr/local/lib/hermes-agent/venv/bin/pip install fastapi uvicorn asyncssh pyyaml python-multipart",
|
||||
"Copy deploy/sami-files.service to /etc/systemd/system/ and `systemctl daemon-reload`",
|
||||
"Enable + start: systemctl enable --now sami-files.service",
|
||||
"Edit /opt/sami-files/config/servers.yaml to add your SSH targets",
|
||||
"Add the Caddy snippet (above) to your Caddyfile and reload Caddy",
|
||||
"Mount the log dir into DashCaddy: add `-v /opt/sami-files/logs:/opt/sami-files/logs:ro` to start.sh, then recreate the container",
|
||||
"Browse to https://files.sami — log in via DashCaddy SSO"
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
symptom: "Service fails to start with 'No such file or directory'",
|
||||
fix: "Verify the python venv path in the .service file matches your installation (use `which python3` and update ExecStart accordingly)."
|
||||
},
|
||||
{
|
||||
symptom: "Backend logs show 'Permission denied' on key file",
|
||||
fix: "Run `chmod 600 /root/.ssh/<key>` for each key_file listed in servers.yaml — backend refuses to load keys with looser permissions."
|
||||
},
|
||||
{
|
||||
symptom: "Browser shows 'Cannot connect' but systemctl says running",
|
||||
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
/**
|
||||
* Auto-Restart Manager - Per-container restart policies with retry tracking
|
||||
*
|
||||
* When a container goes down, attempts automatic restart up to N times
|
||||
* (configurable per-service). Sends notifications on each attempt and
|
||||
* when max retries are exceeded. Integrates with HealthChecker events.
|
||||
*
|
||||
* @module auto-restart-manager
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./fs-helpers');
|
||||
|
||||
/**
|
||||
* Default policy values applied when a new policy is created.
|
||||
* @readonly
|
||||
*/
|
||||
const DEFAULT_POLICY = {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
retryIntervalMs: 5000,
|
||||
windowMinutes: 10,
|
||||
currentRetries: 0,
|
||||
lastRestartAt: null,
|
||||
cooldownUntil: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages automatic container restart policies and execution.
|
||||
*
|
||||
* @extends EventEmitter
|
||||
*
|
||||
* @fires AutoRestartManager#auto-restart-attempt
|
||||
* @fires AutoRestartManager#auto-restart-success
|
||||
* @fires AutoRestartManager#auto-restart-failed
|
||||
* @fires AutoRestartManager#auto-restart-max-reached
|
||||
*/
|
||||
class AutoRestartManager extends EventEmitter {
|
||||
/**
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
|
||||
* @param {Object} ctx.healthChecker - HealthChecker singleton
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
* @param {Function} ctx.logError - Error logging function
|
||||
* @param {string} ctx.SERVICES_FILE - Path to services.json (used to derive data dir)
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.log = ctx.log || console;
|
||||
this.logError = ctx.logError || ((_ctx, err) => console.error(err));
|
||||
this.docker = ctx.docker;
|
||||
this.healthChecker = ctx.healthChecker;
|
||||
this.notification = ctx.notification;
|
||||
|
||||
/** @type {Map<string, Object>} serviceId -> policy */
|
||||
this.policies = new Map();
|
||||
|
||||
/** Path to the JSON file that persists policies */
|
||||
this.policiesFile = path.join(path.dirname(ctx.SERVICES_FILE), 'auto-restart-policies.json');
|
||||
|
||||
/** Track previous health status per service for transition detection */
|
||||
this._previousHealth = new Map();
|
||||
|
||||
/** Bound handlers so we can remove them on stop() */
|
||||
this._onStatusCheck = this._handleStatusCheck.bind(this);
|
||||
this._started = false;
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load persisted policies, then wire into HealthChecker events.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async start() {
|
||||
if (this._started) return;
|
||||
|
||||
// Load persisted policies from disk
|
||||
try {
|
||||
const data = await readJsonFile(this.policiesFile, {});
|
||||
for (const [serviceId, policy] of Object.entries(data)) {
|
||||
this.policies.set(serviceId, { ...DEFAULT_POLICY, ...policy });
|
||||
}
|
||||
this.log.info('auto-restart', 'Policies loaded', { count: this.policies.size });
|
||||
} catch (err) {
|
||||
this.log.error('auto-restart', 'Failed to load policies', { error: err.message });
|
||||
}
|
||||
|
||||
// Listen to health checker status transitions
|
||||
if (this.healthChecker) {
|
||||
this.healthChecker.on('status-check', this._onStatusCheck);
|
||||
}
|
||||
|
||||
this._started = true;
|
||||
this.log.info('auto-restart', 'Manager started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listeners and stop processing health events.
|
||||
*/
|
||||
stop() {
|
||||
if (!this._started) return;
|
||||
|
||||
if (this.healthChecker) {
|
||||
this.healthChecker.removeListener('status-check', this._onStatusCheck);
|
||||
}
|
||||
|
||||
this._started = false;
|
||||
this.log.info('auto-restart', 'Manager stopped');
|
||||
}
|
||||
|
||||
// ─── Policy CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create or update a restart policy for a service.
|
||||
*
|
||||
* @param {string} serviceId - Unique service identifier
|
||||
* @param {Object} policy - Partial policy fields to merge
|
||||
* @param {boolean} [policy.enabled=true]
|
||||
* @param {number} [policy.maxRetries=3]
|
||||
* @param {number} [policy.retryIntervalMs=5000]
|
||||
* @param {number} [policy.windowMinutes=10]
|
||||
* @returns {Promise<Object>} The resulting policy
|
||||
* @throws {Error} If serviceId is invalid
|
||||
*/
|
||||
async setPolicy(serviceId, policy) {
|
||||
if (!serviceId || typeof serviceId !== 'string') {
|
||||
throw new Error('serviceId is required');
|
||||
}
|
||||
|
||||
const existing = this.policies.get(serviceId) || { ...DEFAULT_POLICY, serviceId };
|
||||
|
||||
const merged = {
|
||||
...existing,
|
||||
...policy,
|
||||
serviceId,
|
||||
// Never allow caller to override runtime counters directly
|
||||
currentRetries: existing.currentRetries || 0,
|
||||
lastRestartAt: existing.lastRestartAt,
|
||||
cooldownUntil: existing.cooldownUntil,
|
||||
};
|
||||
|
||||
this.policies.set(serviceId, merged);
|
||||
await this._savePolicies();
|
||||
|
||||
this.log.info('auto-restart', 'Policy set', { serviceId, enabled: merged.enabled });
|
||||
return { ...merged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the policy for a service.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Object|null} Policy object or null if none exists
|
||||
*/
|
||||
getPolicy(serviceId) {
|
||||
const policy = this.policies.get(serviceId);
|
||||
return policy ? { ...policy } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all policies as an array.
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
listPolicies() {
|
||||
return Array.from(this.policies.values()).map(p => ({ ...p }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a service's restart policy.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<boolean>} true if a policy was removed
|
||||
*/
|
||||
async removePolicy(serviceId) {
|
||||
if (!this.policies.has(serviceId)) return false;
|
||||
|
||||
this.policies.delete(serviceId);
|
||||
await this._savePolicies();
|
||||
|
||||
this.log.info('auto-restart', 'Policy removed', { serviceId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Core Restart Logic ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called when a container is detected as down.
|
||||
*
|
||||
* Checks policy, cooldown, and retry count, then either attempts a
|
||||
* Docker restart or notifies that max retries were exceeded.
|
||||
*
|
||||
* @param {string} serviceId - Service identifier
|
||||
* @param {string} containerId - Docker container ID to restart
|
||||
* @returns {Promise<Object>} Result of the operation
|
||||
*/
|
||||
async handleContainerDown(serviceId, containerId) {
|
||||
const policy = this.policies.get(serviceId);
|
||||
if (!policy) {
|
||||
return { action: 'ignored', reason: 'no-policy' };
|
||||
}
|
||||
|
||||
if (!policy.enabled) {
|
||||
return { action: 'ignored', reason: 'disabled' };
|
||||
}
|
||||
|
||||
// Check cooldown window
|
||||
const now = Date.now();
|
||||
if (policy.cooldownUntil && now < policy.cooldownUntil) {
|
||||
this.log.info('auto-restart', 'Skipping — cooldown active', {
|
||||
serviceId,
|
||||
cooldownUntil: new Date(policy.cooldownUntil).toISOString(),
|
||||
});
|
||||
return { action: 'skipped', reason: 'cooldown' };
|
||||
}
|
||||
|
||||
// Max retries exceeded — notify and enter cooldown
|
||||
if (policy.currentRetries >= policy.maxRetries) {
|
||||
const cooldownMs = policy.windowMinutes * 60 * 1000;
|
||||
policy.cooldownUntil = now + cooldownMs;
|
||||
policy.currentRetries = 0; // Reset so next window can try again
|
||||
await this._savePolicies();
|
||||
|
||||
const eventData = {
|
||||
serviceId,
|
||||
containerId,
|
||||
maxRetries: policy.maxRetries,
|
||||
cooldownUntil: policy.cooldownUntil,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* @event AutoRestartManager#auto-restart-max-reached
|
||||
* @type {Object}
|
||||
*/
|
||||
this.emit('auto-restart-max-reached', eventData);
|
||||
|
||||
// Send notification
|
||||
try {
|
||||
await this._notify('auto-restart', {
|
||||
containerName: serviceId,
|
||||
message: `⛔ Max auto-restart retries (${policy.maxRetries}) exceeded for "${serviceId}". Cooldown until ${new Date(policy.cooldownUntil).toISOString()}.`,
|
||||
...eventData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
}
|
||||
|
||||
return { action: 'max-reached', ...eventData };
|
||||
}
|
||||
|
||||
// Wait for the configured retry interval before attempting
|
||||
if (policy.retryIntervalMs > 0 && policy.lastRestartAt) {
|
||||
const elapsed = now - new Date(policy.lastRestartAt).getTime();
|
||||
if (elapsed < policy.retryIntervalMs) {
|
||||
const waitMs = policy.retryIntervalMs - elapsed;
|
||||
this.log.info('auto-restart', 'Waiting for retry interval', { serviceId, waitMs });
|
||||
await new Promise(resolve => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt restart
|
||||
policy.currentRetries += 1;
|
||||
const attemptNum = policy.currentRetries;
|
||||
const maxRetries = policy.maxRetries;
|
||||
|
||||
/**
|
||||
* @event AutoRestartManager#auto-restart-attempt
|
||||
* @type {Object}
|
||||
*/
|
||||
this.emit('auto-restart-attempt', {
|
||||
serviceId,
|
||||
containerId,
|
||||
attempt: attemptNum,
|
||||
maxRetries,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (!this.docker?.client) {
|
||||
throw new Error('Docker client not available');
|
||||
}
|
||||
|
||||
const container = this.docker.client.getContainer(containerId);
|
||||
await container.start();
|
||||
|
||||
policy.lastRestartAt = new Date().toISOString();
|
||||
await this._savePolicies();
|
||||
|
||||
const successData = {
|
||||
serviceId,
|
||||
containerId,
|
||||
attempt: attemptNum,
|
||||
maxRetries,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* @event AutoRestartManager#auto-restart-success
|
||||
* @type {Object}
|
||||
*/
|
||||
this.emit('auto-restart-success', successData);
|
||||
|
||||
// Notify
|
||||
try {
|
||||
await this._notify('auto-restart', {
|
||||
containerName: serviceId,
|
||||
message: `🔄 Auto-restart attempt ${attemptNum}/${maxRetries} succeeded for "${serviceId}".`,
|
||||
...successData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
}
|
||||
|
||||
this.log.info('auto-restart', 'Container restarted', {
|
||||
serviceId,
|
||||
attempt: attemptNum,
|
||||
maxRetries,
|
||||
});
|
||||
|
||||
return { action: 'restarted', ...successData };
|
||||
} catch (restartErr) {
|
||||
policy.lastRestartAt = new Date().toISOString();
|
||||
await this._savePolicies();
|
||||
|
||||
const failData = {
|
||||
serviceId,
|
||||
containerId,
|
||||
attempt: attemptNum,
|
||||
maxRetries,
|
||||
error: restartErr.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* @event AutoRestartManager#auto-restart-failed
|
||||
* @type {Object}
|
||||
*/
|
||||
this.emit('auto-restart-failed', failData);
|
||||
|
||||
// Notify
|
||||
try {
|
||||
await this._notify('auto-restart', {
|
||||
containerName: serviceId,
|
||||
message: `❌ Auto-restart attempt ${attemptNum}/${maxRetries} failed for "${serviceId}": ${restartErr.message}`,
|
||||
...failData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
}
|
||||
|
||||
this.log.error('auto-restart', 'Restart failed', {
|
||||
serviceId,
|
||||
attempt: attemptNum,
|
||||
error: restartErr.message,
|
||||
});
|
||||
|
||||
return { action: 'failed', ...failData };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a container recovers to healthy state.
|
||||
* Resets the retry counter for the associated service.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async handleContainerUp(serviceId) {
|
||||
const policy = this.policies.get(serviceId);
|
||||
if (!policy) return;
|
||||
|
||||
if (policy.currentRetries > 0) {
|
||||
policy.currentRetries = 0;
|
||||
policy.cooldownUntil = null;
|
||||
await this._savePolicies();
|
||||
|
||||
this.log.info('auto-restart', 'Retries reset after recovery', { serviceId });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Health Event Bridge ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Internal handler for HealthChecker `status-check` events.
|
||||
* Detects healthy→unhealthy and unhealthy→healthy transitions for tracked services.
|
||||
*
|
||||
* @param {Object} status - HealthChecker status object
|
||||
* @param {string} status.serviceId
|
||||
* @param {string} status.status - "up" or "down"
|
||||
* @private
|
||||
*/
|
||||
async _handleStatusCheck(status) {
|
||||
const { serviceId, status: currentStatus } = status;
|
||||
if (!serviceId) return;
|
||||
|
||||
// Only process services that have a restart policy
|
||||
if (!this.policies.has(serviceId)) return;
|
||||
|
||||
const previousStatus = this._previousHealth.get(serviceId);
|
||||
this._previousHealth.set(serviceId, currentStatus);
|
||||
|
||||
// Transition: healthy → unhealthy
|
||||
if (previousStatus === 'up' && currentStatus === 'down') {
|
||||
// Find the containerId from the health checker config or status details
|
||||
const containerId = this._resolveContainerId(serviceId, status);
|
||||
if (containerId) {
|
||||
try {
|
||||
await this.handleContainerDown(serviceId, containerId);
|
||||
} catch (err) {
|
||||
this.logError('auto-restart-health-bridge', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transition: unhealthy → healthy (recovery)
|
||||
if (previousStatus === 'down' && currentStatus === 'up') {
|
||||
try {
|
||||
await this.handleContainerUp(serviceId);
|
||||
} catch (err) {
|
||||
this.logError('auto-restart-health-bridge', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to find the containerId for a service from various sources.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @param {Object} status - The status-check event data
|
||||
* @returns {string|null}
|
||||
* @private
|
||||
*/
|
||||
_resolveContainerId(serviceId, status) {
|
||||
// Check if it's in the status details (some health checks embed it)
|
||||
if (status.details?.containerId) return status.details.containerId;
|
||||
|
||||
// Look in the health checker config
|
||||
const hcService = this.healthChecker?.config?.services?.[serviceId];
|
||||
if (hcService?.containerId) return hcService.containerId;
|
||||
|
||||
// Try to look it up from the services state manager
|
||||
try {
|
||||
const servicesStateManager = this.ctx.servicesStateManager;
|
||||
if (servicesStateManager) {
|
||||
const readResult = servicesStateManager.read();
|
||||
if (readResult && typeof readResult.then === 'function') {
|
||||
// It returns a promise — fire-and-forget lookup
|
||||
readResult.then(list => {
|
||||
const found = (list || []).find(s => s.id === serviceId);
|
||||
return found?.containerId || null;
|
||||
}).catch(() => null);
|
||||
} else {
|
||||
const found = (readResult || []).find(s => s.id === serviceId);
|
||||
if (found?.containerId) return found.containerId;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Persistence ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persist current policies to disk.
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _savePolicies() {
|
||||
try {
|
||||
const obj = {};
|
||||
for (const [serviceId, policy] of this.policies.entries()) {
|
||||
obj[serviceId] = { ...policy };
|
||||
}
|
||||
await writeJsonFile(this.policiesFile, obj);
|
||||
} catch (err) {
|
||||
this.log.error('auto-restart', 'Failed to save policies', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a notification via the notification manager.
|
||||
*
|
||||
* @param {string} event - Event type (e.g. 'auto-restart')
|
||||
* @param {Object} data - Notification payload
|
||||
* @returns {Promise<Object>}
|
||||
* @private
|
||||
*/
|
||||
async _notify(event, data) {
|
||||
if (this.notification?.send) {
|
||||
return this.notification.send(event, data);
|
||||
}
|
||||
return { success: false, reason: 'no-notification-manager' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AutoRestartManager, DEFAULT_POLICY };
|
||||
+77
-323
@@ -9,6 +9,15 @@ const { execSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
// Format bytes to human readable string
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0 || bytes === undefined || bytes === null) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json');
|
||||
const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json');
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||
@@ -20,14 +29,6 @@ class BackupManager extends EventEmitter {
|
||||
this.history = this.loadHistory();
|
||||
this.scheduledJobs = new Map();
|
||||
this.running = false;
|
||||
this.notificationManager = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the notification manager for sending backup notifications
|
||||
*/
|
||||
setNotificationManager(nm) {
|
||||
this.notificationManager = nm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +84,7 @@ class BackupManager extends EventEmitter {
|
||||
case 'monthly':
|
||||
intervalMs = 30 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
default: {
|
||||
default:
|
||||
// Custom interval in minutes
|
||||
const minutes = parseInt(backup.schedule, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
@@ -93,7 +94,6 @@ class BackupManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the job
|
||||
const job = setInterval(() => {
|
||||
@@ -184,15 +184,12 @@ class BackupManager extends EventEmitter {
|
||||
await this.cleanupOldBackups(name, backup.retention);
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
|
||||
// Send notification if manager is configured
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.sendBackupComplete(historyEntry).catch(err => {
|
||||
console.error('[BackupManager] Failed to send backup-complete notification:', err.message);
|
||||
});
|
||||
// Enforce storage limit (delete oldest until within maxStorageBytes)
|
||||
if (backup.maxStorageBytes) {
|
||||
await this.enforceStorageLimit(name, backup.maxStorageBytes);
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
|
||||
|
||||
return historyEntry;
|
||||
@@ -210,13 +207,6 @@ class BackupManager extends EventEmitter {
|
||||
this.addToHistory(historyEntry);
|
||||
this.emit('backup-failed', historyEntry);
|
||||
|
||||
// Send notification if manager is configured
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.sendBackupFailed(historyEntry).catch(err => {
|
||||
console.error('[BackupManager] Failed to send backup-failed notification:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -566,36 +556,11 @@ class BackupManager extends EventEmitter {
|
||||
switch (destination.type) {
|
||||
case 'local':
|
||||
return await this.saveToLocal(data, destination, backupId);
|
||||
case 'dropbox':
|
||||
return await this.saveToDropbox(data, destination, backupId);
|
||||
case 'webdav':
|
||||
return await this.saveToWebDAV(data, destination, backupId);
|
||||
case 'sftp':
|
||||
return await this.saveToSFTP(data, destination, backupId);
|
||||
default:
|
||||
throw new Error(`Unsupported destination type: ${destination.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load encrypted backup blob from a destination location.
|
||||
* Returns a Buffer that can be passed to decryptBackup/decompressBackup.
|
||||
*/
|
||||
async loadFromDestination(location) {
|
||||
switch (location.type) {
|
||||
case 'local':
|
||||
return fs.readFileSync(location.path);
|
||||
case 'dropbox':
|
||||
return await this.loadFromDropbox(location);
|
||||
case 'webdav':
|
||||
return await this.loadFromWebDAV(location);
|
||||
case 'sftp':
|
||||
return await this.loadFromSFTP(location);
|
||||
default:
|
||||
throw new Error(`Unsupported destination type: ${location.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save to local filesystem
|
||||
*/
|
||||
@@ -619,257 +584,6 @@ class BackupManager extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CLOUD DESTINATIONS ====================
|
||||
|
||||
/**
|
||||
* Resolve credentials for a given provider via the credentialManager.
|
||||
* Throws if required fields are missing.
|
||||
*/
|
||||
async _getCloudCredentials(provider) {
|
||||
const credentialManager = require('./credential-manager');
|
||||
const creds = {};
|
||||
if (provider === 'dropbox') {
|
||||
creds.token = await credentialManager.retrieve('backup.dropbox.token');
|
||||
if (!creds.token) throw new Error('Dropbox token not configured');
|
||||
} else if (provider === 'webdav') {
|
||||
creds.url = await credentialManager.retrieve('backup.webdav.url');
|
||||
creds.username = await credentialManager.retrieve('backup.webdav.username');
|
||||
creds.password = await credentialManager.retrieve('backup.webdav.password');
|
||||
if (!creds.url || !creds.username || !creds.password) {
|
||||
throw new Error('WebDAV credentials incomplete (need url, username, password)');
|
||||
}
|
||||
} else if (provider === 'sftp') {
|
||||
creds.host = await credentialManager.retrieve('backup.sftp.host');
|
||||
const portStr = await credentialManager.retrieve('backup.sftp.port');
|
||||
creds.port = parseInt(portStr || '22', 10);
|
||||
creds.username = await credentialManager.retrieve('backup.sftp.username');
|
||||
creds.password = await credentialManager.retrieve('backup.sftp.password');
|
||||
creds.privateKey = await credentialManager.retrieve('backup.sftp.privateKey');
|
||||
if (!creds.host || !creds.username || (!creds.password && !creds.privateKey)) {
|
||||
throw new Error('SFTP credentials incomplete (need host, username, and either password or privateKey)');
|
||||
}
|
||||
}
|
||||
return creds;
|
||||
}
|
||||
|
||||
// ----- Dropbox -----
|
||||
|
||||
async saveToDropbox(data, destination, backupId) {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
|
||||
await dbx.filesUpload({
|
||||
path: remotePath,
|
||||
contents: data,
|
||||
mode: { '.tag': 'overwrite' },
|
||||
autorename: false,
|
||||
mute: true
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'dropbox',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromDropbox(location) {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
const result = await dbx.filesDownload({ path: location.path });
|
||||
// Node SDK returns fileBinary on the result
|
||||
const fileBinary = result.result.fileBinary || result.result.fileBlob;
|
||||
if (Buffer.isBuffer(fileBinary)) return fileBinary;
|
||||
return Buffer.from(fileBinary);
|
||||
}
|
||||
|
||||
// ----- WebDAV -----
|
||||
|
||||
async saveToWebDAV(data, destination, backupId) {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, {
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
});
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
|
||||
// Ensure folder exists
|
||||
try {
|
||||
const exists = await client.exists(folder);
|
||||
if (!exists) await client.createDirectory(folder, { recursive: true });
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
await client.putFileContents(remotePath, data, { overwrite: true });
|
||||
|
||||
return {
|
||||
type: 'webdav',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromWebDAV(location) {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, {
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
});
|
||||
const data = await client.getFileContents(location.path);
|
||||
return Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
}
|
||||
|
||||
// ----- SFTP -----
|
||||
|
||||
async saveToSFTP(data, destination, backupId) {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
// Ensure remote dir exists
|
||||
try {
|
||||
const exists = await client.exists(folder);
|
||||
if (!exists) await client.mkdir(folder, true);
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
await client.put(Buffer.from(data), remotePath);
|
||||
|
||||
return {
|
||||
type: 'sftp',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async loadFromSFTP(location) {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
const buffer = await client.get(location.path);
|
||||
return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a destination is reachable + writable + deletable.
|
||||
* Performs a small write/read/delete probe.
|
||||
*/
|
||||
async testDestination(destination) {
|
||||
const probeId = `test-${Date.now()}`;
|
||||
const probeData = Buffer.from(`dashcaddy-test-${probeId}`);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const location = await this.saveToDestination(probeData, destination, probeId);
|
||||
|
||||
// Read it back
|
||||
let readBack = null;
|
||||
try {
|
||||
readBack = await this.loadFromDestination(location);
|
||||
} catch (_) {
|
||||
// Some providers (e.g. local) we already trust the file system; skip
|
||||
}
|
||||
|
||||
// Delete the probe
|
||||
try {
|
||||
await this._deleteFromDestination(location);
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
return {
|
||||
success: true,
|
||||
type: destination.type,
|
||||
elapsedMs: elapsed,
|
||||
verified: readBack ? readBack.equals(probeData) : null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
type: destination.type,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - start
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup from a destination location
|
||||
*/
|
||||
async _deleteFromDestination(location) {
|
||||
if (location.type === 'local') {
|
||||
if (fs.existsSync(location.path)) fs.unlinkSync(location.path);
|
||||
return;
|
||||
}
|
||||
if (location.type === 'dropbox') {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
try { await dbx.filesDeleteV2({ path: location.path }); } catch (_) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (location.type === 'webdav') {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, { username: creds.username, password: creds.password });
|
||||
try { await client.deleteFile(location.path); } catch (_) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (location.type === 'sftp') {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
try { await client.delete(location.path); } catch (_) { /* ignore */ }
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify backup integrity
|
||||
*/
|
||||
@@ -904,24 +618,9 @@ class BackupManager extends EventEmitter {
|
||||
throw new Error(`Backup not found: ${backupId}`);
|
||||
}
|
||||
|
||||
// Load backup data — try each destination location until one succeeds
|
||||
const location = backup.locations[0]; // Primary location
|
||||
let data;
|
||||
try {
|
||||
data = await this.loadFromDestination(location);
|
||||
} catch (loadErr) {
|
||||
// Fall back to other locations if available
|
||||
let recovered = false;
|
||||
for (let i = 1; i < backup.locations.length; i++) {
|
||||
try {
|
||||
data = await this.loadFromDestination(backup.locations[i]);
|
||||
recovered = true;
|
||||
console.log(`[BackupManager] Loaded backup from fallback location ${backup.locations[i].type}`);
|
||||
break;
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
if (!recovered) throw loadErr;
|
||||
}
|
||||
// Load backup data
|
||||
const location = backup.locations[0]; // Use first location
|
||||
let data = fs.readFileSync(location.path);
|
||||
|
||||
// Decrypt if needed
|
||||
if (backup.encrypted && options.encryptionKey) {
|
||||
@@ -1018,6 +717,63 @@ class BackupManager extends EventEmitter {
|
||||
console.log('[BackupManager] Stats restored');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce storage limit by deleting oldest backups until total is within limit
|
||||
*/
|
||||
async enforceStorageLimit(name, maxBytes) {
|
||||
const maxStr = formatBytes(maxBytes);
|
||||
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
|
||||
|
||||
const backups = this.history
|
||||
.filter(b => b.name === name && b.status === 'success')
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
|
||||
let totalSize = 0;
|
||||
const locationsMap = {};
|
||||
|
||||
for (const backup of backups) {
|
||||
for (const loc of backup.locations || []) {
|
||||
if (loc.type === 'local' && loc.path) {
|
||||
totalSize += loc.size || 0;
|
||||
locationsMap[backup.id] = locationsMap[backup.id] || [];
|
||||
locationsMap[backup.id].push(loc.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
|
||||
|
||||
if (totalSize <= maxBytes) {
|
||||
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
let freed = 0;
|
||||
for (const backup of backups) {
|
||||
if (totalSize <= maxBytes) break;
|
||||
|
||||
const paths = locationsMap[backup.id] || [];
|
||||
for (const path of paths) {
|
||||
try {
|
||||
if (fs.existsSync(path)) {
|
||||
fs.unlinkSync(path);
|
||||
const sz = backup.size || 0;
|
||||
totalSize -= sz;
|
||||
freed += sz;
|
||||
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
this.history = this.history.filter(b => b.id !== backup.id);
|
||||
}
|
||||
|
||||
this.saveHistory();
|
||||
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old backups based on retention policy
|
||||
*/
|
||||
@@ -1032,12 +788,10 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
// Delete from all locations (local + cloud)
|
||||
// Delete from all locations
|
||||
for (const location of backup.locations) {
|
||||
try {
|
||||
await this._deleteFromDestination(location);
|
||||
} catch (delErr) {
|
||||
console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message);
|
||||
if (location.type === 'local' && fs.existsSync(location.path)) {
|
||||
fs.unlinkSync(location.path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* Config Drift Detector - Compares services.json with live Docker state
|
||||
*
|
||||
* Detects discrepancies between the configured service list and what is
|
||||
* actually running in Docker, including missing containers, unknown
|
||||
* containers, port mismatches, state mismatches, and stale records.
|
||||
*
|
||||
* @module config-drift-detector
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/**
|
||||
* @typedef {Object} DriftReport
|
||||
* @property {string} checkedAt - ISO timestamp of the check
|
||||
* @property {Object[]} missingContainers - Services with containerId but container absent in Docker
|
||||
* @property {Object[]} unknownContainers - Running Docker containers with sami.managed label but not in services.json
|
||||
* @property {Object[]} portMismatch - Service port != container mapped port
|
||||
* @property {Object[]} stateMismatch - Service expected up but container stopped/absent
|
||||
* @property {Object[]} staleRecords - Services with containerId pointing to removed containers
|
||||
* @property {boolean} hasDrift - Whether any drift category is non-empty
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detects and reports configuration drift between services.json and Docker.
|
||||
*
|
||||
* @extends EventEmitter
|
||||
*
|
||||
* @fires ConfigDriftDetector#drift-detected
|
||||
*/
|
||||
class ConfigDriftDetector extends EventEmitter {
|
||||
/**
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
|
||||
* @param {Object} ctx.servicesStateManager - StateManager for services.json
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
* @param {Function} ctx.logError - Error logging function
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.log = ctx.log || console;
|
||||
this.logError = ctx.logError || ((_c, err) => console.error(err));
|
||||
this.docker = ctx.docker;
|
||||
this.servicesStateManager = ctx.servicesStateManager;
|
||||
this.notification = ctx.notification;
|
||||
|
||||
/** @type {DriftReport|null} Cached report from last detection */
|
||||
this.lastReport = null;
|
||||
|
||||
/** @type {NodeJS.Timeout|null} Polling timer reference */
|
||||
this._pollTimer = null;
|
||||
|
||||
/** Whether polling is currently active */
|
||||
this._polling = false;
|
||||
}
|
||||
|
||||
// ─── Detection ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run a full drift detection and return the report.
|
||||
*
|
||||
* Reads services from servicesStateManager and live containers from Docker,
|
||||
* then compares them across five drift categories.
|
||||
*
|
||||
* @returns {Promise<DriftReport>}
|
||||
*/
|
||||
async detect() {
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
// Gather configured services
|
||||
let services = [];
|
||||
try {
|
||||
const data = await this.servicesStateManager.read();
|
||||
services = Array.isArray(data) ? data : (data.services || []);
|
||||
} catch (err) {
|
||||
this.log.error('drift', 'Failed to read services', { error: err.message });
|
||||
}
|
||||
|
||||
// Gather live Docker containers
|
||||
let containers = [];
|
||||
try {
|
||||
containers = await this.docker.client.listContainers({ all: true });
|
||||
} catch (err) {
|
||||
this.log.error('drift', 'Failed to list containers', { error: err.message });
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
const containerById = new Map(); // containerId (short or long) → container info
|
||||
const containerByName = new Map(); // container name → container info
|
||||
|
||||
for (const c of containers) {
|
||||
// Store by full ID
|
||||
containerById.set(c.Id, c);
|
||||
// Store by short ID (first 12 chars)
|
||||
if (c.Id && c.Id.length >= 12) {
|
||||
containerById.set(c.Id.substring(0, 12), c);
|
||||
}
|
||||
// Store by name (strip leading /)
|
||||
for (const name of (c.Names || [])) {
|
||||
containerByName.set(name.replace(/^\//, ''), c);
|
||||
}
|
||||
}
|
||||
|
||||
// Build set of service containerIds for reverse lookup
|
||||
const serviceContainerIds = new Set();
|
||||
const serviceByContainerId = new Map();
|
||||
|
||||
for (const svc of services) {
|
||||
if (svc.containerId) {
|
||||
serviceContainerIds.add(svc.containerId);
|
||||
// Index by both full and short ID
|
||||
serviceByContainerId.set(svc.containerId, svc);
|
||||
if (svc.containerId.length >= 12) {
|
||||
serviceByContainerId.set(svc.containerId.substring(0, 12), svc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missingContainers = [];
|
||||
const portMismatch = [];
|
||||
const stateMismatch = [];
|
||||
const staleRecords = [];
|
||||
|
||||
for (const svc of services) {
|
||||
if (!svc.containerId) continue;
|
||||
|
||||
// Look up the container
|
||||
const container = containerById.get(svc.containerId)
|
||||
|| containerById.get(svc.containerId.substring(0, 12));
|
||||
|
||||
if (!container) {
|
||||
// Container ID referenced but not found in Docker at all
|
||||
staleRecords.push({
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
containerId: svc.containerId,
|
||||
reason: 'Container not found in Docker',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Missing container — service expects it but it's not running
|
||||
if (container.State !== 'running') {
|
||||
missingContainers.push({
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
containerId: svc.containerId,
|
||||
containerState: container.State,
|
||||
containerStatus: container.Status,
|
||||
});
|
||||
|
||||
// Also a state mismatch if the service is expected to be up
|
||||
stateMismatch.push({
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
expectedState: 'running',
|
||||
actualState: container.State,
|
||||
containerId: svc.containerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Port mismatch detection
|
||||
if (svc.port && container.State === 'running') {
|
||||
const actualPorts = this._extractContainerPorts(container);
|
||||
if (actualPorts.length > 0 && !actualPorts.includes(svc.port)) {
|
||||
portMismatch.push({
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
configuredPort: svc.port,
|
||||
actualPorts,
|
||||
containerId: svc.containerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown managed containers: Docker containers with sami.managed label
|
||||
// that are NOT in services.json
|
||||
const unknownContainers = [];
|
||||
for (const c of containers) {
|
||||
const isManaged = c.Labels && c.Labels['sami.managed'] === 'true';
|
||||
if (!isManaged) continue;
|
||||
|
||||
const isInServices = serviceByContainerId.has(c.Id)
|
||||
|| serviceByContainerId.has(c.Id.substring(0, 12));
|
||||
|
||||
if (!isInServices) {
|
||||
unknownContainers.push({
|
||||
containerId: c.Id,
|
||||
name: (c.Names && c.Names[0] || '').replace(/^\//, ''),
|
||||
image: c.Image,
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
app: c.Labels?.['sami.app'] || null,
|
||||
subdomain: c.Labels?.['sami.subdomain'] || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
checkedAt,
|
||||
missingContainers,
|
||||
unknownContainers,
|
||||
portMismatch,
|
||||
stateMismatch,
|
||||
staleRecords,
|
||||
hasDrift: missingContainers.length > 0
|
||||
|| unknownContainers.length > 0
|
||||
|| portMismatch.length > 0
|
||||
|| stateMismatch.length > 0
|
||||
|| staleRecords.length > 0,
|
||||
};
|
||||
|
||||
// Cache for quick API access
|
||||
this.lastReport = report;
|
||||
|
||||
// Emit and notify if drift detected
|
||||
if (report.hasDrift) {
|
||||
/**
|
||||
* @event ConfigDriftDetector#drift-detected
|
||||
* @type {DriftReport}
|
||||
*/
|
||||
this.emit('drift-detected', report);
|
||||
|
||||
try {
|
||||
await this._sendDriftNotification(report);
|
||||
} catch (notifErr) {
|
||||
this.log.error('drift', 'Failed to send drift notification', {
|
||||
error: notifErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.log.info('drift', 'Detection complete', {
|
||||
hasDrift: report.hasDrift,
|
||||
missing: report.missingContainers.length,
|
||||
unknown: report.unknownContainers.length,
|
||||
portMismatch: report.portMismatch.length,
|
||||
stateMismatch: report.stateMismatch.length,
|
||||
stale: report.staleRecords.length,
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ─── Auto-fix ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempt to auto-fix drift:
|
||||
* - Remove stale records (services referencing removed containers)
|
||||
* - Flag unknown containers for review
|
||||
*
|
||||
* @returns {Promise<{ staleRemoved: number, unknownFlagged: number }>}
|
||||
*/
|
||||
async autoFix() {
|
||||
const report = await this.detect();
|
||||
let staleRemoved = 0;
|
||||
|
||||
// Remove stale records from services.json
|
||||
if (report.staleRecords.length > 0) {
|
||||
const staleIds = new Set(report.staleRecords.map(r => r.serviceId));
|
||||
await this.servicesStateManager.update(services => {
|
||||
const before = services.length;
|
||||
const cleaned = services.filter(s => !staleIds.has(s.id));
|
||||
staleRemoved = before - cleaned.length;
|
||||
return cleaned;
|
||||
});
|
||||
}
|
||||
|
||||
const unknownFlagged = report.unknownContainers.length;
|
||||
|
||||
this.log.info('drift', 'Auto-fix applied', { staleRemoved, unknownFlagged });
|
||||
|
||||
return { staleRemoved, unknownFlagged };
|
||||
}
|
||||
|
||||
// ─── Polling ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start periodic drift detection.
|
||||
*
|
||||
* @param {number} [intervalMs=300000] - Polling interval in milliseconds (default 5 min)
|
||||
*/
|
||||
startPolling(intervalMs = 300000) {
|
||||
this.stopPolling();
|
||||
|
||||
this._polling = true;
|
||||
this._pollTimer = setInterval(async () => {
|
||||
try {
|
||||
await this.detect();
|
||||
} catch (err) {
|
||||
this.logError('drift-poll', err);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
this.log.info('drift', 'Polling started', { intervalMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic drift detection.
|
||||
*/
|
||||
stopPolling() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
this._polling = false;
|
||||
this.log.info('drift', 'Polling stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polling is currently active.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPolling() {
|
||||
return this._polling;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract mapped host ports from a Docker container info object.
|
||||
*
|
||||
* @param {Object} container - Dockerode container info
|
||||
* @returns {number[]} Array of host port numbers
|
||||
* @private
|
||||
*/
|
||||
_extractContainerPorts(container) {
|
||||
const ports = [];
|
||||
if (!container.Ports) return ports;
|
||||
|
||||
for (const p of container.Ports) {
|
||||
if (p.PublicPort) {
|
||||
ports.push(p.PublicPort);
|
||||
}
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification about detected drift.
|
||||
*
|
||||
* @param {DriftReport} report
|
||||
* @returns {Promise<Object>}
|
||||
* @private
|
||||
*/
|
||||
async _sendDriftNotification(report) {
|
||||
if (!this.notification?.send) {
|
||||
return { success: false, reason: 'no-notification-manager' };
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (report.missingContainers.length > 0) {
|
||||
parts.push(`Missing containers: ${report.missingContainers.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
if (report.unknownContainers.length > 0) {
|
||||
parts.push(`Unknown managed containers: ${report.unknownContainers.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
if (report.portMismatch.length > 0) {
|
||||
parts.push(`Port mismatches: ${report.portMismatch.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
if (report.staleRecords.length > 0) {
|
||||
parts.push(`Stale records: ${report.staleRecords.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
|
||||
return this.notification.send('drift-detected', {
|
||||
text: `⚠️ Configuration drift detected:\n${parts.join('\n')}`,
|
||||
report,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConfigDriftDetector };
|
||||
@@ -59,15 +59,6 @@ function validateConfig(config) {
|
||||
errors.push('dns.servers must be an object');
|
||||
}
|
||||
}
|
||||
// DNS provider validation
|
||||
if (config.dns.provider !== undefined) {
|
||||
const validProviders = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
|
||||
if (typeof config.dns.provider !== 'string') {
|
||||
errors.push('dns.provider must be a string');
|
||||
} else if (!validProviders.includes(config.dns.provider)) {
|
||||
warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${validProviders.join(', ')}. It may still work if a custom adapter is installed.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR',
|
||||
// ── Docker ──────────────────────────────────────────────────────
|
||||
const DOCKER = {
|
||||
CONTAINER_PREFIX: 'sami-',
|
||||
TIMEOUT: 300000, // 300s — timeout for docker pull/create operations
|
||||
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
|
||||
|
||||
@@ -10,26 +10,7 @@ const lockfile = require('proper-lockfile');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Resolve credentials file path — supports both standard install (/app/credentials.json)
|
||||
// and custom deployments with consolidated data directory (/app/data/credentials.json)
|
||||
function resolveCredentialsFile() {
|
||||
if (process.env.CREDENTIALS_FILE) {
|
||||
return process.env.CREDENTIALS_FILE;
|
||||
}
|
||||
const candidates = [
|
||||
path.join(__dirname, 'credentials.json'),
|
||||
path.join(__dirname, 'data', 'credentials.json'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// No existing file — return standard path so first store() creates it there
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const CREDENTIALS_FILE = resolveCredentialsFile();
|
||||
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(__dirname, 'credentials.json');
|
||||
|
||||
class CredentialManager {
|
||||
constructor() {
|
||||
@@ -338,6 +319,53 @@ class CredentialManager {
|
||||
: data.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a credential with diagnostic info on failure.
|
||||
*
|
||||
* Used by the TOTP recovery flow: when a user is locked out and the secret
|
||||
* can't be decrypted (e.g. encryption key was rotated by a container
|
||||
* recreate), we need to distinguish "no secret was ever set" from "secret
|
||||
* is on disk but unreadable" so the UI can show a useful next step.
|
||||
*
|
||||
* Status codes:
|
||||
* 'ok' — value decrypted / returned as-is
|
||||
* 'missing' — key is not present in the store at all
|
||||
* 'unreadable' — key is present but decryption failed (key mismatch / corruption)
|
||||
* 'malformed' — entry exists but value is not in expected encrypted format
|
||||
*
|
||||
* @param {string} key - Credential identifier
|
||||
* @returns {Promise<{ status: string, value: string|null, error?: string }>}
|
||||
*/
|
||||
async diagnose(key) {
|
||||
try {
|
||||
const credentials = await this.loadCredentialsFile();
|
||||
const data = credentials[key];
|
||||
if (!data) return { status: 'missing', value: null };
|
||||
|
||||
if (!cryptoUtils.isEncrypted(data.value)) {
|
||||
// Plaintext entry — return as-is
|
||||
return { status: 'ok', value: data.value };
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = cryptoUtils.decrypt(data.value);
|
||||
return { status: 'ok', value: decrypted };
|
||||
} catch (decryptErr) {
|
||||
// Most common cause: the encryption key on disk is different from
|
||||
// the key that originally encrypted this entry (rotated by a
|
||||
// container recreate that didn't preserve CREDENTIALS_FILE env).
|
||||
console.warn(
|
||||
`[CredentialManager] '${key}' is present but cannot be decrypted ` +
|
||||
`(likely encryption-key mismatch): ${decryptErr.message}`
|
||||
);
|
||||
return { status: 'unreadable', value: null, error: decryptErr.message };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[CredentialManager] diagnose('${key}') failed:`, err.message);
|
||||
return { status: 'malformed', value: null, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFromFile(key) {
|
||||
await this._lockedUpdate(credentials => {
|
||||
delete credentials[key];
|
||||
|
||||
@@ -15,26 +15,8 @@ const IV_LENGTH = 16; // 128 bits for GCM
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const SALT_LENGTH = 32;
|
||||
|
||||
// Resolve encryption key file path — supports both standard install (/app/.encryption-key)
|
||||
// and custom deployments with consolidated data directory (/app/data/.encryption-key)
|
||||
function resolveKeyFile() {
|
||||
if (process.env.ENCRYPTION_KEY_FILE) {
|
||||
return process.env.ENCRYPTION_KEY_FILE;
|
||||
}
|
||||
const candidates = [
|
||||
path.join(__dirname, '.encryption-key'),
|
||||
path.join(__dirname, 'data', '.encryption-key'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// No existing file — return standard path so first load creates it there
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const KEY_FILE = resolveKeyFile();
|
||||
// Key file location (should be outside of mounted volumes for security)
|
||||
const KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(__dirname, '.encryption-key');
|
||||
|
||||
let encryptionKey = null;
|
||||
|
||||
@@ -84,6 +66,31 @@ function loadOrCreateKey() {
|
||||
if (keyData.length >= 64) {
|
||||
encryptionKey = Buffer.from(keyData, 'hex');
|
||||
console.log('[Crypto] Loaded encryption key from file');
|
||||
// First-run bootstrap: if .bak doesn't exist yet, write the current
|
||||
// key to it. This ensures the silent recovery path is available from
|
||||
// the very next restart without requiring an explicit rotateKey().
|
||||
if (!fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', keyData, { mode: 0o600 });
|
||||
console.log(`[Crypto] Seeded ${KEY_FILE}.bak with current key for future fallback`);
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not seed .bak key file:', e.message);
|
||||
}
|
||||
}
|
||||
// Try fallback to .bak key if primary can't decrypt existing credentials.
|
||||
// This handles the "container recreate rotated the key" case where the
|
||||
// backup key on disk is the ORIGINAL key that can still read the
|
||||
// bind-mounted /app/data/credentials.json written before the upgrade.
|
||||
if (fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
const backupData = fs.readFileSync(KEY_FILE + '.bak', 'utf8').trim();
|
||||
if (backupData.length >= 64) {
|
||||
encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not check backup key:', e.message);
|
||||
}
|
||||
}
|
||||
return encryptionKey;
|
||||
}
|
||||
// File exists but key is invalid/empty - will generate new one below
|
||||
@@ -107,6 +114,64 @@ function loadOrCreateKey() {
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the primary key fails to decrypt any existing credentials, try the backup
|
||||
* key. This is the silent recovery path: if a container recreate replaced
|
||||
* .encryption-key with a fresh one but left .encryption-key.bak (the previous
|
||||
* key), the old key can still decrypt the bind-mounted credentials.json and
|
||||
* the user stays logged in without ever noticing.
|
||||
*
|
||||
* Called only at startup when both key files exist. Returns the working key
|
||||
* (either primary or backup). If neither works, returns the primary (existing
|
||||
* behavior — `retrieve()` will surface "unreadable" via credential-manager.diagnose).
|
||||
*
|
||||
* @param {Buffer} primaryKey - key from .encryption-key
|
||||
* @param {Buffer} backupKey - key from .encryption-key.bak
|
||||
* @returns {Buffer} the key that should be used
|
||||
*/
|
||||
function tryFallbackToBackupKey(primaryKey, backupKey) {
|
||||
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE ||
|
||||
require('path').join(__dirname, 'credentials.json');
|
||||
if (!fs.existsSync(CREDENTIALS_FILE)) return primaryKey;
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return primaryKey;
|
||||
}
|
||||
|
||||
// Find the first encrypted entry to probe
|
||||
const probeEntry = Object.values(credentials).find(v => v && v.value && isEncrypted(v.value));
|
||||
if (!probeEntry) return primaryKey;
|
||||
|
||||
const tryDecrypt = (key) => {
|
||||
const parts = probeEntry.value.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
try {
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
const tag = Buffer.from(parts[1], 'base64');
|
||||
const ct = Buffer.from(parts[2], 'base64');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
if (tryDecrypt(primaryKey)) return primaryKey;
|
||||
if (tryDecrypt(backupKey)) {
|
||||
console.warn(
|
||||
'[Crypto] Primary encryption key failed to decrypt credentials; ' +
|
||||
'fell back to .encryption-key.bak. The current primary key was set ' +
|
||||
'without preserving the original. Consider rotating the key explicitly ' +
|
||||
'via the credential-manager API to avoid this warning next restart.'
|
||||
);
|
||||
return backupKey;
|
||||
}
|
||||
return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data
|
||||
* @param {string|object} data - Data to encrypt (strings or objects)
|
||||
@@ -294,6 +359,17 @@ function rotateKey() {
|
||||
const oldKey = loadOrCreateKey(); // Ensure we have the current key loaded
|
||||
const newKey = generateKey();
|
||||
|
||||
// Save the OLD key to .bak BEFORE swapping the primary. This gives the
|
||||
// startup-time fallback a way to recover the previous key if a future
|
||||
// restart loses the new one (e.g. another accidental recreate). The .bak
|
||||
// file is overwritten on each rotate so it always holds the previous key,
|
||||
// not an ever-accumulating history.
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', oldKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.warn(`[Crypto] Could not save backup key to ${KEY_FILE}.bak:`, error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
[
|
||||
{
|
||||
"id": "router",
|
||||
"name": "Router UI",
|
||||
"logo": "/assets/router.png",
|
||||
"url": "https://router.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "chat",
|
||||
"name": "Chat",
|
||||
"logo": "/assets/chat.png",
|
||||
"url": "https://chat.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "sync",
|
||||
"name": "Syncthing",
|
||||
"logo": "/assets/syncthing.png",
|
||||
"url": "https://sync.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "torrent",
|
||||
"name": "qBittorrent",
|
||||
"logo": "/assets/qBittorrent.png",
|
||||
"url": "https://torrent.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T06:04:55.246Z"
|
||||
},
|
||||
{
|
||||
"id": "sonarr",
|
||||
"name": "Sonarr",
|
||||
"logo": "/assets/sonarr.png",
|
||||
"url": "https://sonarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T06:04:56.612Z"
|
||||
},
|
||||
{
|
||||
"id": "radarr",
|
||||
"name": "Radarr",
|
||||
"logo": "/assets/radarr.png",
|
||||
"url": "https://radarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T08:28:12.359Z"
|
||||
},
|
||||
{
|
||||
"id": "prowlarr",
|
||||
"name": "Prowlarr",
|
||||
"logo": "/assets/prowlarr.png",
|
||||
"url": "https://prowlarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T08:28:13.739Z"
|
||||
},
|
||||
{
|
||||
"id": "ca",
|
||||
"name": "DashCA",
|
||||
"logo": "/assets/certificate-icon.png",
|
||||
"containerId": null,
|
||||
"appTemplate": "dashca",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-02-11T11:47:08.383Z",
|
||||
"url": "https://ca.sami"
|
||||
},
|
||||
{
|
||||
"id": "plex",
|
||||
"name": "Plex",
|
||||
"logo": "/assets/plex.png",
|
||||
"containerId": null,
|
||||
"appTemplate": "plex",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-02-12T02:18:36.067Z",
|
||||
"url": "https://plex.sami"
|
||||
},
|
||||
{
|
||||
"id": "requests",
|
||||
"name": "Seerr",
|
||||
"logo": "/assets/seerr.png",
|
||||
"url": "https://requests.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Gitea",
|
||||
"logo": "/assets/gitea.png",
|
||||
"url": "https://git.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "files",
|
||||
"name": "Sami Files",
|
||||
"logo": "/assets/sami-files.png",
|
||||
"url": "https://files.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"containerId": null,
|
||||
"appTemplate": "sami-files",
|
||||
"deployedAt": "2026-06-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -1,605 +0,0 @@
|
||||
/**
|
||||
* Dependency Manager - Service dependency tracking with ordered restart chains
|
||||
*
|
||||
* Manages directed acyclic graph (DAG) of service dependencies. Services can
|
||||
* declare which other services they depend on, and this manager provides:
|
||||
* - Full dependency graph inspection
|
||||
* - Topological ordering for safe restart chains
|
||||
* - Circular dependency detection
|
||||
* - Health-aware restart with per-service polling
|
||||
*
|
||||
* Dependencies are stored directly on service objects in services.json:
|
||||
* { id, name, ..., dependsOn: ['service-id-1', 'service-id-2'] }
|
||||
*
|
||||
* @module dependency-manager
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/** Maximum seconds to wait for a single container to become healthy after restart */
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Interval between container health polls */
|
||||
const HEALTH_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
/**
|
||||
* @typedef {Object} ServiceNode
|
||||
* @property {string} serviceId
|
||||
* @property {string} name
|
||||
* @property {string|null} containerId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyEdge
|
||||
* @property {string} from - The service that depends
|
||||
* @property {string} to - The service being depended upon
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyGraph
|
||||
* @property {ServiceNode[]} nodes
|
||||
* @property {DependencyEdge[]} edges
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyStatusEntry
|
||||
* @property {string} serviceId
|
||||
* @property {string} name
|
||||
* @property {boolean} isUp
|
||||
* @property {string} [error]
|
||||
*/
|
||||
|
||||
/**
|
||||
* DependencyManager — tracks service dependencies and orchestrates ordered restarts.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `dependency-restart-start` ({ serviceId, chain: string[] })
|
||||
* - `dependency-restart-progress` ({ serviceId, currentServiceId, index, total })
|
||||
* - `dependency-restart-complete` ({ serviceId, chain: string[], results: Array })
|
||||
* - `dependency-restart-failed` ({ serviceId, failedServiceId, error, chain: string[] })
|
||||
*
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
class DependencyManager extends EventEmitter {
|
||||
/**
|
||||
* @param {Object} ctx - Application context
|
||||
* @param {Object} ctx.servicesStateManager - StateManager for services.json
|
||||
* @param {Object} ctx.docker - Docker context ({ client: Dockerode })
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
/** @private */
|
||||
this.ctx = ctx;
|
||||
/** @private */
|
||||
this._servicesStateManager = ctx.servicesStateManager;
|
||||
/** @private */
|
||||
this._docker = ctx.docker;
|
||||
/** @private */
|
||||
this._notification = ctx.notification;
|
||||
/** @private */
|
||||
this._log = ctx.log || console;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load all services from the state manager.
|
||||
* @private
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async _loadServices() {
|
||||
const data = await this._servicesStateManager.read();
|
||||
return Array.isArray(data) ? data : (data.services || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single service by ID.
|
||||
* @private
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async _findService(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
return services.find(s => s.id === serviceId) || null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the full dependency graph for visualisation.
|
||||
*
|
||||
* @returns {Promise<DependencyGraph>}
|
||||
*/
|
||||
async getDependencyGraph() {
|
||||
const services = await this._loadServices();
|
||||
|
||||
const nodes = services.map(s => ({
|
||||
serviceId: s.id,
|
||||
name: s.name,
|
||||
containerId: s.containerId || null,
|
||||
}));
|
||||
|
||||
const edges = [];
|
||||
for (const service of services) {
|
||||
const deps = service.dependsOn || [];
|
||||
for (const depId of deps) {
|
||||
edges.push({ from: service.id, to: depId });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the services that depend on the given service (reverse deps).
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object[]>} Services whose `dependsOn` includes `serviceId`.
|
||||
*/
|
||||
async getDependents(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
return services.filter(s => (s.dependsOn || []).includes(serviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the direct dependencies for a service.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object[]>} Services that `serviceId` depends on.
|
||||
*/
|
||||
async getDependencies(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) return [];
|
||||
const depIds = service.dependsOn || [];
|
||||
return services.filter(s => depIds.includes(s.id));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topological sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build an adjacency list for the current dependency graph.
|
||||
* Edge direction: service → its dependencies (i.e. what it depends on).
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} services
|
||||
* @returns {Map<string, string[]>}
|
||||
*/
|
||||
_buildAdjacencyList(services) {
|
||||
const adj = new Map();
|
||||
for (const service of services) {
|
||||
adj.set(service.id, (service.dependsOn || []).slice());
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFS-based topological sort with cycle detection (white/gray/black coloring).
|
||||
*
|
||||
* Returns services in restart order: dependencies first, dependents last.
|
||||
* The target service is included at the end.
|
||||
*
|
||||
* @private
|
||||
* @param {string} serviceId - Target service (will be last in the result).
|
||||
* @param {Object[]} services - All services.
|
||||
* @param {Map<string, string[]>} adj - Adjacency list (service → deps).
|
||||
* @returns {string[]} Ordered service IDs for restart.
|
||||
* @throws {Error} If a circular dependency is detected.
|
||||
*/
|
||||
_topologicalSort(serviceId, services, adj) {
|
||||
// Collect only the reachable sub-graph from serviceId
|
||||
const visited = new Set();
|
||||
const reachable = new Set();
|
||||
|
||||
const collectReachable = (id) => {
|
||||
if (reachable.has(id)) return;
|
||||
reachable.add(id);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
collectReachable(dep);
|
||||
}
|
||||
};
|
||||
collectReachable(serviceId);
|
||||
|
||||
// DFS topological sort on the reachable sub-graph
|
||||
const WHITE = 0, GRAY = 1, BLACK = 2;
|
||||
const color = new Map();
|
||||
for (const id of reachable) color.set(id, WHITE);
|
||||
|
||||
const result = [];
|
||||
|
||||
const dfs = (id) => {
|
||||
if (color.get(id) === BLACK) return;
|
||||
if (color.get(id) === GRAY) {
|
||||
throw new Error(`Circular dependency detected involving service "${id}"`);
|
||||
}
|
||||
color.set(id, GRAY);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
dfs(dep);
|
||||
}
|
||||
color.set(id, BLACK);
|
||||
result.push(id);
|
||||
};
|
||||
|
||||
// Visit the target last so it ends up at the end of the result
|
||||
// Actually, we want deps *first* then the target.
|
||||
// The DFS naturally puts deps before dependents, so starting from
|
||||
// serviceId will place it last (which is correct for restart order).
|
||||
dfs(serviceId);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the topologically ordered restart chain for a service.
|
||||
*
|
||||
* The returned array lists all services that must be restarted,
|
||||
* starting with leaf dependencies and ending with the target service.
|
||||
*
|
||||
* @param {string} serviceId - The service to build the chain for.
|
||||
* @returns {Promise<string[]>} Ordered service IDs.
|
||||
* @throws {Error} If `serviceId` doesn't exist or a circular dependency is found.
|
||||
*/
|
||||
async getOrderedRestartChain(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) {
|
||||
throw new Error(`Service "${serviceId}" not found`);
|
||||
}
|
||||
|
||||
const adj = this._buildAdjacencyList(services);
|
||||
return this._topologicalSort(serviceId, services, adj);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a proposed set of dependencies for a service.
|
||||
*
|
||||
* Checks:
|
||||
* - All referenced service IDs exist.
|
||||
* - Adding these dependencies would not create a circular dependency.
|
||||
* - A service cannot depend on itself.
|
||||
*
|
||||
* @param {string} serviceId - The service to set dependencies on.
|
||||
* @param {string[]} dependsOn - Proposed dependency IDs.
|
||||
* @returns {Promise<{ valid: boolean, errors: string[] }>}
|
||||
*/
|
||||
async validateDependencies(serviceId, dependsOn) {
|
||||
const errors = [];
|
||||
|
||||
if (!Array.isArray(dependsOn)) {
|
||||
return { valid: false, errors: ['dependsOn must be an array'] };
|
||||
}
|
||||
|
||||
const services = await this._loadServices();
|
||||
const allIds = new Set(services.map(s => s.id));
|
||||
|
||||
// Service must exist
|
||||
if (!allIds.has(serviceId)) {
|
||||
return { valid: false, errors: [`Service "${serviceId}" not found`] };
|
||||
}
|
||||
|
||||
// Self-dependency
|
||||
if (dependsOn.includes(serviceId)) {
|
||||
errors.push(`Service "${serviceId}" cannot depend on itself`);
|
||||
}
|
||||
|
||||
// Existence check
|
||||
for (const depId of dependsOn) {
|
||||
if (!allIds.has(depId)) {
|
||||
errors.push(`Dependency service "${depId}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Circular dependency check: temporarily set the proposed dependsOn
|
||||
// and attempt a topological sort.
|
||||
const tempServices = services.map(s => {
|
||||
if (s.id === serviceId) {
|
||||
return { ...s, dependsOn: dependsOn.slice() };
|
||||
}
|
||||
return { ...s };
|
||||
});
|
||||
|
||||
const adj = this._buildAdjacencyList(tempServices);
|
||||
|
||||
// Check every node for cycles with the new edges
|
||||
try {
|
||||
const WHITE = 0, GRAY = 1, BLACK = 2;
|
||||
const color = new Map();
|
||||
for (const s of tempServices) color.set(s.id, WHITE);
|
||||
|
||||
const dfs = (id) => {
|
||||
if (color.get(id) === BLACK) return;
|
||||
if (color.get(id) === GRAY) {
|
||||
throw new Error(`Circular dependency detected involving service "${id}"`);
|
||||
}
|
||||
color.set(id, GRAY);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
dfs(dep);
|
||||
}
|
||||
color.set(id, BLACK);
|
||||
};
|
||||
|
||||
for (const s of tempServices) {
|
||||
if (color.get(s.id) === WHITE) {
|
||||
dfs(s.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(err.message);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the current container status for a service and all its transitive dependencies.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<DependencyStatusEntry[]>}
|
||||
* @throws {Error} If `serviceId` doesn't exist.
|
||||
*/
|
||||
async getDependencyStatus(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) {
|
||||
throw new Error(`Service "${serviceId}" not found`);
|
||||
}
|
||||
|
||||
// Collect all transitive dependencies via BFS
|
||||
const serviceMap = new Map(services.map(s => [s.id, s]));
|
||||
const visited = new Set();
|
||||
const queue = [serviceId];
|
||||
const allRelated = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift();
|
||||
if (visited.has(currentId)) continue;
|
||||
visited.add(currentId);
|
||||
|
||||
const svc = serviceMap.get(currentId);
|
||||
if (!svc) continue;
|
||||
|
||||
allRelated.push(svc);
|
||||
|
||||
for (const depId of (svc.dependsOn || [])) {
|
||||
if (!visited.has(depId)) {
|
||||
queue.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query container status for each
|
||||
const results = [];
|
||||
for (const svc of allRelated) {
|
||||
const entry = {
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
isUp: false,
|
||||
};
|
||||
|
||||
if (!svc.containerId) {
|
||||
entry.error = 'No container associated with this service';
|
||||
results.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = this._docker.client.getContainer(svc.containerId);
|
||||
const info = await container.inspect();
|
||||
entry.isUp = info.State?.Running === true;
|
||||
} catch (err) {
|
||||
entry.error = err.message || 'Unable to inspect container';
|
||||
}
|
||||
|
||||
results.push(entry);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restart with dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wait for a container to report as running after a restart.
|
||||
*
|
||||
* @private
|
||||
* @param {string} containerId
|
||||
* @param {number} [timeoutMs=30000]
|
||||
* @returns {Promise<boolean>} `true` if healthy, `false` if timed out.
|
||||
*/
|
||||
async _waitForContainerHealthy(containerId, timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const container = this._docker.client.getContainer(containerId);
|
||||
const info = await container.inspect();
|
||||
if (info.State?.Running === true) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Container might not be inspectable during restart — keep polling
|
||||
}
|
||||
await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a service and all its dependencies in topological order.
|
||||
*
|
||||
* Emits progress events and sends a notification on completion/failure.
|
||||
* This method is designed to be called from the route handler and
|
||||
* **does not throw** — errors are reported via events and notifications.
|
||||
*
|
||||
* @param {string} serviceId - Target service to restart (with deps).
|
||||
* @returns {Promise<{ success: boolean, chain: string[], results: Array }>}
|
||||
*/
|
||||
async restartWithDependencies(serviceId) {
|
||||
const service = await this._findService(serviceId);
|
||||
if (!service) {
|
||||
const err = new Error(`Service "${serviceId}" not found`);
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: serviceId,
|
||||
error: err.message,
|
||||
chain: [],
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
let chain;
|
||||
try {
|
||||
chain = await this.getOrderedRestartChain(serviceId);
|
||||
} catch (err) {
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: serviceId,
|
||||
error: err.message,
|
||||
chain: [],
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const services = await this._loadServices();
|
||||
const serviceMap = new Map(services.map(s => [s.id, s]));
|
||||
|
||||
this._log.info('dependency', 'Starting dependency restart chain', {
|
||||
serviceId,
|
||||
chain,
|
||||
});
|
||||
|
||||
this.emit('dependency-restart-start', { serviceId, chain });
|
||||
|
||||
const results = [];
|
||||
const total = chain.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const currentId = chain[i];
|
||||
const svc = serviceMap.get(currentId);
|
||||
|
||||
this.emit('dependency-restart-progress', {
|
||||
serviceId,
|
||||
currentServiceId: currentId,
|
||||
index: i,
|
||||
total,
|
||||
});
|
||||
|
||||
if (!svc || !svc.containerId) {
|
||||
const msg = !svc
|
||||
? `Service "${currentId}" not found in state`
|
||||
: `Service "${currentId}" has no container — skipping restart`;
|
||||
this._log.warn('dependency', msg);
|
||||
results.push({ serviceId: currentId, restarted: false, skipped: true, reason: msg });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = this._docker.client.getContainer(svc.containerId);
|
||||
this._log.info('dependency', `Restarting container for service "${currentId}"`, {
|
||||
containerId: svc.containerId,
|
||||
});
|
||||
await container.restart();
|
||||
|
||||
// Wait for it to come back up
|
||||
const healthy = await this._waitForContainerHealthy(svc.containerId);
|
||||
if (!healthy) {
|
||||
const msg = `Container for service "${currentId}" did not become healthy within ${HEALTH_CHECK_TIMEOUT_MS / 1000}s`;
|
||||
this._log.warn('dependency', msg);
|
||||
results.push({ serviceId: currentId, restarted: true, healthy: false, error: msg });
|
||||
|
||||
// Abort chain — dependency didn't come back
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: currentId,
|
||||
error: msg,
|
||||
chain,
|
||||
});
|
||||
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
|
||||
return { success: false, chain, results };
|
||||
}
|
||||
|
||||
this._log.info('dependency', `Service "${currentId}" is healthy after restart`);
|
||||
results.push({ serviceId: currentId, restarted: true, healthy: true });
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error during restart';
|
||||
this._log.error('dependency', `Failed to restart service "${currentId}"`, {
|
||||
error: msg,
|
||||
});
|
||||
results.push({ serviceId: currentId, restarted: false, error: msg });
|
||||
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: currentId,
|
||||
error: msg,
|
||||
chain,
|
||||
});
|
||||
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
|
||||
return { success: false, chain, results };
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('dependency-restart-complete', { serviceId, chain, results });
|
||||
await this._notifyRestartResult(serviceId, true, chain, results);
|
||||
return { success: true, chain, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification about the restart result.
|
||||
*
|
||||
* @private
|
||||
* @param {string} serviceId
|
||||
* @param {boolean} success
|
||||
* @param {string[]} chain
|
||||
* @param {Array} results
|
||||
* @param {string} [failedServiceId]
|
||||
*/
|
||||
async _notifyRestartResult(serviceId, success, chain, results, failedServiceId) {
|
||||
if (!this._notification) return;
|
||||
|
||||
try {
|
||||
if (success) {
|
||||
await this._notification.send('dependency-restart-complete', {
|
||||
text: `✅ Dependency restart chain completed for "${serviceId}". Restarted: ${chain.join(' → ')}`,
|
||||
serviceId,
|
||||
chain,
|
||||
results,
|
||||
});
|
||||
} else {
|
||||
await this._notification.send('dependency-restart-failed', {
|
||||
text: `❌ Dependency restart chain failed for "${serviceId}" at "${failedServiceId}". Chain: ${chain.join(' → ')}`,
|
||||
serviceId,
|
||||
failedServiceId,
|
||||
chain,
|
||||
results,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this._log.error('dependency', 'Failed to send restart notification', {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DependencyManager;
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* DNS Propagation Checker
|
||||
* Verifies DNS record propagation by querying multiple resolvers.
|
||||
* Runs as background jobs with configurable timeout and interval.
|
||||
*
|
||||
* @module dns-propagation
|
||||
*/
|
||||
|
||||
const dns = require('dns').promises;
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/** Default verification options */
|
||||
const DEFAULT_OPTIONS = {
|
||||
timeout: 300000, // 5 minutes
|
||||
interval: 10000, // 10 seconds
|
||||
resolvers: ['1.1.1.1', '8.8.8.8', '9.9.9.9']
|
||||
};
|
||||
|
||||
/** Maximum age for stored verification results (1 hour) */
|
||||
const MAX_RESULT_AGE_MS = 3600000;
|
||||
|
||||
class DNSPropagationChecker extends EventEmitter {
|
||||
/**
|
||||
* Create a DNSPropagationChecker instance.
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.log = ctx.log || console;
|
||||
|
||||
/** @type {Map<string, Object>} domain → verification status */
|
||||
this.verifications = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a DNS record has propagated by querying multiple resolvers.
|
||||
* Retries every `interval` ms until `timeout` is reached.
|
||||
*
|
||||
* @param {string} domain - The domain to check (e.g., 'test.sami')
|
||||
* @param {string} expectedIp - The expected IP address
|
||||
* @param {Object} [options={}] - Verification options
|
||||
* @param {number} [options.timeout=300000] - Maximum time to wait (ms)
|
||||
* @param {number} [options.interval=10000] - Time between retries (ms)
|
||||
* @param {string[]} [options.resolvers] - DNS resolvers to query
|
||||
* @returns {Promise<Object>} Verification result
|
||||
*/
|
||||
async verifyRecord(domain, expectedIp, options = {}) {
|
||||
const startTime = Date.now();
|
||||
const {
|
||||
timeout = DEFAULT_OPTIONS.timeout,
|
||||
interval = DEFAULT_OPTIONS.interval,
|
||||
resolvers = DEFAULT_OPTIONS.resolvers
|
||||
} = options;
|
||||
|
||||
const allResults = [];
|
||||
let propagated = false;
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const roundResults = [];
|
||||
|
||||
for (const resolver of resolvers) {
|
||||
const checkStart = Date.now();
|
||||
try {
|
||||
// Use dns.resolve4 with a custom resolver
|
||||
const resolverInstance = new dns.Resolver();
|
||||
resolverInstance.setServers([resolver]);
|
||||
resolverInstance.setTimeout(5000);
|
||||
|
||||
const addresses = await resolverInstance.resolve4(domain);
|
||||
const matched = addresses.includes(expectedIp);
|
||||
|
||||
const result = {
|
||||
resolver,
|
||||
ips: addresses,
|
||||
matched,
|
||||
checkedAt: new Date().toISOString(),
|
||||
responseTime: Date.now() - checkStart
|
||||
};
|
||||
|
||||
roundResults.push(result);
|
||||
|
||||
if (matched) {
|
||||
propagated = true;
|
||||
}
|
||||
} catch (err) {
|
||||
roundResults.push({
|
||||
resolver,
|
||||
ips: [],
|
||||
matched: false,
|
||||
checkedAt: new Date().toISOString(),
|
||||
error: err.code || err.message,
|
||||
responseTime: Date.now() - checkStart
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
allResults.push(...roundResults);
|
||||
|
||||
// Emit progress event
|
||||
this.emit('propagation-check', {
|
||||
domain,
|
||||
expectedIp,
|
||||
roundResults,
|
||||
elapsed: Date.now() - startTime,
|
||||
propagated
|
||||
});
|
||||
|
||||
if (propagated) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait before next attempt
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
domain,
|
||||
expectedIp,
|
||||
propagated,
|
||||
results: allResults,
|
||||
totalTime,
|
||||
checkedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a background DNS propagation verification.
|
||||
* Does not block — returns immediately with the job reference.
|
||||
*
|
||||
* @param {string} domain - The domain to verify
|
||||
* @param {string} expectedIp - The expected IP address
|
||||
* @param {Object} [options={}] - Verification options
|
||||
* @returns {Object} Job status object
|
||||
*/
|
||||
startVerification(domain, expectedIp, options = {}) {
|
||||
// If there's already a running verification for this domain, return it
|
||||
const existing = this.verifications.get(domain);
|
||||
if (existing && existing.status === 'running') {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const job = {
|
||||
domain,
|
||||
expectedIp,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
progress: [],
|
||||
result: null
|
||||
};
|
||||
|
||||
this.verifications.set(domain, job);
|
||||
|
||||
// Run verification in background (non-blocking)
|
||||
this.verifyRecord(domain, expectedIp, options)
|
||||
.then(result => {
|
||||
job.status = 'completed';
|
||||
job.result = result;
|
||||
job.completedAt = new Date().toISOString();
|
||||
|
||||
if (result.propagated) {
|
||||
this.emit('propagation-complete', result);
|
||||
|
||||
if (this.ctx.notification) {
|
||||
this.ctx.notification.send('dns-propagation', {
|
||||
text: `✅ DNS record for ${domain} propagated successfully to ${expectedIp}`,
|
||||
domain,
|
||||
expectedIp,
|
||||
totalTime: result.totalTime
|
||||
}, 'success').catch(err => {
|
||||
this.log.error('dns-propagation', 'Failed to send propagation notification', {
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.emit('propagation-timeout', result);
|
||||
|
||||
if (this.ctx.notification) {
|
||||
this.ctx.notification.send('dns-propagation', {
|
||||
text: `⏱️ DNS propagation timeout for ${domain} — expected ${expectedIp} not found after ${Math.round(result.totalTime / 1000)}s`,
|
||||
domain,
|
||||
expectedIp,
|
||||
totalTime: result.totalTime
|
||||
}, 'warning').catch(err => {
|
||||
this.log.error('dns-propagation', 'Failed to send timeout notification', {
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
job.status = 'error';
|
||||
job.error = err.message;
|
||||
job.completedAt = new Date().toISOString();
|
||||
|
||||
this.log.error('dns-propagation', `Verification failed for ${domain}`, {
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current verification status for a domain.
|
||||
*
|
||||
* @param {string} domain - The domain to look up
|
||||
* @returns {Object|null} Verification status or null if not found
|
||||
*/
|
||||
getVerificationStatus(domain) {
|
||||
const job = this.verifications.get(domain);
|
||||
if (!job) return null;
|
||||
return {
|
||||
domain: job.domain,
|
||||
expectedIp: job.expectedIp,
|
||||
status: job.status,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt || null,
|
||||
result: job.result || null,
|
||||
error: job.error || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recent verifications.
|
||||
*
|
||||
* @returns {Object[]} Array of verification statuses
|
||||
*/
|
||||
getAllVerifications() {
|
||||
const results = [];
|
||||
for (const [domain, job] of this.verifications.entries()) {
|
||||
results.push({
|
||||
domain,
|
||||
expectedIp: job.expectedIp,
|
||||
status: job.status,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt || null,
|
||||
propagated: job.result?.propagated || null,
|
||||
totalTime: job.result?.totalTime || null,
|
||||
error: job.error || null
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove verifications older than 1 hour.
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [domain, job] of this.verifications.entries()) {
|
||||
const completedAt = job.completedAt ? new Date(job.completedAt).getTime() : null;
|
||||
const startedAt = new Date(job.startedAt).getTime();
|
||||
|
||||
// Clean up completed/error jobs older than 1 hour
|
||||
// Also clean up stale running jobs that started over 2 hours ago
|
||||
const age = completedAt ? (now - completedAt) : (now - startedAt);
|
||||
const maxAge = job.status === 'running' ? MAX_RESULT_AGE_MS * 2 : MAX_RESULT_AGE_MS;
|
||||
|
||||
if (age > maxAge) {
|
||||
this.verifications.delete(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DNSPropagationChecker;
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Base DNS Provider Adapter
|
||||
* All DNS provider adapters must extend this class and implement the required methods.
|
||||
*
|
||||
* Each adapter handles the specifics of talking to a particular DNS provider's API.
|
||||
* The routes layer calls these methods generically — no provider-specific logic in routes.
|
||||
*/
|
||||
class BaseDNSProvider {
|
||||
constructor(config, ctx) {
|
||||
this.config = config; // Provider-specific config (api token, server url, etc.)
|
||||
this.ctx = ctx; // Shared app context (log, credentialManager, fetchT, etc.)
|
||||
this.providerId = 'base';
|
||||
this.displayName = 'Base DNS Provider';
|
||||
}
|
||||
|
||||
/** Check if this provider supports a given capability */
|
||||
supportsCapability(cap) {
|
||||
// Capabilities: 'create-record', 'delete-record', 'resolve', 'list-records',
|
||||
// 'logs', 'restart', 'update-check', 'credentials', 'zones'
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Authenticate and return a token/session */
|
||||
async authenticate() { throw new Error('Not implemented'); }
|
||||
|
||||
/** Create a DNS record */
|
||||
async createRecord({ domain, zone, type, value, ttl, overwrite }) { throw new Error('Not implemented'); }
|
||||
|
||||
/** Delete a DNS record */
|
||||
async deleteRecord({ domain, type, value }) { throw new Error('Not implemented'); }
|
||||
|
||||
/** Resolve/query existing records for a domain */
|
||||
async resolveRecords({ domain, zone, type }) { throw new Error('Not implemented'); }
|
||||
|
||||
/** List all records in a zone */
|
||||
async listRecords({ zone }) { throw new Error('Not implemented'); }
|
||||
|
||||
/** Get DNS query logs */
|
||||
async getLogs({ limit, server }) { throw new Error('Not implemented'); }
|
||||
|
||||
/** Restart the DNS server */
|
||||
async restartServer({ server }) { throw new Error('Not implemented'); }
|
||||
|
||||
/** Check for DNS server updates */
|
||||
async checkUpdate({ server }) { throw new Error('Not implemented'); }
|
||||
|
||||
/** Get provider status info */
|
||||
async getStatus() {
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
capabilities: this.getCapabilities(),
|
||||
authenticated: false
|
||||
};
|
||||
}
|
||||
|
||||
/** Get list of supported capabilities */
|
||||
getCapabilities() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Validate provider-specific config */
|
||||
validateConfig() { return { valid: true, errors: [] }; }
|
||||
|
||||
/** Clean up resources on shutdown */
|
||||
async shutdown() {}
|
||||
}
|
||||
|
||||
module.exports = BaseDNSProvider;
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Cloudflare DNS Provider Adapter
|
||||
* Manages DNS records via the Cloudflare API v4.
|
||||
*/
|
||||
const BaseDNSProvider = require('./base');
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
class CloudflareDNSProvider extends BaseDNSProvider {
|
||||
constructor(config, ctx) {
|
||||
super(config, ctx);
|
||||
this.providerId = 'cloudflare';
|
||||
this.displayName = 'Cloudflare DNS';
|
||||
|
||||
// Resolve API token: explicit config takes priority, then credential manager
|
||||
this.apiToken = config.apiToken
|
||||
|| (ctx.credentialManager && ctx.credentialManager.get('dns.cloudflare.apiToken'))
|
||||
|| null;
|
||||
this.zoneId = config.zoneId || null;
|
||||
this.domain = config.domain || null;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build common request headers for Cloudflare API calls */
|
||||
_headers() {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/** Make an authenticated request to the Cloudflare API */
|
||||
async _cfRequest(method, path, body) {
|
||||
const url = `${CF_API_BASE}${path}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: this._headers(),
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
return this.ctx.fetchT(url, opts);
|
||||
}
|
||||
|
||||
/** Map a Cloudflare DNS record to the normalised format expected by routes */
|
||||
_mapRecord(rec) {
|
||||
return {
|
||||
id: rec.id,
|
||||
type: rec.type,
|
||||
name: rec.name,
|
||||
value: rec.content,
|
||||
ttl: rec.ttl,
|
||||
proxied: rec.proxied || false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Capabilities ───────────────────────────────────────────────────────
|
||||
|
||||
supportsCapability(cap) {
|
||||
return this.getCapabilities().includes(cap);
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return ['create-record', 'delete-record', 'resolve', 'list-records', 'credentials', 'zones'];
|
||||
}
|
||||
|
||||
// ── Authentication ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate the API token by calling the Cloudflare verify endpoint.
|
||||
* Stores basic zone info on success.
|
||||
*/
|
||||
async authenticate() {
|
||||
this.ctx.log('[cloudflare] Authenticating – verifying API token…');
|
||||
|
||||
if (!this.apiToken) {
|
||||
return { status: 'error', message: 'No Cloudflare API token provided' };
|
||||
}
|
||||
|
||||
const res = await this._cfRequest('GET', '/user/tokens/verify');
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Token verification failed';
|
||||
this.ctx.log(`[cloudflare] Authentication failed: ${msg}`);
|
||||
return { status: 'error', message: msg };
|
||||
}
|
||||
|
||||
this.ctx.log(`[cloudflare] Token verified for status "${data.status}"`);
|
||||
|
||||
// Optionally fetch zone info if zoneId is configured
|
||||
if (this.zoneId) {
|
||||
try {
|
||||
const zoneRes = await this._cfRequest('GET', `/zones/${this.zoneId}`);
|
||||
const zoneData = await zoneRes.json();
|
||||
if (zoneData.success && zoneData.result) {
|
||||
this.zoneInfo = zoneData.result;
|
||||
this.ctx.log(`[cloudflare] Zone loaded: ${zoneData.result.name} (${zoneData.result.id})`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.ctx.log(`[cloudflare] Could not fetch zone info: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'ok', response: { status: data.status } };
|
||||
}
|
||||
|
||||
// ── Create Record ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a DNS record.
|
||||
* If overwrite is true, first delete any existing record with the same name+type.
|
||||
*/
|
||||
async createRecord({ domain, zone, type, value, ttl, overwrite }) {
|
||||
const targetDomain = domain || this.domain;
|
||||
const targetZone = zone || this.zoneId;
|
||||
|
||||
if (!targetZone) {
|
||||
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
|
||||
}
|
||||
|
||||
if (overwrite) {
|
||||
this.ctx.log(`[cloudflare] Overwrite requested – deleting existing ${type} record for ${targetDomain}`);
|
||||
try {
|
||||
await this.deleteRecord({ domain: targetDomain, type, value });
|
||||
} catch (err) {
|
||||
this.ctx.log(`[cloudflare] No existing record to overwrite (or delete failed): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
type,
|
||||
name: targetDomain,
|
||||
content: value,
|
||||
ttl: ttl || 1, // 1 = automatic TTL in Cloudflare
|
||||
proxied: false,
|
||||
};
|
||||
|
||||
this.ctx.log(`[cloudflare] Creating ${type} record: ${targetDomain} → ${value}`);
|
||||
const res = await this._cfRequest('POST', `/zones/${targetZone}/dns_records`, body);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Record creation failed';
|
||||
this.ctx.log(`[cloudflare] Create failed: ${msg}`);
|
||||
return { status: 'error', message: msg };
|
||||
}
|
||||
|
||||
return { status: 'ok', response: { record: this._mapRecord(data.result) } };
|
||||
}
|
||||
|
||||
// ── Delete Record ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Delete DNS records matching domain+type.
|
||||
* Lists matching records first, then deletes each one.
|
||||
*/
|
||||
async deleteRecord({ domain, type, value }) {
|
||||
const targetDomain = domain || this.domain;
|
||||
const targetZone = this.zoneId;
|
||||
|
||||
if (!targetZone) {
|
||||
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
|
||||
}
|
||||
|
||||
// List records matching name + type
|
||||
let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`;
|
||||
if (type) {
|
||||
queryPath += `&type=${encodeURIComponent(type)}`;
|
||||
}
|
||||
|
||||
const listRes = await this._cfRequest('GET', queryPath);
|
||||
const listData = await listRes.json();
|
||||
|
||||
if (!listData.success) {
|
||||
const msg = (listData.errors && listData.errors[0] && listData.errors[0].message) || 'Failed to list records for deletion';
|
||||
this.ctx.log(`[cloudflare] Delete – list failed: ${msg}`);
|
||||
return { status: 'error', message: msg };
|
||||
}
|
||||
|
||||
const matching = listData.result || [];
|
||||
if (matching.length === 0) {
|
||||
this.ctx.log(`[cloudflare] No records found for ${targetDomain} (${type || 'any type'})`);
|
||||
return { status: 'ok', response: { deleted: 0 } };
|
||||
}
|
||||
|
||||
// If a specific value is given, only delete records matching that value
|
||||
const toDelete = value
|
||||
? matching.filter((r) => r.content === value)
|
||||
: matching;
|
||||
|
||||
let deleted = 0;
|
||||
for (const record of toDelete) {
|
||||
const delRes = await this._cfRequest('DELETE', `/zones/${targetZone}/dns_records/${record.id}`);
|
||||
const delData = await delRes.json();
|
||||
if (delData.success) {
|
||||
deleted++;
|
||||
this.ctx.log(`[cloudflare] Deleted record ${record.id} (${record.type} ${record.name})`);
|
||||
} else {
|
||||
const msg = (delData.errors && delData.errors[0] && delData.errors[0].message) || 'Delete failed';
|
||||
this.ctx.log(`[cloudflare] Failed to delete record ${record.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'ok', response: { deleted } };
|
||||
}
|
||||
|
||||
// ── Resolve Records ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve/query existing records for a domain.
|
||||
* Returns records matching domain (and optionally type).
|
||||
*/
|
||||
async resolveRecords({ domain, zone, type }) {
|
||||
const targetDomain = domain || this.domain;
|
||||
const targetZone = zone || this.zoneId;
|
||||
|
||||
if (!targetZone) {
|
||||
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
|
||||
}
|
||||
|
||||
let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`;
|
||||
if (type) {
|
||||
queryPath += `&type=${encodeURIComponent(type)}`;
|
||||
}
|
||||
|
||||
this.ctx.log(`[cloudflare] Resolving records for ${targetDomain}${type ? ` (${type})` : ''}`);
|
||||
const res = await this._cfRequest('GET', queryPath);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Resolve failed';
|
||||
this.ctx.log(`[cloudflare] Resolve failed: ${msg}`);
|
||||
return { status: 'error', message: msg };
|
||||
}
|
||||
|
||||
const records = (data.result || []).map(this._mapRecord);
|
||||
return { status: 'ok', response: { records } };
|
||||
}
|
||||
|
||||
// ── List Records ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all DNS records in a zone.
|
||||
*/
|
||||
async listRecords({ zone }) {
|
||||
const targetZone = zone || this.zoneId;
|
||||
|
||||
if (!targetZone) {
|
||||
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
|
||||
}
|
||||
|
||||
this.ctx.log(`[cloudflare] Listing all records in zone ${targetZone}`);
|
||||
const res = await this._cfRequest('GET', `/zones/${targetZone}/dns_records`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'List failed';
|
||||
this.ctx.log(`[cloudflare] List failed: ${msg}`);
|
||||
return { status: 'error', message: msg };
|
||||
}
|
||||
|
||||
const records = (data.result || []).map(this._mapRecord);
|
||||
return { status: 'ok', response: { records } };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CloudflareDNSProvider;
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Manual DNS Provider Adapter
|
||||
* No-op adapter for users who manage DNS externally (manual, cPanel, other control panels).
|
||||
* Provides propagation checking only — all record operations return helpful instructions.
|
||||
*/
|
||||
const BaseDNSProvider = require('./base');
|
||||
|
||||
class ManualDNSProvider extends BaseDNSProvider {
|
||||
constructor(config, ctx) {
|
||||
super(config, ctx);
|
||||
this.providerId = 'manual';
|
||||
this.displayName = 'Manual / External DNS';
|
||||
this.description = 'Manage DNS records yourself via your provider\'s control panel';
|
||||
}
|
||||
|
||||
supportsCapability(cap) {
|
||||
return ['credentials'].includes(cap);
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return ['credentials'];
|
||||
}
|
||||
|
||||
async authenticate() {
|
||||
return { success: true, message: 'Manual DNS — no authentication needed' };
|
||||
}
|
||||
|
||||
async createRecord({ domain, zone, type, value, ttl }) {
|
||||
return {
|
||||
status: 'manual',
|
||||
message: `Create this record manually in your DNS control panel:`,
|
||||
instructions: {
|
||||
name: domain,
|
||||
type: type || 'A',
|
||||
value,
|
||||
ttl: ttl || 300
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async deleteRecord({ domain, type, value }) {
|
||||
return {
|
||||
status: 'manual',
|
||||
message: `Delete this record manually from your DNS control panel:`,
|
||||
instructions: {
|
||||
name: domain,
|
||||
type: type || 'A',
|
||||
value: value || '(any)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async resolveRecords({ domain, zone, type }) {
|
||||
// Use Node.js built-in DNS to resolve regardless of provider
|
||||
const dns = require('dns').promises;
|
||||
try {
|
||||
const resolver = new dns.Resolver();
|
||||
resolver.setServers(['1.1.1.1', '8.8.8.8']);
|
||||
const records = await resolver.resolve(domain, type || 'A');
|
||||
return {
|
||||
status: 'ok',
|
||||
response: {
|
||||
records: records.map(r => ({
|
||||
type: type || 'A',
|
||||
domain,
|
||||
rData: { ipAddress: r },
|
||||
ttl: 0,
|
||||
manual: true
|
||||
}))
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
return { status: 'ok', response: { records: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
description: this.description,
|
||||
capabilities: this.getCapabilities(),
|
||||
authenticated: true,
|
||||
note: 'DNS records are managed externally. Use propagation checks to verify changes.'
|
||||
};
|
||||
}
|
||||
|
||||
validateConfig() {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ManualDNSProvider;
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* DNS Provider Registry
|
||||
* Manages available DNS provider adapters.
|
||||
* Providers register themselves, and the active provider is selected by config.
|
||||
*/
|
||||
const path = require('path');
|
||||
|
||||
class DNSProviderRegistry {
|
||||
constructor() {
|
||||
this.providers = new Map(); // providerId -> adapter class
|
||||
this.instances = new Map(); // providerId -> adapter instance
|
||||
}
|
||||
|
||||
/** Register a provider adapter class */
|
||||
register(adapterClass) {
|
||||
const instance = new adapterClass({}, {});
|
||||
const id = instance.providerId;
|
||||
if (this.providers.has(id)) {
|
||||
console.warn(`DNS provider "${id}" already registered, overwriting`);
|
||||
}
|
||||
this.providers.set(id, adapterClass);
|
||||
}
|
||||
|
||||
/** Get list of all registered provider IDs */
|
||||
getProviderIds() {
|
||||
return Array.from(this.providers.keys());
|
||||
}
|
||||
|
||||
/** Get metadata for all providers (without instantiating with real config) */
|
||||
getProviderMeta() {
|
||||
return this.getProviderIds().map(id => {
|
||||
const Adapter = this.providers.get(id);
|
||||
const inst = new Adapter({}, {});
|
||||
return {
|
||||
id: inst.providerId,
|
||||
displayName: inst.displayName,
|
||||
capabilities: inst.getCapabilities()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an adapter instance for the given provider + config
|
||||
* @param {string} providerId - The provider to instantiate
|
||||
* @param {Object} config - Provider-specific configuration
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @returns {BaseDNSProvider} The provider adapter instance
|
||||
*/
|
||||
getProvider(providerId, config, ctx) {
|
||||
// Re-create if config changed
|
||||
const cacheKey = providerId;
|
||||
const Adapter = this.providers.get(providerId);
|
||||
if (!Adapter) {
|
||||
throw new Error(`Unknown DNS provider: ${providerId}. Available: ${this.getProviderIds().join(', ')}`);
|
||||
}
|
||||
const instance = new Adapter(config, ctx);
|
||||
this.instances.set(cacheKey, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Auto-discover and register all providers in this directory */
|
||||
autoDiscover() {
|
||||
const fs = require('fs');
|
||||
const dir = __dirname;
|
||||
const files = fs.readdirSync(dir).filter(f =>
|
||||
f !== 'base.js' && f !== 'registry.js' && f.endsWith('.js') && !f.startsWith('.')
|
||||
);
|
||||
for (const file of files) {
|
||||
try {
|
||||
const Loaded = require(path.join(dir, file));
|
||||
// Support: module.exports = Class, module.exports = { Class }, or plain objects
|
||||
let cls = null;
|
||||
if (typeof Loaded === 'function') {
|
||||
cls = Loaded;
|
||||
} else if (typeof Loaded === 'object' && Loaded !== null) {
|
||||
// Try to find a class in the exported object
|
||||
cls = Object.values(Loaded).find(v => typeof v === 'function');
|
||||
}
|
||||
if (cls) {
|
||||
// Verify it has providerId (on prototype or set in constructor)
|
||||
try {
|
||||
const test = new cls({}, {});
|
||||
if (test.providerId && typeof test.getCapabilities === 'function') {
|
||||
this.register(cls);
|
||||
}
|
||||
} catch {
|
||||
// Not a valid provider adapter, skip
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load DNS provider from ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
const registry = new DNSProviderRegistry();
|
||||
registry.autoDiscover();
|
||||
|
||||
module.exports = registry;
|
||||
@@ -1,383 +0,0 @@
|
||||
/**
|
||||
* RFC 2136 Dynamic DNS Provider Adapter
|
||||
*
|
||||
* Manages DNS records via RFC 2136 dynamic updates using the nsupdate CLI tool.
|
||||
* Compatible with BIND, PowerDNS, Windows DNS, and any RFC 2136-compliant server.
|
||||
*
|
||||
* Capabilities: create-record, delete-record, resolve, credentials
|
||||
* Not supported: logs, restart, update-check, list-records, zones
|
||||
*/
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const dns = require('dns');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const BaseDNSProvider = require('./base');
|
||||
|
||||
const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'credentials'];
|
||||
|
||||
const DEFAULT_PORT = 53;
|
||||
const DEFAULT_TSIG_ALGORITHM = 'hmac-sha256';
|
||||
const NSUPDATE_TIMEOUT_MS = 15000;
|
||||
|
||||
class RFC2136Provider extends BaseDNSProvider {
|
||||
static providerId = 'rfc2136';
|
||||
static displayName = 'RFC 2136 (Dynamic DNS)';
|
||||
|
||||
constructor(config, ctx) {
|
||||
super(config, ctx);
|
||||
|
||||
this.providerId = 'rfc2136';
|
||||
this.displayName = 'RFC 2136 (Dynamic DNS)';
|
||||
|
||||
// Core config
|
||||
this.server = config.server || null;
|
||||
this.port = config.port || DEFAULT_PORT;
|
||||
this.zone = config.zone || null;
|
||||
|
||||
// TSIG authentication
|
||||
this.tsigAlgorithm = config.tsigAlgorithm || DEFAULT_TSIG_ALGORITHM;
|
||||
this.tsigKeyName = config.tsigKeyName || null;
|
||||
this.tsigSecret = config.tsigSecret || null;
|
||||
|
||||
// Resolve credentials from credential manager if available
|
||||
if (ctx && ctx.credentialManager) {
|
||||
if (!this.tsigKeyName && ctx.credentialManager.get) {
|
||||
this.tsigKeyName = ctx.credentialManager.get('rfc2136_tsigKeyName') || null;
|
||||
}
|
||||
if (!this.tsigSecret && ctx.credentialManager.get) {
|
||||
this.tsigSecret = ctx.credentialManager.get('rfc2136_tsigSecret') || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Logger shorthand
|
||||
this._log = ctx && ctx.log ? ctx.ctx : null;
|
||||
}
|
||||
|
||||
// ── Logging helper ────────────────────────────────────────────────────────
|
||||
|
||||
_log(level, message, meta) {
|
||||
if (this.ctx && this.ctx.log && typeof this.ctx.log[level] === 'function') {
|
||||
this.ctx.log[level](`[rfc2136] ${message}`, meta || {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────────────────
|
||||
|
||||
supportsCapability(cap) {
|
||||
return CAPABILITIES.includes(cap);
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return [...CAPABILITIES];
|
||||
}
|
||||
|
||||
// ── Config validation ─────────────────────────────────────────────────────
|
||||
|
||||
validateConfig() {
|
||||
const errors = [];
|
||||
if (!this.server) errors.push('Missing required config: server');
|
||||
if (!this.zone) errors.push('Missing required config: zone');
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure a domain name ends with a trailing dot (FQDN for nsupdate).
|
||||
*/
|
||||
_ensureFqdn(domain) {
|
||||
if (!domain) return domain;
|
||||
return domain.endsWith('.') ? domain : `${domain}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the common nsupdate header lines (server, zone, key).
|
||||
*/
|
||||
_buildHeader() {
|
||||
const lines = [];
|
||||
lines.push(`server ${this.server} ${this.port}`);
|
||||
lines.push(`zone ${this.zone}`);
|
||||
|
||||
if (this.tsigKeyName && this.tsigSecret) {
|
||||
lines.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an nsupdate script and return { stdout, stderr }.
|
||||
* Writes commands to a temporary file and runs `nsupdate <file>`.
|
||||
*/
|
||||
async _runNsupdate(commands) {
|
||||
const script = commands.join('\n') + '\n';
|
||||
const tmpFile = path.join(os.tmpdir(), `nsupdate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cmd`);
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(tmpFile, script, { mode: 0o600 });
|
||||
this._log('debug', `Executing nsupdate script`, { script: script.trim() });
|
||||
|
||||
const { stdout, stderr } = await execFileAsync('nsupdate', [tmpFile], {
|
||||
timeout: NSUPDATE_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
this._log('debug', 'nsupdate completed', { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() });
|
||||
|
||||
if (stderr && stderr.toLowerCase().includes('refused')) {
|
||||
throw new Error(`nsupdate refused: ${stderr.trim()}`);
|
||||
}
|
||||
if (stderr && stderr.toLowerCase().includes('failed')) {
|
||||
throw new Error(`nsupdate failed: ${stderr.trim()}`);
|
||||
}
|
||||
|
||||
return { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() };
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new Error('nsupdate command not found. Install bind9utils (Debian/Ubuntu) or bind-utils (RHEL/CentOS).');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
try { await fs.promises.unlink(tmpFile); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authenticate ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify nsupdate is available and optionally test connectivity.
|
||||
* Runs a minimal nsupdate with just "show" (no-op) to confirm the tool works.
|
||||
*/
|
||||
async authenticate() {
|
||||
const validation = this.validateConfig();
|
||||
if (!validation.valid) {
|
||||
throw new Error(`RFC 2136 config invalid: ${validation.errors.join('; ')}`);
|
||||
}
|
||||
|
||||
// Check nsupdate binary is available with a dry-run command set
|
||||
const commands = [
|
||||
...this._buildHeader(),
|
||||
'show',
|
||||
];
|
||||
|
||||
try {
|
||||
const { stdout } = await this._runNsupdate(commands);
|
||||
this._log('info', 'Authenticated to RFC 2136 server', { server: this.server, port: this.port });
|
||||
return { success: true, server: this.server, port: this.port };
|
||||
} catch (err) {
|
||||
this._log('error', 'Authentication test failed', { error: err.message });
|
||||
// If nsupdate is missing, rethrow immediately
|
||||
if (err.message.includes('not found')) throw err;
|
||||
// Otherwise, the server might be unreachable but the tool works — return partial
|
||||
return { success: false, error: err.message, server: this.server };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create Record ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create (add) a DNS record via RFC 2136 UPDATE.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.domain - Record name (e.g. "www.example.com")
|
||||
* @param {string} params.zone - Zone name (overrides constructor zone)
|
||||
* @param {string} params.type - Record type (A, AAAA, CNAME, TXT, etc.)
|
||||
* @param {string} params.value - Record value
|
||||
* @param {number} [params.ttl=300] - TTL in seconds
|
||||
*/
|
||||
async createRecord({ domain, zone, type, value, ttl }) {
|
||||
const effectiveZone = zone || this.zone;
|
||||
const effectiveTtl = ttl || 300;
|
||||
const fqdn = this._ensureFqdn(domain);
|
||||
|
||||
const commands = [
|
||||
`server ${this.server} ${this.port}`,
|
||||
`zone ${effectiveZone}`,
|
||||
];
|
||||
|
||||
if (this.tsigKeyName && this.tsigSecret) {
|
||||
commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
|
||||
}
|
||||
|
||||
commands.push(`update add ${fqdn} ${effectiveTtl} ${type} ${value}`);
|
||||
commands.push('show');
|
||||
commands.push('send');
|
||||
|
||||
this._log('info', 'Creating DNS record', { domain: fqdn, type, value, ttl: effectiveTtl });
|
||||
|
||||
const result = await this._runNsupdate(commands);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'create-record',
|
||||
domain: fqdn,
|
||||
type,
|
||||
value,
|
||||
ttl: effectiveTtl,
|
||||
zone: effectiveZone,
|
||||
raw: result.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Delete Record ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Delete a DNS record via RFC 2136 UPDATE.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.domain - Record name
|
||||
* @param {string} params.type - Record type
|
||||
* @param {string} [params.value] - Optional specific value to match
|
||||
*/
|
||||
async deleteRecord({ domain, type, value }) {
|
||||
const effectiveZone = this.zone;
|
||||
const fqdn = this._ensureFqdn(domain);
|
||||
|
||||
const commands = [
|
||||
`server ${this.server} ${this.port}`,
|
||||
`zone ${effectiveZone}`,
|
||||
];
|
||||
|
||||
if (this.tsigKeyName && this.tsigSecret) {
|
||||
commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
|
||||
}
|
||||
|
||||
// "update delete" with value removes that specific RR;
|
||||
// without value it removes all RRs of that type for the name.
|
||||
const deleteClause = value
|
||||
? `update delete ${fqdn} ${type} ${value}`
|
||||
: `update delete ${fqdn} ${type}`;
|
||||
|
||||
commands.push(deleteClause);
|
||||
commands.push('show');
|
||||
commands.push('send');
|
||||
|
||||
this._log('info', 'Deleting DNS record', { domain: fqdn, type, value: value || '(all)' });
|
||||
|
||||
const result = await this._runNsupdate(commands);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'delete-record',
|
||||
domain: fqdn,
|
||||
type,
|
||||
value: value || null,
|
||||
zone: effectiveZone,
|
||||
raw: result.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Resolve Records ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve DNS records for a domain.
|
||||
* First attempts dig against the configured server, then falls back to Node dns module.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.domain - Domain to resolve
|
||||
* @param {string} [params.zone] - Zone (unused for resolution, kept for interface consistency)
|
||||
* @param {string} [params.type='A'] - Record type to query
|
||||
*/
|
||||
async resolveRecords({ domain, zone, type }) {
|
||||
const queryType = type || 'A';
|
||||
const fqdn = domain.endsWith('.') ? domain : domain;
|
||||
|
||||
// Strategy 1: Use dig against the configured RFC 2136 server
|
||||
try {
|
||||
const { stdout } = await execFileAsync('dig', [
|
||||
`@${this.server}`,
|
||||
'-p', String(this.port),
|
||||
fqdn,
|
||||
queryType,
|
||||
'+short',
|
||||
'+time=5',
|
||||
'+tries=1',
|
||||
], { timeout: 10000 });
|
||||
|
||||
const records = stdout
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (records.length > 0) {
|
||||
this._log('debug', `Resolved ${fqdn} ${queryType} via dig`, { records });
|
||||
return {
|
||||
domain: fqdn,
|
||||
type: queryType,
|
||||
records: records.map(r => ({ value: r, type: queryType })),
|
||||
source: 'dig',
|
||||
server: this.server,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this._log('warn', 'dig resolution failed, falling back to Node dns', { error: err.message });
|
||||
}
|
||||
|
||||
// Strategy 2: Fallback to Node.js built-in resolver
|
||||
try {
|
||||
const resolver = new dns.Resolver();
|
||||
resolver.setServers([this.server]);
|
||||
|
||||
const resolveMethod = this._getResolveMethod(queryType);
|
||||
const resolveAsync = promisify(resolver[resolveMethod]).bind(resolver);
|
||||
|
||||
const results = await resolveAsync(fqdn);
|
||||
const records = Array.isArray(results) ? results : [results];
|
||||
|
||||
this._log('debug', `Resolved ${fqdn} ${queryType} via Node dns`, { records });
|
||||
|
||||
return {
|
||||
domain: fqdn,
|
||||
type: queryType,
|
||||
records: records.map(r => ({ value: String(r), type: queryType })),
|
||||
source: 'node-dns',
|
||||
server: this.server,
|
||||
};
|
||||
} catch (err) {
|
||||
this._log('warn', 'Node dns resolution also failed', { error: err.message });
|
||||
return {
|
||||
domain: fqdn,
|
||||
type: queryType,
|
||||
records: [],
|
||||
source: 'none',
|
||||
server: this.server,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map record type to the Node dns resolver method name.
|
||||
*/
|
||||
_getResolveMethod(type) {
|
||||
const map = {
|
||||
A: 'resolve4',
|
||||
AAAA: 'resolve6',
|
||||
CNAME: 'resolveCname',
|
||||
MX: 'resolveMx',
|
||||
TXT: 'resolveTxt',
|
||||
NS: 'resolveNs',
|
||||
SOA: 'resolveSoa',
|
||||
SRV: 'resolveSrv',
|
||||
PTR: 'reverse',
|
||||
};
|
||||
return map[(type || '').toUpperCase()] || 'resolve4';
|
||||
}
|
||||
|
||||
// ── Shutdown ──────────────────────────────────────────────────────────────
|
||||
|
||||
async shutdown() {
|
||||
this._log('info', 'RFC 2136 provider shutting down');
|
||||
}
|
||||
}
|
||||
|
||||
// Expose providerId on the prototype so the registry's auto-discover can detect it
|
||||
RFC2136Provider.prototype.providerId = 'rfc2136';
|
||||
|
||||
module.exports = RFC2136Provider;
|
||||
@@ -1,507 +0,0 @@
|
||||
/**
|
||||
* Technitium DNS Server Provider Adapter
|
||||
*
|
||||
* Wraps Technitium-specific DNS logic into the standard adapter interface.
|
||||
* Uses the Technitium HTTP API (default port 5380) for all operations.
|
||||
*/
|
||||
const BaseDNSProvider = require('./base');
|
||||
|
||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24-hour token lifetime
|
||||
|
||||
class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
constructor(config, ctx) {
|
||||
super(config, ctx);
|
||||
this.providerId = 'technitium';
|
||||
this.displayName = 'Technitium DNS Server';
|
||||
|
||||
this.serverIp = config.serverIp;
|
||||
this.serverPort = config.serverPort || 5380;
|
||||
this.dnsId = config.dnsId || null;
|
||||
|
||||
// Token state
|
||||
this.token = null;
|
||||
this.tokenExpiry = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capabilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static CAPABILITIES = [
|
||||
'create-record',
|
||||
'delete-record',
|
||||
'resolve',
|
||||
'list-records',
|
||||
'logs',
|
||||
'restart',
|
||||
'update-check',
|
||||
'credentials',
|
||||
'zones'
|
||||
];
|
||||
|
||||
supportsCapability(cap) {
|
||||
return TechnitiumDNSProvider.CAPABILITIES.includes(cap);
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return [...TechnitiumDNSProvider.CAPABILITIES];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build the base URL for this server */
|
||||
_baseUrl() {
|
||||
return `http://${this.serverIp}:${this.serverPort}`;
|
||||
}
|
||||
|
||||
/** Build a full API URL with query-string params */
|
||||
_buildUrl(apiPath, params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return `${this._baseUrl()}${apiPath}${qs ? '?' + qs : ''}`;
|
||||
}
|
||||
|
||||
/** Ensure we have a valid token; throws on failure */
|
||||
async _requireToken() {
|
||||
// Re-use existing token if still valid
|
||||
if (this.token && this.tokenExpiry && new Date() < new Date(this.tokenExpiry)) {
|
||||
return this.token;
|
||||
}
|
||||
const result = await this.authenticate();
|
||||
if (!result.success) {
|
||||
const err = new Error('No valid DNS token available. ' + (result.error || ''));
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Authenticate against the Technitium server.
|
||||
* Checks per-server credentials first (dns.{dnsId}.readonly.username),
|
||||
* then falls back to global credentials (dns.username).
|
||||
*
|
||||
* Stores token + expiry on success.
|
||||
*/
|
||||
async authenticate() {
|
||||
const { credentialManager, log } = this.ctx;
|
||||
|
||||
// Try per-server credentials first
|
||||
if (this.dnsId) {
|
||||
for (const role of ['readonly', 'admin']) {
|
||||
try {
|
||||
const username = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.username`);
|
||||
const password = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.password`);
|
||||
if (username && password) {
|
||||
const result = await this._doLogin(username, password);
|
||||
if (result.success) return result;
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('technitium', `Per-server ${role} credential error`, {
|
||||
dnsId: this.dnsId,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 this._doLogin(username, password);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('technitium', 'Global credential error', { error: err.message });
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual login POST to Technitium.
|
||||
* Stores token on success.
|
||||
*/
|
||||
async _doLogin(username, password) {
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
user: username,
|
||||
pass: password,
|
||||
includeInfo: 'false'
|
||||
});
|
||||
|
||||
const url = `${this._baseUrl()}/api/user/login?${params.toString()}`;
|
||||
const response = await fetchT(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok' && result.token) {
|
||||
this.token = result.token;
|
||||
this.tokenExpiry = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
||||
log.info('technitium', 'DNS token obtained', {
|
||||
server: this.serverIp,
|
||||
expires: this.tokenExpiry
|
||||
});
|
||||
return { success: true, token: this.token };
|
||||
}
|
||||
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Login error', { error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create (or overwrite) a DNS record.
|
||||
* GET /api/zones/records/add?token=...&domain=...&zone=...&type=...&ipAddress=...&ttl=...&overwrite=...
|
||||
*/
|
||||
async createRecord({ domain, zone, type, value, ttl, overwrite }) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
const params = {
|
||||
token,
|
||||
domain,
|
||||
zone,
|
||||
type: type || 'A',
|
||||
ipAddress: value,
|
||||
ttl: String(ttl || 300),
|
||||
overwrite: String(overwrite !== false)
|
||||
};
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Creating DNS record', { domain, type, value });
|
||||
const url = this._buildUrl('/api/zones/records/add', params);
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
log.info('technitium', 'DNS record created', { domain, type, value });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// If token expired, re-authenticate and retry once
|
||||
if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) {
|
||||
log.info('technitium', 'Token expired, re-authenticating');
|
||||
this.token = null;
|
||||
this.tokenExpiry = null;
|
||||
const retryToken = await this._requireToken();
|
||||
params.token = retryToken;
|
||||
const retryUrl = this._buildUrl('/api/zones/records/add', params);
|
||||
const retryResp = await fetchT(retryUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const retryResult = await retryResp.json();
|
||||
if (retryResult.status === 'ok') {
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(retryResult.errorMessage || 'Failed after token refresh');
|
||||
}
|
||||
|
||||
throw new Error(result.errorMessage || 'Unknown error');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a DNS record.
|
||||
* GET /api/zones/records/delete?token=...&domain=...&type=... (+ ipAddress if value provided)
|
||||
*/
|
||||
async deleteRecord({ domain, type, value }) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
const params = {
|
||||
token,
|
||||
domain,
|
||||
type: type || 'A'
|
||||
};
|
||||
if (value) {
|
||||
params.ipAddress = value;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Deleting DNS record', { domain, type, value });
|
||||
const url = this._buildUrl('/api/zones/records/delete', params);
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
log.info('technitium', 'DNS record deleted', { domain, type, value });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw new Error(result.errorMessage || 'Unknown error');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to delete DNS record for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve/query records for a domain in a zone.
|
||||
* GET /api/zones/records/get?token=...&domain=...&zone=...&listZone=true
|
||||
* Filters returned records by type if provided.
|
||||
*/
|
||||
async resolveRecords({ domain, zone, type }) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
const params = {
|
||||
token,
|
||||
domain,
|
||||
zone,
|
||||
listZone: 'true'
|
||||
};
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Resolving records', { domain, zone, type });
|
||||
const url = this._buildUrl('/api/zones/records/get', params);
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status !== 'ok') {
|
||||
throw new Error(result.errorMessage || 'Failed to resolve records');
|
||||
}
|
||||
|
||||
let records = (result.response && result.response.records) || [];
|
||||
|
||||
// Filter by type if specified
|
||||
if (type) {
|
||||
records = records.filter(r => r.type === type);
|
||||
}
|
||||
|
||||
return { success: true, records };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to resolve records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all records in a zone.
|
||||
* Delegates to resolveRecords with a wildcard domain.
|
||||
*/
|
||||
async listRecords({ zone }) {
|
||||
return this.resolveRecords({ domain: zone, zone, type: null });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch and parse DNS query logs.
|
||||
* 1. GET /api/logs/list to discover the latest log file
|
||||
* 2. GET /api/logs/download?token=...&fileName=... to download it
|
||||
* 3. Parse text format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer]
|
||||
*/
|
||||
async getLogs({ limit, server } = {}) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
const targetIp = server || this.serverIp;
|
||||
const targetPort = this.serverPort;
|
||||
const baseUrl = `http://${targetIp}:${targetPort}`;
|
||||
|
||||
try {
|
||||
// Step 1: Get log file list
|
||||
const listUrl = this._buildUrl('/api/logs/list', { token });
|
||||
const listResp = await fetchT(listUrl.replace(this._baseUrl(), baseUrl), {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const listResult = await listResp.json();
|
||||
|
||||
if (listResult.status !== 'ok' || !listResult.response || !listResult.response.length) {
|
||||
throw new Error(listResult.errorMessage || 'No log files found');
|
||||
}
|
||||
|
||||
// Pick the latest log file (last entry)
|
||||
const logFile = listResult.response[listResult.response.length - 1];
|
||||
const fileName = logFile.name || logFile.fileName || logFile;
|
||||
|
||||
// Step 2: Download the log file
|
||||
const downloadUrl = `${baseUrl}/api/logs/download?${new URLSearchParams({ token, fileName }).toString()}`;
|
||||
const downloadResp = await fetchT(downloadUrl, {
|
||||
method: 'GET'
|
||||
});
|
||||
const logText = await downloadResp.text();
|
||||
|
||||
// Step 3: Parse lines
|
||||
const parsed = this._parseLogText(logText, limit);
|
||||
return { success: true, logs: parsed };
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Failed to fetch DNS logs', { error: error.message });
|
||||
throw new Error(`Failed to get DNS logs: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Technitium DNS log text format.
|
||||
* Line format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer]
|
||||
*/
|
||||
_parseLogText(text, limit) {
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
const parsed = [];
|
||||
|
||||
// Process newest first if we need to limit
|
||||
const iterable = limit ? lines.slice(-limit).reverse() : lines;
|
||||
|
||||
for (const line of iterable) {
|
||||
try {
|
||||
const entry = {};
|
||||
|
||||
// Extract timestamp: [2024-01-15 10:30:45]
|
||||
const tsMatch = line.match(/\[([^\]]+)\]/);
|
||||
if (tsMatch) entry.timestamp = tsMatch[1];
|
||||
|
||||
// Extract client:port: [192.168.1.100:12345]
|
||||
const clientMatch = line.match(/\[([^\]]+:\d+)\]/g);
|
||||
if (clientMatch && clientMatch.length >= 2) {
|
||||
entry.client = clientMatch[1].replace(/\[|\]/g, '');
|
||||
}
|
||||
|
||||
// Extract protocol: [UDP] or [TCP]
|
||||
const protoMatch = line.match(/\]\s*\[(UDP|TCP|DoH|DoT|DoH2)\]/i);
|
||||
if (protoMatch) entry.protocol = protoMatch[1];
|
||||
|
||||
// Extract key-value pairs: QNAME: value; QTYPE: value; etc.
|
||||
const kvPattern = /(\w+):\s*([^;]+)/g;
|
||||
let match;
|
||||
while ((match = kvPattern.exec(line)) !== null) {
|
||||
const key = match[1];
|
||||
const val = match[2].trim();
|
||||
if (['QNAME', 'QTYPE', 'QCLASS', 'RCODE'].includes(key)) {
|
||||
entry[key.toLowerCase()] = val;
|
||||
} else if (key === 'ANSWER') {
|
||||
entry.answer = val;
|
||||
}
|
||||
}
|
||||
|
||||
entry.raw = line;
|
||||
parsed.push(entry);
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Restart the DNS server.
|
||||
* POST /api/admin/restart?token=...
|
||||
* Requires admin credentials.
|
||||
*/
|
||||
async restartServer({ server } = {}) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Restarting DNS server', { server: this.serverIp });
|
||||
const url = this._buildUrl('/api/admin/restart', { token });
|
||||
const response = await fetchT(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
log.info('technitium', 'DNS server restart initiated');
|
||||
return { success: true, message: 'Server restart initiated' };
|
||||
}
|
||||
|
||||
throw new Error(result.errorMessage || 'Restart failed');
|
||||
} catch (error) {
|
||||
log.error('technitium', 'DNS restart error', { error: error.message });
|
||||
throw new Error(`Failed to restart DNS server: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for DNS server updates.
|
||||
* GET /api/user/checkForUpdate?token=...
|
||||
*/
|
||||
async checkUpdate({ server } = {}) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Checking for DNS server update', { server: this.serverIp });
|
||||
const url = this._buildUrl('/api/user/checkForUpdate', { token });
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
return {
|
||||
success: true,
|
||||
updateAvailable: !!(result.response && result.response.updateAvailable),
|
||||
latestVersion: (result.response && result.response.latestVersion) || null,
|
||||
currentVersion: (result.response && result.response.currentVersion) || null,
|
||||
response: result.response
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(result.errorMessage || 'Update check failed');
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Update check error', { error: error.message });
|
||||
throw new Error(`Failed to check for updates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
validateConfig() {
|
||||
const errors = [];
|
||||
if (!this.serverIp) {
|
||||
errors.push('serverIp is required');
|
||||
}
|
||||
if (this.serverPort && (typeof this.serverPort !== 'number' || this.serverPort < 1 || this.serverPort > 65535)) {
|
||||
errors.push('serverPort must be a valid port number (1-65535)');
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TechnitiumDNSProvider;
|
||||
@@ -9,9 +9,24 @@ const http = require('http');
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const paths = require('./platform-paths');
|
||||
|
||||
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(__dirname, 'health-config.json');
|
||||
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(__dirname, 'health-history.json');
|
||||
// Persist health config + history alongside the other state files (services.json,
|
||||
// config.json) rather than next to the source. In a container that data dir is the
|
||||
// mounted /app/data volume, so uptime history survives container recreates/updates;
|
||||
// previously these defaulted to __dirname (unmounted /app) and every recreate wiped
|
||||
// the accumulated history, blanking the dashboard uptime bars. Explicit env vars
|
||||
// still override.
|
||||
const HEALTH_DATA_DIR = process.env.HEALTH_DATA_DIR || path.dirname(paths.configFile);
|
||||
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(HEALTH_DATA_DIR, 'health-config.json');
|
||||
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(HEALTH_DATA_DIR, 'health-history.json');
|
||||
|
||||
// Legacy locations (next to the source) used before the data-dir default. Read these
|
||||
// once on first load if the new files are absent, so upgrading installs migrate their
|
||||
// accumulated history/config instead of starting empty. The next save() rewrites to
|
||||
// the new location.
|
||||
const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
|
||||
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
|
||||
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
|
||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||
@@ -541,8 +556,10 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
loadConfig() {
|
||||
try {
|
||||
if (fs.existsSync(HEALTH_CONFIG_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(HEALTH_CONFIG_FILE, 'utf8'));
|
||||
const file = fs.existsSync(HEALTH_CONFIG_FILE) ? HEALTH_CONFIG_FILE
|
||||
: (HEALTH_CONFIG_FILE !== LEGACY_HEALTH_CONFIG_FILE && fs.existsSync(LEGACY_HEALTH_CONFIG_FILE) ? LEGACY_HEALTH_CONFIG_FILE : null);
|
||||
if (file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error loading config: ${error.message}`);
|
||||
@@ -566,8 +583,10 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
loadHistory() {
|
||||
try {
|
||||
if (fs.existsSync(HEALTH_HISTORY_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(HEALTH_HISTORY_FILE, 'utf8'));
|
||||
const file = fs.existsSync(HEALTH_HISTORY_FILE) ? HEALTH_HISTORY_FILE
|
||||
: (HEALTH_HISTORY_FILE !== LEGACY_HEALTH_HISTORY_FILE && fs.existsSync(LEGACY_HEALTH_HISTORY_FILE) ? LEGACY_HEALTH_HISTORY_FILE : null);
|
||||
if (file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error loading history: ${error.message}`);
|
||||
|
||||
@@ -317,9 +317,6 @@ class LicenseManager {
|
||||
*/
|
||||
isExpired() {
|
||||
if (!this.activation) return true;
|
||||
// Lifetime licenses never expire
|
||||
if (this.activation.lifetime || this.activation.durationDays === 0) return false;
|
||||
if (!this.activation.expiresAt) return false; // No expiry set = lifetime
|
||||
return Date.now() > new Date(this.activation.expiresAt).getTime();
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/probe/', prefix: true },
|
||||
{ path: '/api/v1/tailscale/', prefix: true },
|
||||
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/totp/recovery-info', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/totp/verify', exact: true },
|
||||
{ path: '/api/v1/totp/setup', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
|
||||
@@ -304,10 +305,19 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
|
||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/version', exact: true, method: 'GET' },
|
||||
// System Overview widget on the dashboard — needs the flattened CPU/mem
|
||||
// data without going through auth. See skill references/totp-and-system-overview-pitfalls.md §3.
|
||||
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||
// Read-only update/version info shown on the dashboard view (verification
|
||||
// modal, topbar version, update badges). Mutating actions — update-apply,
|
||||
// rollback (POST) — are NOT listed here and stay TOTP-protected.
|
||||
{ path: '/api/v1/system/version', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-history', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-check', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/updates/available', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||
];
|
||||
|
||||
function isPublicRoute(req) {
|
||||
@@ -395,7 +405,7 @@ module.exports = function configureMiddleware(app, {
|
||||
...RATE_LIMITS.GENERAL,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config',
|
||||
skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/monitoring/stats') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config',
|
||||
message: { success: false, error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.13.0",
|
||||
"version": "1.7.8",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// All paths can be overridden via environment variables.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Base directories
|
||||
@@ -35,8 +34,6 @@ const paths = {
|
||||
caCertDir: path.join(CADDY_SITES, 'ca'),
|
||||
pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
|
||||
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
|
||||
generatedCertsDir: path.join(CADDY_SITES, 'generated-certs'),
|
||||
pkiDir: CADDY_PKI,
|
||||
|
||||
// Static site base path
|
||||
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
|
||||
@@ -44,24 +41,6 @@ const paths = {
|
||||
// Docker data path for app volumes
|
||||
appData: (appName) => path.join(DOCKER_DATA, appName),
|
||||
|
||||
// In-container paths (used by self-updater and Docker deployments)
|
||||
// Override via env vars for custom Docker layouts
|
||||
containerUpdatesDir: process.env.DASHCADDY_UPDATES_DIR || '/app/updates',
|
||||
containerFrontendDir: process.env.DASHCADDY_FRONTEND_DIR || '/app/dashboard',
|
||||
containerAssetsDir: process.env.ASSETS_DIR || '/app/assets',
|
||||
|
||||
// Asset path resolution — supports both Docker (single file mount) and
|
||||
// consolidated data directory layouts
|
||||
resolveAssetsPath: (envPath) => {
|
||||
if (envPath) return envPath;
|
||||
// Standard Docker mount: /app/assets (volume-mounted)
|
||||
if (fs.existsSync('/app/assets')) return '/app/assets';
|
||||
// Consolidated data directory: /app/data/assets
|
||||
if (fs.existsSync(path.join(CADDY_BASE, 'assets'))) return path.join(CADDY_BASE, 'assets');
|
||||
// Fall back to /app/assets even if it doesn't exist (will create on write)
|
||||
return '/app/assets';
|
||||
},
|
||||
|
||||
// Log digest directory
|
||||
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'),
|
||||
|
||||
|
||||
@@ -226,23 +226,7 @@ const server = http.createServer(async (req, res) => {
|
||||
json(res, 404, { error: 'Not found' });
|
||||
});
|
||||
|
||||
const PYLON_PORT = parseInt(process.env.PYLON_PORT, 10) || 7842;
|
||||
const PYLON_HOST = process.env.PYLON_HOST || '0.0.0.0';
|
||||
|
||||
server.listen(PYLON_PORT, PYLON_HOST, () => {
|
||||
console.log(`[Pylon] ${PYLON_NAME} listening on ${PYLON_HOST}:${PYLON_PORT}`);
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`);
|
||||
if (API_KEY) console.log('[Pylon] API key authentication enabled');
|
||||
});
|
||||
|
||||
// Graceful shutdown — drain connections, then exit
|
||||
const shutdown = (signal) => {
|
||||
console.log(`[Pylon] ${signal} received, draining...`);
|
||||
server.close(() => {
|
||||
console.log('[Pylon] 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'));
|
||||
|
||||
@@ -197,18 +197,8 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
}
|
||||
}
|
||||
|
||||
let container;
|
||||
try {
|
||||
container = await docker.client.createContainer(containerConfig);
|
||||
const container = await docker.client.createContainer(containerConfig);
|
||||
await container.start();
|
||||
} catch (createErr) {
|
||||
// If create fails with "no such image", wrap with user-friendly message
|
||||
const errMsg = createErr?.message || String(createErr);
|
||||
if (errMsg.includes('No such image') || errMsg.includes('no such image')) {
|
||||
throw new Error(`[DC-201] Image pull succeeded but container creation failed — image may be corrupted: ${processedTemplate.docker.image}. ${errMsg}`);
|
||||
}
|
||||
throw createErr;
|
||||
}
|
||||
|
||||
// Prune dangling images to prevent disk bloat
|
||||
try {
|
||||
@@ -316,7 +306,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
} else {
|
||||
containerId = await deployContainer(appId, config, template);
|
||||
log.info('deploy', 'Container deployed', { containerId });
|
||||
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort, 30);
|
||||
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
|
||||
log.info('deploy', 'Container is healthy', { containerId });
|
||||
}
|
||||
|
||||
@@ -326,7 +316,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
let dnsWarning = null;
|
||||
if (config.createDns && !isSubdirectoryMode) {
|
||||
try {
|
||||
await ctx.dns.universalCreateRecord(config.subdomain, config.ip);
|
||||
await ctx.dns.createRecord(config.subdomain, config.ip);
|
||||
log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
|
||||
} catch (dnsError) {
|
||||
await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip });
|
||||
@@ -430,11 +420,10 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
||||
const msg = error?.message || String(error || 'Unknown error');
|
||||
log.error('deploy', 'Deployment failed', { appId, error: msg });
|
||||
await logError('app-deploy', error, { appId, config });
|
||||
log.error('deploy', 'Deployment failed', { appId, error: error.message });
|
||||
const template = ctx.APP_TEMPLATES[appId];
|
||||
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
||||
ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error');
|
||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||
}
|
||||
}, 'apps-deploy'));
|
||||
|
||||
@@ -379,12 +379,9 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
||||
});
|
||||
|
||||
if (!result.success && result.error !== 'No changes to apply') {
|
||||
if (!result.success) {
|
||||
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
|
||||
}
|
||||
if (result.error === 'No changes to apply') {
|
||||
log.info('caddy', 'Subpath config already exists, reusing', { subdomain });
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a subpath config block from between its markers in the Caddyfile. */
|
||||
|
||||
@@ -25,6 +25,7 @@ module.exports = function(ctx) {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
errorResponse: ctx.errorResponse,
|
||||
log: ctx.log,
|
||||
// Additional context properties needed by routes
|
||||
APP_TEMPLATES: ctx.APP_TEMPLATES,
|
||||
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
|
||||
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
|
||||
@@ -39,27 +40,26 @@ module.exports = function(ctx) {
|
||||
ctx: ctx
|
||||
};
|
||||
|
||||
// Initialize helpers with dependencies (ctx is the Koa context)
|
||||
const helpers = initHelpers({ ...deps, ctx });
|
||||
|
||||
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
|
||||
const subCtx = Object.assign({}, ctx, { helpers });
|
||||
|
||||
// Mount sub-routers at their prefix paths.
|
||||
// Sub-modules define routes at '/' (root of their sub-router).
|
||||
// Final paths: /api/v1/apps/deploy, /api/v1/apps/remove, /api/v1/apps/templates, etc.
|
||||
try { router.use('/deploy', initDeploy(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
|
||||
|
||||
try { router.use('/apps', initDeploy(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message, e.stack); }
|
||||
|
||||
try { router.use('/apps', initRemoval(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message, e.stack); }
|
||||
try { router.use('/remove', initRemoval(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); }
|
||||
|
||||
try { router.use('/apps', initTemplates(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message, e.stack); }
|
||||
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
|
||||
|
||||
try { router.use('/apps', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
||||
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message, e.stack); }
|
||||
try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
||||
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
|
||||
|
||||
try { router.use('/apps', initCompose(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message, e.stack); }
|
||||
try { router.use('/compose', initCompose(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); }
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -71,13 +71,18 @@ module.exports = function({
|
||||
if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) {
|
||||
try {
|
||||
const domain = ctx.buildDomain(subdomain);
|
||||
const resolveResult = await ctx.dns.universalResolveRecord(domain, 'A');
|
||||
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'
|
||||
});
|
||||
let recordIp = ip || 'localhost';
|
||||
if (resolveResult) {
|
||||
recordIp = resolveResult;
|
||||
if (getResult.status === 'ok' && getResult.response?.records) {
|
||||
const aRecord = getResult.response.records.find(r => r.type === 'A');
|
||||
if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress;
|
||||
}
|
||||
await ctx.dns.universalDeleteRecord(domain, recordIp);
|
||||
results.dns = 'deleted';
|
||||
const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
|
||||
token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp
|
||||
});
|
||||
results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed');
|
||||
log.info('dns', 'DNS record removal', { result: results.dns });
|
||||
} catch (error) {
|
||||
results.dns = error.message;
|
||||
|
||||
@@ -458,7 +458,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
||||
// DNS record
|
||||
if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') {
|
||||
try {
|
||||
await ctx.dns.universalCreateRecord(manifest.config.subdomain, manifest.config.ip);
|
||||
await ctx.dns.createRecord(manifest.config.subdomain, manifest.config.ip);
|
||||
log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain });
|
||||
} catch (e) {
|
||||
log.warn('restore', `DNS recreation failed: ${e.message}`);
|
||||
|
||||
@@ -107,8 +107,10 @@ module.exports = function({
|
||||
if (oldSubdomain && ctx.dns.getToken()) {
|
||||
try {
|
||||
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
|
||||
await ctx.dns.universalDeleteRecord(oldDomain, ip || 'localhost');
|
||||
results.oldDns = 'deleted';
|
||||
const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
|
||||
token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost'
|
||||
});
|
||||
results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage;
|
||||
log.info('dns', 'Old DNS record deleted', { domain: oldDomain });
|
||||
} catch (error) {
|
||||
results.oldDns = `failed: ${error.message}`;
|
||||
@@ -118,7 +120,7 @@ module.exports = function({
|
||||
|
||||
if (newSubdomain && ctx.dns.getToken()) {
|
||||
try {
|
||||
await ctx.dns.universalCreateRecord(newSubdomain, ip || 'localhost');
|
||||
await ctx.dns.createRecord(newSubdomain, ip || 'localhost');
|
||||
results.newDns = 'created';
|
||||
log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) });
|
||||
} catch (error) {
|
||||
|
||||
@@ -37,6 +37,66 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
||||
});
|
||||
}, 'totp-config-get'));
|
||||
|
||||
// Recovery diagnostic (public, no auth required).
|
||||
//
|
||||
// Returns information a locked-out user needs to choose a recovery path:
|
||||
// - whether TOTP is configured at all (isSetUp)
|
||||
// - whether the stored secret is readable by the current encryption key
|
||||
// - a human-readable hint matching the situation
|
||||
//
|
||||
// Status values:
|
||||
// 'not_configured' — no TOTP setup yet, user should set it up
|
||||
// 'healthy' — secret present and decryptable, normal login
|
||||
// 'unreadable' — secret on disk but can't decrypt (key rotated)
|
||||
// 'corrupt' — entry exists but value is malformed
|
||||
//
|
||||
// This route never returns the secret itself — only metadata about it.
|
||||
router.get('/totp/recovery-info', asyncHandler(async (req, res) => {
|
||||
if (!ctx.totpConfig.isSetUp) {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'not_configured',
|
||||
isSetUp: false,
|
||||
hint: 'TOTP has not been set up on this server yet. Open settings to configure it.'
|
||||
});
|
||||
}
|
||||
|
||||
const diag = await ctx.credentialManager.diagnose('totp.secret');
|
||||
if (diag.status === 'ok') {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'healthy',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP is configured and the stored secret is readable. Enter your authenticator code to log in.'
|
||||
});
|
||||
}
|
||||
if (diag.status === 'unreadable') {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'unreadable',
|
||||
isSetUp: true,
|
||||
hint: 'Your stored TOTP secret is on disk but cannot be decrypted — this usually means the encryption key changed during an upgrade. ' +
|
||||
'If you saved your Base32 secret when you first set up TOTP, paste it below to restore access. ' +
|
||||
'Otherwise you will need SSH access to the server to recover or rotate the key.'
|
||||
});
|
||||
}
|
||||
if (diag.status === 'missing') {
|
||||
// Config says isSetUp:true but no secret in store — corrupted config state
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'corrupt',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP is marked as configured but the secret is missing. Set up TOTP again with a fresh secret.'
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'corrupt',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP storage is in an unexpected state. ' + (diag.error || '')
|
||||
});
|
||||
}, 'totp-recovery-info'));
|
||||
|
||||
// Generate new TOTP secret + QR code
|
||||
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Auto-Restart Policy Routes
|
||||
*
|
||||
* CRUD endpoints for per-container auto-restart policies.
|
||||
* Also provides a dry-run test endpoint.
|
||||
*
|
||||
* @module routes/auto-restart
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
|
||||
/**
|
||||
* Auto-restart route factory
|
||||
*
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.autoRestartManager - AutoRestartManager instance
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function ({ autoRestartManager, asyncHandler, logError }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /auto-restart/policies
|
||||
* List all configured auto-restart policies.
|
||||
*/
|
||||
router.get('/policies', asyncHandler(async (_req, res) => {
|
||||
const policies = autoRestartManager.listPolicies();
|
||||
success(res, { policies });
|
||||
}, 'auto-restart-list'));
|
||||
|
||||
/**
|
||||
* GET /auto-restart/policies/:serviceId
|
||||
* Get the restart policy for a single service.
|
||||
*/
|
||||
router.get('/policies/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
throw new ValidationError('Invalid service ID format');
|
||||
}
|
||||
|
||||
const policy = autoRestartManager.getPolicy(serviceId);
|
||||
if (!policy) {
|
||||
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
|
||||
}
|
||||
|
||||
success(res, { policy });
|
||||
}, 'auto-restart-get'));
|
||||
|
||||
/**
|
||||
* POST /auto-restart/policies/:serviceId
|
||||
* Create or update a restart policy.
|
||||
*
|
||||
* Body: { enabled, maxRetries, retryIntervalMs, windowMinutes }
|
||||
*/
|
||||
router.post('/policies/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
throw new ValidationError('Invalid service ID format');
|
||||
}
|
||||
|
||||
const { enabled, maxRetries, retryIntervalMs, windowMinutes } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
throw new ValidationError('enabled must be a boolean');
|
||||
}
|
||||
if (maxRetries !== undefined) {
|
||||
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 100) {
|
||||
throw new ValidationError('maxRetries must be an integer between 0 and 100');
|
||||
}
|
||||
}
|
||||
if (retryIntervalMs !== undefined) {
|
||||
if (!Number.isInteger(retryIntervalMs) || retryIntervalMs < 0 || retryIntervalMs > 3600000) {
|
||||
throw new ValidationError('retryIntervalMs must be an integer between 0 and 3600000');
|
||||
}
|
||||
}
|
||||
if (windowMinutes !== undefined) {
|
||||
if (!Number.isInteger(windowMinutes) || windowMinutes < 0 || windowMinutes > 1440) {
|
||||
throw new ValidationError('windowMinutes must be an integer between 0 and 1440');
|
||||
}
|
||||
}
|
||||
|
||||
const policy = await autoRestartManager.setPolicy(serviceId, {
|
||||
...(enabled !== undefined && { enabled }),
|
||||
...(maxRetries !== undefined && { maxRetries }),
|
||||
...(retryIntervalMs !== undefined && { retryIntervalMs }),
|
||||
...(windowMinutes !== undefined && { windowMinutes }),
|
||||
});
|
||||
|
||||
success(res, { policy, message: `Policy ${serviceId} saved` });
|
||||
}, 'auto-restart-set'));
|
||||
|
||||
/**
|
||||
* DELETE /auto-restart/policies/:serviceId
|
||||
* Remove a restart policy.
|
||||
*/
|
||||
router.delete('/policies/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
throw new ValidationError('Invalid service ID format');
|
||||
}
|
||||
|
||||
const removed = await autoRestartManager.removePolicy(serviceId);
|
||||
if (!removed) {
|
||||
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
|
||||
}
|
||||
|
||||
success(res, { message: `Policy for "${serviceId}" removed` });
|
||||
}, 'auto-restart-delete'));
|
||||
|
||||
/**
|
||||
* POST /auto-restart/policies/:serviceId/test
|
||||
* Dry-run: simulate a restart attempt without actually restarting.
|
||||
* Returns what *would* happen given the current policy state.
|
||||
*/
|
||||
router.post('/policies/:serviceId/test', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
throw new ValidationError('Invalid service ID format');
|
||||
}
|
||||
|
||||
const policy = autoRestartManager.getPolicy(serviceId);
|
||||
if (!policy) {
|
||||
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const inCooldown = policy.cooldownUntil && now < policy.cooldownUntil;
|
||||
const wouldRetry = !inCooldown && policy.currentRetries < policy.maxRetries;
|
||||
const nextAttempt = policy.currentRetries + 1;
|
||||
|
||||
success(res, {
|
||||
dryRun: true,
|
||||
serviceId,
|
||||
policy: {
|
||||
enabled: policy.enabled,
|
||||
currentRetries: policy.currentRetries,
|
||||
maxRetries: policy.maxRetries,
|
||||
cooldownUntil: policy.cooldownUntil,
|
||||
inCooldown,
|
||||
},
|
||||
wouldRestart: policy.enabled && wouldRetry,
|
||||
wouldMaxOut: !wouldRetry && !inCooldown,
|
||||
nextAttempt: wouldRetry ? nextAttempt : null,
|
||||
message: !policy.enabled
|
||||
? 'Policy is disabled — no restart would occur'
|
||||
: inCooldown
|
||||
? `In cooldown until ${new Date(policy.cooldownUntil).toISOString()} — would skip`
|
||||
: wouldRetry
|
||||
? `Would attempt restart ${nextAttempt}/${policy.maxRetries}`
|
||||
: `Max retries (${policy.maxRetries}) already reached — would enter cooldown`,
|
||||
});
|
||||
}, 'auto-restart-test'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { success } = require('../response-helpers');
|
||||
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
|
||||
? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10)
|
||||
: 0;
|
||||
|
||||
/**
|
||||
* Backups routes factory
|
||||
@@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
runImmediately: backup.runImmediately || false,
|
||||
destination: backup.destination || 'local',
|
||||
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: backup.maxStorageBytes || null,
|
||||
lastRun: lastRun ? lastRun.toISOString() : null,
|
||||
nextRun: nextRun ? nextRun.toISOString() : null,
|
||||
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
|
||||
@@ -52,7 +57,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
// Create or update a scheduled backup for an app
|
||||
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body;
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
||||
|
||||
if (!appId) {
|
||||
const { ValidationError } = require('../errors');
|
||||
@@ -62,6 +67,11 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
const config = backupManager.getConfig();
|
||||
if (!config.backups) config.backups = {};
|
||||
|
||||
// Parse maxStorageBytes if provided as string (e.g. "10GB")
|
||||
const parsedMaxStorage = maxStorageBytes
|
||||
? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes)
|
||||
: null;
|
||||
|
||||
// Build the backup config for this app
|
||||
const backupConfig = {
|
||||
enabled: enabled !== undefined ? enabled : true,
|
||||
@@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
destination: destination || 'local',
|
||||
destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
|
||||
include: ['all'],
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }]
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }],
|
||||
maxStorageBytes: parsedMaxStorage
|
||||
};
|
||||
|
||||
config.backups[appId] = backupConfig;
|
||||
@@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
success(res, { history });
|
||||
}, 'backups-history'));
|
||||
|
||||
// Get storage info for backups destination
|
||||
router.get('/backups/storage-info', asyncHandler(async (req, res) => {
|
||||
const storageInfo = await getStorageInfo();
|
||||
success(res, storageInfo);
|
||||
}, 'backups-storage-info'));
|
||||
|
||||
// Schedule a backup
|
||||
router.post('/backups/schedule', asyncHandler(async (req, res) => {
|
||||
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
|
||||
|
||||
if (!name || !schedule) {
|
||||
return res.status(400).json({ error: 'name and schedule are required' });
|
||||
}
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
|
||||
// Store maxStorageBytes in the backup config (converted to bytes)
|
||||
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
|
||||
? maxStorageBytes
|
||||
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
|
||||
|
||||
config.backups[name] = {
|
||||
...backupConfig,
|
||||
enabled: true,
|
||||
schedule,
|
||||
maxStorageBytes: maxBytes,
|
||||
destinations: backupConfig.destinations || [{ type: 'local' }]
|
||||
};
|
||||
|
||||
backupManager.updateConfig(config);
|
||||
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
|
||||
}, 'backups-schedule'));
|
||||
|
||||
// Restore from backup
|
||||
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
|
||||
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
||||
@@ -653,3 +697,121 @@ function formatBytes(bytes) {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage information for the backup directory
|
||||
*/
|
||||
async function getStorageInfo() {
|
||||
const result = {
|
||||
destination: DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
|
||||
usedBytes: 0,
|
||||
availableBytes: 0,
|
||||
usagePercent: 0,
|
||||
backupCount: 0,
|
||||
oldestBackup: null,
|
||||
newestBackup: null
|
||||
};
|
||||
|
||||
try {
|
||||
// Get disk space info
|
||||
const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR);
|
||||
result.availableBytes = diskSpace.available;
|
||||
|
||||
// Scan for backup files
|
||||
if (DEFAULT_MAX_STORAGE_BYTES > 0) {
|
||||
result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
|
||||
} else {
|
||||
result.maxStorageBytes = diskSpace.total || 0;
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
let oldestTime = null;
|
||||
let newestTime = null;
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(DEFAULT_BACKUP_DIR);
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith('.backup')) {
|
||||
const filePath = path.join(DEFAULT_BACKUP_DIR, entry);
|
||||
try {
|
||||
const stats = await fsp.stat(filePath);
|
||||
totalSize += stats.size;
|
||||
result.backupCount++;
|
||||
|
||||
const fileTime = new Date(stats.mtime);
|
||||
if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime;
|
||||
if (!newestTime || fileTime > newestTime) newestTime = fileTime;
|
||||
} catch (e) {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backup directory might not exist yet
|
||||
}
|
||||
|
||||
result.usedBytes = totalSize;
|
||||
result.oldestBackup = oldestTime ? oldestTime.toISOString() : null;
|
||||
result.newestBackup = newestTime ? newestTime.toISOString() : null;
|
||||
|
||||
// Calculate available (total limit - used), or from disk space if no limit set
|
||||
if (result.maxStorageBytes > 0) {
|
||||
result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize);
|
||||
result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2));
|
||||
} else if (diskSpace.total) {
|
||||
result.availableBytes = diskSpace.available;
|
||||
result.usagePercent = diskSpace.total > 0
|
||||
? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2))
|
||||
: 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupsRouter] Error getting storage info:', error.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk space info (filesystem-agnostic)
|
||||
*/
|
||||
async function getDiskSpaceInfo(dirPath) {
|
||||
try {
|
||||
const diskInfo = await fsp.statfs(dirPath);
|
||||
return {
|
||||
total: diskInfo.blocks * diskInfo.bsize,
|
||||
available: diskInfo.bfree * diskInfo.bsize,
|
||||
used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize
|
||||
};
|
||||
} catch (error) {
|
||||
// Directory might not exist or be accessible
|
||||
return { total: 0, available: 0, used: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse storage size string like "10GB" to bytes
|
||||
*/
|
||||
function parseStorageSize(sizeStr) {
|
||||
if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0;
|
||||
|
||||
const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = (match[2] || 'B').toUpperCase();
|
||||
|
||||
const multipliers = {
|
||||
'B': 1,
|
||||
'K': 1024,
|
||||
'KB': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
return Math.floor(value * (multipliers[unit] || 1));
|
||||
}
|
||||
|
||||
+16
-10
@@ -12,11 +12,14 @@ module.exports = function(ctx) {
|
||||
|
||||
// Get CA certificate information
|
||||
router.get('/info', ctx.asyncHandler(async (req, res) => {
|
||||
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||
const certInfoPath = '/app/ca/cert-info.json';
|
||||
const fallbackCertInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||
|
||||
let certInfoFile;
|
||||
if (await exists(certInfoPath)) {
|
||||
certInfoFile = certInfoPath;
|
||||
} else if (await exists(fallbackCertInfoPath)) {
|
||||
certInfoFile = fallbackCertInfoPath;
|
||||
} else {
|
||||
const { NotFoundError } = require('../errors');
|
||||
throw new NotFoundError('CA certificate information');
|
||||
@@ -43,11 +46,13 @@ module.exports = function(ctx) {
|
||||
|
||||
// Serve root CA certificate directly (works even without DashCA deployed)
|
||||
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
|
||||
const pkiCertPath = '/app/pki/root.crt';
|
||||
const hostCertPath = platformPaths.pkiRootCert;
|
||||
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
|
||||
|
||||
let certPath;
|
||||
if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
||||
if (await exists(pkiCertPath)) certPath = pkiCertPath;
|
||||
else if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
||||
else if (await exists(hostCertPath)) certPath = hostCertPath;
|
||||
else {
|
||||
const { NotFoundError } = require('../errors');
|
||||
@@ -67,12 +72,13 @@ module.exports = function(ctx) {
|
||||
}
|
||||
|
||||
// Load cert info to get the fingerprint
|
||||
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||
const certInfoPath = '/app/ca/cert-info.json';
|
||||
const fallbackCertInfoPath2 = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||
|
||||
let certInfoFile;
|
||||
if (await exists(certInfoPath)) {
|
||||
certInfoFile = certInfoPath;
|
||||
} else {
|
||||
if (await exists(certInfoPath)) certInfoFile = certInfoPath;
|
||||
else if (await exists(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2;
|
||||
else {
|
||||
const { NotFoundError } = require('../errors');
|
||||
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
|
||||
}
|
||||
@@ -94,7 +100,7 @@ module.exports = function(ctx) {
|
||||
// Look for template in multiple locations (packaged app vs dev)
|
||||
const templatePaths = [
|
||||
path.join(__dirname, '..', 'scripts', templateName),
|
||||
path.join(platformPaths.caddyBase, 'scripts', templateName)
|
||||
path.join('/app', 'scripts', templateName)
|
||||
];
|
||||
|
||||
let templateContent;
|
||||
@@ -136,8 +142,8 @@ module.exports = function(ctx) {
|
||||
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
||||
}
|
||||
|
||||
const pkiPath = platformPaths.pkiDir;
|
||||
const certsDir = platformPaths.generatedCertsDir;
|
||||
const pkiPath = '/app/pki';
|
||||
const certsDir = '/app/generated-certs';
|
||||
const domainDir = path.join(certsDir, domain);
|
||||
|
||||
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
|
||||
@@ -240,7 +246,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
||||
|
||||
// List generated certificates
|
||||
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
||||
const certsDir = platformPaths.generatedCertsDir;
|
||||
const certsDir = '/app/generated-certs';
|
||||
|
||||
if (!await exists(certsDir)) {
|
||||
return res.json({ success: true, certificates: [] });
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Config Drift Detection Routes
|
||||
*
|
||||
* API endpoints for running drift detection, reading cached reports,
|
||||
* auto-fixing drift, and controlling periodic polling.
|
||||
*
|
||||
* @module routes/config-drift
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
|
||||
/**
|
||||
* Config-drift route factory
|
||||
*
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.driftDetector - ConfigDriftDetector instance
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function ({ driftDetector, asyncHandler, logError }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /config-drift/report
|
||||
* Run a fresh drift detection and return the full report.
|
||||
*/
|
||||
router.get('/report', asyncHandler(async (_req, res) => {
|
||||
const report = await driftDetector.detect();
|
||||
success(res, { report });
|
||||
}, 'drift-report'));
|
||||
|
||||
/**
|
||||
* GET /config-drift/last
|
||||
* Return the last cached drift report (no re-detection).
|
||||
*/
|
||||
router.get('/last', asyncHandler(async (_req, res) => {
|
||||
if (!driftDetector.lastReport) {
|
||||
throw new NotFoundError('No cached drift report — run detection first');
|
||||
}
|
||||
|
||||
success(res, { report: driftDetector.lastReport });
|
||||
}, 'drift-last'));
|
||||
|
||||
/**
|
||||
* POST /config-drift/fix
|
||||
* Auto-fix detected drift: remove stale records, flag unknown containers.
|
||||
*/
|
||||
router.post('/fix', asyncHandler(async (_req, res) => {
|
||||
const result = await driftDetector.autoFix();
|
||||
success(res, {
|
||||
message: 'Auto-fix applied',
|
||||
staleRemoved: result.staleRemoved,
|
||||
unknownFlagged: result.unknownFlagged,
|
||||
});
|
||||
}, 'drift-fix'));
|
||||
|
||||
/**
|
||||
* POST /config-drift/polling
|
||||
* Enable or disable periodic drift detection polling.
|
||||
*
|
||||
* Body: { enabled: boolean, intervalMs?: number }
|
||||
*/
|
||||
router.post('/polling', asyncHandler(async (req, res) => {
|
||||
const { enabled, intervalMs } = req.body;
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw new ValidationError('enabled must be a boolean');
|
||||
}
|
||||
|
||||
if (intervalMs !== undefined) {
|
||||
if (!Number.isInteger(intervalMs) || intervalMs < 10000 || intervalMs > 86400000) {
|
||||
throw new ValidationError('intervalMs must be an integer between 10000 and 86400000 (10s – 24h)');
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
driftDetector.startPolling(intervalMs || 300000);
|
||||
success(res, {
|
||||
message: 'Drift polling enabled',
|
||||
intervalMs: intervalMs || 300000,
|
||||
});
|
||||
} else {
|
||||
driftDetector.stopPolling();
|
||||
success(res, { message: 'Drift polling disabled' });
|
||||
}
|
||||
}, 'drift-polling'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -4,7 +4,6 @@ const path = require('path');
|
||||
const { LIMITS } = require('../../constants');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
const { ValidationError } = require('../../errors');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
/**
|
||||
* Config assets routes factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
@@ -52,7 +51,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Determine assets path (mounted volume)
|
||||
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
|
||||
// Ensure directory exists
|
||||
if (!await exists(assetsPath)) {
|
||||
@@ -97,7 +96,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
||||
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
|
||||
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
if (!await exists(assetsPath)) {
|
||||
await fsp.mkdir(assetsPath, { recursive: true });
|
||||
}
|
||||
@@ -171,7 +170,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
||||
// Reset all branding to defaults
|
||||
router.delete('/logo', asyncHandler(async (req, res) => {
|
||||
const config = await ctx.readConfig();
|
||||
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
|
||||
// Delete all custom logo files
|
||||
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
|
||||
@@ -235,7 +234,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
||||
const base64Data = matches[2];
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
if (!await exists(assetsPath)) {
|
||||
await fsp.mkdir(assetsPath, { recursive: true });
|
||||
}
|
||||
@@ -280,7 +279,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
||||
const config = await ctx.readConfig();
|
||||
|
||||
// Delete custom favicon files
|
||||
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
const filesToDelete = ['favicon.ico', 'favicon.png'];
|
||||
for (const file of filesToDelete) {
|
||||
const filePath = `${assetsPath}/${file}`;
|
||||
|
||||
@@ -4,7 +4,6 @@ const path = require('path');
|
||||
const { CADDY } = require('../../constants');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
const { ValidationError, AuthenticationError } = require('../../errors');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
/**
|
||||
* Config backup routes factory
|
||||
@@ -116,7 +115,7 @@ module.exports = function(deps) {
|
||||
|
||||
// Include custom assets (logo, favicon) as base64
|
||||
try {
|
||||
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
|
||||
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
|
||||
const configData = backup.files.config?.data || {};
|
||||
const assetFiles = [configData.customLogo, configData.customFavicon]
|
||||
.filter(Boolean)
|
||||
@@ -347,7 +346,7 @@ module.exports = function(deps) {
|
||||
|
||||
// Restore custom assets from base64
|
||||
if (backup.assets && typeof backup.assets === 'object') {
|
||||
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
|
||||
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
|
||||
for (const [name, b64] of Object.entries(backup.assets)) {
|
||||
try {
|
||||
const safeName = path.basename(name); // prevent path traversal
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Dependencies Route — REST API for service dependency tracking
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /dependencies/graph Full dependency graph
|
||||
* GET /dependencies/validate Validate a proposed dep chain
|
||||
* GET /dependencies/:serviceId Direct deps for one service
|
||||
* GET /dependencies/:serviceId/chain Ordered restart chain
|
||||
* GET /dependencies/:serviceId/status Dependency health status
|
||||
* POST /dependencies/:serviceId Set dependencies
|
||||
* DELETE /dependencies/:serviceId Remove all dependencies
|
||||
* POST /dependencies/:serviceId/restart Restart with dependency chain
|
||||
*
|
||||
* @module routes/dependencies
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../response-helpers');
|
||||
const { NotFoundError, ValidationError } = require('../errors');
|
||||
|
||||
/**
|
||||
* Dependencies route factory
|
||||
*
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.dependencyManager - DependencyManager instance
|
||||
* @param {Object} deps.servicesStateManager - State manager for services.json
|
||||
* @param {Object} deps.docker - Docker client wrapper
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @param {Function} deps.resyncHealthChecker - Health checker resync function
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({
|
||||
dependencyManager,
|
||||
servicesStateManager,
|
||||
docker,
|
||||
asyncHandler,
|
||||
logError,
|
||||
resyncHealthChecker,
|
||||
log,
|
||||
}) {
|
||||
const router = express.Router();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/graph — Full dependency graph
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/graph', asyncHandler(async (req, res) => {
|
||||
const graph = await dependencyManager.getDependencyGraph();
|
||||
success(res, { graph });
|
||||
}, 'dep-graph'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/validate — Validate a proposed dep chain (query params)
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/validate', asyncHandler(async (req, res) => {
|
||||
const { serviceId, dependsOn } = req.query;
|
||||
|
||||
if (!serviceId) {
|
||||
throw new ValidationError('serviceId query parameter is required');
|
||||
}
|
||||
|
||||
// dependsOn may be a comma-separated string or already an array
|
||||
let parsed;
|
||||
if (Array.isArray(dependsOn)) {
|
||||
parsed = dependsOn;
|
||||
} else if (typeof dependsOn === 'string' && dependsOn.length > 0) {
|
||||
parsed = dependsOn.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else {
|
||||
parsed = [];
|
||||
}
|
||||
|
||||
const result = await dependencyManager.validateDependencies(serviceId, parsed);
|
||||
success(res, result);
|
||||
}, 'dep-validate'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/:serviceId — Direct deps for one service
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const dependencies = await dependencyManager.getDependencies(serviceId);
|
||||
const dependents = await dependencyManager.getDependents(serviceId);
|
||||
|
||||
// Read the service's current dependsOn array
|
||||
const services = await servicesStateManager.read();
|
||||
const allServices = Array.isArray(services) ? services : (services.services || []);
|
||||
const service = allServices.find(s => s.id === serviceId);
|
||||
|
||||
if (!service) {
|
||||
throw new NotFoundError(`Service "${serviceId}"`);
|
||||
}
|
||||
|
||||
success(res, {
|
||||
serviceId,
|
||||
dependsOn: service.dependsOn || [],
|
||||
dependencies,
|
||||
dependents: dependents.map(d => ({ id: d.id, name: d.name })),
|
||||
});
|
||||
}, 'dep-get'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/:serviceId/chain — Ordered restart chain
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/:serviceId/chain', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const chain = await dependencyManager.getOrderedRestartChain(serviceId);
|
||||
success(res, { serviceId, chain });
|
||||
}, 'dep-chain'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/:serviceId/status — Dependency health status
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/:serviceId/status', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const statuses = await dependencyManager.getDependencyStatus(serviceId);
|
||||
success(res, { serviceId, statuses });
|
||||
}, 'dep-status'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /dependencies/:serviceId — Set dependencies
|
||||
// -------------------------------------------------------------------------
|
||||
router.post('/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const { dependsOn } = req.body;
|
||||
|
||||
if (!Array.isArray(dependsOn)) {
|
||||
throw new ValidationError('Request body must include dependsOn as an array of service IDs');
|
||||
}
|
||||
|
||||
// Validate first
|
||||
const validation = await dependencyManager.validateDependencies(serviceId, dependsOn);
|
||||
if (!validation.valid) {
|
||||
return errorResponse(res, validation.errors.join('; '), 400);
|
||||
}
|
||||
|
||||
// Update the service
|
||||
let found = false;
|
||||
await servicesStateManager.update(services => {
|
||||
const arr = Array.isArray(services) ? services : [];
|
||||
return arr.map(s => {
|
||||
if (s.id === serviceId) {
|
||||
found = true;
|
||||
return { ...s, dependsOn: dependsOn.slice() };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
throw new NotFoundError(`Service "${serviceId}"`);
|
||||
}
|
||||
|
||||
log.info('dependency', 'Dependencies updated', { serviceId, dependsOn });
|
||||
|
||||
success(res, {
|
||||
message: `Dependencies updated for "${serviceId}"`,
|
||||
serviceId,
|
||||
dependsOn,
|
||||
});
|
||||
}, 'dep-set'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DELETE /dependencies/:serviceId — Remove all dependencies for a service
|
||||
// -------------------------------------------------------------------------
|
||||
router.delete('/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
let found = false;
|
||||
await servicesStateManager.update(services => {
|
||||
const arr = Array.isArray(services) ? services : [];
|
||||
return arr.map(s => {
|
||||
if (s.id === serviceId) {
|
||||
found = true;
|
||||
const updated = { ...s };
|
||||
delete updated.dependsOn;
|
||||
return updated;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
throw new NotFoundError(`Service "${serviceId}"`);
|
||||
}
|
||||
|
||||
log.info('dependency', 'Dependencies removed', { serviceId });
|
||||
|
||||
success(res, {
|
||||
message: `All dependencies removed for "${serviceId}"`,
|
||||
serviceId,
|
||||
});
|
||||
}, 'dep-delete'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /dependencies/:serviceId/restart — Restart with dependency chain
|
||||
// -------------------------------------------------------------------------
|
||||
router.post('/:serviceId/restart', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Verify the service exists
|
||||
const services = await servicesStateManager.read();
|
||||
const allServices = Array.isArray(services) ? services : (services.services || []);
|
||||
if (!allServices.find(s => s.id === serviceId)) {
|
||||
throw new NotFoundError(`Service "${serviceId}"`);
|
||||
}
|
||||
|
||||
// Get the chain first for the response (before async restart begins)
|
||||
let chain;
|
||||
try {
|
||||
chain = await dependencyManager.getOrderedRestartChain(serviceId);
|
||||
} catch (err) {
|
||||
return errorResponse(res, err.message, 400);
|
||||
}
|
||||
|
||||
// Respond immediately with the chain order
|
||||
success(res, {
|
||||
message: `Dependency restart initiated for "${serviceId}"`,
|
||||
serviceId,
|
||||
chain,
|
||||
});
|
||||
|
||||
// Run the restart chain asynchronously so the client doesn't block
|
||||
dependencyManager.restartWithDependencies(serviceId).catch(err => {
|
||||
if (log) {
|
||||
log.error('dependency', 'Async dependency restart failed', {
|
||||
serviceId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 'dep-restart'));
|
||||
|
||||
return router;
|
||||
};
|
||||
+8
-228
@@ -26,8 +26,7 @@ module.exports = function({
|
||||
log,
|
||||
safeErrorMessage,
|
||||
fetchT,
|
||||
credentialManager,
|
||||
dnsPropagationChecker
|
||||
credentialManager
|
||||
}) {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -42,137 +41,7 @@ module.exports = function({
|
||||
return serverIp;
|
||||
}
|
||||
|
||||
// ===== DNS PROVIDER ENDPOINTS =====
|
||||
|
||||
// GET /providers — List all available DNS providers
|
||||
router.get('/providers', asyncHandler(async (req, res) => {
|
||||
const providers = dns.getAvailableProviders ? dns.getAvailableProviders() : [];
|
||||
const activeProvider = dns.getProviderId ? dns.getProviderId() : 'technitium';
|
||||
success(res, { providers, activeProvider });
|
||||
}, 'dns-providers-list'));
|
||||
|
||||
// GET /provider/status — Get active provider status
|
||||
router.get('/provider/status', asyncHandler(async (req, res) => {
|
||||
if (!dns.getActiveProvider) {
|
||||
return success(res, { providerId: 'technitium', capabilities: ['create-record', 'delete-record', 'resolve', 'list-records', 'logs', 'restart', 'update-check', 'credentials', 'zones'] });
|
||||
}
|
||||
try {
|
||||
const provider = dns.getActiveProvider();
|
||||
const status = await provider.getStatus();
|
||||
success(res, status);
|
||||
} catch (err) {
|
||||
errorResponse(res, safeErrorMessage(err), 500);
|
||||
}
|
||||
}, 'dns-provider-status'));
|
||||
|
||||
// ===== UNIVERSAL RECORD ENDPOINTS (work with any provider) =====
|
||||
|
||||
// POST /universal/record — Create a DNS record via any provider
|
||||
router.post('/universal/record', asyncHandler(async (req, res) => {
|
||||
if (!dns.getActiveProvider) {
|
||||
// Fallback to legacy Technitium route
|
||||
return res.redirect(307, '/api/dns/record');
|
||||
}
|
||||
const { domain, ip, ttl, type, server } = req.body;
|
||||
if (!domain || !ip) throw new ValidationError('domain and ip are required');
|
||||
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
||||
if (!validatorLib.isIP(ip)) throw new ValidationError('[DC-210] Invalid IP address');
|
||||
|
||||
try {
|
||||
const provider = dns.getActiveProvider();
|
||||
if (!provider.supportsCapability('create-record')) {
|
||||
const result = await provider.createRecord({
|
||||
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
|
||||
type: type || 'A', value: ip, ttl: ttl || 300, overwrite: true
|
||||
});
|
||||
return success(res, {
|
||||
message: result.message || `DNS record instructions provided`,
|
||||
manual: true,
|
||||
instructions: result.instructions
|
||||
});
|
||||
}
|
||||
|
||||
const result = await provider.createRecord({
|
||||
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
|
||||
type: type || 'A', value: ip, ttl: ttl || 300, overwrite: true
|
||||
});
|
||||
|
||||
// Start propagation check in background
|
||||
if (dnsPropagationChecker && ip) {
|
||||
dnsPropagationChecker.startVerification(domain, ip).catch(err => {
|
||||
log('DNS propagation check start failed:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
success(res, {
|
||||
message: result.status === 'manual' ? result.message : `DNS record ${domain} -> ${ip} created`,
|
||||
provider: dns.getProviderId(),
|
||||
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS record creation error', { error: error.message });
|
||||
errorResponse(res, safeErrorMessage(error), 500);
|
||||
}
|
||||
}, 'dns-universal-create'));
|
||||
|
||||
// DELETE /universal/record — Delete a DNS record via any provider
|
||||
router.delete('/universal/record', asyncHandler(async (req, res) => {
|
||||
if (!dns.getActiveProvider) {
|
||||
return res.redirect(307, '/api/dns/record');
|
||||
}
|
||||
const { domain, type, value } = req.query;
|
||||
if (!domain) throw new ValidationError('domain is required');
|
||||
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
||||
|
||||
try {
|
||||
const provider = dns.getActiveProvider();
|
||||
const result = await provider.deleteRecord({
|
||||
domain, type: type || 'A', value
|
||||
});
|
||||
|
||||
success(res, {
|
||||
message: result.status === 'manual' ? result.message : `DNS record ${domain} deleted`,
|
||||
provider: dns.getProviderId(),
|
||||
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS record deletion error', { error: error.message });
|
||||
errorResponse(res, safeErrorMessage(error), 500);
|
||||
}
|
||||
}, 'dns-universal-delete'));
|
||||
|
||||
// GET /universal/resolve — Resolve a domain via any provider
|
||||
router.get('/universal/resolve', asyncHandler(async (req, res) => {
|
||||
if (!dns.getActiveProvider) {
|
||||
return res.redirect(307, '/api/dns/resolve');
|
||||
}
|
||||
const { domain, type } = req.query;
|
||||
if (!domain) throw new ValidationError('domain is required');
|
||||
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
||||
|
||||
try {
|
||||
const provider = dns.getActiveProvider();
|
||||
const result = await provider.resolveRecords({
|
||||
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
|
||||
type: type || 'A'
|
||||
});
|
||||
|
||||
if (result.response?.records?.length > 0) {
|
||||
const ipAddresses = result.response.records
|
||||
.filter(r => r.type === (type || 'A'))
|
||||
.map(r => r.rData?.ipAddress || r.content || r.rData?.address)
|
||||
.filter(Boolean);
|
||||
success(res, { answer: ipAddresses });
|
||||
} else {
|
||||
throw new NotFoundError('No records found for domain');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS resolve error', { error: error.message });
|
||||
errorResponse(res, safeErrorMessage(error), error.statusCode || 500);
|
||||
}
|
||||
}, 'dns-universal-resolve'));
|
||||
|
||||
// ===== LEGACY TECHNITIUM-SPECIFIC ROUTES (unchanged) =====
|
||||
// DELETE /record — Delete a DNS record from Technitium
|
||||
router.delete('/record', asyncHandler(async (req, res) => {
|
||||
const { domain, type, token, server, ipAddress } = req.query;
|
||||
|
||||
@@ -270,14 +139,6 @@ module.exports = function({
|
||||
});
|
||||
|
||||
if (result.status === 'ok') {
|
||||
// Start DNS propagation verification in background
|
||||
if (dnsPropagationChecker && ip) {
|
||||
const fullDomain = domain;
|
||||
dnsPropagationChecker.startVerification(fullDomain, ip).catch(err => {
|
||||
log('DNS propagation check start failed:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
success(res, { message: `DNS record ${domain} -> ${ip} created` });
|
||||
} else {
|
||||
// Error handled by middleware
|
||||
@@ -333,13 +194,8 @@ module.exports = function({
|
||||
}
|
||||
}, 'dns-resolve'));
|
||||
|
||||
// GET /logs — Fetch DNS query logs (Technitium only)
|
||||
// GET /logs — Fetch DNS query logs from Technitium
|
||||
router.get('/logs', asyncHandler(async (req, res) => {
|
||||
// Capability gate: logs are provider-specific
|
||||
if (dns.supportsCapability && !dns.supportsCapability('logs')) {
|
||||
return success(res, { server: 'N/A', count: 0, logs: [], message: 'DNS logs not supported by current provider' });
|
||||
}
|
||||
|
||||
const { server, limit } = req.query;
|
||||
|
||||
if (!server) {
|
||||
@@ -619,13 +475,8 @@ module.exports = function({
|
||||
success(res, { message: 'DNS credentials removed' });
|
||||
}, 'dns-credentials-delete'));
|
||||
|
||||
// POST /restart/:dnsId — Restart a DNS server (Technitium only)
|
||||
// POST /restart/:dnsId — Restart a DNS server (proxied through backend for auth)
|
||||
router.post('/restart/:dnsId', asyncHandler(async (req, res) => {
|
||||
// Capability gate
|
||||
if (dns.supportsCapability && !dns.supportsCapability('restart')) {
|
||||
return errorResponse(res, 'Server restart not supported by current DNS provider', 501);
|
||||
}
|
||||
|
||||
const { dnsId } = req.params;
|
||||
const serverInfo = siteConfig.dnsServers?.[dnsId];
|
||||
if (!serverInfo?.ip) {
|
||||
@@ -667,13 +518,8 @@ module.exports = function({
|
||||
}
|
||||
}, 'dns-refresh-token'));
|
||||
|
||||
// GET /check-update — Check for DNS server updates (Technitium only)
|
||||
// GET /check-update — Check for Technitium DNS server updates
|
||||
router.get('/check-update', asyncHandler(async (req, res) => {
|
||||
// Capability gate
|
||||
if (dns.supportsCapability && !dns.supportsCapability('update-check')) {
|
||||
return success(res, { updateAvailable: false, message: 'Update check not supported by current DNS provider' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { server } = req.query;
|
||||
if (!server) {
|
||||
@@ -730,13 +576,10 @@ module.exports = function({
|
||||
}
|
||||
}, 'dns-check-update'));
|
||||
|
||||
// POST /update — Update DNS server (Technitium only)
|
||||
// POST /update — Update Technitium DNS server
|
||||
// Note: Technitium v14+ has no installUpdate API. This endpoint checks for updates
|
||||
// and returns download info. The frontend handles showing update instructions.
|
||||
router.post('/update', asyncHandler(async (req, res) => {
|
||||
// Capability gate
|
||||
if (dns.supportsCapability && !dns.supportsCapability('update-check')) {
|
||||
return errorResponse(res, 'Server update not supported by current DNS provider', 501);
|
||||
}
|
||||
|
||||
try {
|
||||
const { server } = req.query;
|
||||
if (!server) {
|
||||
@@ -798,68 +641,5 @@ module.exports = function({
|
||||
}
|
||||
}, 'dns-update'));
|
||||
|
||||
// ===== DNS PROPAGATION =====
|
||||
|
||||
// GET /propagation — Get all recent DNS propagation checks
|
||||
router.get('/propagation', asyncHandler(async (req, res) => {
|
||||
if (!dnsPropagationChecker) {
|
||||
return success(res, { verifications: [], message: 'DNS propagation checker not available' });
|
||||
}
|
||||
|
||||
// Cleanup old entries
|
||||
dnsPropagationChecker.cleanup();
|
||||
|
||||
const verifications = dnsPropagationChecker.getAllVerifications();
|
||||
success(res, { verifications });
|
||||
}, 'dns-propagation-all'));
|
||||
|
||||
// POST /propagation/verify — Manually trigger DNS propagation verification
|
||||
router.post('/propagation/verify', asyncHandler(async (req, res) => {
|
||||
if (!dnsPropagationChecker) {
|
||||
return errorResponse(res, 'DNS propagation checker not available', 503);
|
||||
}
|
||||
|
||||
const { domain, expectedIp } = req.body;
|
||||
|
||||
if (!domain || !expectedIp) {
|
||||
throw new ValidationError('domain and expectedIp are required');
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
if (!REGEX.DOMAIN.test(domain)) {
|
||||
throw new ValidationError('[DC-301] Invalid domain format');
|
||||
}
|
||||
|
||||
// Validate IP address
|
||||
const validatorLib = require('validator');
|
||||
if (!validatorLib.isIP(expectedIp)) {
|
||||
throw new ValidationError('[DC-210] Invalid IP address');
|
||||
}
|
||||
|
||||
const job = dnsPropagationChecker.startVerification(domain, expectedIp);
|
||||
success(res, {
|
||||
message: 'DNS propagation verification started',
|
||||
domain,
|
||||
expectedIp,
|
||||
status: job.status
|
||||
});
|
||||
}, 'dns-propagation-verify'));
|
||||
|
||||
// GET /propagation/:domain — Get propagation status for a specific domain
|
||||
router.get('/propagation/:domain', asyncHandler(async (req, res) => {
|
||||
if (!dnsPropagationChecker) {
|
||||
return success(res, { verification: null, message: 'DNS propagation checker not available' });
|
||||
}
|
||||
|
||||
const { domain } = req.params;
|
||||
const status = dnsPropagationChecker.getVerificationStatus(domain);
|
||||
|
||||
if (!status) {
|
||||
throw new NotFoundError(`No propagation check found for domain: ${domain}`);
|
||||
}
|
||||
|
||||
success(res, { verification: status });
|
||||
}, 'dns-propagation-domain'));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -8,10 +8,9 @@ const express = require('express');
|
||||
* @param {Object} deps.healthChecker - Health checker
|
||||
* @param {Object} deps.updateManager - Update manager
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @param {Object} deps.dependencyManager - Dependency manager for restart chain events
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, dependencyManager, autoRestartManager, driftDetector, sslMonitor, dnsPropagationChecker }) {
|
||||
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError }) {
|
||||
const router = express.Router();
|
||||
const clients = new Set();
|
||||
|
||||
@@ -75,48 +74,6 @@ module.exports = function({ resourceMonitor, healthChecker, updateManager, logEr
|
||||
});
|
||||
}
|
||||
|
||||
// Dependency manager events
|
||||
if (dependencyManager) {
|
||||
dependencyManager.on('dependency-restart-start', (data) => {
|
||||
broadcast('dependency-restart-start', data);
|
||||
});
|
||||
dependencyManager.on('dependency-restart-progress', (data) => {
|
||||
broadcast('dependency-restart-progress', data);
|
||||
});
|
||||
dependencyManager.on('dependency-restart-complete', (data) => {
|
||||
broadcast('dependency-restart-complete', data);
|
||||
});
|
||||
dependencyManager.on('dependency-restart-failed', (data) => {
|
||||
broadcast('dependency-restart-failed', data);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-restart manager events
|
||||
if (autoRestartManager) {
|
||||
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
|
||||
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data));
|
||||
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
|
||||
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
|
||||
}
|
||||
|
||||
// Config drift detector events
|
||||
if (driftDetector) {
|
||||
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
|
||||
}
|
||||
|
||||
// SSL monitor events
|
||||
if (sslMonitor) {
|
||||
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data));
|
||||
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
|
||||
}
|
||||
|
||||
// DNS propagation checker events
|
||||
if (dnsPropagationChecker) {
|
||||
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data));
|
||||
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data));
|
||||
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
|
||||
}
|
||||
|
||||
// SSE endpoint
|
||||
router.get('/stream', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
|
||||
@@ -322,16 +322,26 @@ module.exports = function({
|
||||
// ===== HEALTH CHECK (health-checker module) =====
|
||||
|
||||
// Get current status for all services
|
||||
// Returns per-service status plus a summary for the System Overview widget:
|
||||
// { status: { ... }, summary: { healthy, unhealthy, total } }
|
||||
// Returns {status: {...per-service}} plus a {summary} block for the System Overview widget
|
||||
// — see skill references/totp-and-system-overview-pitfalls.md §3
|
||||
router.get('/health-checks/status', asyncHandler(async (req, res) => {
|
||||
const status = healthChecker.getCurrentStatus();
|
||||
// Build summary for the overview widget
|
||||
const entries = Object.values(status);
|
||||
const healthy = entries.filter(s => s.status === 'up' || s.status === 'healthy').length;
|
||||
const unhealthy = entries.filter(s => s.status === 'down' || s.status === 'unhealthy').length;
|
||||
const total = entries.length;
|
||||
success(res, { status, summary: { healthy, unhealthy, total } });
|
||||
const entries = Object.values(status || {});
|
||||
// Treat 'up'/'healthy' as healthy, everything else as unhealthy.
|
||||
// Health check status values come from healthChecker — typically 'up'/'down' but
|
||||
// also 'healthy'/'unhealthy' or 'online'/'offline' depending on the source. Be
|
||||
// permissive on the healthy side so a service in any positive state counts.
|
||||
const healthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'up' || st === 'healthy' || st === 'online';
|
||||
}).length;
|
||||
const unhealthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'down' || st === 'unhealthy' || st === 'offline' || st === 'error';
|
||||
}).length;
|
||||
const unknown = entries.length - healthy - unhealthy;
|
||||
const summary = { healthy, unhealthy, unknown, total: entries.length };
|
||||
success(res, { status, summary });
|
||||
}, 'health-check-status'));
|
||||
|
||||
// Get service statistics
|
||||
|
||||
@@ -16,20 +16,19 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
||||
// ===== RESOURCE MONITORING ENDPOINTS =====
|
||||
|
||||
// Get all container stats (from resource monitor module)
|
||||
// Returns a flat summary format for the System Overview widget:
|
||||
// { containerId: { cpu: <percent>, memory: <percent>, memoryUsage: <bytes>, name } }
|
||||
// Flattened for the System Overview widget — see skill references/totp-and-system-overview-pitfalls.md §3
|
||||
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
|
||||
const raw = resourceMonitor.getAllStats();
|
||||
// Transform nested { current: { cpu: { percent }, memory: { percent, usage } } }
|
||||
// into flat { cpu: number, memory: number, memoryUsage: number } for the frontend widget
|
||||
const stats = {};
|
||||
for (const [id, data] of Object.entries(raw)) {
|
||||
for (const [id, data] of Object.entries(raw || {})) {
|
||||
const cur = data.current || {};
|
||||
const cpuObj = (cur.cpu && typeof cur.cpu === 'object') ? cur.cpu : null;
|
||||
const memObj = (cur.memory && typeof cur.memory === 'object') ? cur.memory : null;
|
||||
stats[id] = {
|
||||
name: data.name,
|
||||
cpu: typeof cur.cpu === 'object' ? (cur.cpu.percent ?? 0) : (Number(cur.cpu) || 0),
|
||||
memory: typeof cur.memory === 'object' ? (cur.memory.percent ?? 0) : (Number(cur.memory) || 0),
|
||||
memoryUsage: typeof cur.memory === 'object' ? (cur.memory.usage ?? 0) : 0,
|
||||
cpu: cpuObj ? (cpuObj.percent ?? 0) : (Number(cur.cpu) || 0),
|
||||
memory: memObj ? (memObj.percent ?? 0) : (Number(cur.memory) || 0),
|
||||
memoryUsage: memObj ? (memObj.usage ?? 0) : 0,
|
||||
};
|
||||
}
|
||||
success(res, { stats });
|
||||
|
||||
@@ -11,7 +11,6 @@ const { paginate, parsePaginationParams } = require('../pagination');
|
||||
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
|
||||
const { resolveServiceUrl } = require('../url-resolver');
|
||||
const { success, error: errorResponse } = require('../response-helpers');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
/**
|
||||
* Services route factory
|
||||
@@ -47,7 +46,7 @@ module.exports = function({
|
||||
dns
|
||||
}) {
|
||||
const router = express.Router();
|
||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
||||
const PROBE_CONCURRENCY = 6;
|
||||
let probeHttpsAgent;
|
||||
|
||||
@@ -197,12 +196,12 @@ module.exports = function({
|
||||
// ===== SERVICE CREDENTIAL ENDPOINTS =====
|
||||
|
||||
// Store credentials for a service
|
||||
router.post('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.post('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
|
||||
const { apiKey, username, password } = req.body;
|
||||
@@ -221,12 +220,12 @@ module.exports = function({
|
||||
}, 'store-service-creds'));
|
||||
|
||||
// Delete credentials for a service
|
||||
router.delete('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.delete('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
|
||||
await credentialManager.delete(`service.${serviceId}.apikey`);
|
||||
@@ -236,12 +235,12 @@ module.exports = function({
|
||||
}, 'delete-service-creds'));
|
||||
|
||||
// Check credential status for a service (what's stored)
|
||||
router.get('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.get('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||
@@ -373,7 +372,7 @@ module.exports = function({
|
||||
// Add a new service
|
||||
router.post('/services', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body;
|
||||
const { id, name, logo } = req.body;
|
||||
|
||||
if (!id || !name) {
|
||||
throw new ValidationError('id and name are required');
|
||||
@@ -392,14 +391,7 @@ module.exports = function({
|
||||
throw new ConflictError(`Service "${id}" already exists`, id);
|
||||
}
|
||||
|
||||
const newService = { id, name, logo: logo || `/assets/${id}.png` };
|
||||
// Persist optional metadata fields if provided
|
||||
if (category) newService.category = category;
|
||||
if (containerId) newService.containerId = containerId;
|
||||
if (port) newService.port = port;
|
||||
if (ip) newService.ip = ip;
|
||||
if (typeof tailscaleOnly === 'boolean') newService.tailscaleOnly = tailscaleOnly;
|
||||
services.push(newService);
|
||||
services.push({ id, name, logo: logo || `/assets/${id}.png` });
|
||||
return services;
|
||||
});
|
||||
|
||||
@@ -521,8 +513,9 @@ module.exports = function({
|
||||
|
||||
if (oldSubdomain !== newSubdomain) {
|
||||
try {
|
||||
await dns.universalDeleteRecord(oldDomain);
|
||||
await dns.universalCreateRecord(newSubdomain, ip || 'localhost');
|
||||
const dnsToken = dns.getToken();
|
||||
await dns.call(siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' });
|
||||
await dns.createRecord(newSubdomain, ip || 'localhost');
|
||||
results.dns = 'updated';
|
||||
} catch (e) {
|
||||
results.dns = `failed: ${e.message}`;
|
||||
@@ -549,8 +542,6 @@ module.exports = function({
|
||||
};
|
||||
if (name) services[serviceIndex].name = name;
|
||||
if (logo) services[serviceIndex].logo = logo;
|
||||
// Allow category update via update endpoint too (optional body field)
|
||||
if (req.body.category !== undefined) services[serviceIndex].category = req.body.category || undefined;
|
||||
results.services = 'updated';
|
||||
} else {
|
||||
results.services = 'not found';
|
||||
|
||||
@@ -205,7 +205,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
|
||||
|
||||
if (createDns) {
|
||||
try {
|
||||
await dns.universalCreateRecord(subdomain, siteConfig.dnsServerIp);
|
||||
await dns.createRecord(subdomain, siteConfig.dnsServerIp);
|
||||
log.info('dns', 'DNS record created for external proxy', { domain, ip: siteConfig.dnsServerIp });
|
||||
} catch (dnsError) {
|
||||
dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* SSL Monitor Routes
|
||||
* REST API endpoints for SSL certificate monitoring.
|
||||
*
|
||||
* @module routes/ssl-monitor
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse, notFound } = require('../response-helpers');
|
||||
|
||||
/**
|
||||
* SSL Monitor route factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.sslMonitor - SSLMonitor instance
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ sslMonitor, asyncHandler, logError }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /ssl/certificates
|
||||
* Get all SSL certificate statuses
|
||||
*/
|
||||
router.get('/certificates', asyncHandler(async (req, res) => {
|
||||
const status = sslMonitor.getStatus();
|
||||
success(res, { certificates: status });
|
||||
}, 'ssl-certificates'));
|
||||
|
||||
/**
|
||||
* GET /ssl/certificates/:serviceId
|
||||
* Get SSL certificate status for a specific service
|
||||
*/
|
||||
router.get('/certificates/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const certStatus = sslMonitor.getServiceCertStatus(serviceId);
|
||||
|
||||
if (!certStatus) {
|
||||
return notFound(res, `No SSL certificate status found for service: ${serviceId}`);
|
||||
}
|
||||
|
||||
success(res, { certificate: certStatus });
|
||||
}, 'ssl-certificate-service'));
|
||||
|
||||
/**
|
||||
* POST /ssl/check
|
||||
* Trigger an on-demand check of all SSL certificates
|
||||
*/
|
||||
router.post('/check', asyncHandler(async (req, res) => {
|
||||
const results = await sslMonitor.checkAll();
|
||||
success(res, { certificates: results, message: 'SSL check completed' });
|
||||
}, 'ssl-check-all'));
|
||||
|
||||
/**
|
||||
* POST /ssl/check/:serviceId
|
||||
* Check the SSL certificate for a specific service
|
||||
*/
|
||||
router.post('/check/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Look up the existing cert status to find the hostname
|
||||
const existingCert = sslMonitor.getServiceCertStatus(serviceId);
|
||||
if (!existingCert) {
|
||||
return notFound(res, `No HTTPS URL found for service: ${serviceId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sslMonitor.checkCert(existingCert.hostname, existingCert.port);
|
||||
success(res, { certificate: { ...result, serviceId } });
|
||||
} catch (err) {
|
||||
errorResponse(res, `Failed to check SSL certificate: ${err.message}`, 500);
|
||||
}
|
||||
}, 'ssl-check-service'));
|
||||
|
||||
/**
|
||||
* GET /ssl/config
|
||||
* Get current SSL monitoring configuration
|
||||
*/
|
||||
router.get('/config', asyncHandler(async (req, res) => {
|
||||
const config = sslMonitor.getConfig();
|
||||
success(res, { config });
|
||||
}, 'ssl-config-get'));
|
||||
|
||||
/**
|
||||
* POST /ssl/config
|
||||
* Update SSL monitoring configuration
|
||||
* Body: { enabled: boolean, intervalMs: number }
|
||||
*/
|
||||
router.post('/config', asyncHandler(async (req, res) => {
|
||||
const { enabled, intervalMs } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
return errorResponse(res, 'enabled must be a boolean', 400);
|
||||
}
|
||||
if (intervalMs !== undefined) {
|
||||
if (typeof intervalMs !== 'number' || intervalMs < 60000) {
|
||||
return errorResponse(res, 'intervalMs must be a number >= 60000 (1 minute)', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
if (enabled !== undefined) updates.enabled = enabled;
|
||||
if (intervalMs !== undefined) updates.intervalMs = intervalMs;
|
||||
|
||||
sslMonitor.updateConfig(updates);
|
||||
const config = sslMonitor.getConfig();
|
||||
success(res, { config, message: 'SSL monitoring config updated' });
|
||||
}, 'ssl-config-update'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -3,7 +3,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { success } = require('../response-helpers');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
/**
|
||||
* Themes routes factory
|
||||
@@ -14,7 +13,7 @@ const platformPaths = require('../platform-paths');
|
||||
*/
|
||||
module.exports = function({ asyncHandler, log }) {
|
||||
const router = express.Router();
|
||||
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(platformPaths.servicesFile), 'themes');
|
||||
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes');
|
||||
|
||||
// Ensure themes directory exists
|
||||
if (!fs.existsSync(THEMES_DIR)) {
|
||||
|
||||
Regular → Executable
+6
-1
@@ -134,11 +134,16 @@ restart_container() {
|
||||
# Stop and remove existing container so new env var is applied
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
# Re-create with same volumes and the SERVICES_FILE env var
|
||||
# Re-create with same volumes. CRITICAL: must include CREDENTIALS_FILE +
|
||||
# ENCRYPTION_KEY_FILE pointing at /app/data/ so the container reads the TOTP
|
||||
# secret from the bind-mounted host data dir (not image-local /app/credentials.json
|
||||
# which gets a fresh encryption key on every container recreate = TOTP breaks).
|
||||
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
-e CREDENTIALS_FILE=/app/d...son \
|
||||
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
|
||||
"$image"
|
||||
log "Container restarted with fresh env"
|
||||
}
|
||||
|
||||
@@ -21,17 +21,17 @@ const isWindows = platformPaths.isWindows;
|
||||
|
||||
const DEFAULTS = {
|
||||
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
|
||||
UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
|
||||
MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
|
||||
UPDATES_DIR: platformPaths.containerUpdatesDir,
|
||||
UPDATE_URL: 'https://get.dashcaddy.net/release',
|
||||
MIRROR_URL: 'https://get2.dashcaddy.net/release',
|
||||
UPDATES_DIR: platformPaths.isWindows ? path.join(platformPaths.caddyBase, 'updates') : '/app/updates',
|
||||
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
|
||||
API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
|
||||
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
|
||||
FRONTEND_DIR: platformPaths.containerFrontendDir,
|
||||
FRONTEND_DIR: platformPaths.isWindows ? path.join(platformPaths.caddySites, 'status') : '/app/dashboard',
|
||||
MAX_BACKUPS: 3,
|
||||
HEALTH_TIMEOUT: 60000,
|
||||
DOWNLOAD_TIMEOUT: 120000,
|
||||
CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
|
||||
CHANNEL: 'stable',
|
||||
INSTANCE_ID_FILE: platformPaths.isWindows
|
||||
? path.join(platformPaths.caddyBase, 'instance-id')
|
||||
: '/etc/dashcaddy/instance-id',
|
||||
|
||||
+8
-10
@@ -3,6 +3,7 @@
|
||||
* Minimal startup script - all logic moved to src/
|
||||
*/
|
||||
const { createApp } = require('./src/app');
|
||||
const { fetchT } = require('./src/utils/http');
|
||||
const platformPaths = require('./platform-paths');
|
||||
|
||||
// Unhandled error handlers
|
||||
@@ -25,8 +26,7 @@ process.on('uncaughtException', (error) => {
|
||||
// Load license
|
||||
await licenseManager.load();
|
||||
|
||||
const PORT = parseInt(process.env.PORT, 10) || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const PORT = process.env.PORT || 3001;
|
||||
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;
|
||||
@@ -44,10 +44,9 @@ process.on('uncaughtException', (error) => {
|
||||
});
|
||||
|
||||
// Start HTTP server
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
log.info('server', 'DashCaddy API server started', {
|
||||
port: PORT,
|
||||
host: HOST,
|
||||
caddyfile: CADDYFILE_PATH,
|
||||
caddyAdmin: CADDY_ADMIN_URL,
|
||||
services: SERVICES_FILE,
|
||||
@@ -68,6 +67,10 @@ process.on('uncaughtException', (error) => {
|
||||
const selfUpdater = require('./self-updater');
|
||||
const portLockManager = require('./port-lock-manager');
|
||||
|
||||
// Create servicesStateManager early — needed by workflow engine init
|
||||
const StateManager = require('./state-manager');
|
||||
const servicesStateManager = new StateManager(SERVICES_FILE);
|
||||
|
||||
// Optional modules
|
||||
let dockerMaintenance, logDigest, bundledWorkflows;
|
||||
try { dockerMaintenance = require('./docker-maintenance'); } catch { /* optional */ }
|
||||
@@ -75,17 +78,14 @@ process.on('uncaughtException', (error) => {
|
||||
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
|
||||
|
||||
// Initialize workflow engine if bundled-workflows is available
|
||||
// NOTE: createApp() already initializes the workflow engine in src/app.js
|
||||
// This block is kept for backward compat with entry points that don't use createApp()
|
||||
let workflowEngine = null;
|
||||
if (bundledWorkflows) {
|
||||
try {
|
||||
const { fetchT } = require('./src/utils/http');
|
||||
const { WorkflowEngine } = bundledWorkflows;
|
||||
// Create a context with needed services
|
||||
const workflowCtx = {
|
||||
docker: { client: require('dockerode')() },
|
||||
notification: require('./notification-manager')({
|
||||
notification: new (require('./notification-manager'))({
|
||||
NOTIFICATIONS_FILE: process.env.NOTIFICATIONS_FILE || require('./platform-paths').notificationsFile,
|
||||
fetchT,
|
||||
log,
|
||||
@@ -138,8 +138,6 @@ process.on('uncaughtException', (error) => {
|
||||
(async () => {
|
||||
try {
|
||||
const { syncHealthCheckerServices } = require('./startup-validator');
|
||||
const StateManager = require('./state-manager');
|
||||
const servicesStateManager = new StateManager(SERVICES_FILE);
|
||||
|
||||
await syncHealthCheckerServices({
|
||||
log,
|
||||
|
||||
+61
-136
@@ -12,11 +12,12 @@ const { assembleContext } = require('./context');
|
||||
const { createLogger, logError, safeErrorMessage } = require('./utils/logging');
|
||||
const { fetchT } = require('./utils/http');
|
||||
const { errorResponse, ok } = require('./utils/responses');
|
||||
// Note: 3-arg asyncHandler signature (logError, fn, context) preserved per Hermes review
|
||||
// — 49 route files still use this signature.
|
||||
const { asyncHandler } = require('./utils/async-handler');
|
||||
|
||||
// Managers and utilities
|
||||
const StateManager = require('../state-manager');
|
||||
const platformPaths = require('../platform-paths');
|
||||
const { LicenseManager } = require('../license-manager');
|
||||
const credentialManager = require('../credential-manager');
|
||||
const authManager = require('../auth-manager');
|
||||
@@ -29,7 +30,7 @@ const healthChecker = require('../health-checker');
|
||||
const updateManager = require('../update-manager');
|
||||
const selfUpdater = require('../self-updater');
|
||||
const configureMiddleware = require('../middleware');
|
||||
const { validateStartupConfig, syncHealthCheckerServices } = require('../startup-validator');
|
||||
const { syncHealthCheckerServices } = require('../startup-validator');
|
||||
const { CSRF_HEADER_NAME } = require('../csrf-protection');
|
||||
const { resolveServiceUrl } = require('../url-resolver');
|
||||
const metrics = require('../metrics');
|
||||
@@ -78,15 +79,6 @@ const themesRoutes = require('../routes/themes');
|
||||
const dockerResourcesRoutes = require('../routes/docker-resources');
|
||||
const eventsRoutes = require('../routes/events');
|
||||
const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const DependencyManager = require('../dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
const sslMonitorRoutes = require('../routes/ssl-monitor');
|
||||
const { AutoRestartManager } = require('../auto-restart-manager');
|
||||
const { ConfigDriftDetector } = require('../config-drift-detector');
|
||||
const SSLMonitor = require('../ssl-monitor');
|
||||
const DNSPropagationChecker = require('../dns-propagation');
|
||||
|
||||
// Constants
|
||||
const { APP } = require('../constants');
|
||||
@@ -94,22 +86,9 @@ const { APP } = require('../constants');
|
||||
/**
|
||||
* Create and configure the Express application
|
||||
*/
|
||||
async function createApp() {
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
||||
// Global request timeout (default 5 minutes — covers slow Docker pulls)
|
||||
// Routes that need longer can override per-request with req.setTimeout()
|
||||
const REQUEST_TIMEOUT_MS = parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5 * 60 * 1000;
|
||||
app.use((req, res, next) => {
|
||||
req.setTimeout(REQUEST_TIMEOUT_MS);
|
||||
res.setTimeout(REQUEST_TIMEOUT_MS);
|
||||
next();
|
||||
});
|
||||
// Disable x-powered-by header for security (don't advertise framework)
|
||||
app.disable('x-powered-by');
|
||||
// Trust first proxy (Caddy/nginx in front of us) so req.ip works correctly
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Initialize logging
|
||||
const log = createLogger(config.LOG_LEVEL);
|
||||
|
||||
@@ -125,7 +104,7 @@ async function createApp() {
|
||||
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
||||
|
||||
// HTTPS agent for internal CA
|
||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
||||
let httpsAgent;
|
||||
try {
|
||||
const caCert = fs.readFileSync(CA_CERT_PATH);
|
||||
@@ -182,11 +161,26 @@ async function createApp() {
|
||||
return first === 100 && second >= 64 && second <= 127;
|
||||
}
|
||||
|
||||
async function getTailscaleStatus() {
|
||||
function getTailscaleStatus() {
|
||||
// Stub for now - will be populated by context
|
||||
return null;
|
||||
}
|
||||
|
||||
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
|
||||
// auto-login pages) historically call these endpoints under the pre-1.5.0
|
||||
// prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained
|
||||
// Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO
|
||||
// gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these two
|
||||
// auth paths to the v1 mount so the gate is tolerant of that drift. Must run
|
||||
// before configureMiddleware() so CSRF/auth see the canonical path. This is
|
||||
// deliberately narrow — NOT a general `/api` -> `/api/v1` alias.
|
||||
app.use((req, res, next) => {
|
||||
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) {
|
||||
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Configure middleware
|
||||
const middlewareResult = configureMiddleware(app, {
|
||||
siteConfig: config.siteConfig,
|
||||
@@ -217,7 +211,7 @@ async function createApp() {
|
||||
|
||||
async function readConfig() {
|
||||
const { readJsonFile } = require('../fs-helpers');
|
||||
return readJsonFile(config.CONFIG_FILE, {});
|
||||
return await readJsonFile(config.CONFIG_FILE, {});
|
||||
}
|
||||
|
||||
async function saveConfig(updates) {
|
||||
@@ -250,7 +244,9 @@ async function createApp() {
|
||||
// Stub - will be implemented
|
||||
}
|
||||
|
||||
async function resyncHealthChecker() {
|
||||
// Forwards the promise from syncHealthCheckerServices — intentionally not
|
||||
// `async` since there is no `await` inside. Callers use `.catch()` on it.
|
||||
function resyncHealthChecker() {
|
||||
return syncHealthCheckerServices({
|
||||
log,
|
||||
SERVICES_FILE: config.SERVICES_FILE,
|
||||
@@ -262,11 +258,13 @@ async function createApp() {
|
||||
});
|
||||
}
|
||||
|
||||
// Create bound logError function
|
||||
// Create bound logError function (3-arg signature: ctx, err, extra)
|
||||
// The unified logger module has its own ERROR_LOG_FILE from process.env,
|
||||
// so we just route through its logErrorWrapper.
|
||||
const boundLogError = (context, error, additionalInfo) =>
|
||||
logError(config.ERROR_LOG_FILE, config.MAX_ERROR_LOG_SIZE, context, error, additionalInfo, log);
|
||||
logError(context, error, additionalInfo);
|
||||
|
||||
// Create bound asyncHandler
|
||||
// Create bound asyncHandler (3-arg: logError, fn, context)
|
||||
const boundAsyncHandler = (fn, context) => asyncHandler(boundLogError, fn, context);
|
||||
|
||||
// Assemble context
|
||||
@@ -358,65 +356,9 @@ async function createApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dependency manager
|
||||
const dependencyManager = new DependencyManager({
|
||||
servicesStateManager,
|
||||
docker: ctx.docker,
|
||||
notification: ctx.notification,
|
||||
log,
|
||||
});
|
||||
ctx.dependencyManager = dependencyManager;
|
||||
log.info('app', 'Dependency manager initialized');
|
||||
|
||||
// Initialize auto-restart manager
|
||||
const autoRestartManager = new AutoRestartManager(ctx);
|
||||
ctx.autoRestartManager = autoRestartManager;
|
||||
autoRestartManager.start();
|
||||
log.info('app', 'Auto-restart manager initialized');
|
||||
|
||||
// Initialize config drift detector
|
||||
const driftDetector = new ConfigDriftDetector(ctx);
|
||||
ctx.driftDetector = driftDetector;
|
||||
driftDetector.startPolling(300000); // 5 min
|
||||
log.info('app', 'Config drift detector initialized');
|
||||
|
||||
// Initialize SSL monitor
|
||||
const sslMonitor = new SSLMonitor(ctx);
|
||||
ctx.sslMonitor = sslMonitor;
|
||||
sslMonitor.start(3600000); // 1 hour
|
||||
log.info('app', 'SSL monitor initialized');
|
||||
|
||||
// Initialize DNS propagation checker
|
||||
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
|
||||
ctx.dnsPropagationChecker = dnsPropagationChecker;
|
||||
log.info('app', 'DNS propagation checker initialized');
|
||||
|
||||
// Build versioned API router
|
||||
const apiRouter = express.Router();
|
||||
|
||||
// Version endpoint — public, no auth required
|
||||
// Reads version from package.json at startup so the response always matches the running code
|
||||
let appVersion = '0.0.0';
|
||||
let appName = 'dashcaddy-api';
|
||||
try {
|
||||
const pkg = require('../package.json');
|
||||
appVersion = pkg.version || appVersion;
|
||||
appName = pkg.name || appName;
|
||||
} catch { /* package.json unreadable — keep fallback */ }
|
||||
apiRouter.get('/version', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
name: appName,
|
||||
version: appVersion,
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
uptime: process.uptime(),
|
||||
instanceId: process.env.DASHCADDY_INSTANCE_ID || null
|
||||
});
|
||||
});
|
||||
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
|
||||
|
||||
// Wire up notification listeners for resourceMonitor and backupManager
|
||||
if (ctx.notification && ctx.resourceMonitor) {
|
||||
ctx.resourceMonitor.on('alert', (alertData) => {
|
||||
@@ -454,8 +396,7 @@ async function createApp() {
|
||||
log: ctx.log,
|
||||
safeErrorMessage: ctx.safeErrorMessage,
|
||||
fetchT: ctx.fetchT,
|
||||
credentialManager: ctx.credentialManager,
|
||||
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||
credentialManager: ctx.credentialManager
|
||||
}));
|
||||
apiRouter.use('/notifications', notificationRoutes({
|
||||
notification: ctx.notification,
|
||||
@@ -569,42 +510,13 @@ async function createApp() {
|
||||
resourceMonitor: ctx.resourceMonitor,
|
||||
healthChecker: ctx.healthChecker,
|
||||
updateManager: ctx.updateManager,
|
||||
logError: ctx.logError,
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
autoRestartManager: ctx.autoRestartManager,
|
||||
driftDetector: ctx.driftDetector,
|
||||
sslMonitor: ctx.sslMonitor,
|
||||
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||
logError: ctx.logError
|
||||
}));
|
||||
apiRouter.use('/workflows', workflowsRoutes({
|
||||
apiRouter.use(workflowsRoutes({
|
||||
workflowEngine: ctx.workflowEngine,
|
||||
licenseManager: ctx.licenseManager,
|
||||
asyncHandler: ctx.asyncHandler
|
||||
}));
|
||||
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
servicesStateManager: ctx.servicesStateManager,
|
||||
docker: ctx.docker,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
resyncHealthChecker: ctx.resyncHealthChecker,
|
||||
log: ctx.log,
|
||||
}));
|
||||
apiRouter.use(autoRestartRoutes({
|
||||
autoRestartManager: ctx.autoRestartManager,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
apiRouter.use(configDriftRoutes({
|
||||
driftDetector: ctx.driftDetector,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
apiRouter.use(sslMonitorRoutes({
|
||||
sslMonitor: ctx.sslMonitor,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
|
||||
// Inline API routes
|
||||
apiRouter.get('/health', (req, res) => {
|
||||
@@ -719,10 +631,33 @@ async function createApp() {
|
||||
res.status(statusCode).send();
|
||||
}, 'probe'));
|
||||
|
||||
// Scan OS network interfaces and classify the first LAN + Tailscale IPv4
|
||||
// addresses. Extracted to keep the route handler below ESLint's max-depth.
|
||||
function detectInterfaceIps() {
|
||||
const os = require('os');
|
||||
const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
|
||||
const all = [];
|
||||
let lan = null;
|
||||
let tailscale = null;
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs || []) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
const { address: ip } = addr;
|
||||
all.push({ name, ip });
|
||||
if (!tailscale && ip.startsWith('100.')) {
|
||||
tailscale = ip;
|
||||
} else if (!lan && LAN_RANGE.test(ip)) {
|
||||
lan = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lan, tailscale, all };
|
||||
}
|
||||
|
||||
// Network IPs endpoint
|
||||
app.get('/api/v1/network/ips', (req, res) => {
|
||||
try {
|
||||
const os = require('os');
|
||||
const envLan = process.env.HOST_LAN_IP;
|
||||
const envTailscale = process.env.HOST_TAILSCALE_IP;
|
||||
|
||||
@@ -734,20 +669,10 @@ async function createApp() {
|
||||
};
|
||||
|
||||
if (!envLan || !envTailscale) {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
const ip = addr.address;
|
||||
result.all.push({ name, ip });
|
||||
|
||||
if (!result.tailscale && ip.startsWith('100.')) {
|
||||
result.tailscale = ip;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const detected = detectInterfaceIps();
|
||||
if (!result.lan) result.lan = detected.lan;
|
||||
if (!result.tailscale) result.tailscale = detected.tailscale;
|
||||
result.all = detected.all;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Config migration system
|
||||
*
|
||||
* When config.json schema changes between versions, register a migration
|
||||
* function here. On load, the loader detects the stored version, runs all
|
||||
* migrations from that version forward, and writes the result back.
|
||||
*
|
||||
* Migration format:
|
||||
* migrations[<toVersion>] = (rawConfig) => { ...mutations, _version: toVersion }
|
||||
*
|
||||
* Each migration is responsible for transforming the previous version's
|
||||
* shape into the next version's shape. They run sequentially, so v1→v2→v3
|
||||
* all execute in order.
|
||||
*
|
||||
* For first-time users with no config file, the loader creates a fresh
|
||||
* config with CURRENT_VERSION, so they start at the latest schema.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const CURRENT_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Migrations: keys are the version they PRODUCE.
|
||||
* Each migration takes a raw config object and returns the next version.
|
||||
*/
|
||||
const migrations = {
|
||||
// v0 (unversioned) → v1: add _version field, normalize dns structure
|
||||
1: (raw) => {
|
||||
const migrated = { ...raw };
|
||||
if (!migrated._version) migrated._version = 1;
|
||||
// Normalize: older configs may have dns as a string IP, convert to object
|
||||
if (typeof migrated.dns === 'string') {
|
||||
migrated.dns = { ip: migrated.dns, port: 5380 };
|
||||
} else if (!migrated.dns) {
|
||||
migrated.dns = { ip: '', port: 5380 };
|
||||
}
|
||||
return migrated;
|
||||
},
|
||||
|
||||
// v1 → v2: add dns.provider field (default: 'technitium' for backwards compat)
|
||||
2: (raw) => {
|
||||
const migrated = { ...raw };
|
||||
if (migrated.dns && !migrated.dns.provider) {
|
||||
migrated.dns.provider = 'technitium';
|
||||
}
|
||||
migrated._version = 2;
|
||||
return migrated;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run all migrations from `fromVersion` (or detected) to CURRENT_VERSION.
|
||||
* @param {object} raw - The raw config object (may or may not have _version)
|
||||
* @returns {object} The migrated config
|
||||
*/
|
||||
function migrate(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
// First-time load: return minimal config at current version
|
||||
return { _version: CURRENT_VERSION };
|
||||
}
|
||||
|
||||
const fromVersion = raw._version || 0;
|
||||
if (fromVersion > CURRENT_VERSION) {
|
||||
// Config from a future version — bail out, don't corrupt it
|
||||
// The validation step will catch any actual issues
|
||||
return raw;
|
||||
}
|
||||
|
||||
let current = { ...raw };
|
||||
for (let v = fromVersion + 1; v <= CURRENT_VERSION; v++) {
|
||||
if (migrations[v]) {
|
||||
current = migrations[v](current);
|
||||
} else {
|
||||
// No migration defined for this version, just bump _version
|
||||
current._version = v;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from disk, run migrations if needed, and write back the
|
||||
* migrated version. Safe to call on every startup.
|
||||
* @param {string} configFile - Absolute path to config.json
|
||||
* @param {object} log - Logger instance
|
||||
* @returns {object} The migrated config object
|
||||
*/
|
||||
function loadAndMigrate(configFile, log) {
|
||||
let raw = null;
|
||||
let fileExisted = false;
|
||||
|
||||
if (fs.existsSync(configFile)) {
|
||||
fileExisted = true;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message });
|
||||
}
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
|
||||
const fromVersion = raw && raw._version ? raw._version : 0;
|
||||
const migrated = migrate(raw);
|
||||
|
||||
// Only write back to disk if:
|
||||
// 1. The file already existed (we don't create configs on fresh installs —
|
||||
// the loader's defaults handle that case), AND
|
||||
// 2. The version actually changed (no point rewriting identical content)
|
||||
if (fileExisted && fromVersion < CURRENT_VERSION) {
|
||||
if (log && log.info) {
|
||||
log.info('config-migration', `Migrated config v${fromVersion} → v${CURRENT_VERSION}`, {
|
||||
from: fromVersion,
|
||||
to: CURRENT_VERSION,
|
||||
path: configFile
|
||||
});
|
||||
}
|
||||
// Write back the migrated config
|
||||
try {
|
||||
// Ensure parent dir exists
|
||||
const dir = path.dirname(configFile);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(configFile, JSON.stringify(migrated, null, 2));
|
||||
} catch (e) {
|
||||
if (log && log.warn) {
|
||||
log.warn('config-migration', 'Failed to write migrated config back to disk', { error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CURRENT_VERSION,
|
||||
migrations,
|
||||
migrate,
|
||||
loadAndMigrate
|
||||
};
|
||||
@@ -1,15 +1,10 @@
|
||||
/**
|
||||
* Site configuration loader
|
||||
* Loads and manages site-wide settings from config.json
|
||||
*
|
||||
* Includes automatic migration from older config versions (see migrations.js).
|
||||
* Users never see the migration — it runs silently on startup, writes the
|
||||
* updated config back, and the rest of the app only ever sees the current
|
||||
* schema.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const { validateConfig } = require('../../config-schema');
|
||||
const { CADDY } = require('../../constants');
|
||||
const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
|
||||
|
||||
const siteConfig = {
|
||||
tld: '.home',
|
||||
@@ -26,11 +21,9 @@ const siteConfig = {
|
||||
|
||||
function loadSiteConfig(CONFIG_FILE, log) {
|
||||
try {
|
||||
// Run migrations first — this handles config.json files from older
|
||||
// versions of DashCaddy and writes the migrated version back to disk.
|
||||
const raw = loadAndMigrate(CONFIG_FILE, log);
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
|
||||
if (raw && Object.keys(raw).length > 0) {
|
||||
// Validate config and log any issues
|
||||
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
|
||||
if (log && log.warn) {
|
||||
@@ -83,5 +76,4 @@ module.exports = {
|
||||
loadSiteConfig,
|
||||
buildDomain,
|
||||
buildServiceUrl,
|
||||
CURRENT_VERSION
|
||||
};
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
/**
|
||||
* DNS context - Technitium DNS operations and token management
|
||||
*
|
||||
* DEPRECATED: This module is kept for backward compatibility.
|
||||
* New code should use src/context/provider-dns.js which supports multiple providers.
|
||||
*
|
||||
* This module now delegates to the provider system internally.
|
||||
*/
|
||||
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants');
|
||||
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
|
||||
const { createProviderDnsContext } = require('./provider-dns');
|
||||
|
||||
// DNS token management
|
||||
let dnsToken = process.env.DNS_ADMIN_TOKEN || '';
|
||||
@@ -287,10 +281,6 @@ function invalidateTokenForServer(serverIp) {
|
||||
}
|
||||
|
||||
function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) {
|
||||
// Create the new provider-aware context
|
||||
const providerCtx = createProviderDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE);
|
||||
|
||||
// Legacy Technitium-specific wrappers (kept for backward compat)
|
||||
const ensureToken = () => ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
|
||||
const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log);
|
||||
const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role);
|
||||
@@ -299,7 +289,6 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht
|
||||
const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent);
|
||||
|
||||
return {
|
||||
// Legacy Technitium-specific interface (unchanged)
|
||||
call,
|
||||
buildUrl: buildDnsUrl,
|
||||
requireToken: require,
|
||||
@@ -313,17 +302,6 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht
|
||||
invalidateTokenForServer,
|
||||
refresh,
|
||||
credentialsFile: DNS_CREDENTIALS_FILE,
|
||||
|
||||
// Provider-aware methods (new)
|
||||
getProviderId: providerCtx.getProviderId,
|
||||
getActiveProvider: providerCtx.getActiveProvider,
|
||||
getAvailableProviders: providerCtx.getAvailableProviders,
|
||||
supportsCapability: providerCtx.supportsCapability,
|
||||
|
||||
// Universal DNS helpers (delegated to provider context)
|
||||
universalCreateRecord: providerCtx.universalCreateRecord,
|
||||
universalDeleteRecord: providerCtx.universalDeleteRecord,
|
||||
universalResolveRecord: providerCtx.universalResolveRecord,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* Provider-aware DNS Context
|
||||
* Replaces the Technitium-only context with a provider-agnostic layer.
|
||||
* Delegates to the active DNS provider adapter based on config.
|
||||
*
|
||||
* Falls back to legacy Technitium context for backward compatibility
|
||||
* when no provider is explicitly configured.
|
||||
*/
|
||||
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
|
||||
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants');
|
||||
const registry = require('../../dns-providers/registry');
|
||||
|
||||
// Per-server token cache (legacy Technitium)
|
||||
const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens);
|
||||
let dnsToken = '';
|
||||
let dnsTokenExpiry = null;
|
||||
|
||||
/**
|
||||
* Create a provider-aware DNS context.
|
||||
* This wraps both the new provider system and the legacy Technitium context
|
||||
* for seamless migration.
|
||||
*/
|
||||
function createProviderDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) {
|
||||
/** Resolve the active provider from config */
|
||||
function getProviderId() {
|
||||
// New explicit provider field
|
||||
if (siteConfig.dns?.provider) return siteConfig.dns.provider;
|
||||
// Legacy: if dns.ip is set, default to technitium
|
||||
if (siteConfig.dnsServerIp || siteConfig.dns?.ip) return 'technitium';
|
||||
// No DNS configured
|
||||
return 'manual';
|
||||
}
|
||||
|
||||
/** Get provider-specific config from site config */
|
||||
function getProviderConfig(providerId) {
|
||||
const dnsConfig = siteConfig.dns || {};
|
||||
|
||||
switch (providerId) {
|
||||
case 'technitium':
|
||||
return {
|
||||
serverIp: siteConfig.dnsServerIp || dnsConfig.ip || '',
|
||||
serverPort: siteConfig.dnsServerPort || dnsConfig.port || '5380',
|
||||
dnsServers: siteConfig.dnsServers || {},
|
||||
dnsId: Object.keys(siteConfig.dnsServers || {})[0] || 'dns1'
|
||||
};
|
||||
case 'cloudflare':
|
||||
return {
|
||||
apiToken: dnsConfig.apiToken || '',
|
||||
zoneId: dnsConfig.zoneId || '',
|
||||
domain: siteConfig.domain || ''
|
||||
};
|
||||
case 'rfc2136':
|
||||
return {
|
||||
server: dnsConfig.server || siteConfig.dnsServerIp || '',
|
||||
port: dnsConfig.port || 53,
|
||||
zone: siteConfig.tld?.replace(/^\./, '') || '',
|
||||
tsigAlgorithm: dnsConfig.tsigAlgorithm || 'hmac-sha256',
|
||||
tsigKeyName: dnsConfig.tsigKeyName || '',
|
||||
tsigSecret: dnsConfig.tsigSecret || ''
|
||||
};
|
||||
case 'manual':
|
||||
return {};
|
||||
default:
|
||||
return dnsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get or create the active provider adapter */
|
||||
function getActiveProvider() {
|
||||
const providerId = getProviderId();
|
||||
const config = getProviderConfig(providerId);
|
||||
const ctx = { log, credentialManager, fetchT, httpsAgent };
|
||||
return registry.getProvider(providerId, config, ctx);
|
||||
}
|
||||
|
||||
// ===== Legacy Technitium helpers (kept for backward compat) =====
|
||||
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}`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async function refreshDnsToken(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() + 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 };
|
||||
}
|
||||
}
|
||||
|
||||
function dnsIpToDnsId(serverIp) {
|
||||
for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) {
|
||||
if (info.ip === serverIp) return dnsId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureValidDnsToken() {
|
||||
if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
|
||||
return { success: true, token: dnsToken };
|
||||
}
|
||||
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 refreshDnsToken(username, password, primaryIp);
|
||||
} catch (err) { /* try next */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
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) { /* no global creds */ }
|
||||
return { success: false, error: 'No DNS credentials configured' };
|
||||
}
|
||||
|
||||
async function getTokenForServer(targetServer, role = 'readonly') {
|
||||
const cacheKey = `${targetServer}:${role}`;
|
||||
const cached = dnsServerTokens.get(cacheKey);
|
||||
if (cached?.token && cached?.expiry && new Date() < new Date(cached.expiry)) {
|
||||
return { success: true, token: cached.token };
|
||||
}
|
||||
const serverPort = siteConfig.dnsServerPort || '5380';
|
||||
async function authToServer(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);
|
||||
if (dnsId) {
|
||||
for (const r of [role, role === 'readonly' ? 'admin' : 'readonly']) {
|
||||
try {
|
||||
const username = await credentialManager.retrieve(`dns.${dnsId}.${r}.username`);
|
||||
const password = await credentialManager.retrieve(`dns.${dnsId}.${r}.password`);
|
||||
if (username && password) return await authToServer(username, password);
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
}
|
||||
try {
|
||||
const username = await credentialManager.retrieve('dns.username');
|
||||
const password = await credentialManager.retrieve('dns.password');
|
||||
if (username && password) return await authToServer(username, password);
|
||||
} catch { /* no global creds */ }
|
||||
return { success: false, error: 'No DNS credentials configured' };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function invalidateTokenForServer(serverIp) {
|
||||
dnsServerTokens.delete(`${serverIp}:readonly`);
|
||||
dnsServerTokens.delete(`${serverIp}:admin`);
|
||||
}
|
||||
|
||||
// ===== Public context API =====
|
||||
// This maintains the same interface as the old createDnsContext()
|
||||
// but adds provider-aware methods on top.
|
||||
|
||||
return {
|
||||
// --- Provider-aware methods ---
|
||||
/** Get the active provider ID */
|
||||
getProviderId,
|
||||
|
||||
/** Get the active provider adapter instance */
|
||||
getActiveProvider,
|
||||
|
||||
/** Get metadata for all available providers */
|
||||
getAvailableProviders: () => registry.getProviderMeta(),
|
||||
|
||||
/** Check if the active provider supports a capability */
|
||||
supportsCapability: (cap) => {
|
||||
try { return getActiveProvider().supportsCapability(cap); }
|
||||
catch { return false; }
|
||||
},
|
||||
|
||||
// --- Legacy Technitium context (backward compat) ---
|
||||
call: callDns,
|
||||
buildUrl: buildDnsUrl,
|
||||
requireToken: requireDnsToken,
|
||||
ensureToken: ensureValidDnsToken,
|
||||
getToken: () => dnsToken,
|
||||
setToken: (t) => { dnsToken = t; },
|
||||
getTokenExpiry: () => dnsTokenExpiry,
|
||||
setTokenExpiry: (e) => { dnsTokenExpiry = e; },
|
||||
getTokenForServer,
|
||||
invalidateTokenForServer,
|
||||
refresh: refreshDnsToken,
|
||||
credentialsFile: DNS_CREDENTIALS_FILE,
|
||||
|
||||
// --- Universal DNS helpers (provider-agnostic) ---
|
||||
|
||||
/**
|
||||
* Create a DNS A record using the active provider.
|
||||
* Gracefully handles manual adapters that return instructions instead of performing the action.
|
||||
*/
|
||||
async universalCreateRecord(subdomain, ip) {
|
||||
const provider = getActiveProvider();
|
||||
const result = await provider.createRecord({
|
||||
domain: buildDomain(subdomain),
|
||||
zone: siteConfig.tld?.replace(/^\./, '') || '',
|
||||
type: 'A',
|
||||
value: ip,
|
||||
ttl: 300,
|
||||
overwrite: true,
|
||||
});
|
||||
// Manual adapter returns instructions instead of performing the action
|
||||
if (result?.manual || result?.instructions) {
|
||||
return { success: true, manual: true, instructions: result.instructions || result };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a DNS A record using the active provider.
|
||||
* Gracefully handles manual adapters that return instructions instead of performing the action.
|
||||
*/
|
||||
async universalDeleteRecord(domain, ip) {
|
||||
const provider = getActiveProvider();
|
||||
const result = await provider.deleteRecord({
|
||||
domain,
|
||||
type: 'A',
|
||||
value: ip,
|
||||
});
|
||||
if (result?.manual || result?.instructions) {
|
||||
return { success: true, manual: true, instructions: result.instructions || result };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve DNS records using the active provider.
|
||||
* Returns parsed IP addresses from the result.
|
||||
*/
|
||||
async universalResolveRecord(domain, type) {
|
||||
const provider = getActiveProvider();
|
||||
const result = await provider.resolveRecords({
|
||||
domain,
|
||||
zone: siteConfig.tld?.replace(/^\./, '') || '',
|
||||
type: type || 'A',
|
||||
});
|
||||
// Parse IP addresses from the result
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
if (result?.records) {
|
||||
return result.records.map(r => r.ipAddress || r.value || r.address || r).filter(Boolean);
|
||||
}
|
||||
if (result?.ips) {
|
||||
return result.ips;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createProviderDnsContext };
|
||||
@@ -1,123 +1,431 @@
|
||||
/**
|
||||
* Logging utilities - Structured logging and error handling
|
||||
* DashCaddy Unified Logger
|
||||
*
|
||||
* Single logging system for the entire application.
|
||||
* - Structured JSON to stdout/stderr (pretty-printed in development)
|
||||
* - Human-readable errors to error.log with rotation
|
||||
* - Audit entries to audit-log.json
|
||||
* - All via log.info / log.warn / log.error / log.debug
|
||||
*
|
||||
* Usage:
|
||||
* const { log } = require('./logger');
|
||||
* log.info('server', 'Server started', { port: 3001 });
|
||||
* log.error('container', 'Failed to start', err, { req });
|
||||
* log.audit({ action: 'service.create', resource: 'nginx', outcome: 'success', ip, details });
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a structured logger
|
||||
*/
|
||||
function createLogger(LOG_LEVEL) {
|
||||
function log(level, context, message, data = {}) {
|
||||
if (LOG_LEVELS[level] < LOG_LEVEL) return;
|
||||
const LOG_DIR = process.env.LOG_DIR || __dirname;
|
||||
const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(LOG_DIR, 'error.log');
|
||||
const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(LOG_DIR, 'audit-log.json');
|
||||
const MAX_ERROR_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const MAX_AUDIT_ENTRIES = 1000;
|
||||
const AUDIT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
const entry = {
|
||||
t: new Date().toISOString(),
|
||||
level,
|
||||
ctx: context,
|
||||
msg: message,
|
||||
};
|
||||
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
const IS_DEV = NODE_ENV !== 'production';
|
||||
|
||||
if (Object.keys(data).length) entry.data = data;
|
||||
// ─── Log levels ───────────────────────────────────────────────────────────────
|
||||
|
||||
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.info;
|
||||
fn(JSON.stringify(entry));
|
||||
}
|
||||
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
|
||||
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);
|
||||
let GLOBAL_LEVEL = IS_DEV ? LEVELS.debug : LEVELS.info;
|
||||
|
||||
return log;
|
||||
// ─── Console colours ─────────────────────────────────────────────────────────
|
||||
|
||||
const C = {
|
||||
reset: '\x1b[0m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
green: '\x1b[32m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
const LEVEL_COLOUR = { debug: C.dim, info: C.green, warn: C.yellow, error: C.red };
|
||||
|
||||
const LEVEL_PREFIX = {
|
||||
debug: `${C.dim}[DBG]${C.reset}`,
|
||||
info: `${C.green}[INF]${C.reset}`,
|
||||
warn: `${C.yellow}[WRN]${C.reset}`,
|
||||
error: `${C.red}[ERR]${C.reset}`,
|
||||
};
|
||||
|
||||
// ─── Time formatter ───────────────────────────────────────────────────────────
|
||||
|
||||
function pad(n, len = 2) { return String(n).padStart(len, '0'); }
|
||||
function formatTime() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error logging with context tracking
|
||||
*/
|
||||
async function logError(ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE, context, error, additionalInfo = {}, log) {
|
||||
const timestamp = new Date().toISOString();
|
||||
// ─── Console output (dev = pretty, prod = JSON) ─────────────────────────────
|
||||
|
||||
// Extract request context
|
||||
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;
|
||||
delete additionalInfo.req;
|
||||
function consoleWrite(level, ctx, msg, data) {
|
||||
if (GLOBAL_LEVEL > LEVELS[level]) return;
|
||||
if (IS_DEV) {
|
||||
const colour = LEVEL_COLOUR[level] || C.reset;
|
||||
const parts = [
|
||||
`${C.dim}${formatTime()}${C.reset}`,
|
||||
LEVEL_PREFIX[level],
|
||||
`${C.cyan}${ctx}${C.reset}`,
|
||||
`${msg}`,
|
||||
];
|
||||
if (data && typeof data === 'object' && !(data instanceof Error)) {
|
||||
parts.push(`${C.dim}${JSON.stringify(data)}${C.reset}`);
|
||||
}
|
||||
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
context,
|
||||
...requestContext,
|
||||
error: {
|
||||
message: error.message || error,
|
||||
stack: error.stack,
|
||||
code: error.code
|
||||
},
|
||||
...additionalInfo
|
||||
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
|
||||
fn(parts.join(' '));
|
||||
} else {
|
||||
const entry = {
|
||||
t: new Date().toISOString(), level, ctx, msg,
|
||||
...(data instanceof Error
|
||||
? { error: { message: data.message, code: data.code, stack: data.stack } }
|
||||
: (data && typeof data === 'object' ? { data } : {})),
|
||||
};
|
||||
(level === 'error' ? console.error : console.info)(JSON.stringify(entry));
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
// ─── Error log file ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function appendErrorLog(line) {
|
||||
try {
|
||||
// Rotate log if it exceeds max size
|
||||
try {
|
||||
const stats = await fsp.stat(ERROR_LOG_FILE);
|
||||
if (stats.size > MAX_ERROR_LOG_SIZE) {
|
||||
const stats = await fsp.stat(ERROR_LOG_FILE).catch(() => null);
|
||||
if (stats && stats.size > MAX_ERROR_LOG_SIZE) {
|
||||
const rotated = ERROR_LOG_FILE + '.1';
|
||||
const exists = await fsp.access(rotated).then(() => true).catch(() => false);
|
||||
if (exists) await fsp.unlink(rotated);
|
||||
await fsp.unlink(rotated).catch(() => {});
|
||||
await fsp.rename(ERROR_LOG_FILE, rotated);
|
||||
}
|
||||
} catch (_) { /* file may not exist yet */ }
|
||||
|
||||
await fsp.appendFile(ERROR_LOG_FILE, logLine);
|
||||
await fsp.appendFile(ERROR_LOG_FILE, line + '\n');
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('errorlog', 'Failed to write to error log', { error: e.message });
|
||||
}
|
||||
console.error('[logger] Failed to write error.log:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a safe error message without leaking internals
|
||||
async function writeErrorLog(ctx, error, req, extra) {
|
||||
const ts = new Date().toISOString();
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
const errStack = error instanceof Error ? error.stack : '';
|
||||
const parts = [`[${ts}] [ERR] ${ctx}: ${errMsg}`];
|
||||
if (errStack) parts.push(errStack);
|
||||
if (req) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || '';
|
||||
const ua = req.get ? req.get('user-agent') : '';
|
||||
parts.push(` request: ${req.method || ''} ${req.path || ''} | ip: ${ip} | ua: ${ua}${req.id ? ' | id: ' + req.id : ''}`);
|
||||
}
|
||||
if (extra && Object.keys(extra).length) {
|
||||
parts.push(` context: ${JSON.stringify(extra)}`);
|
||||
}
|
||||
parts.push('─'.repeat(72));
|
||||
await appendErrorLog(parts.join('\n'));
|
||||
}
|
||||
|
||||
// ─── Audit log ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const AUDIT_SKIP_PATHS = [
|
||||
'/api/v1/totp/verify',
|
||||
'/api/v1/totp/check-session',
|
||||
'/api/v1/auth/gate/',
|
||||
'/api/v1/auth/app-token/',
|
||||
'/api/v1/audit-logs',
|
||||
'/api/v1/health',
|
||||
'/health',
|
||||
'/api/v1/notifications/test',
|
||||
'/api/v1/notifications/health-check',
|
||||
];
|
||||
|
||||
const AUDIT_ACTION_MAP = {
|
||||
'POST /api/v1/services/update': 'service.reorder',
|
||||
'POST /api/v1/services': 'service.create',
|
||||
'PUT /api/v1/services': 'service.update',
|
||||
'DELETE /api/v1/services/': 'service.delete',
|
||||
'POST /api/v1/site': 'caddy.add-site',
|
||||
'POST /api/v1/site/external': 'caddy.add-external',
|
||||
'DELETE /api/v1/site/': 'caddy.remove-site',
|
||||
'POST /api/v1/caddy/reload': 'caddy.reload',
|
||||
'POST /api/v1/dns/record': 'dns.add-record',
|
||||
'DELETE /api/v1/dns/record': 'dns.delete-record',
|
||||
'POST /api/v1/dns/credentials': 'dns.save-credentials',
|
||||
'DELETE /api/v1/dns/credentials': 'dns.delete-credentials',
|
||||
'POST /api/v1/dns/refresh-token': 'dns.refresh-token',
|
||||
'POST /api/v1/dns/update': 'dns.update-server',
|
||||
'POST /api/v1/containers/': 'container.action',
|
||||
'DELETE /api/v1/containers/': 'container.delete',
|
||||
'POST /api/v1/apps/deploy': 'container.deploy',
|
||||
'DELETE /api/v1/apps/': 'container.undeploy',
|
||||
'POST /api/v1/backups/execute': 'backup.execute',
|
||||
'POST /api/v1/backups/restore/': 'backup.restore',
|
||||
'POST /api/v1/backups/config': 'backup.config',
|
||||
'POST /api/v1/config': 'config.update',
|
||||
'DELETE /api/v1/config': 'config.reset',
|
||||
'POST /api/v1/notifications/config': 'config.notifications',
|
||||
'POST /api/v1/totp/setup': 'auth.totp-setup',
|
||||
'POST /api/v1/totp/verify-setup': 'auth.totp-activate',
|
||||
'POST /api/v1/totp/disable': 'auth.totp-disable',
|
||||
'POST /api/v1/totp/config': 'auth.totp-config',
|
||||
'POST /api/v1/credentials/rotate-key': 'config.rotate-key',
|
||||
'POST /api/v1/updates/update/': 'container.update',
|
||||
'POST /api/v1/updates/rollback/': 'container.rollback',
|
||||
'POST /api/v1/updates/auto-update/': 'container.auto-update',
|
||||
'POST /api/v1/updates/check': 'container.check-updates',
|
||||
'POST /api/v1/health-checks/': 'config.health-check',
|
||||
'DELETE /api/v1/health-checks/': 'config.health-check-delete',
|
||||
'POST /api/v1/monitoring/alerts/': 'config.monitoring-alert',
|
||||
'DELETE /api/v1/monitoring/alerts/': 'config.monitoring-alert-delete',
|
||||
'POST /api/v1/arr/smart-connect': 'service.arr-connect',
|
||||
'POST /api/v1/arr/credentials': 'config.arr-credentials',
|
||||
'DELETE /api/v1/arr/credentials/': 'config.arr-credentials-delete',
|
||||
'POST /api/v1/logo': 'config.logo-upload',
|
||||
'DELETE /api/v1/logo': 'config.logo-delete',
|
||||
'POST /api/v1/favicon': 'config.favicon-upload',
|
||||
'DELETE /api/v1/favicon': 'config.favicon-delete',
|
||||
'POST /api/v1/tailscale/config': 'config.tailscale',
|
||||
'POST /api/v1/tailscale/protect-service': 'config.tailscale-protect',
|
||||
};
|
||||
|
||||
const SENSITIVE_KEYS = ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code', 'secretKey', 'authToken'];
|
||||
|
||||
function sanitize(obj) {
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
const clean = Array.isArray(obj) ? [] : {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
clean[k] = SENSITIVE_KEYS.includes(k) ? '***' : v && typeof v === 'object' ? sanitize(v) : v;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
async function appendAuditLog(entries) {
|
||||
try {
|
||||
let existing = [];
|
||||
try {
|
||||
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
|
||||
existing = JSON.parse(raw);
|
||||
if (!Array.isArray(existing)) existing = [];
|
||||
} catch (_) { /* start fresh */ }
|
||||
|
||||
const merged = [...entries, ...existing].slice(0, MAX_AUDIT_ENTRIES);
|
||||
const stats = await fsp.stat(AUDIT_LOG_FILE).catch(() => null);
|
||||
if (stats && stats.size > AUDIT_MAX_FILE_SIZE) {
|
||||
await fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify(merged.slice(0, Math.floor(MAX_AUDIT_ENTRIES / 2)), null, 2));
|
||||
} else {
|
||||
await fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify(merged, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[logger] Failed to write audit log:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Logger class ──────────────────────────────────────────────────────────
|
||||
|
||||
class Logger extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this._level = GLOBAL_LEVEL;
|
||||
}
|
||||
|
||||
_should(level) {
|
||||
return LEVELS[level] >= this._level;
|
||||
}
|
||||
|
||||
debug(ctx, msg, data) { this._log('debug', ctx, msg, data); }
|
||||
info(ctx, msg, data) { this._log('info', ctx, msg, data); }
|
||||
warn(ctx, msg, data) { this._log('warn', ctx, msg, data); }
|
||||
|
||||
/**
|
||||
* Log an error — always writes to error.log and console.
|
||||
* @param {string} ctx — context label (e.g. 'container', 'dns')
|
||||
* @param {Error|string} err — the error
|
||||
* @param {object} req — optional request for request context
|
||||
* @param {object} extra — extra context data (not the error itself)
|
||||
*/
|
||||
error(ctx, err, req, extra) {
|
||||
const errObj = err instanceof Error ? err : new Error(String(err));
|
||||
const payload = extra && Object.keys(extra).length ? extra : undefined;
|
||||
this._log('error', ctx, errObj.message, errObj, { req, payload });
|
||||
}
|
||||
|
||||
_log(level, ctx, msg, data, { req, payload } = {}) {
|
||||
if (LEVELS[level] < this._level) return;
|
||||
|
||||
const entry = {
|
||||
t: new Date().toISOString(), level, ctx, msg,
|
||||
...(data instanceof Error ? { error: { message: data.message, code: data.code, stack: data.stack } } : {}),
|
||||
...(payload ? { data: payload } : {}),
|
||||
};
|
||||
if (req && (req.id || req.ip || req.path)) {
|
||||
entry.requestId = req.id || null;
|
||||
entry.ip = req.ip || req.socket?.remoteAddress || null;
|
||||
entry.method = req.method || null;
|
||||
entry.path = req.path || null;
|
||||
}
|
||||
this.emit('entry', entry);
|
||||
consoleWrite(level, ctx, msg, data);
|
||||
|
||||
if (level === 'error') {
|
||||
const errObj = data instanceof Error ? data : (data && data.message ? new Error(data.message) : new Error(msg));
|
||||
// Await the error log write so callers using await on log.error()
|
||||
// can rely on the file being flushed before proceeding.
|
||||
return writeErrorLog(ctx, errObj, req, payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an audit entry directly
|
||||
*/
|
||||
async audit({ action, resource, details, outcome, ip }) {
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: ip || '',
|
||||
action: action || '',
|
||||
resource: resource || '',
|
||||
details: details ? sanitize(details) : {},
|
||||
outcome: outcome || 'unknown',
|
||||
};
|
||||
await appendAuditLog([entry]);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express audit middleware — call app.use(log.auditMiddleware()) once
|
||||
*/
|
||||
auditMiddleware() {
|
||||
return (req, res, next) => {
|
||||
if (req.method === 'GET') return next();
|
||||
if (AUDIT_SKIP_PATHS.some(p => req.path.startsWith(p))) return next();
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (body) => {
|
||||
const action = this._resolveAuditAction(req.method, req.path);
|
||||
const resource = this._resolveAuditResource(req.path);
|
||||
const outcome = body && body.success === false ? 'failure' : 'success';
|
||||
const ip = req.ip || req.socket?.remoteAddress || '';
|
||||
const details = {};
|
||||
if (req.params && Object.keys(req.params).length) details.params = req.params;
|
||||
if (req.body) details.body = sanitize(req.body);
|
||||
|
||||
this.audit({ action, resource, details, outcome, ip });
|
||||
return originalJson(body);
|
||||
};
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
_resolveAuditAction(method, urlPath) {
|
||||
const key = `${method} ${urlPath}`;
|
||||
if (AUDIT_ACTION_MAP[key]) return AUDIT_ACTION_MAP[key];
|
||||
for (const [pattern, action] of Object.entries(AUDIT_ACTION_MAP)) {
|
||||
if (key.startsWith(pattern)) return action;
|
||||
}
|
||||
const parts = urlPath.replace('/api/v1/', '').split('/');
|
||||
return `${parts[0] || 'unknown'}.${method.toLowerCase()}`;
|
||||
}
|
||||
|
||||
_resolveAuditResource(urlPath) {
|
||||
const parts = urlPath.replace('/api/v1/', '').split('/');
|
||||
if (parts.length >= 2) return parts.slice(1).join('/');
|
||||
return parts[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Query audit log entries
|
||||
*/
|
||||
async queryAudit({ limit = 50, offset = 0, action } = {}) {
|
||||
try {
|
||||
let entries = JSON.parse(await fsp.readFile(AUDIT_LOG_FILE, 'utf8'));
|
||||
if (!Array.isArray(entries)) entries = [];
|
||||
if (action) entries = entries.filter(e => e.action && e.action.startsWith(action));
|
||||
return entries.slice(offset, offset + limit);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
clearAuditLog() {
|
||||
return fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify([])).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read error log (raw lines from error.log + error.log.1)
|
||||
*/
|
||||
async readErrorLog(tail = 100) {
|
||||
const results = [];
|
||||
for (const file of [ERROR_LOG_FILE, ERROR_LOG_FILE + '.1']) {
|
||||
try {
|
||||
const lines = (await fsp.readFile(file, 'utf8')).split('\n').filter(Boolean);
|
||||
results.push(...lines.map(l => ({ file: path.basename(file), text: l })));
|
||||
} catch (_) { /* missing */ }
|
||||
}
|
||||
return results.slice(-tail);
|
||||
}
|
||||
|
||||
setLevel(lvl) {
|
||||
if (lvl in LEVELS) this._level = LEVELS[lvl];
|
||||
}
|
||||
|
||||
getLevel() {
|
||||
return Object.entries(LEVELS).find(([, v]) => v === this._level)?.[0] ?? 'debug';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Global singleton ──────────────────────────────────────────────────────────
|
||||
|
||||
const log = new Logger();
|
||||
|
||||
// ─── Safe error messages ─────────────────────────────────────────────────────────
|
||||
|
||||
function safeErrorMessage(error) {
|
||||
if (!error) return 'An internal error occurred';
|
||||
const msg = error.message || String(error);
|
||||
|
||||
// Always expose DC-prefixed user-facing errors
|
||||
if (/\[DC-\d+\]/.test(msg)) return msg;
|
||||
|
||||
// Detect port conflict errors
|
||||
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 or stop the service using that port first.`;
|
||||
return `[DC-200] Port ${portMatch ? portMatch[1] : 'requested'} is already in use. Try a different port or stop the service using that port first.`;
|
||||
}
|
||||
|
||||
// Only expose short, user-facing messages (no paths, stack traces, or internal details)
|
||||
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (msg.includes('No such container')) return 'Container not found';
|
||||
if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) return 'Service unavailable';
|
||||
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) return msg;
|
||||
return 'An internal error occurred';
|
||||
}
|
||||
|
||||
// ─── Convenience wrapper compatible with the old logError(logDir)() signature ──────
|
||||
// Supports: logError(context, error, extra) → existing route call pattern
|
||||
|
||||
async function logErrorWrapper(ctx, err, extra) {
|
||||
const req = extra?.req;
|
||||
const payload = extra ? { ...extra } : {};
|
||||
if (payload.req) delete payload.req;
|
||||
await log.error(ctx, err instanceof Error ? err : new Error(String(err)), req, payload);
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
LOG_LEVELS,
|
||||
createLogger,
|
||||
logError,
|
||||
log,
|
||||
setLevel: (lvl) => {
|
||||
if (lvl in LEVELS) {
|
||||
GLOBAL_LEVEL = LEVELS[lvl];
|
||||
log.setLevel(lvl); // also update the singleton instance
|
||||
}
|
||||
},
|
||||
// Backwards-compatible alias: older callers (src/app.js) use createLogger(LOG_LEVEL)
|
||||
// and expect a `log.info/warn/error/debug` function back. The unified logger is
|
||||
// a single global instance, so we set the level and return it.
|
||||
createLogger: (level) => { if (level in LEVELS) GLOBAL_LEVEL = LEVELS[level]; return log; },
|
||||
safeErrorMessage,
|
||||
logError: logErrorWrapper,
|
||||
AUDIT_LOG_FILE,
|
||||
ERROR_LOG_FILE,
|
||||
MAX_ERROR_LOG_SIZE,
|
||||
MAX_AUDIT_ENTRIES,
|
||||
AUDIT_SKIP_PATHS,
|
||||
AUDIT_ACTION_MAP,
|
||||
SENSITIVE_KEYS,
|
||||
};
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
/**
|
||||
* SSL Certificate Monitor
|
||||
* Periodically checks SSL certificates on services with HTTPS URLs.
|
||||
* Alerts at 30, 14, and 7 days before expiry.
|
||||
*
|
||||
* @module ssl-monitor
|
||||
*/
|
||||
|
||||
const tls = require('tls');
|
||||
const EventEmitter = require('events');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./fs-helpers');
|
||||
const { resolveServiceUrl } = require('./url-resolver');
|
||||
|
||||
/** Default check interval: 1 hour */
|
||||
const DEFAULT_INTERVAL_MS = 3600000;
|
||||
|
||||
/** Alert thresholds in days */
|
||||
const THRESHOLDS = {
|
||||
WARNING: 30,
|
||||
URGENT: 14,
|
||||
CRITICAL: 7
|
||||
};
|
||||
|
||||
/** TLS connection timeout in milliseconds */
|
||||
const TLS_TIMEOUT_MS = 10000;
|
||||
|
||||
class SSLMonitor extends EventEmitter {
|
||||
/**
|
||||
* Create an SSLMonitor instance.
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @param {Object} ctx.servicesStateManager - State manager for reading services
|
||||
* @param {Function} ctx.buildServiceUrl - URL builder helper
|
||||
* @param {Object} ctx.siteConfig - Site configuration
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
* @param {string} [ctx.SSL_CACHE_FILE] - Path to persist SSL cache
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.log = ctx.log || console;
|
||||
|
||||
/** @type {Map<string, Object>} hostname → last cert check result */
|
||||
this.certStatus = new Map();
|
||||
|
||||
/** @type {Map<string, number>} hostname → last notified threshold level */
|
||||
this.notifiedThresholds = new Map();
|
||||
|
||||
/** @type {Map<string, string>} hostname → service ID mapping */
|
||||
this.hostnameToServiceId = new Map();
|
||||
|
||||
/** @type {NodeJS.Timeout|null} */
|
||||
this.intervalHandle = null;
|
||||
|
||||
/** Current config */
|
||||
this.config = {
|
||||
enabled: true,
|
||||
intervalMs: DEFAULT_INTERVAL_MS
|
||||
};
|
||||
|
||||
/** Cache file path */
|
||||
this.cacheFile = ctx.SSL_CACHE_FILE ||
|
||||
path.join(path.dirname(ctx.SERVICES_FILE || './data'), 'ssl-cache.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the SSL certificate for a given hostname and port.
|
||||
* Connects via TLS with rejectUnauthorized: false to retrieve certificate info.
|
||||
*
|
||||
* @param {string} hostname - The hostname to check
|
||||
* @param {number} [port=443] - The port to connect to
|
||||
* @returns {Promise<Object>} Certificate information
|
||||
*/
|
||||
async checkCert(hostname, port = 443) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = tls.connect({
|
||||
host: hostname,
|
||||
port,
|
||||
rejectUnauthorized: false,
|
||||
servername: hostname,
|
||||
timeout: TLS_TIMEOUT_MS
|
||||
}, () => {
|
||||
try {
|
||||
const cert = socket.getPeerCertificate();
|
||||
|
||||
if (!cert || Object.keys(cert).length === 0) {
|
||||
socket.destroy();
|
||||
return reject(new Error(`No certificate returned for ${hostname}:${port}`));
|
||||
}
|
||||
|
||||
const validFrom = new Date(cert.valid_from);
|
||||
const validTo = new Date(cert.valid_to);
|
||||
const now = new Date();
|
||||
const msRemaining = validTo.getTime() - now.getTime();
|
||||
const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24));
|
||||
|
||||
const result = {
|
||||
hostname,
|
||||
port,
|
||||
subject: cert.subject?.CN || cert.subject?.O || 'Unknown',
|
||||
issuer: cert.issuer?.CN || cert.issuer?.O || 'Unknown',
|
||||
validFrom: cert.valid_from,
|
||||
validTo: cert.valid_to,
|
||||
daysRemaining,
|
||||
fingerprint: cert.fingerprint || null,
|
||||
isExpiring: daysRemaining <= THRESHOLDS.WARNING,
|
||||
checkedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
reject(new Error(`TLS connect error for ${hostname}:${port}: ${err.message}`));
|
||||
});
|
||||
|
||||
socket.setTimeout(TLS_TIMEOUT_MS, () => {
|
||||
socket.destroy(new Error(`TLS connection timeout for ${hostname}:${port}`));
|
||||
reject(new Error(`TLS connection timeout for ${hostname}:${port}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SSL certificates for all services that have HTTPS URLs.
|
||||
* Reads services from ctx.servicesStateManager, resolves URLs, and checks each HTTPS cert.
|
||||
*
|
||||
* @returns {Promise<Object>} Map of hostname → cert status
|
||||
*/
|
||||
async checkAll() {
|
||||
if (!this.config.enabled) {
|
||||
this.log.info('ssl-monitor', 'SSL monitoring is disabled, skipping check');
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
let servicesData;
|
||||
try {
|
||||
servicesData = await this.ctx.servicesStateManager.read();
|
||||
} catch (err) {
|
||||
this.log.error('ssl-monitor', 'Failed to read services', { error: err.message });
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
const services = Array.isArray(servicesData) ? servicesData : (servicesData.services || []);
|
||||
|
||||
for (const service of services) {
|
||||
const serviceId = service.id || service.name?.toLowerCase();
|
||||
if (!serviceId) continue;
|
||||
|
||||
try {
|
||||
const url = resolveServiceUrl(serviceId, service, this.ctx.siteConfig, this.ctx.buildServiceUrl);
|
||||
if (!url) continue;
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') continue;
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
const port = parseInt(parsed.port) || 443;
|
||||
|
||||
// Map hostname back to service ID
|
||||
this.hostnameToServiceId.set(hostname, serviceId);
|
||||
|
||||
const result = await this.checkCert(hostname, port);
|
||||
|
||||
// Store result
|
||||
this.certStatus.set(hostname, result);
|
||||
|
||||
// Emit check event
|
||||
this.emit('cert-check', { serviceId, hostname, result });
|
||||
|
||||
// Check alert thresholds
|
||||
await this._checkAndNotify(hostname, result, serviceId);
|
||||
} catch (err) {
|
||||
this.log.warn('ssl-monitor', `Failed to check cert for service ${serviceId}`, {
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Persist results
|
||||
await this._saveCache();
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic SSL certificate checking.
|
||||
*
|
||||
* @param {number} [intervalMs=3600000] - Check interval in milliseconds
|
||||
*/
|
||||
start(intervalMs) {
|
||||
if (intervalMs !== undefined) {
|
||||
this.config.intervalMs = intervalMs;
|
||||
}
|
||||
if (this.intervalHandle) {
|
||||
this.log.warn('ssl-monitor', 'SSL monitor is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.enabled = true;
|
||||
|
||||
// Load cached data
|
||||
this._loadCache().catch(err => {
|
||||
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
|
||||
});
|
||||
|
||||
// Initial check (non-blocking)
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Initial SSL check failed', { error: err.message });
|
||||
});
|
||||
|
||||
// Schedule periodic checks
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
|
||||
});
|
||||
}, this.config.intervalMs);
|
||||
|
||||
this.log.info('ssl-monitor', 'SSL monitoring started', {
|
||||
intervalMs: this.config.intervalMs
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic SSL certificate checking.
|
||||
*/
|
||||
stop() {
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
this.config.enabled = false;
|
||||
this.log.info('ssl-monitor', 'SSL monitoring stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current SSL certificate status for all checked hostnames.
|
||||
*
|
||||
* @returns {Object} Map of hostname → cert status
|
||||
*/
|
||||
getStatus() {
|
||||
const status = {};
|
||||
for (const [hostname, cert] of this.certStatus.entries()) {
|
||||
status[hostname] = { ...cert };
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SSL certificate status for a specific service.
|
||||
*
|
||||
* @param {string} serviceId - The service ID to look up
|
||||
* @returns {Object|null} Certificate status or null if not found
|
||||
*/
|
||||
getServiceCertStatus(serviceId) {
|
||||
// Find hostname mapped to this service
|
||||
for (const [hostname, id] of this.hostnameToServiceId.entries()) {
|
||||
if (id === serviceId) {
|
||||
const cert = this.certStatus.get(hostname);
|
||||
return cert ? { ...cert, serviceId } : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current monitoring configuration.
|
||||
*
|
||||
* @returns {Object} Config with interval and enabled state
|
||||
*/
|
||||
getConfig() {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update monitoring configuration.
|
||||
*
|
||||
* @param {Object} updates - Config updates
|
||||
* @param {boolean} [updates.enabled] - Enable/disable monitoring
|
||||
* @param {number} [updates.intervalMs] - Check interval in milliseconds
|
||||
*/
|
||||
updateConfig(updates) {
|
||||
if (typeof updates.enabled === 'boolean') {
|
||||
this.config.enabled = updates.enabled;
|
||||
if (!updates.enabled && this.intervalHandle) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
if (typeof updates.intervalMs === 'number' && updates.intervalMs >= 60000) {
|
||||
this.config.intervalMs = updates.intervalMs;
|
||||
// Restart interval if running
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
|
||||
});
|
||||
}, this.config.intervalMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Private Methods =====
|
||||
|
||||
/**
|
||||
* Check alert thresholds and send notifications if thresholds are crossed.
|
||||
* Only sends one notification per threshold per hostname.
|
||||
*
|
||||
* @param {string} hostname
|
||||
* @param {Object} certResult
|
||||
* @param {string} serviceId
|
||||
*/
|
||||
async _checkAndNotify(hostname, certResult, serviceId) {
|
||||
const { daysRemaining } = certResult;
|
||||
const key = hostname;
|
||||
const lastNotified = this.notifiedThresholds.get(key) || Infinity;
|
||||
|
||||
let level = null;
|
||||
let eventType = null;
|
||||
let message = null;
|
||||
|
||||
if (daysRemaining <= THRESHOLDS.CRITICAL) {
|
||||
level = THRESHOLDS.CRITICAL;
|
||||
eventType = 'cert-critical';
|
||||
message = `🔒 CRITICAL: SSL certificate for ${hostname} expires in ${daysRemaining} days!`;
|
||||
} else if (daysRemaining <= THRESHOLDS.URGENT) {
|
||||
level = THRESHOLDS.URGENT;
|
||||
eventType = 'cert-expiring';
|
||||
message = `⚠️ URGENT: SSL certificate for ${hostname} expires in ${daysRemaining} days`;
|
||||
} else if (daysRemaining <= THRESHOLDS.WARNING) {
|
||||
level = THRESHOLDS.WARNING;
|
||||
eventType = 'cert-expiring';
|
||||
message = `⚠️ SSL certificate for ${hostname} expires in ${daysRemaining} days`;
|
||||
}
|
||||
|
||||
if (level !== null && level < lastNotified) {
|
||||
// New threshold crossed — send notification
|
||||
this.notifiedThresholds.set(key, level);
|
||||
this.emit(eventType, { hostname, serviceId, daysRemaining, level });
|
||||
|
||||
if (this.ctx.notification) {
|
||||
try {
|
||||
await this.ctx.notification.send('ssl-cert-expiry', {
|
||||
text: message,
|
||||
hostname,
|
||||
serviceId,
|
||||
daysRemaining,
|
||||
level,
|
||||
validTo: certResult.validTo
|
||||
}, level <= THRESHOLDS.CRITICAL ? 'error' : 'warning');
|
||||
} catch (err) {
|
||||
this.log.error('ssl-monitor', 'Failed to send SSL notification', { error: err.message });
|
||||
}
|
||||
}
|
||||
} else if (level === null) {
|
||||
// Cert is healthy — reset notification tracking
|
||||
this.notifiedThresholds.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist cert status cache to disk.
|
||||
*/
|
||||
async _saveCache() {
|
||||
try {
|
||||
const data = {
|
||||
lastChecked: new Date().toISOString(),
|
||||
certs: {},
|
||||
hostnameToServiceId: Object.fromEntries(this.hostnameToServiceId)
|
||||
};
|
||||
for (const [hostname, cert] of this.certStatus.entries()) {
|
||||
data.certs[hostname] = cert;
|
||||
}
|
||||
await writeJsonFile(this.cacheFile, data);
|
||||
} catch (err) {
|
||||
this.log.warn('ssl-monitor', 'Failed to save SSL cache', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cert status cache from disk.
|
||||
*/
|
||||
async _loadCache() {
|
||||
try {
|
||||
const data = await readJsonFile(this.cacheFile, null);
|
||||
if (data && data.certs) {
|
||||
for (const [hostname, cert] of Object.entries(data.certs)) {
|
||||
this.certStatus.set(hostname, cert);
|
||||
}
|
||||
if (data.hostnameToServiceId) {
|
||||
for (const [hostname, serviceId] of Object.entries(data.hostnameToServiceId)) {
|
||||
this.hostnameToServiceId.set(hostname, serviceId);
|
||||
}
|
||||
}
|
||||
this.log.info('ssl-monitor', 'Loaded SSL cache', {
|
||||
certCount: this.certStatus.size
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SSLMonitor;
|
||||
@@ -155,16 +155,47 @@
|
||||
return b.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function setServicesCard() {
|
||||
const total = (window.APPS || []).length;
|
||||
let up = 0;
|
||||
document.querySelectorAll('#cards .card').forEach(c => {
|
||||
if (c.dataset.status === 'on') up++;
|
||||
});
|
||||
// ----- Robust services count -----
|
||||
// Read from multiple sources so we always have a number:
|
||||
// 1. window.APPS (populated by grid.js after loadServices)
|
||||
// 2. #cards .card elements (post-buildGrid)
|
||||
// 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
|
||||
async function fetchServicesCount() {
|
||||
// Source 1+2: window.APPS / DOM cards
|
||||
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: window.APPS.length, up, source: 'APPS' };
|
||||
}
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
if (cards.length > 0) {
|
||||
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
|
||||
return { total: cards.length, up, source: 'DOM' };
|
||||
}
|
||||
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
|
||||
try {
|
||||
const r = await fetch('/api/v1/services', { cache: 'no-store' });
|
||||
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
|
||||
const body = await r.json();
|
||||
const list = (body && Array.isArray(body.services)) ? body.services
|
||||
: (Array.isArray(body)) ? body
|
||||
: [];
|
||||
// Persist for the grid so this fallback only fires once
|
||||
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: list.length, up, source: 'fetch' };
|
||||
} catch (_) {
|
||||
return { total: 0, up: 0, source: 'fetch-error' };
|
||||
}
|
||||
}
|
||||
|
||||
async function setServicesCard() {
|
||||
const { total, up } = await fetchServicesCount();
|
||||
const el = document.getElementById('dc-monitor-services');
|
||||
const sub = document.getElementById('dc-monitor-services-sub');
|
||||
if (el) el.textContent = `${up} / ${total}`;
|
||||
if (sub) sub.textContent = total === 0 ? 'no services yet' : `${up} online · ${total - up} offline`;
|
||||
if (sub) sub.textContent = total === 0
|
||||
? 'no services yet'
|
||||
: `${up} online · ${total - up} offline`;
|
||||
}
|
||||
|
||||
function applyHealthSummary(data) {
|
||||
@@ -27,10 +27,14 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api"
|
||||
readonly DASHBOARD_DIR="${SITES_DIR}/status"
|
||||
readonly CONTAINER_NAME="dashcaddy-api"
|
||||
readonly CADDY_ADMIN_PORT=2019
|
||||
readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
|
||||
readonly DEFAULT_MAX_STORAGE_BYTES=""
|
||||
|
||||
# ---- Tunables (overridable via flags) --------------------------------------
|
||||
API_PORT=3001
|
||||
LOCAL_PORT=8080
|
||||
BACKUP_DIR=""
|
||||
BACKUP_LIMIT=""
|
||||
|
||||
# ---- Runtime state ---------------------------------------------------------
|
||||
DOMAIN_MODE="" # public | custom-tld | local
|
||||
@@ -389,6 +393,7 @@ EOF
|
||||
create_directories() {
|
||||
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
|
||||
mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
ok "Directories created"
|
||||
}
|
||||
|
||||
@@ -626,7 +631,41 @@ CEOF
|
||||
# Docker Compose
|
||||
# ============================================================================
|
||||
|
||||
# Parse size string like "10GB" or "1TB" to bytes
|
||||
parse_size_to_bytes() {
|
||||
local size="$1"
|
||||
local value unit
|
||||
|
||||
# Strip whitespace
|
||||
size=$(echo "$size" | tr -d ' ')
|
||||
|
||||
# Extract numeric value and unit
|
||||
if [[ $size =~ ^([0-9.]+)([kmgtKMGT][bb]?|[bB]?)$ ]]; then
|
||||
value="${BASH_REMATCH[1]}"
|
||||
unit="${BASH_REMATCH[2]}"
|
||||
|
||||
# Normalize unit to uppercase without 'B' suffix for simplicity
|
||||
unit=$(echo "$unit" | tr '[:lower:]' '[:upper:]')
|
||||
case "$unit" in
|
||||
K|KB) echo $((value * 1024)) ;;
|
||||
M|MB) echo $((value * 1024 * 1024)) ;;
|
||||
G|GB) echo $((value * 1024 * 1024 * 1024)) ;;
|
||||
T|TB) echo $((value * 1024 * 1024 * 1024 * 1024)) ;;
|
||||
*) echo "$value" ;;
|
||||
esac
|
||||
else
|
||||
# Not recognized, treat as raw bytes
|
||||
echo "$size"
|
||||
fi
|
||||
}
|
||||
|
||||
generate_docker_compose() {
|
||||
# Convert BACKUP_LIMIT to bytes if set (e.g., "10GB" -> 10737418240)
|
||||
local backup_limit_bytes=""
|
||||
if [[ -n "$BACKUP_LIMIT" ]]; then
|
||||
backup_limit_bytes=$(parse_size_to_bytes "$BACKUP_LIMIT")
|
||||
fi
|
||||
|
||||
cat > "${API_DIR}/docker-compose.yml" <<DCEOF
|
||||
services:
|
||||
dashcaddy-api:
|
||||
@@ -648,6 +687,7 @@ services:
|
||||
- ${DASHBOARD_DIR}:/app/dashboard:rw
|
||||
- /opt/dashcaddy/updates:/app/updates:rw
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dashcaddy-backups:/app/backups
|
||||
environment:
|
||||
- CADDYFILE_PATH=/caddyfile
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
|
||||
@@ -665,6 +705,10 @@ services:
|
||||
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
|
||||
- DASHCADDY_API_SOURCE_DIR=${API_DIR}
|
||||
- DASHCADDY_FRONTEND_DIR=/app/dashboard
|
||||
- BACKUP_DIR=/app/backups
|
||||
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
|
||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
@@ -673,6 +717,14 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
dashcaddy-backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ${BACKUP_DIR}
|
||||
DCEOF
|
||||
|
||||
ok "docker-compose.yml generated"
|
||||
@@ -880,6 +932,8 @@ parse_args() {
|
||||
--skip-caddy) SKIP_CADDY=true; shift ;;
|
||||
--uninstall) UNINSTALL=true; shift ;;
|
||||
--keep-config) KEEP_CONFIG=true; shift ;;
|
||||
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
|
||||
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
|
||||
--yes|-y) AUTO_YES=true; shift ;;
|
||||
--help|-h) print_help; exit 0 ;;
|
||||
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
||||
@@ -914,6 +968,8 @@ print_help() {
|
||||
--source PATH Use local source files
|
||||
--skip-docker Already have Docker
|
||||
--skip-caddy Already have Caddy
|
||||
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
|
||||
--backup-limit SIZE Storage limit for backups (e.g., 10GB, 1TB)
|
||||
--uninstall Remove DashCaddy
|
||||
--keep-config Keep configs during uninstall
|
||||
--yes Skip confirmations
|
||||
|
||||
@@ -226,17 +226,34 @@ class ConfigManager {
|
||||
* @returns {Promise<Object>} Disk space info
|
||||
*/
|
||||
async getDiskSpace(testPath) {
|
||||
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
|
||||
try {
|
||||
const stats = await fs.stat(testPath);
|
||||
const fsPromises = require('fs').promises;
|
||||
const pathModule = require('path');
|
||||
|
||||
// Ensure directory exists
|
||||
await fsPromises.mkdir(testPath, { recursive: true });
|
||||
|
||||
// Use statfs for true disk space (works on all filesystems: ext4, Btrfs, XFS, ZFS, APFS, NTFS)
|
||||
const stats = await fsPromises.statfs(testPath);
|
||||
|
||||
const totalBytes = stats.blocks * stats.bsize;
|
||||
const freeBytes = stats.bfree * stats.bsize;
|
||||
const availableBytes = stats.bavail * stats.bsize; // Available to non-root users
|
||||
const usedBytes = totalBytes - freeBytes;
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: testPath
|
||||
path: testPath,
|
||||
total: totalBytes,
|
||||
used: usedBytes,
|
||||
free: freeBytes,
|
||||
availableBytes: availableBytes,
|
||||
usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2))
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
path: testPath,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ const state = {
|
||||
installPath: '',
|
||||
health: null
|
||||
},
|
||||
// Backup configuration
|
||||
backup: {
|
||||
maxStorageGB: 10,
|
||||
backupDir: ''
|
||||
},
|
||||
// Uninstall mode
|
||||
uninstallMode: false,
|
||||
uninstall: {
|
||||
@@ -373,6 +378,24 @@ function updateBranding(field, value) {
|
||||
if (field === 'primaryColor') render();
|
||||
}
|
||||
|
||||
// Backup functions
|
||||
function updateBackup(field, value) {
|
||||
state.backup[field] = value;
|
||||
render();
|
||||
}
|
||||
|
||||
async function selectBackupDir() {
|
||||
try {
|
||||
const result = await window.electronAPI.selectFolder();
|
||||
if (result.success && result.path) {
|
||||
state.backup.backupDir = result.path;
|
||||
render();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Backup dir selection failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectLogo() {
|
||||
try {
|
||||
const result = await window.electronAPI.selectFile({
|
||||
@@ -420,6 +443,10 @@ async function startInstallation() {
|
||||
password: state.dns.password,
|
||||
token: state.dns.token
|
||||
} : null,
|
||||
backup: {
|
||||
maxStorageGB: state.backup.maxStorageGB,
|
||||
backupDir: state.backup.backupDir || null
|
||||
},
|
||||
autoStart: true
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -963,6 +990,31 @@ function renderDashboardSetup() {
|
||||
<p class="hint">Port for the DashCaddy API server (default: 3001)</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="folder-input">
|
||||
<label>Backup Storage Limit (GB)</label>
|
||||
<div class="input-row">
|
||||
<input type="number"
|
||||
value="${state.backup.maxStorageGB}"
|
||||
min="1" max="10240"
|
||||
oninput="updateBackup('maxStorageGB', parseInt(this.value) || 10)">
|
||||
</div>
|
||||
<p class="hint">Maximum storage for backups in GB (default: 10, max: 10TB)</p>
|
||||
</div>
|
||||
|
||||
${state.tier !== 'basic' ? `
|
||||
<div class="folder-input">
|
||||
<label>Backup Directory</label>
|
||||
<div class="input-row">
|
||||
<input type="text"
|
||||
value="${escapeHtml(state.backup.backupDir)}"
|
||||
readonly
|
||||
placeholder="Default: $INSTALL_DIR/backups">
|
||||
<button class="btn-browse" onclick="selectBackupDir()">Browse...</button>
|
||||
</div>
|
||||
<p class="hint">Where backup files are stored on the host</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -7,15 +7,28 @@ services:
|
||||
volumes:
|
||||
- {{API_PATH}}:/app
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dashcaddy-backups:/app/backups
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT={{API_PORT}}
|
||||
- SERVICES_FILE=/app/services.json
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:2019
|
||||
- BACKUP_DIR=/app/backups
|
||||
- BACKUP_MAX_STORAGE_BYTES={{BACKUP_MAX_STORAGE_BYTES}}
|
||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dashcaddy
|
||||
|
||||
volumes:
|
||||
dashcaddy-backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: {{BACKUP_DIR}}
|
||||
|
||||
networks:
|
||||
dashcaddy:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
/**
|
||||
* OpenClaw management routes
|
||||
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
|
||||
*
|
||||
* GET /openclaw/status → container info + gateway health
|
||||
* POST /openclaw/deploy → deploy OpenClaw container
|
||||
* GET /openclaw/proxy/* → proxy GET to gateway
|
||||
* POST /openclaw/proxy/* → proxy POST to gateway
|
||||
* DELETE /openclaw → remove container
|
||||
*/
|
||||
module.exports = function openClawRoutes(ctx) {
|
||||
const router = express.Router();
|
||||
const docker = ctx.docker;
|
||||
const asyncHandler = ctx.asyncHandler;
|
||||
const log = ctx.log || console;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function findOpenClawContainer() {
|
||||
const containers = await docker.client.listContainers({ all: true });
|
||||
return containers.find(function(c) {
|
||||
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
|
||||
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
|
||||
c.Names.some(function(n) { return n.includes('openclaw'); }));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function getGatewayToken(containerId) {
|
||||
try {
|
||||
const info = await docker.client.containerInfo(containerId);
|
||||
const entry = (info.Config.Env || []).find(function(e) {
|
||||
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
|
||||
});
|
||||
return entry ? entry.split('=')[1] : null;
|
||||
} catch(err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerPort(containerId) {
|
||||
try {
|
||||
const containers = await docker.client.listContainers({ all: true });
|
||||
const c = containers.find(function(x) {
|
||||
return x.Id === containerId || x.Id.startsWith(containerId);
|
||||
});
|
||||
if (c && c.Ports) {
|
||||
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
|
||||
if (p && p.PublicPort) return String(p.PublicPort);
|
||||
}
|
||||
return '18792';
|
||||
} catch(err) {
|
||||
return '18792';
|
||||
}
|
||||
}
|
||||
|
||||
async function gatewayHealth(baseUrl, token) {
|
||||
return new Promise(function(resolve) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
|
||||
let data = '';
|
||||
res.on('data', function(d) { data += d; });
|
||||
res.on('end', function() {
|
||||
try { resolve({ ok: true, data: JSON.parse(data) }); }
|
||||
catch(e) { resolve({ ok: true, data: data }); }
|
||||
});
|
||||
});
|
||||
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
|
||||
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
|
||||
});
|
||||
}
|
||||
|
||||
function proxyRequest(req, res, targetBase, path, token) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
headers['X-Forwarded-For'] = req.ip;
|
||||
headers['X-Forwarded-Proto'] = req.protocol;
|
||||
|
||||
const url = targetBase + '/' + path;
|
||||
const method = req.method;
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const body = JSON.stringify(req.body);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(body);
|
||||
|
||||
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
||||
proxyReq.write(body);
|
||||
proxyReq.end();
|
||||
} else {
|
||||
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /openclaw/status ────────────────────────────────────────────────
|
||||
|
||||
router.get('/status', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
|
||||
if (!container) {
|
||||
return res.json({ success: true, deployed: false });
|
||||
}
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const health = await gatewayHealth(baseUrl, token);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deployed: true,
|
||||
container: {
|
||||
id: container.Id.slice(0, 12),
|
||||
name: container.Name,
|
||||
state: container.State,
|
||||
status: container.Status,
|
||||
created: container.Created,
|
||||
image: container.Image
|
||||
},
|
||||
gateway: {
|
||||
url: baseUrl,
|
||||
port: port,
|
||||
healthy: health.ok,
|
||||
healthData: health.data || null,
|
||||
tokenSet: !!token
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// ── POST /openclaw/deploy ───────────────────────────────────────────────
|
||||
|
||||
router.post('/deploy', asyncHandler(async function(req, res) {
|
||||
const existing = await findOpenClawContainer();
|
||||
if (existing) {
|
||||
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
|
||||
}
|
||||
|
||||
const image = 'ghcr.io/nousresearch/openclaw:latest';
|
||||
const name = 'openclaw-' + Date.now();
|
||||
const gatewayToken = generateToken();
|
||||
|
||||
// Pull image
|
||||
log.info('Pulling ' + image + '...');
|
||||
try {
|
||||
await new Promise(function(resolve, reject) {
|
||||
docker.client.pull(image, function(err, stream) {
|
||||
if (err) return reject(err);
|
||||
docker.client.modem.followProgress(stream, function(err2) {
|
||||
if (err2) return reject(err2);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch(e) {
|
||||
log.error('OpenClaw pull failed: ' + e.message);
|
||||
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
|
||||
}
|
||||
|
||||
// Create + start container
|
||||
try {
|
||||
const container = await docker.client.createContainer({
|
||||
name: name,
|
||||
Image: image,
|
||||
Env: [
|
||||
'OPENCLAW_GATEWAY_MODE=local',
|
||||
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
|
||||
],
|
||||
HostConfig: {
|
||||
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
Labels: {
|
||||
'dashcaddy.managed': 'true',
|
||||
'dashcaddy.app': 'openclaw'
|
||||
}
|
||||
},
|
||||
ExposedPorts: { '18792/tcp': {} }
|
||||
});
|
||||
|
||||
await container.start();
|
||||
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deployed: true,
|
||||
container: { id: container.id.slice(0, 12), name: name },
|
||||
gateway: {
|
||||
url: 'http://localhost:18792',
|
||||
token: gatewayToken
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
log.error('OpenClaw deploy failed: ' + e.message);
|
||||
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
|
||||
}
|
||||
}));
|
||||
|
||||
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
|
||||
|
||||
router.get('/proxy/*', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const path = req.params[0];
|
||||
|
||||
proxyRequest(req, res, baseUrl, path, token);
|
||||
}));
|
||||
|
||||
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
|
||||
|
||||
router.post('/proxy/*', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const path = req.params[0];
|
||||
|
||||
proxyRequest(req, res, baseUrl, path, token);
|
||||
}));
|
||||
|
||||
// ── DELETE /openclaw ───────────────────────────────────────────────────
|
||||
|
||||
router.delete('/', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
try {
|
||||
const c = docker.client.container(container.Id);
|
||||
await c.stop().catch(function() {});
|
||||
await c.remove({ force: true });
|
||||
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
|
||||
res.json({ success: true, message: 'OpenClaw removed' });
|
||||
} catch(e) {
|
||||
log.error('Failed to remove OpenClaw: ' + e.message);
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
// ── token generator ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateToken() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# DashCaddy Gitea — Off-host backup to Dropbox
|
||||
# =============================================================================
|
||||
# - Stops gitea container briefly to ensure SQLite DB consistency
|
||||
# - Syncs /var/lib/docker/volumes/gitea-data to dropbox:/Apps/dashcaddy-gitea-backups/<date>/
|
||||
# - Date-stamped snapshots (one per day), kept for 7 days locally
|
||||
# - Restarts gitea even if sync fails
|
||||
# - Logs to /var/log/gitea-backup.log
|
||||
# =============================================================================
|
||||
set -u # don't use -e: we want to always restart gitea
|
||||
|
||||
LOG=/var/log/gitea-backup.log
|
||||
DATA_SRC=/var/lib/docker/volumes/gitea-data/_data
|
||||
DEST="dropbox:/Apps/dashcaddy-gitea-backups"
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
BACKUP_PATH="${DEST}/${TODAY}"
|
||||
RETENTION_DAYS=7
|
||||
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG"; }
|
||||
|
||||
log "=== Backup start ==="
|
||||
|
||||
# 0. Sanity checks
|
||||
if [ ! -d "$DATA_SRC" ]; then
|
||||
log "ERROR: data dir $DATA_SRC missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Stop gitea to flush SQLite
|
||||
log "Stopping gitea container..."
|
||||
docker stop gitea >> "$LOG" 2>&1
|
||||
STOP_RC=$?
|
||||
if [ $STOP_RC -ne 0 ]; then
|
||||
log "WARNING: docker stop returned $STOP_RC — container may not be running"
|
||||
fi
|
||||
|
||||
# 2. Sync (use copy so source files are preserved as-is, no --delete)
|
||||
log "Syncing $DATA_SRC -> $BACKUP_PATH"
|
||||
rclone copy "$DATA_SRC" "$BACKUP_PATH" \
|
||||
--transfers 4 \
|
||||
--checkers 8 \
|
||||
--retries 3 \
|
||||
--low-level-retries 10 \
|
||||
--stats 30s \
|
||||
--log-file "$LOG" \
|
||||
--log-level INFO
|
||||
SYNC_RC=$?
|
||||
|
||||
# 3. Always restart gitea
|
||||
log "Starting gitea container..."
|
||||
docker start gitea >> "$LOG" 2>&1
|
||||
START_RC=$?
|
||||
|
||||
# Wait for gitea to be ready
|
||||
for i in {1..30}; do
|
||||
if curl -sf http://localhost:3000/api/v1/version > /dev/null 2>&1; then
|
||||
log "Gitea is up after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 4. Cleanup old backups (older than RETENTION_DAYS)
|
||||
log "Pruning local + remote snapshots older than ${RETENTION_DAYS} days..."
|
||||
CUTOFF=$(date -u -d "${RETENTION_DAYS} days ago" +%Y-%m-%d)
|
||||
rclone lsf "$DEST/" --dirs-only 2>/dev/null | while read -r d; do
|
||||
# rclone returns names with trailing /
|
||||
name="${d%/}"
|
||||
if [[ "$name" < "$CUTOFF" ]]; then
|
||||
log " removing old: $name"
|
||||
rclone purge "${DEST}/${name}" >> "$LOG" 2>&1
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. Report
|
||||
if [ $SYNC_RC -eq 0 ] && [ $START_RC -eq 0 ]; then
|
||||
log "=== Backup OK ==="
|
||||
exit 0
|
||||
else
|
||||
log "=== Backup completed with errors (sync=$SYNC_RC, start=$START_RC) ==="
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,379 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# DashCaddy Host-Side Updater
|
||||
# Triggered by systemd path unit when the container writes trigger.json.
|
||||
# Reads the trigger, backs up current API + data/, copies new files, rebuilds container.
|
||||
# Writes result.json so the new container knows the outcome.
|
||||
#
|
||||
# This runs on the HOST, outside the container.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly UPDATES_DIR="/opt/dashcaddy/updates"
|
||||
readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json"
|
||||
readonly RESULT_FILE="${UPDATES_DIR}/result.json"
|
||||
readonly BACKUPS_DIR="${UPDATES_DIR}/backups"
|
||||
readonly CONTAINER_NAME="dashcaddy-api"
|
||||
readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest"
|
||||
readonly MAX_BACKUPS=3
|
||||
readonly HEALTH_TIMEOUT=60
|
||||
|
||||
# Data directory backup — stored alongside code backups so everything rolls back together
|
||||
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
||||
readonly DATA_BACKUP_PREFIX="data-backup"
|
||||
|
||||
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
|
||||
|
||||
write_result() {
|
||||
local success="$1" version="$2" duration="$3"
|
||||
shift 3
|
||||
local error="${1:-}"
|
||||
|
||||
if [[ "$success" == "true" ]]; then
|
||||
cat > "$RESULT_FILE" <<EOF
|
||||
{
|
||||
"success": true,
|
||||
"version": "${version}",
|
||||
"duration": ${duration},
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat > "$RESULT_FILE" <<EOF
|
||||
{
|
||||
"success": false,
|
||||
"version": "${version}",
|
||||
"duration": ${duration},
|
||||
"error": "${error}",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_old_backups() {
|
||||
local count
|
||||
count=$(find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l)
|
||||
if (( count > MAX_BACKUPS )); then
|
||||
log "Cleaning old backups (${count} > ${MAX_BACKUPS})"
|
||||
find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf '%T+ %p\n' \
|
||||
| sort | head -n $(( count - MAX_BACKUPS )) | cut -d' ' -f2- \
|
||||
| xargs rm -rf
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Data backup (rsync for efficiency + permissions) ──────────────────────────
|
||||
backup_data_dir() {
|
||||
local backup_dir="$1"
|
||||
if [[ -d "$DATA_SOURCE_DIR" ]]; then
|
||||
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
|
||||
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|
||||
|| cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
|
||||
else
|
||||
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Data restore ──────────────────────────────────────────────────────────────
|
||||
restore_data_dir() {
|
||||
local backup_dir="$1"
|
||||
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
if [[ -d "$data_backup" ]]; then
|
||||
log "Restoring data/ from backup..."
|
||||
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \
|
||||
|| cp -a "$data_backup" "$DATA_SOURCE_DIR"
|
||||
log "Data restored successfully"
|
||||
else
|
||||
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local port="${1:-3001}"
|
||||
local timeout="$HEALTH_TIMEOUT"
|
||||
local elapsed=0
|
||||
|
||||
log "Waiting for health check (timeout: ${timeout}s)..."
|
||||
while (( elapsed < timeout )); do
|
||||
if curl -fsSL --max-time 3 "http://localhost:${port}/health" &>/dev/null; then
|
||||
log "Health check passed after ${elapsed}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$(( elapsed + 2 ))
|
||||
done
|
||||
|
||||
log "Health check FAILED after ${timeout}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Shared rollback: restore code + data ────────────────────────────────────
|
||||
rollback_restore() {
|
||||
local backup_dir="$1"
|
||||
log "Rolling back: restoring code files..."
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
if [[ -d "$backup_dir/dns-providers" ]]; then
|
||||
rm -rf "$api_source_dir/dns-providers"
|
||||
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||
fi
|
||||
restore_data_dir "$backup_dir"
|
||||
}
|
||||
|
||||
# ── Deployment mode ───────────────────────────────────────────────────────────
|
||||
# Reproduce the SAME container the install created so an auto-update keeps every
|
||||
# volume + env var (docker socket, Caddyfile, config/credentials, updates mount),
|
||||
# not a minimal subset. Standard installs use docker-compose (compose file in the
|
||||
# api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a
|
||||
# bare docker run is the last resort. build_image() and restart_container() both
|
||||
# honor the detected mode so build and run stay consistent.
|
||||
deploy_mode() {
|
||||
if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then
|
||||
echo compose
|
||||
elif [[ -x /opt/dashcaddy/start.sh ]]; then
|
||||
echo startsh
|
||||
else
|
||||
echo run
|
||||
fi
|
||||
}
|
||||
|
||||
# Build the API image using whatever the install is wired for. Returns the build
|
||||
# command's exit status so callers can detect failure.
|
||||
build_image() {
|
||||
cd "$api_source_dir" || return 1
|
||||
case "$(deploy_mode)" in
|
||||
compose) docker compose build 2>&1 || docker-compose build 2>&1 ;;
|
||||
*) docker build -t "$IMAGE_TAG" . 2>&1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Shared container restart — recreate with the full, install-defined spec ───
|
||||
# Recreates (rm + run / compose up) so new code AND new env vars take effect.
|
||||
restart_container() {
|
||||
cd "$api_source_dir" 2>/dev/null || true
|
||||
case "$(deploy_mode)" in
|
||||
compose)
|
||||
log "Recreating container via docker compose (full compose spec)..."
|
||||
docker compose up -d 2>&1 || docker-compose up -d 2>&1
|
||||
;;
|
||||
startsh)
|
||||
log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..."
|
||||
bash /opt/dashcaddy/start.sh
|
||||
;;
|
||||
*)
|
||||
log "Recreating container via minimal docker run (fallback)..."
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
"$IMAGE_TAG"
|
||||
;;
|
||||
esac
|
||||
log "Container recreated"
|
||||
}
|
||||
|
||||
# ── Code-only restore (used after failed build when data hasn't changed yet) ──
|
||||
code_restore() {
|
||||
local backup_dir="$1"
|
||||
log "Restoring code files..."
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
if [[ -d "$backup_dir/dns-providers" ]]; then
|
||||
rm -rf "$api_source_dir/dns-providers"
|
||||
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
# 1. Read trigger
|
||||
if [[ ! -f "$TRIGGER_FILE" ]]; then
|
||||
log "No trigger file found — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse trigger.json (uses python3 which is available on all supported distros)
|
||||
local action version from_version staging_dir api_source_dir commit
|
||||
local frontend_staging_dir frontend_target_dir
|
||||
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
||||
version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])")
|
||||
from_version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['fromVersion'])")
|
||||
staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])")
|
||||
api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])")
|
||||
commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')")
|
||||
frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')")
|
||||
frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')")
|
||||
# Handle action=rollback (no new version to deploy)
|
||||
local to_version="${version}"
|
||||
|
||||
log "=== ${action^^}: v${from_version} -> v${to_version} ==="
|
||||
log "Staging: ${staging_dir}"
|
||||
log "API source: ${api_source_dir}"
|
||||
|
||||
# Consume the trigger immediately so we don't re-process on failure
|
||||
mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing"
|
||||
|
||||
# ── Handle rollback ────────────────────────────────────────────────────────
|
||||
if [[ "$action" == "rollback" ]]; then
|
||||
local backup_dir="${BACKUPS_DIR}/${version}"
|
||||
if [[ ! -d "$backup_dir" ]]; then
|
||||
log "ERROR: No backup found for version ${version}"
|
||||
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Performing rollback to v${version}..."
|
||||
rollback_restore "$backup_dir"
|
||||
|
||||
# Rebuild old code
|
||||
log "Rebuilding container..."
|
||||
build_image 2>&1 | tail -3 || true
|
||||
|
||||
restart_container
|
||||
wait_for_health || log "WARNING: Health check failed after rollback"
|
||||
|
||||
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
log "=== Rollback complete ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Handle update ───────────────────────────────────────────────────────────
|
||||
if [[ ! -d "$staging_dir" ]]; then
|
||||
log "ERROR: Staging directory not found: ${staging_dir}"
|
||||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Backup current API code + data/
|
||||
local backup_dir="${BACKUPS_DIR}/${from_version}"
|
||||
mkdir -p "$backup_dir"
|
||||
log "Backing up current API files to ${backup_dir}"
|
||||
for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true
|
||||
done
|
||||
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
|
||||
[[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
|
||||
[[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$backup_dir/"
|
||||
|
||||
# Backup data/ directory (services.json, config.json, credentials, etc.)
|
||||
backup_data_dir "$backup_dir"
|
||||
|
||||
cleanup_old_backups
|
||||
|
||||
# 3. Copy new files from staging to API source
|
||||
log "Deploying new API files..."
|
||||
for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$staging_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$staging_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$staging_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
if [[ -d "$staging_dir/dns-providers" ]]; then
|
||||
rm -rf "$api_source_dir/dns-providers"
|
||||
cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||
fi
|
||||
if [[ -n "$commit" ]]; then
|
||||
echo "$commit" > "$api_source_dir/VERSION"
|
||||
fi
|
||||
|
||||
# 3b. Sync frontend
|
||||
if [[ -z "$frontend_staging_dir" ]]; then
|
||||
parent_staging=$(dirname "$staging_dir")
|
||||
[[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status"
|
||||
fi
|
||||
if [[ -z "$frontend_target_dir" ]]; then
|
||||
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
|
||||
[[ -d "$candidate" ]] && frontend_target_dir="$candidate" && break
|
||||
done
|
||||
fi
|
||||
if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then
|
||||
log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir"
|
||||
mkdir -p "$frontend_target_dir"
|
||||
[[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html"
|
||||
[[ -f "$frontend_staging_dir/sw.js" ]] && cp -f "$frontend_staging_dir/sw.js" "$frontend_target_dir/sw.js"
|
||||
for sub in dist css vendor js; do
|
||||
if [[ -d "$frontend_staging_dir/$sub" ]]; then
|
||||
mkdir -p "$frontend_target_dir/$sub"
|
||||
cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [[ -d "$frontend_staging_dir/assets" ]]; then
|
||||
mkdir -p "$frontend_target_dir/assets"
|
||||
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Rebuild container
|
||||
log "Rebuilding container..."
|
||||
local build_ok=false
|
||||
if build_image; then
|
||||
build_ok=true
|
||||
fi
|
||||
|
||||
if [[ "$build_ok" != "true" ]]; then
|
||||
log "ERROR: Docker build failed — rolling back code + data"
|
||||
code_restore "$backup_dir"
|
||||
build_image 2>&1 | tail -3 || true
|
||||
restart_container
|
||||
wait_for_health || true
|
||||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Restart container (recreate so new code + env vars take effect)
|
||||
restart_container
|
||||
|
||||
# 6. Health check
|
||||
if wait_for_health; then
|
||||
local duration=$(( $(date +%s) - start_time ))
|
||||
log "=== Update successful: v${to_version} in ${duration}s ==="
|
||||
write_result "true" "$to_version" "$duration"
|
||||
else
|
||||
local duration=$(( $(date +%s) - start_time ))
|
||||
log "ERROR: Health check failed after update — rolling back code + data"
|
||||
rollback_restore "$backup_dir"
|
||||
build_image 2>&1 | tail -3 || true
|
||||
restart_container
|
||||
wait_for_health || log "WARNING: Rollback health check also failed"
|
||||
write_result "false" "$to_version" "$duration" "Health check failed after update"
|
||||
fi
|
||||
|
||||
# 7. Cleanup
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
|
||||
|
||||
log "=== Update process complete ==="
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Samihost fail2ban watchdog — auto-unban whitelisted IPs and keep ignoreip list in sync.
|
||||
# Deployed to /usr/local/bin/samihost-fail2ban-watchdog.sh on 194.163.161.162
|
||||
# Cron: every 30 min (0,30 * * * *)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
JAIL_LOCAL=/etc/fail2ban/jail.local
|
||||
BACKUP=/etc/fail2ban/jail.local.watchdog.bak
|
||||
EXPECTED_IGNOREIP="127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 fe80::/10 100.64.0.0/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162"
|
||||
LOG=/var/log/samihost-fail2ban-watchdog.log
|
||||
TELEGRAM_LOG=/tmp/fail2ban-watchdog-last-action
|
||||
|
||||
ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
|
||||
log() { echo "$(ts) $*" | tee -a "$LOG"; }
|
||||
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
touch "$LOG"
|
||||
|
||||
# --- 1. Verify ignoreip line is intact and matches expected ---
|
||||
CURRENT=$(grep '^ignoreip' "$JAIL_LOCAL" | sed 's/^ignoreip[[:space:]]*=[[:space:]]*//' || true)
|
||||
EXPECTED_NORMALIZED=$(echo "$EXPECTED_IGNOREIP" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
CURRENT_NORMALIZED=$(echo "$CURRENT" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
|
||||
if [ "$CURRENT_NORMALIZED" != "$EXPECTED_NORMALIZED" ]; then
|
||||
log "ALERT: ignoreip line drifted. Restoring."
|
||||
cp "$JAIL_LOCAL" "$BACKUP"
|
||||
sed -i "s|^ignoreip = .*|ignoreip = $EXPECTED_IGNOREIP|" "$JAIL_LOCAL"
|
||||
fail2ban-client reload
|
||||
echo "ignoreip restored at $(ts)" > "$TELEGRAM_LOG"
|
||||
log "ignoreip restored, fail2ban reloaded"
|
||||
fi
|
||||
|
||||
# --- 2. Unban any currently-banned IPs that match our trusted set ---
|
||||
BANNED=$(fail2ban-client status sshd 2>/dev/null | awk -F: '/Banned IP list/{print $2}' | tr ' ' '\n' | grep -v '^$' || true)
|
||||
UNBANNED=0
|
||||
for ip in $BANNED; do
|
||||
# Match against any trusted network
|
||||
is_trusted=0
|
||||
for net in 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 ::1 fc00::/7 fe80::/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162; do
|
||||
if [[ "$net" == *"/"* ]]; then
|
||||
# CIDR match (simple IPv4 only — IPv6 needs python or ipcalc, skip for now)
|
||||
base="${net%/*}"
|
||||
mask="${net#*/}"
|
||||
if [[ "$ip" == "$base"* ]] || python3 -c "import ipaddress,sys; sys.exit(0 if ipaddress.ip_address('$ip') in ipaddress.ip_network('$net', strict=False) else 1)" 2>/dev/null; then
|
||||
is_trusted=1
|
||||
break
|
||||
fi
|
||||
else
|
||||
if [ "$ip" = "$net" ]; then
|
||||
is_trusted=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$is_trusted" = "1" ]; then
|
||||
if fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1; then
|
||||
log "auto-unbanned trusted IP: $ip"
|
||||
UNBANNED=$((UNBANNED+1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$UNBANNED" -gt 0 ] && echo "auto-unbanned $UNBANNED trusted IPs at $(ts)" > "$TELEGRAM_LOG"
|
||||
|
||||
# --- 3. Cap the ban count — if more than 200 are banned, mass-unban stale ones ---
|
||||
TOTAL_BANNED=$(fail2ban-client status sshd 2>/dev/null | awk '/Currently banned/{print $NF}' || echo 0)
|
||||
if [ "$TOTAL_BANNED" -gt 200 ]; then
|
||||
log "ALERT: $TOTAL_BANNED IPs banned. Mass-unbanning all."
|
||||
for ip in $BANNED; do
|
||||
fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1 || true
|
||||
done
|
||||
echo "mass-unbanned $TOTAL_BANNED stale bans at $(ts)" > "$TELEGRAM_LOG"
|
||||
fi
|
||||
|
||||
log "watchdog run complete (unbanned=$UNBANNED, total_banned=$TOTAL_BANNED)"
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
CONTAINER_NAME="dashcaddy-api"
|
||||
IMAGE="dashcaddy-dashcaddy-api:latest"
|
||||
DATA_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
||||
CADDYFILE="/etc/caddy/Caddyfile"
|
||||
ASSETS_DIR="/var/www/dashcaddy-status/assets"
|
||||
UPDATES_DIR="/opt/dashcaddy/updates"
|
||||
BACKUPS_DIR="/opt/dashcaddy/backups"
|
||||
HOST_IP="172.17.0.1"
|
||||
# Local Technitium (binds 0.0.0.0:53) resolves *.sami + recurses for docker subnet
|
||||
# external fallback. Without this the container only has 8.8.8.8 and every
|
||||
# *.sami health-check probe fails with ENOTFOUND (uptime bars stay empty).
|
||||
DNS_PRIMARY="100.121.150.22" # Technitium (Tailscale IP) — resolves *.sami
|
||||
DNS_FALLBACK="8.8.8.8"
|
||||
|
||||
# Always recreate to ensure env vars are correct (CONFIG_FILE defaults to /etc/dashcaddy/ which doesn't exist)
|
||||
if docker ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "[start.sh] Recreating container to apply correct env vars..."
|
||||
docker rm -f ${CONTAINER_NAME}
|
||||
fi
|
||||
|
||||
echo "[start.sh] Creating container with full config..."
|
||||
docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
|
||||
--dns ${DNS_PRIMARY} \
|
||||
--dns ${DNS_FALLBACK} \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v ${DATA_DIR}:/app/data \
|
||||
-v ${BACKUPS_DIR}:/app/backups \
|
||||
-v ${CADDYFILE}:/caddyfile \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ${ASSETS_DIR}:/app/assets \
|
||||
-v ${UPDATES_DIR}:/app/updates \
|
||||
-v /opt/sami-files/logs:/opt/sami-files/logs:ro \
|
||||
-e NODE_ENV=production \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
-e CONFIG_FILE=/app/data/config.json \
|
||||
-e BACKUP_DIR=/app/backups \
|
||||
-e DNS_CREDENTIALS_FILE=/app/data/dns-credentials.json \
|
||||
-e CREDENTIALS_FILE=/app/data/credentials.json \
|
||||
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
|
||||
-e HEALTH_HISTORY_FILE=/app/data/health-history.json \
|
||||
-e HEALTH_CONFIG_FILE=/app/data/health-config.json \
|
||||
-e CADDYFILE_PATH=/caddyfile \
|
||||
-e CADDY_ADMIN_URL=http://${HOST_IP}:2019 \
|
||||
-e ASSETS_DIR=/app/assets \
|
||||
-e DASHCADDY_API_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api \
|
||||
${IMAGE}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -19,6 +19,9 @@ const bundles = {
|
||||
JS('skeleton-loader.js'),
|
||||
JS('theme.js'),
|
||||
JS('totp-auth.js'),
|
||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||
JS('totp-recovery.js'),
|
||||
JS('service-credentials.js'),
|
||||
JS('totp-settings.js'),
|
||||
JS('core', 'credentials.js'),
|
||||
|
||||
Vendored
-800
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+19
-19
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+55
-1
@@ -43,6 +43,59 @@
|
||||
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]">
|
||||
</div>
|
||||
<div class="totp-error" id="totp-error"></div>
|
||||
<div class="totp-recovery-link" id="totp-recovery-link" style="display: none;">
|
||||
<a href="#" id="totp-show-recovery">Lost access? Recover with saved Base32 key →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Recovery Panel (hidden by default, shown via "Lost access?" link on overlay) -->
|
||||
<div id="totp-recovery-panel" class="weather-modal" style="display: none;">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 540px;">
|
||||
<h3 style="margin: 0 0 12px; font-size: 1.1rem;">Recover TOTP Access</h3>
|
||||
<div id="totp-recovery-status" style="margin-bottom: 12px; padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem; line-height: 1.4;"></div>
|
||||
|
||||
<!-- Path A: Paste saved Base32 secret -->
|
||||
<div id="totp-recovery-import">
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">
|
||||
Paste the Base32 secret you saved when you first set up TOTP (e.g. <code>JBSWY3DPEHPK3PXP</code>).
|
||||
If you don't have it, you'll need to SSH into the server to rotate the encryption key.
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<input type="text" id="totp-recovery-secret" placeholder="Paste your Base32 key"
|
||||
autocomplete="off" spellcheck="false"
|
||||
style="flex: 1; padding: 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; font-family: monospace; letter-spacing: 1px; text-transform: uppercase;" />
|
||||
<button id="totp-recovery-submit"
|
||||
style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-recovery-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Path B: After successful import, ask user to verify with code -->
|
||||
<div id="totp-recovery-verify" style="display: none;">
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">
|
||||
Secret accepted. Add it to your authenticator app and enter a 6-digit code to confirm.
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<input type="text" id="totp-recovery-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}"
|
||||
placeholder="000000" autocomplete="one-time-code"
|
||||
style="flex: 1; padding: 10px; text-align: center; font-size: 1.2rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
<button id="totp-recovery-confirm"
|
||||
style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-recovery-confirm-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 14px; text-align: right;">
|
||||
<button id="totp-recovery-close"
|
||||
style="padding: 8px 18px; background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.85rem;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +252,8 @@
|
||||
<div class="btn-row"><!-- No button for Internet --></div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-app="auth" data-status="off" id="auth-card">
|
||||
<div class="card" data-app="auth" data-status="off" id="auth-card"
|
||||
title="Two-factor authentication (TOTP). On first setup, save the Base32 secret — it's the only way to recover if you ever lose your authenticator.">
|
||||
<span id="auth-dot" class="dot bad at-bl"></span>
|
||||
<div class="row">
|
||||
<div class="logo-wrap">
|
||||
|
||||
@@ -95,8 +95,6 @@
|
||||
const card = el('div', 'card');
|
||||
card.setAttribute('data-app', s.id);
|
||||
card.setAttribute('data-status', 'off'); // Initial status
|
||||
if (s.containerId) card.setAttribute('data-container-id', s.containerId);
|
||||
if (s.category) card.setAttribute('data-category', s.category);
|
||||
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
|
||||
|
||||
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
||||
@@ -158,16 +156,6 @@
|
||||
nameSpan.appendChild(tsBadge);
|
||||
}
|
||||
|
||||
// Add Category badge if service has one (colored pill with icon)
|
||||
if (s.category) {
|
||||
const cats = (typeof DC !== 'undefined' && DC.CATEGORIES) || window.DC_CATEGORIES || {};
|
||||
const catInfo = cats[s.category] || {};
|
||||
const catBadge = el('span', 'cat-badge', `${catInfo.icon || ''} ${s.category}`.trim());
|
||||
catBadge.title = `Category: ${s.category}`;
|
||||
catBadge.style.cssText = `margin-left: 6px; font-size: 0.65rem; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 25%, transparent); color: ${catInfo.color || '#7f8c8d'}; border: 1px solid color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 50%, transparent); white-space: nowrap; font-weight: 500;`;
|
||||
nameSpan.appendChild(catBadge);
|
||||
}
|
||||
|
||||
row.appendChild(el('span', 'spacer'));
|
||||
|
||||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||||
@@ -294,9 +282,6 @@
|
||||
|
||||
// Group recipe cards visually after grid is built
|
||||
if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards());
|
||||
|
||||
// Refresh the service filter so the category dropdown reflects new services
|
||||
if (window.refreshServiceFilter) window.refreshServiceFilter();
|
||||
}
|
||||
|
||||
function setBadge(id, up, responseTime = null) {
|
||||
|
||||
@@ -59,13 +59,11 @@
|
||||
}
|
||||
_dashboardInitialized = true;
|
||||
await window.loadServices();
|
||||
await loadTemplateCategories();
|
||||
window.buildGrid();
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
||||
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
||||
if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets();
|
||||
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
||||
if (typeof window._updateAuthCard === 'function') {
|
||||
try {
|
||||
@@ -202,55 +200,6 @@
|
||||
window.loadCustomServices = loadCustomServices;
|
||||
registerServiceWorker();
|
||||
|
||||
// ===== TEMPLATE CATEGORIES =====
|
||||
// Cached template categories from /api/v1/templates for use across the UI
|
||||
// (service create/edit, filter dropdown, category badges, etc.)
|
||||
async function loadTemplateCategories() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/templates', { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
if (data && data.categories) {
|
||||
window.DC_CATEGORIES = data.categories;
|
||||
// Also expose via globals.js constant for convenience
|
||||
if (typeof DC !== 'undefined') DC.CATEGORIES = data.categories;
|
||||
// Populate any category <select> that's already in the DOM
|
||||
populateCategorySelects();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[init] Failed to load template categories:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function populateCategorySelects() {
|
||||
const cats = window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES);
|
||||
if (!cats) return;
|
||||
document.querySelectorAll('select[data-role="service-category"]').forEach(select => {
|
||||
const current = select.dataset.current || '';
|
||||
// Clear options but keep the first (placeholder)
|
||||
const placeholder = select.querySelector('option[value=""]');
|
||||
select.innerHTML = '';
|
||||
if (placeholder) select.appendChild(placeholder);
|
||||
else {
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = '— Select category —';
|
||||
select.appendChild(ph);
|
||||
}
|
||||
Object.entries(cats).forEach(([name, info]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = `${info.icon || ''} ${name}`.trim();
|
||||
if (name === current) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Allow other modules to re-run population after they (re)inject selects
|
||||
window.populateCategorySelects = populateCategorySelects;
|
||||
window.loadTemplateCategories = loadTemplateCategories;
|
||||
|
||||
// TOTP-gated initialization
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -262,7 +262,6 @@
|
||||
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
|
||||
const preserveHost = document.getElementById('external-preserve-host').checked;
|
||||
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
||||
const category = document.getElementById('external-service-category')?.value || '';
|
||||
|
||||
if (!name || !externalUrl) {
|
||||
showNotification('Please fill in Name and External URL', 'warning');
|
||||
@@ -342,8 +341,6 @@
|
||||
isExternal: true,
|
||||
isCustom: true
|
||||
};
|
||||
// Only attach category if user actually picked one
|
||||
if (category) newService.category = category;
|
||||
|
||||
window.APPS.push(newService);
|
||||
results.dashboard = true;
|
||||
@@ -460,13 +457,6 @@
|
||||
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
||||
const timeout = document.getElementById('timeout-input')?.value || 30;
|
||||
|
||||
// Category is optional — pulled from either local or external select by the
|
||||
// openAddServiceModal reset. If user doesn't choose one, it stays undefined
|
||||
// and we don't send it (so the backend keeps the existing behavior).
|
||||
const categoryEl = document.getElementById('service-category-input')
|
||||
|| document.getElementById('external-service-category');
|
||||
const category = categoryEl?.value || '';
|
||||
|
||||
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
|
||||
|
||||
if (!name || !port || !ip) {
|
||||
@@ -535,8 +525,6 @@
|
||||
logo: logo || `/assets/${subdomain}.png`,
|
||||
tailscaleOnly: tailscaleOnly || false
|
||||
};
|
||||
// Only include category if user actually picked one
|
||||
if (category) serviceConfig.category = category;
|
||||
|
||||
await window.addServiceToConfig(serviceConfig);
|
||||
results.dashboard = true;
|
||||
|
||||
@@ -19,16 +19,6 @@
|
||||
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
||||
document.getElementById('edit-logo-url').value = service.logo || '';
|
||||
|
||||
// Populate the category select for this service, then set the current value.
|
||||
// populateCategorySelects() uses data-current so we set it first, then call.
|
||||
const categorySelect = document.getElementById('edit-service-category');
|
||||
if (categorySelect) {
|
||||
categorySelect.dataset.current = service.category || '';
|
||||
if (typeof window.populateCategorySelects === 'function') {
|
||||
window.populateCategorySelects();
|
||||
}
|
||||
}
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
@@ -46,7 +36,6 @@
|
||||
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
||||
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
||||
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
||||
const newCategory = document.getElementById('edit-service-category')?.value || '';
|
||||
|
||||
if (!newSubdomain) {
|
||||
showNotification('Subdomain is required', 'warning');
|
||||
@@ -62,7 +51,6 @@
|
||||
if (newIp !== currentEditService.ip) changes.push('ip');
|
||||
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
||||
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
|
||||
if (newCategory !== (currentEditService.category || '')) changes.push('category');
|
||||
|
||||
if (changes.length === 0) {
|
||||
closeServiceEditModal();
|
||||
@@ -84,8 +72,7 @@
|
||||
port: newPort || currentEditService.port,
|
||||
ip: newIp,
|
||||
tailscaleOnly,
|
||||
logo: newLogo || undefined,
|
||||
category: newCategory
|
||||
logo: newLogo || undefined
|
||||
})
|
||||
});
|
||||
|
||||
@@ -104,8 +91,7 @@
|
||||
port: newPort || window.APPS[appIndex].port,
|
||||
ip: newIp,
|
||||
tailscaleOnly,
|
||||
logo: newLogo || window.APPS[appIndex].logo,
|
||||
category: newCategory || undefined
|
||||
logo: newLogo || window.APPS[appIndex].logo
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -187,9 +187,6 @@
|
||||
name: serviceConfig.name,
|
||||
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
|
||||
};
|
||||
// Forward optional metadata fields if provided
|
||||
if (serviceConfig.category) newService.category = serviceConfig.category;
|
||||
if (serviceConfig.containerId) newService.containerId = serviceConfig.containerId;
|
||||
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/services', {
|
||||
|
||||
@@ -82,16 +82,6 @@
|
||||
Enter a URL or upload an image file (PNG, JPG, SVG)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="edit-service-category" class="form-label-accent-sm">
|
||||
Category
|
||||
</label>
|
||||
<select id="edit-service-category" data-role="service-category" class="form-input-md">
|
||||
<option value="">— No category —</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||||
@@ -249,15 +239,6 @@
|
||||
Reload Caddy after adding
|
||||
</label>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="service-category-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
|
||||
<select id="service-category-input" data-role="service-category" style="width: 100%;">
|
||||
<option value="">— No category —</option>
|
||||
</select>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-top: 3px;">Group services on the dashboard by purpose (Media, Productivity, etc.)</div>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
|
||||
|
||||
<div class="grid-2col">
|
||||
@@ -345,14 +326,6 @@
|
||||
Follow Redirects
|
||||
</label>
|
||||
|
||||
<!-- Category (external) -->
|
||||
<div>
|
||||
<label for="external-service-category" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
|
||||
<select id="external-service-category" data-role="service-category" style="width: 100%;">
|
||||
<option value="">— No category —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -95,36 +95,6 @@
|
||||
'Prometheus metrics'
|
||||
],
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
id: 'cloudflare',
|
||||
name: 'Cloudflare DNS',
|
||||
description: 'Managed DNS with API access — no self-hosting needed',
|
||||
icon: '🔶',
|
||||
difficulty: 'Easy',
|
||||
features: [
|
||||
'Fully managed, no server needed',
|
||||
'API for automated record management',
|
||||
'Global anycast network',
|
||||
'Free tier available'
|
||||
],
|
||||
recommended: false,
|
||||
providerId: 'cloudflare'
|
||||
},
|
||||
{
|
||||
id: 'external',
|
||||
name: 'External / Manual DNS',
|
||||
description: 'Use your own DNS provider (cPanel, Route53, etc.)',
|
||||
icon: '🔗',
|
||||
difficulty: 'Easy',
|
||||
features: [
|
||||
'Works with any DNS provider',
|
||||
'DashCaddy shows you what records to create',
|
||||
'Propagation checking still works',
|
||||
'No API credentials needed'
|
||||
],
|
||||
recommended: false,
|
||||
providerId: 'manual'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,50 +2,11 @@
|
||||
(function() {
|
||||
const searchInput = document.getElementById('service-filter-search');
|
||||
const statusSelect = document.getElementById('service-filter-status');
|
||||
const categorySelect = document.getElementById('service-filter-category');
|
||||
const countSpan = document.getElementById('service-filter-count');
|
||||
|
||||
// Build a single category list from both the API categories and any
|
||||
// categories present on the actual rendered cards (covers custom services
|
||||
// whose category isn't in TEMPLATE_CATEGORIES).
|
||||
function getCategoryList() {
|
||||
const seen = new Set();
|
||||
const fromCards = new Set();
|
||||
document.querySelectorAll('#cards .card[data-category]').forEach(c => {
|
||||
const cat = c.dataset.category.trim();
|
||||
if (cat) fromCards.add(cat);
|
||||
});
|
||||
const apiCats = (window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES)) || {};
|
||||
const all = Object.keys(apiCats).concat([...fromCards].filter(c => !apiCats[c]));
|
||||
all.forEach(c => seen.add(c));
|
||||
return { list: [...seen], apiCats };
|
||||
}
|
||||
|
||||
function refreshCategoryDropdown() {
|
||||
if (!categorySelect) return;
|
||||
const { list, apiCats } = getCategoryList();
|
||||
const current = categorySelect.value;
|
||||
categorySelect.innerHTML = '<option value="all">All Categories</option>';
|
||||
list.sort().forEach(name => {
|
||||
const info = apiCats[name];
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = info ? `${info.icon || ''} ${name}`.trim() : name;
|
||||
categorySelect.appendChild(opt);
|
||||
});
|
||||
// Restore selection if it still exists
|
||||
if (current && [...categorySelect.options].some(o => o.value === current)) {
|
||||
categorySelect.value = current;
|
||||
} else {
|
||||
categorySelect.value = 'all';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilter() {
|
||||
refreshCategoryDropdown();
|
||||
const query = searchInput.value.toLowerCase().trim();
|
||||
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
|
||||
const categoryFilter = categorySelect ? categorySelect.value : 'all';
|
||||
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
let visibleCount = 0;
|
||||
@@ -54,13 +15,11 @@
|
||||
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
|
||||
const app = card.dataset.app?.toLowerCase() || '';
|
||||
const status = card.dataset.status || 'off'; // 'on' or 'off'
|
||||
const category = card.dataset.category || '';
|
||||
|
||||
const matchesSearch = !query || name.includes(query) || app.includes(query);
|
||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||
const matchesCategory = categoryFilter === 'all' || category === categoryFilter;
|
||||
|
||||
if (matchesSearch && matchesStatus && matchesCategory) {
|
||||
if (matchesSearch && matchesStatus) {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
@@ -85,7 +44,6 @@
|
||||
|
||||
searchInput?.addEventListener('input', debounce(updateFilter, 200));
|
||||
statusSelect?.addEventListener('change', updateFilter);
|
||||
categorySelect?.addEventListener('change', updateFilter);
|
||||
|
||||
// Initial count on page load
|
||||
if (document.readyState === 'loading') {
|
||||
@@ -94,7 +52,6 @@
|
||||
setTimeout(updateFilter, 500);
|
||||
}
|
||||
|
||||
// Expose for external triggers (called after buildGrid to repopulate categories)
|
||||
// Expose for external triggers
|
||||
window.refreshServiceFilter = updateFilter;
|
||||
window.refreshCategoryDropdown = refreshCategoryDropdown;
|
||||
})();
|
||||
|
||||
@@ -174,9 +174,8 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
if (currentConfigType === 'homelab') {
|
||||
config.tld = document.getElementById('setup-tld')?.value?.trim() || '.home';
|
||||
config.caName = document.getElementById('setup-ca-name')?.value?.trim() || '';
|
||||
const selectedProvider = document.getElementById('setup-dns-provider')?.value || 'technitium';
|
||||
config.dns = {
|
||||
provider: selectedProvider,
|
||||
provider: 'technitium',
|
||||
ip: document.getElementById('setup-dns-ip')?.value?.trim() || '',
|
||||
port: document.getElementById('setup-dns-port')?.value?.trim() || DC.DEFAULTS.DNS_PORT,
|
||||
token: document.getElementById('setup-dns-token')?.value?.trim() || ''
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
const firstInput = overlay.querySelector('.totp-digits input');
|
||||
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
// Refresh the "Lost access?" recovery link visibility based on server state.
|
||||
// Hides itself if TOTP is healthy; shows if unreadable/corrupt. The user
|
||||
// can still click it even when healthy — but the panel will explain there's
|
||||
// no recovery needed. Cheaper than gating it.
|
||||
if (typeof window._refreshRecoveryLink === 'function') {
|
||||
window._refreshRecoveryLink();
|
||||
}
|
||||
}
|
||||
|
||||
function hideTotpOverlay() {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// ===== TOTP RECOVERY FLOW =====
|
||||
// Public, unauthenticated recovery path for users who can't log in.
|
||||
// Designed for the "encryption key rotated and lost my authenticator" case.
|
||||
// The flow is:
|
||||
//
|
||||
// 1. On TOTP overlay show, call /api/v1/totp/recovery-info (public).
|
||||
// If status === 'unreadable', show the "Lost access?" link on the overlay.
|
||||
// 2. User clicks link → opens recovery panel.
|
||||
// 3. User pastes Base32 secret → POST /api/v1/totp/setup with {secret: ...}.
|
||||
// Backend stores as totp.pending_secret (encrypted with current key).
|
||||
// 4. Panel switches to "verify" mode. User enters a code from the
|
||||
// newly-added authenticator entry. POST /api/v1/totp/verify-setup
|
||||
// promotes pending → active and starts a session.
|
||||
// 5. hideTotpOverlay() and initializeDashboard() — same as normal login.
|
||||
//
|
||||
// The recovery flow never requires the user to be logged in. It does require
|
||||
// them to have their Base32 secret saved (e.g. password manager, screenshot,
|
||||
// the "Download backup file" we offer at setup time — see totp-settings.js).
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── Helpers ──
|
||||
async function fetchRecoveryInfo() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/recovery-info', { cache: 'no-store' });
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
return { success: false, status: 'unknown', hint: 'Could not contact server' };
|
||||
}
|
||||
}
|
||||
|
||||
function showRecoveryLink(show) {
|
||||
const link = document.getElementById('totp-recovery-link');
|
||||
if (link) link.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
function openRecoveryPanel() {
|
||||
const panel = document.getElementById('totp-recovery-panel');
|
||||
if (panel) panel.style.display = '';
|
||||
const statusEl = document.getElementById('totp-recovery-status');
|
||||
const importEl = document.getElementById('totp-recovery-import');
|
||||
const verifyEl = document.getElementById('totp-recovery-verify');
|
||||
if (importEl) importEl.style.display = '';
|
||||
if (verifyEl) verifyEl.style.display = 'none';
|
||||
// Reset state
|
||||
document.getElementById('totp-recovery-error').textContent = '';
|
||||
document.getElementById('totp-recovery-confirm-error').textContent = '';
|
||||
document.getElementById('totp-recovery-secret').value = '';
|
||||
document.getElementById('totp-recovery-code').value = '';
|
||||
// Show current status
|
||||
fetchRecoveryInfo().then(info => {
|
||||
statusEl.textContent = info.hint || '';
|
||||
// Color-code the status banner
|
||||
if (info.status === 'healthy') {
|
||||
statusEl.style.borderColor = 'var(--ok-fg, #7ef2ff)';
|
||||
} else if (info.status === 'unreadable') {
|
||||
statusEl.style.borderColor = 'var(--bad-fg, #ff9aa3)';
|
||||
statusEl.style.background = 'color-mix(in srgb, var(--bad-fg) 6%, transparent)';
|
||||
} else if (info.status === 'not_configured') {
|
||||
statusEl.style.borderColor = 'var(--muted)';
|
||||
} else {
|
||||
statusEl.style.borderColor = 'var(--border)';
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
document.getElementById('totp-recovery-secret')?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function closeRecoveryPanel() {
|
||||
const panel = document.getElementById('totp-recovery-panel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitRecoverySecret() {
|
||||
const secret = document.getElementById('totp-recovery-secret').value.trim();
|
||||
const errorEl = document.getElementById('totp-recovery-error');
|
||||
errorEl.textContent = '';
|
||||
|
||||
if (!secret) {
|
||||
errorEl.textContent = 'Paste your Base32 key first';
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z2-7\s]+=*$/.test(secret)) {
|
||||
errorEl.textContent = 'Invalid Base32 format — should be letters A-Z and digits 2-7 only';
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/v1/totp/setup with {secret} — backend stores as pending
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
errorEl.textContent = data.error || data.message || 'Restore failed';
|
||||
return;
|
||||
}
|
||||
// Switch panel to verify mode
|
||||
document.getElementById('totp-recovery-import').style.display = 'none';
|
||||
document.getElementById('totp-recovery-verify').style.display = '';
|
||||
setTimeout(() => document.getElementById('totp-recovery-code')?.focus(), 100);
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Network error — try again';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRecoveryCode() {
|
||||
const code = document.getElementById('totp-recovery-code').value.trim();
|
||||
const errorEl = document.getElementById('totp-recovery-confirm-error');
|
||||
errorEl.textContent = '';
|
||||
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
errorEl.textContent = 'Enter a 6-digit code';
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/v1/totp/verify-setup — promotes pending → active + starts session
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/verify-setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
errorEl.textContent = data.error || data.message || 'Invalid code';
|
||||
document.getElementById('totp-recovery-code').value = '';
|
||||
document.getElementById('totp-recovery-code')?.focus();
|
||||
return;
|
||||
}
|
||||
// Success — hide everything and initialize dashboard
|
||||
closeRecoveryPanel();
|
||||
const overlay = document.getElementById('totp-overlay');
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
if (typeof window.initializeDashboard === 'function') {
|
||||
window.initializeDashboard();
|
||||
}
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Network error — try again';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire up handlers ──
|
||||
document.getElementById('totp-show-recovery')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openRecoveryPanel();
|
||||
});
|
||||
document.getElementById('totp-recovery-close')?.addEventListener('click', closeRecoveryPanel);
|
||||
document.getElementById('totp-recovery-submit')?.addEventListener('click', submitRecoverySecret);
|
||||
document.getElementById('totp-recovery-confirm')?.addEventListener('click', submitRecoveryCode);
|
||||
|
||||
// Enter key submits in secret field
|
||||
document.getElementById('totp-recovery-secret')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); submitRecoverySecret(); }
|
||||
});
|
||||
// Enter key submits in code field
|
||||
document.getElementById('totp-recovery-code')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); submitRecoveryCode(); }
|
||||
});
|
||||
|
||||
// ── Public API ──
|
||||
// Called by totp-auth.js after showing the overlay, so we can decide whether
|
||||
// to show the recovery link. We do this with a public endpoint that doesn't
|
||||
// require auth — perfect for the locked-out state.
|
||||
window._refreshRecoveryLink = async function() {
|
||||
const info = await fetchRecoveryInfo();
|
||||
// Show the link in any non-healthy state (unreadable / corrupt / unknown).
|
||||
// The hint inside the panel tells the user what the actual issue is.
|
||||
if (info && info.success && info.status && info.status !== 'healthy') {
|
||||
showRecoveryLink(true);
|
||||
} else {
|
||||
showRecoveryLink(false);
|
||||
}
|
||||
return info;
|
||||
};
|
||||
})();
|
||||
@@ -38,11 +38,25 @@
|
||||
<div id="totp-qr-section" style="display: none;">
|
||||
<!-- Manual Key (primary - for WinAuth/desktop authenticators) -->
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">Copy this key into your authenticator app:</p>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<code id="totp-manual-key" style="flex: 1; display: block; padding: 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 2px; word-break: break-all; user-select: all; color: var(--fg);"></code>
|
||||
<button id="totp-copy-key" style="padding: 10px 14px; background: var(--card-base); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 1rem; white-space: nowrap; color: var(--fg);" title="Copy to clipboard">📋</button>
|
||||
</div>
|
||||
|
||||
<!-- Download backup file (recovery aid) -->
|
||||
<div style="margin-bottom: 16px; padding: 10px 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 0.8rem; color: var(--muted); flex: 1;">
|
||||
<strong style="color: var(--fg);">Save a backup file</strong> — if you ever lose your authenticator,
|
||||
this is the only way to recover without SSH access to the server.
|
||||
</span>
|
||||
<button id="totp-download-backup" type="button"
|
||||
style="padding: 8px 14px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
⬇ Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (secondary - for mobile apps) -->
|
||||
<details class="mb-16">
|
||||
<summary style="cursor: pointer; color: var(--muted); font-size: 0.8rem;">Show QR code (for mobile authenticator apps)</summary>
|
||||
@@ -119,7 +133,13 @@
|
||||
statusBanner.style.background = 'color-mix(in srgb, var(--ok-fg) 8%, transparent)';
|
||||
statusText.textContent = 'TOTP is active';
|
||||
statusText.style.color = 'var(--ok-fg, #7ef2ff)';
|
||||
setupSection.style.display = 'none';
|
||||
// Keep the setup section visible (collapsed) so the "Import existing
|
||||
// secret" option is always reachable — users may need to re-enroll
|
||||
// their authenticator with the same secret from a backup file.
|
||||
setupSection.style.display = 'block';
|
||||
const setupBtn = document.getElementById('totp-setup-btn');
|
||||
if (setupBtn) setupBtn.textContent = 'Generate New Secret';
|
||||
// Hide the QR section by default in the active state — setupBtn click shows it
|
||||
qrSection.style.display = 'none';
|
||||
durationSection.style.display = 'block';
|
||||
disableSection.style.display = 'block';
|
||||
@@ -233,6 +253,41 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Download backup file — plain JSON so it round-trips through any password
|
||||
// manager, cloud backup, or printed paper. The secret IS recoverable plaintext
|
||||
// (that's the whole point of the backup), so warn the user and rely on
|
||||
// them to keep it safe.
|
||||
document.getElementById('totp-download-backup')?.addEventListener('click', () => {
|
||||
const secret = document.getElementById('totp-manual-key').textContent.trim();
|
||||
if (!secret) return;
|
||||
const payload = {
|
||||
service: 'DashCaddy',
|
||||
type: 'totp-secret',
|
||||
secret: secret,
|
||||
issuer: 'DashCaddy',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
issued: new Date().toISOString(),
|
||||
// Recovery instructions baked into the file so a year from now the
|
||||
// user (or their future self) knows what this file is and how to use it.
|
||||
recovery_url: `${window.location.origin}/ (login screen → "Lost access?")`,
|
||||
note: 'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `dashcaddy-totp-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
const btn = document.getElementById('totp-download-backup');
|
||||
btn.textContent = '✅ Saved';
|
||||
setTimeout(() => { btn.textContent = '⬇ Download'; }, 2000);
|
||||
});
|
||||
|
||||
// Confirm setup
|
||||
document.getElementById('totp-confirm-setup')?.addEventListener('click', async () => {
|
||||
const code = document.getElementById('totp-setup-code').value;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-43a872cc40';
|
||||
const CACHE = 'dashcaddy-shell-f6673e7190';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in New Issue
Block a user