Merge krystie-improvements into main
Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).
Conflict resolutions:
- src/utils/logging.js: took ours (consumers depend on logError/
safeErrorMessage/createLogger exports)
- src/config/site.js: merged (her factored validateAndLogConfig +
applyConfigFields helpers)
- src/context/dns.js: took hers (admin/readonly role iteration for
write operations)
- src/utilities/backup-
manager.js: took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
sw.js: took hers (minified bundles + newer SW cache)
Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
'require(./platform-paths)' → 'require(../../platform-paths)'
Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
This commit is contained in:
+5
-1
@@ -47,11 +47,15 @@
|
|||||||
- **status:** in-progress
|
- **status:** in-progress
|
||||||
- **owner:** krystie
|
- **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.
|
- **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.
|
||||||
|
- **latent bug (discovered during DC-006, NOT yet fixed):** The DC-005 refactor's path-rewrite script left depth-2 route files (`routes/auth/*.js`, `routes/recipes/*.js`, `routes/apps/*.js`, `routes/arr/*.js`, `routes/config/*.js`) with `'../../../src/...'` — **3 levels up instead of 2**, which goes above `dashcaddy-api/` entirely. Required path should be `'../../src/...'` for depth-2 routes. Tests didn't catch this because no test previously imported any depth-2 route (only depth-1 routes like `routes/services.js` were tested). Confirmed-broken imports (with file → offending line): `routes/auth/totp.js:2` (FIXED in DC-006 commit), `routes/auth/keys.js:2`, `routes/auth/sso-gate.js:2-3`, `routes/auth/session-handlers.js:2-3`, `routes/recipes/manage.js:2-3`, `routes/recipes/deploy.js:2-3`, `routes/recipes/index.js:2-3`, `routes/config/assets.js:2-4`, `routes/config/settings.js:2-4`, `routes/config/backup.js:2-4`, `routes/apps/restore.js:2`, `routes/apps/compose.js:2-3`, `routes/apps/deploy.js:2-5`, `routes/apps/helpers.js:2-3`, `routes/apps/templates.js:2-3`, `routes/apps/removal.js:2-3`, `routes/arr/detect.js:2`, `routes/arr/smart-connect.js:2`, `routes/arr/credentials.js:2-3`, `routes/arr/helpers.js:2`, `routes/arr/config.js:2-5`, `routes/arr/plex.js:2`. The fix is mechanical (3 → 2 levels) but touches ~22 files — should be its own PR/commit for clean review.
|
||||||
|
- **branch state:** Work is complete on `krystie-improvements` (HEAD `7bc2a20`) with 879/879 tests passing on the branch. **NOT YET ON MAIN** — `origin/main` has since moved past the refactor with ~28 newer commits (DC-008/009/010/011, TOTP 4-part recovery, monitoring widget, unified logger, response-shape standardization). `git diff origin/main..HEAD` is 187 files / 10823 insertions / 3187 deletions — large enough to need careful coordination, not silent fast-forward. **MERGED into main via this commit** — 25 conflicts resolved (route files I touched, status dist files, infra docs). See merge commit for full resolution list.
|
||||||
|
|
||||||
### DC-006: Add integration test for TOTP auth flow
|
### DC-006: Add integration test for TOTP auth flow
|
||||||
- **status:** in-progress
|
- **status:** done
|
||||||
- **owner:** krystie
|
- **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.
|
- **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.
|
||||||
|
- **result:** Added `dashcaddy-api/__tests__/routes/auth.totp.routes.test.js` — 25 tests, all passing. Covers: GET `/api/totp/config`, POST `/api/totp/setup` (generate + normalize + reject invalid Base32), POST `/api/totp/verify-setup` (missing/bad/no-pending/valid-code paths), POST `/api/totp/verify` (login — 400/400/401/200), GET `/api/totp/check-session` (passthrough when disabled + 401 no-session + 200 valid-session — the BACKLOG "no token → 401 / authenticated request succeeds" pair), POST `/api/totp/disable` (400/401/200), POST `/api/totp/config` (valid/invalid/never-disables), plus the full end-to-end flow setup→login→check-session→disable and an otplib-not-stubbed sanity check. Uses real `otplib` for code generation (real TOTP math), mocks `credentialManager`/`session`/`totpConfig`/`saveTotpConfig` only. Full suite: 904/904 pass (879 baseline + 25 new). ESLint clean for the new file.
|
||||||
|
- **side-effect (DC-005 latent bug fix):** While writing the test I discovered `routes/auth/totp.js` had broken require paths from the DC-005 refactor (`'../../../src/utilities/errors'` was 3 levels up from `routes/auth/` — wrong by 1). The test couldn't even load the route without this fix. Fixed in this commit (`'../../src/utilities/errors'` and `'../../src/utils/responses'`). **Same depth bug exists in other depth-2 route files — see DC-005 note above.**
|
||||||
|
|
||||||
### DC-007: Add tests for untested modules
|
### DC-007: Add tests for untested modules
|
||||||
- **status:** done
|
- **status:** done
|
||||||
|
|||||||
@@ -39,6 +39,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Stale root-level files: `*.bak`, `server-old.js`, and ad-hoc reports (`DEPLOYMENT-SUCCESS.md`, `FINAL-DEPLOYMENT-REPORT.md`, `DESLOPIFICATION-ROADMAP.md`, etc.) — disk-only cleanup, already gitignored.
|
- Stale root-level files: `*.bak`, `server-old.js`, and ad-hoc reports (`DEPLOYMENT-SUCCESS.md`, `FINAL-DEPLOYMENT-REPORT.md`, `DESLOPIFICATION-ROADMAP.md`, etc.) — disk-only cleanup, already gitignored.
|
||||||
- Dead `routes/` directory at API root (replaced by `src/routes/`).
|
- Dead `routes/` directory at API root (replaced by `src/routes/`).
|
||||||
|
|
||||||
|
### Security (TOTP integration)
|
||||||
|
- TOTP integration tests now cover the full `/api/auth/check` → session → endpoint flow (DC-006). 25 new tests including: `setup` (generate + normalize + reject invalid Base32), `verify-setup` (missing/bad/no-pending/valid-code paths), `verify` login (400/400/401/200), `check-session` (passthrough when disabled + 401 no-session + 200 valid-session), `disable`, `config` (valid/invalid/never-disables), and full end-to-end setup→login→check-session→disable.
|
||||||
|
|
||||||
|
### Fixed (from merge)
|
||||||
|
- **routes/updates.js** — krystie's branch had `if (!ok)` referencing the helper function instead of the `secretOk` boolean. Would have 500'd every `/system/update-notify` request. Caught during merge, kept my version with the correct boolean check.
|
||||||
|
- **routes/notifications.js** — two places where she replaced `res.json({success: result.success, ...})` with `ok(...)` would have forced `success: true` for partial-failure delivery. Kept my version with explicit `res.json` to preserve the semantic.
|
||||||
|
|
||||||
|
## [1.13.4] - 2026-06-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Standardized all route handler responses to use helpers from `src/utils/responses.js`
|
||||||
|
(`ok`, `errorResponse`, `successMessage`, `notFound`, `validationError`, `forbidden`,
|
||||||
|
`unauthorized`, `conflict`). ~160 raw `res.json()` calls converted across 32+ files.
|
||||||
|
No behavior changes — response shapes are identical. This ensures future schema
|
||||||
|
changes (e.g., adding a `requestId` envelope) only need to update one module.
|
||||||
|
- Fixed `error` vs `errorResponse` signature mismatch in `routes/health.js` CA cert
|
||||||
|
endpoint. The `error` helper takes `(res, message, statusCode)` while `errorResponse`
|
||||||
|
takes `(res, statusCode, message, extras)` — the wrong alias was being used for
|
||||||
|
calls that needed the 4-argument form.
|
||||||
|
- Updated `middleware.js`, `csrf-protection.js`, `error-handler.js`, and
|
||||||
|
`license-manager.js` to use response helpers for rejection/error responses
|
||||||
|
instead of inline `res.status().json()`.
|
||||||
|
|
||||||
|
### Note
|
||||||
|
- 4 pre-existing test failures in `services.routes.test.js` (credential storage)
|
||||||
|
remain from before this release. They are unrelated to the standardization pass.
|
||||||
|
|
||||||
## [1.5.0] - 2026-05-17
|
## [1.5.0] - 2026-05-17
|
||||||
|
|
||||||
### Changed (BREAKING)
|
### Changed (BREAKING)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ RUN npm install --production
|
|||||||
COPY *.js ./
|
COPY *.js ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY routes/ ./routes/
|
COPY routes/ ./routes/
|
||||||
|
COPY dns-providers/ ./dns-providers/
|
||||||
COPY openapi.yaml ./
|
COPY openapi.yaml ./
|
||||||
|
|
||||||
# VERSION file holds the short git SHA the image was built from. Committed as
|
# VERSION file holds the short git SHA the image was built from. Committed as
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
a372d62
|
1.13.4
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
|
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../src/docker/app-templates');
|
||||||
|
|
||||||
describe('App Templates', () => {
|
describe('App Templates', () => {
|
||||||
const templates = Object.values(APP_TEMPLATES);
|
const templates = Object.values(APP_TEMPLATES);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Must mock crypto-utils BEFORE auth-manager is required,
|
// Must mock crypto-utils BEFORE auth-manager is required,
|
||||||
// because auth-manager.js line 13: const JWT_SECRET = cryptoUtils.loadOrCreateKey()
|
// because auth-manager.js line 13: const JWT_SECRET = cryptoUtils.loadOrCreateKey()
|
||||||
const mockFixedKey = Buffer.alloc(32, 'jwt-test-key-pad');
|
const mockFixedKey = Buffer.alloc(32, 'jwt-test-key-pad');
|
||||||
jest.mock('../crypto-utils', () => ({
|
jest.mock('../src/security/crypto-utils', () => ({
|
||||||
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../credential-manager', () => ({
|
jest.mock('../src/managers/credential-manager', () => ({
|
||||||
store: jest.fn().mockResolvedValue(true),
|
store: jest.fn().mockResolvedValue(true),
|
||||||
retrieve: jest.fn().mockResolvedValue(null),
|
retrieve: jest.fn().mockResolvedValue(null),
|
||||||
delete: jest.fn().mockResolvedValue(true),
|
delete: jest.fn().mockResolvedValue(true),
|
||||||
@@ -13,8 +13,8 @@ jest.mock('../credential-manager', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const authManager = require('../auth-manager');
|
const authManager = require('../src/managers/auth-manager');
|
||||||
const credentialManager = require('../credential-manager');
|
const credentialManager = require('../src/managers/credential-manager');
|
||||||
|
|
||||||
describe('AuthManager', () => {
|
describe('AuthManager', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for auto-restart-manager.js
|
||||||
|
* Verifies the AutoRestartManager class:
|
||||||
|
* - Policy CRUD (set/get/list/remove)
|
||||||
|
* - handleContainerDown: cooldown, max-retries, restart attempt, failure
|
||||||
|
* - handleContainerUp: retry counter reset
|
||||||
|
* - _handleStatusCheck: healthy→unhealthy and unhealthy→healthy transitions
|
||||||
|
* - _resolveContainerId: lookup precedence
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const { AutoRestartManager, DEFAULT_POLICY } = require('../src/managers/auto-restart-manager');
|
||||||
|
|
||||||
|
jest.mock('../src/utilities/fs-helpers', () => ({
|
||||||
|
readJsonFile: jest.fn().mockResolvedValue({}),
|
||||||
|
writeJsonFile: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fsHelpers = require('../src/utilities/fs-helpers');
|
||||||
|
|
||||||
|
function makeManager(overrides = {}) {
|
||||||
|
const servicesStateManager = {
|
||||||
|
read: jest.fn().mockResolvedValue([]),
|
||||||
|
...(overrides.servicesStateManager || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const docker = {
|
||||||
|
client: {
|
||||||
|
getContainer: jest.fn(),
|
||||||
|
...(overrides.dockerClient || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthChecker = new EventEmitter();
|
||||||
|
if (overrides.healthChecker) {
|
||||||
|
Object.assign(healthChecker, overrides.healthChecker);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = {
|
||||||
|
send: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
...(overrides.notification || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
docker,
|
||||||
|
healthChecker,
|
||||||
|
notification,
|
||||||
|
servicesStateManager,
|
||||||
|
SERVICES_FILE: '/tmp/dc-test/services.json',
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
|
||||||
|
logError: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new AutoRestartManager(ctx);
|
||||||
|
return { manager, ctx, docker, healthChecker, notification, servicesStateManager };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutoRestartManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
fsHelpers.readJsonFile.mockResolvedValue({});
|
||||||
|
fsHelpers.writeJsonFile.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constants & construction', () => {
|
||||||
|
test('DEFAULT_POLICY has the documented fields and sensible defaults', () => {
|
||||||
|
expect(DEFAULT_POLICY).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
maxRetries: 3,
|
||||||
|
retryIntervalMs: 5000,
|
||||||
|
windowMinutes: 10,
|
||||||
|
currentRetries: 0,
|
||||||
|
lastRestartAt: null,
|
||||||
|
cooldownUntil: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manager extends EventEmitter and stores ctx deps', () => {
|
||||||
|
const { manager, ctx } = makeManager();
|
||||||
|
expect(manager).toBeInstanceOf(EventEmitter);
|
||||||
|
expect(manager.docker).toBe(ctx.docker);
|
||||||
|
expect(manager.healthChecker).toBe(ctx.healthChecker);
|
||||||
|
expect(manager.notification).toBe(ctx.notification);
|
||||||
|
expect(manager.policies).toBeInstanceOf(Map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lifecycle', () => {
|
||||||
|
test('start() loads persisted policies from fs-helpers', async () => {
|
||||||
|
fsHelpers.readJsonFile.mockResolvedValue({
|
||||||
|
'svc-1': { enabled: false, maxRetries: 7 },
|
||||||
|
});
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.start();
|
||||||
|
expect(manager.policies.has('svc-1')).toBe(true);
|
||||||
|
const policy = manager.getPolicy('svc-1');
|
||||||
|
expect(policy.maxRetries).toBe(7);
|
||||||
|
expect(policy.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start() is idempotent (second call does nothing new)', async () => {
|
||||||
|
const { manager, healthChecker } = makeManager();
|
||||||
|
await manager.start();
|
||||||
|
const listenerCount = healthChecker.listenerCount('status-check');
|
||||||
|
await manager.start();
|
||||||
|
expect(healthChecker.listenerCount('status-check')).toBe(listenerCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop() removes the status-check listener', async () => {
|
||||||
|
const { manager, healthChecker } = makeManager();
|
||||||
|
await manager.start();
|
||||||
|
expect(healthChecker.listenerCount('status-check')).toBe(1);
|
||||||
|
manager.stop();
|
||||||
|
expect(healthChecker.listenerCount('status-check')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('policy CRUD', () => {
|
||||||
|
test('setPolicy throws on missing serviceId', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await expect(manager.setPolicy('', { enabled: true })).rejects.toThrow(/serviceId/);
|
||||||
|
await expect(manager.setPolicy(null, {})).rejects.toThrow(/serviceId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPolicy merges fields with existing policy', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 5 });
|
||||||
|
await manager.setPolicy('svc-1', { enabled: false });
|
||||||
|
const policy = manager.getPolicy('svc-1');
|
||||||
|
expect(policy.maxRetries).toBe(5); // preserved from earlier
|
||||||
|
expect(policy.enabled).toBe(false); // updated by second call
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPolicy persists via fs-helpers.writeJsonFile', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 4 });
|
||||||
|
expect(fsHelpers.writeJsonFile).toHaveBeenCalled();
|
||||||
|
const [filePath, payload] = fsHelpers.writeJsonFile.mock.calls[0];
|
||||||
|
expect(filePath).toMatch(/auto-restart-policies\.json$/);
|
||||||
|
expect(payload['svc-1'].maxRetries).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPolicy returns a copy, not the internal reference', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 2 });
|
||||||
|
const a = manager.getPolicy('svc-1');
|
||||||
|
a.maxRetries = 999;
|
||||||
|
const b = manager.getPolicy('svc-1');
|
||||||
|
expect(b.maxRetries).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPolicy returns null for unknown service', () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
expect(manager.getPolicy('does-not-exist')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listPolicies returns array of all policies', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 1 });
|
||||||
|
await manager.setPolicy('svc-2', { maxRetries: 2 });
|
||||||
|
const list = manager.listPolicies();
|
||||||
|
expect(Array.isArray(list)).toBe(true);
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
const ids = list.map(p => p.serviceId);
|
||||||
|
expect(ids).toEqual(expect.arrayContaining(['svc-1', 'svc-2']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removePolicy returns true and deletes the policy', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 1 });
|
||||||
|
expect(await manager.removePolicy('svc-1')).toBe(true);
|
||||||
|
expect(manager.getPolicy('svc-1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removePolicy returns false for unknown service', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
expect(await manager.removePolicy('does-not-exist')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleContainerDown', () => {
|
||||||
|
test('returns ignored/no-policy when no policy exists', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
const result = await manager.handleContainerDown('unknown', 'cid');
|
||||||
|
expect(result.action).toBe('ignored');
|
||||||
|
expect(result.reason).toBe('no-policy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns ignored/disabled when policy.enabled is false', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { enabled: false });
|
||||||
|
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||||
|
expect(result.action).toBe('ignored');
|
||||||
|
expect(result.reason).toBe('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns skipped/cooldown when cooldownUntil is in the future', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
// setPolicy() intentionally guards runtime fields; we have to set
|
||||||
|
// cooldownUntil via the internal map to simulate an in-progress cooldown
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 3 });
|
||||||
|
manager.policies.get('svc-1').cooldownUntil = Date.now() + 60_000;
|
||||||
|
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||||
|
expect(result.action).toBe('skipped');
|
||||||
|
expect(result.reason).toBe('cooldown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('increments currentRetries and calls docker.start on a successful restart', async () => {
|
||||||
|
const { manager, docker } = makeManager();
|
||||||
|
docker.client.getContainer.mockReturnValue({
|
||||||
|
start: jest.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||||
|
|
||||||
|
const onAttempt = jest.fn();
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
manager.on('auto-restart-attempt', onAttempt);
|
||||||
|
manager.on('auto-restart-success', onSuccess);
|
||||||
|
|
||||||
|
const result = await manager.handleContainerDown('svc-1', 'cid-abc');
|
||||||
|
expect(result.action).toBe('restarted');
|
||||||
|
expect(result.attempt).toBe(1);
|
||||||
|
expect(result.serviceId).toBe('svc-1');
|
||||||
|
expect(docker.client.getContainer).toHaveBeenCalledWith('cid-abc');
|
||||||
|
expect(onAttempt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(manager.getPolicy('svc-1').currentRetries).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits auto-restart-failed and increments currentRetries when docker.start throws', async () => {
|
||||||
|
const { manager, docker } = makeManager();
|
||||||
|
docker.client.getContainer.mockReturnValue({
|
||||||
|
start: jest.fn().mockRejectedValue(new Error('docker daemon down')),
|
||||||
|
});
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||||
|
|
||||||
|
const onFailed = jest.fn();
|
||||||
|
manager.on('auto-restart-failed', onFailed);
|
||||||
|
|
||||||
|
const result = await manager.handleContainerDown('svc-1', 'cid-abc');
|
||||||
|
expect(result.action).toBe('failed');
|
||||||
|
expect(result.error).toMatch(/docker daemon down/);
|
||||||
|
expect(onFailed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(manager.getPolicy('svc-1').currentRetries).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits auto-restart-max-reached and sets cooldown when maxRetries exceeded', async () => {
|
||||||
|
const { manager, docker } = makeManager();
|
||||||
|
docker.client.getContainer.mockReturnValue({
|
||||||
|
start: jest.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 2, retryIntervalMs: 0 });
|
||||||
|
|
||||||
|
const onMax = jest.fn();
|
||||||
|
manager.on('auto-restart-max-reached', onMax);
|
||||||
|
|
||||||
|
// First attempt: currentRetries=0 -> succeeds, increments to 1
|
||||||
|
await manager.handleContainerDown('svc-1', 'cid');
|
||||||
|
// Second: 1 -> succeeds, increments to 2
|
||||||
|
await manager.handleContainerDown('svc-1', 'cid');
|
||||||
|
// Third: 2 >= maxRetries(2) -> max-reached, currentRetries reset to 0
|
||||||
|
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||||
|
|
||||||
|
expect(result.action).toBe('max-reached');
|
||||||
|
expect(onMax).toHaveBeenCalledTimes(1);
|
||||||
|
const policy = manager.getPolicy('svc-1');
|
||||||
|
expect(policy.currentRetries).toBe(0);
|
||||||
|
expect(policy.cooldownUntil).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleContainerUp', () => {
|
||||||
|
test('resets currentRetries and cooldownUntil when service is tracked', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { currentRetries: 2, cooldownUntil: Date.now() + 10000 });
|
||||||
|
// Mutate via internal map (bypassing the setter guard)
|
||||||
|
manager.policies.get('svc-1').currentRetries = 2;
|
||||||
|
manager.policies.get('svc-1').cooldownUntil = Date.now() + 10000;
|
||||||
|
|
||||||
|
await manager.handleContainerUp('svc-1');
|
||||||
|
const policy = manager.getPolicy('svc-1');
|
||||||
|
expect(policy.currentRetries).toBe(0);
|
||||||
|
expect(policy.cooldownUntil).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a no-op when service is not tracked', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await expect(manager.handleContainerUp('unknown')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_handleStatusCheck', () => {
|
||||||
|
test('triggers handleContainerDown on healthy→unhealthy transition', async () => {
|
||||||
|
const { manager, docker } = makeManager();
|
||||||
|
docker.client.getContainer.mockReturnValue({
|
||||||
|
start: jest.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||||
|
// Pre-set previous health
|
||||||
|
manager._previousHealth.set('svc-1', 'up');
|
||||||
|
|
||||||
|
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||||
|
await manager._handleStatusCheck({
|
||||||
|
serviceId: 'svc-1',
|
||||||
|
status: 'down',
|
||||||
|
details: { containerId: 'cid-1' },
|
||||||
|
});
|
||||||
|
expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers handleContainerUp on unhealthy→healthy transition', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||||
|
manager._previousHealth.set('svc-1', 'down');
|
||||||
|
|
||||||
|
const handleUpSpy = jest.spyOn(manager, 'handleContainerUp');
|
||||||
|
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'up' });
|
||||||
|
expect(handleUpSpy).toHaveBeenCalledWith('svc-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does nothing for services without a policy', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||||
|
const handleUpSpy = jest.spyOn(manager, 'handleContainerUp');
|
||||||
|
await manager._handleStatusCheck({ serviceId: 'untracked', status: 'down' });
|
||||||
|
expect(handleDownSpy).not.toHaveBeenCalled();
|
||||||
|
expect(handleUpSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores status with no serviceId', async () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||||
|
await manager._handleStatusCheck({ status: 'down' });
|
||||||
|
expect(handleDownSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_resolveContainerId', () => {
|
||||||
|
test('returns containerId from status.details when present', () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
||||||
|
expect(cid).toBe('cid-details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to healthChecker.config.services[serviceId].containerId', () => {
|
||||||
|
const { manager, healthChecker } = makeManager();
|
||||||
|
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
|
||||||
|
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||||
|
expect(cid).toBe('cid-hc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to servicesStateManager.read when sync list is returned', () => {
|
||||||
|
const { manager, servicesStateManager } = makeManager();
|
||||||
|
servicesStateManager.read.mockReturnValue([
|
||||||
|
{ id: 'svc-1', containerId: 'cid-state' },
|
||||||
|
]);
|
||||||
|
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||||
|
expect(cid).toBe('cid-state');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no source has a containerId', () => {
|
||||||
|
const { manager } = makeManager();
|
||||||
|
const cid = manager._resolveContainerId('svc-unknown', { details: {} });
|
||||||
|
expect(cid).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,19 +3,19 @@
|
|||||||
|
|
||||||
jest.mock('fs');
|
jest.mock('fs');
|
||||||
jest.mock('child_process');
|
jest.mock('child_process');
|
||||||
jest.mock('../credential-manager', () => ({
|
jest.mock('../src/managers/credential-manager', () => ({
|
||||||
exportBackup: jest.fn().mockReturnValue({ encrypted: 'cred-data' }),
|
exportBackup: jest.fn().mockReturnValue({ encrypted: 'cred-data' }),
|
||||||
importBackup: jest.fn()
|
importBackup: jest.fn()
|
||||||
}));
|
}));
|
||||||
jest.mock('../resource-monitor', () => ({
|
jest.mock('../src/managers/resource-monitor', () => ({
|
||||||
exportStats: jest.fn().mockReturnValue({ stats: [{ cpu: 10 }] }),
|
exportStats: jest.fn().mockReturnValue({ stats: [{ cpu: 10 }] }),
|
||||||
importStats: jest.fn()
|
importStats: jest.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const credentialManager = require('../credential-manager');
|
const credentialManager = require('../src/managers/credential-manager');
|
||||||
const resourceMonitor = require('../resource-monitor');
|
const resourceMonitor = require('../src/managers/resource-monitor');
|
||||||
|
|
||||||
// Setup defaults BEFORE requiring singleton (constructor calls loadConfig/loadHistory)
|
// Setup defaults BEFORE requiring singleton (constructor calls loadConfig/loadHistory)
|
||||||
fs.existsSync.mockReturnValue(false);
|
fs.existsSync.mockReturnValue(false);
|
||||||
@@ -24,7 +24,7 @@ fs.writeFileSync.mockReturnValue(undefined);
|
|||||||
fs.mkdirSync.mockReturnValue(undefined);
|
fs.mkdirSync.mockReturnValue(undefined);
|
||||||
fs.unlinkSync.mockReturnValue(undefined);
|
fs.unlinkSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
const backupManager = require('../backup-manager');
|
const backupManager = require('../src/utilities/backup-manager');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for config-drift-detector.js
|
||||||
|
* Verifies the ConfigDriftDetector class detects drift across all categories,
|
||||||
|
* exposes polling control, extracts container ports, and dispatches
|
||||||
|
* drift notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const { ConfigDriftDetector } = require('../src/managers/config-drift-detector');
|
||||||
|
|
||||||
|
function makeContainer(overrides = {}) {
|
||||||
|
return {
|
||||||
|
Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||||
|
Names: ['/dashcaddy-test'],
|
||||||
|
Image: 'nginx:latest',
|
||||||
|
State: 'running',
|
||||||
|
Status: 'Up 5 minutes',
|
||||||
|
Ports: [],
|
||||||
|
Labels: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDetector(overrides = {}) {
|
||||||
|
const servicesStateManager = {
|
||||||
|
read: jest.fn().mockResolvedValue([]),
|
||||||
|
update: jest.fn().mockImplementation(async (updater) => {
|
||||||
|
const data = await servicesStateManager.read();
|
||||||
|
const list = Array.isArray(data) ? data : (data?.services || []);
|
||||||
|
const next = updater(list);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
...(overrides.servicesStateManager || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const docker = {
|
||||||
|
client: {
|
||||||
|
listContainers: jest.fn().mockResolvedValue([]),
|
||||||
|
...(overrides.dockerClient || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const notification = {
|
||||||
|
send: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
...(overrides.notification || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
docker,
|
||||||
|
servicesStateManager,
|
||||||
|
notification,
|
||||||
|
log: {
|
||||||
|
info: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
},
|
||||||
|
logError: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const detector = new ConfigDriftDetector(ctx);
|
||||||
|
return { detector, ctx, docker, servicesStateManager, notification };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfigDriftDetector', () => {
|
||||||
|
describe('constructor', () => {
|
||||||
|
test('extends EventEmitter and stores ctx dependencies', () => {
|
||||||
|
const { detector, ctx } = makeDetector();
|
||||||
|
expect(detector).toBeInstanceOf(EventEmitter);
|
||||||
|
expect(detector.ctx).toBe(ctx);
|
||||||
|
expect(detector.docker).toBe(ctx.docker);
|
||||||
|
expect(detector.servicesStateManager).toBe(ctx.servicesStateManager);
|
||||||
|
expect(detector.notification).toBe(ctx.notification);
|
||||||
|
expect(detector.lastReport).toBeNull();
|
||||||
|
expect(detector.isPolling()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detect()', () => {
|
||||||
|
test('returns a clean report when services and containers are empty', async () => {
|
||||||
|
const { detector } = makeDetector();
|
||||||
|
const report = await detector.detect();
|
||||||
|
expect(report).toHaveProperty('checkedAt');
|
||||||
|
expect(report.missingContainers).toEqual([]);
|
||||||
|
expect(report.unknownContainers).toEqual([]);
|
||||||
|
expect(report.portMismatch).toEqual([]);
|
||||||
|
expect(report.stateMismatch).toEqual([]);
|
||||||
|
expect(report.staleRecords).toEqual([]);
|
||||||
|
expect(report.hasDrift).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flags missing containers when service containerId is not in Docker', async () => {
|
||||||
|
const services = [{
|
||||||
|
id: 'svc-1',
|
||||||
|
name: 'svc-1',
|
||||||
|
containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef0000000000000000',
|
||||||
|
}];
|
||||||
|
const { detector, servicesStateManager, docker } = makeDetector();
|
||||||
|
servicesStateManager.read.mockResolvedValue(services);
|
||||||
|
docker.client.listContainers.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const report = await detector.detect();
|
||||||
|
expect(report.staleRecords).toHaveLength(1);
|
||||||
|
expect(report.staleRecords[0].serviceId).toBe('svc-1');
|
||||||
|
expect(report.hasDrift).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flags port mismatches between service config and container', async () => {
|
||||||
|
const services = [{
|
||||||
|
id: 'svc-1',
|
||||||
|
name: 'svc-1',
|
||||||
|
port: 8080,
|
||||||
|
containerId: 'abcdef012345',
|
||||||
|
}];
|
||||||
|
const containers = [makeContainer({
|
||||||
|
Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||||
|
Ports: [{ PublicPort: 9090, PrivatePort: 80, Type: 'tcp' }],
|
||||||
|
})];
|
||||||
|
|
||||||
|
const { detector, servicesStateManager, docker } = makeDetector();
|
||||||
|
servicesStateManager.read.mockResolvedValue(services);
|
||||||
|
docker.client.listContainers.mockResolvedValue(containers);
|
||||||
|
|
||||||
|
const report = await detector.detect();
|
||||||
|
expect(report.portMismatch).toHaveLength(1);
|
||||||
|
expect(report.portMismatch[0].configuredPort).toBe(8080);
|
||||||
|
expect(report.portMismatch[0].actualPorts).toEqual([9090]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flags state mismatch when service is not running', async () => {
|
||||||
|
const services = [{
|
||||||
|
id: 'svc-1',
|
||||||
|
name: 'svc-1',
|
||||||
|
containerId: 'abcdef012345',
|
||||||
|
}];
|
||||||
|
const containers = [makeContainer({ State: 'exited', Status: 'Exited (1) 5 minutes ago' })];
|
||||||
|
|
||||||
|
const { detector, servicesStateManager, docker } = makeDetector();
|
||||||
|
servicesStateManager.read.mockResolvedValue(services);
|
||||||
|
docker.client.listContainers.mockResolvedValue(containers);
|
||||||
|
|
||||||
|
const report = await detector.detect();
|
||||||
|
expect(report.missingContainers).toHaveLength(1);
|
||||||
|
expect(report.stateMismatch).toHaveLength(1);
|
||||||
|
expect(report.stateMismatch[0].actualState).toBe('exited');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flags unknown managed containers not in services.json', async () => {
|
||||||
|
const containers = [makeContainer({
|
||||||
|
Labels: { 'sami.managed': 'true', 'sami.app': 'whoami' },
|
||||||
|
})];
|
||||||
|
|
||||||
|
const { detector, docker, servicesStateManager } = makeDetector();
|
||||||
|
docker.client.listContainers.mockResolvedValue(containers);
|
||||||
|
servicesStateManager.read.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const report = await detector.detect();
|
||||||
|
expect(report.unknownContainers).toHaveLength(1);
|
||||||
|
expect(report.unknownContainers[0].name).toBe('dashcaddy-test');
|
||||||
|
expect(report.unknownContainers[0].app).toBe('whoami');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits drift-detected and sends notification when drift exists', async () => {
|
||||||
|
const services = [{
|
||||||
|
id: 'svc-1',
|
||||||
|
name: 'svc-1',
|
||||||
|
containerId: 'missingcontainer00',
|
||||||
|
}];
|
||||||
|
const { detector, servicesStateManager, docker, notification } = makeDetector();
|
||||||
|
servicesStateManager.read.mockResolvedValue(services);
|
||||||
|
docker.client.listContainers.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const onDrift = jest.fn();
|
||||||
|
detector.on('drift-detected', onDrift);
|
||||||
|
await detector.detect();
|
||||||
|
|
||||||
|
expect(onDrift).toHaveBeenCalledTimes(1);
|
||||||
|
expect(notification.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(notification.send.mock.calls[0][0]).toBe('drift-detected');
|
||||||
|
const payload = notification.send.mock.calls[0][1];
|
||||||
|
expect(payload.text).toMatch(/drift/i);
|
||||||
|
expect(payload.report).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caches the report on the instance', async () => {
|
||||||
|
const { detector } = makeDetector();
|
||||||
|
const report = await detector.detect();
|
||||||
|
expect(detector.lastReport).toBe(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles services as a wrapper object with .services field', async () => {
|
||||||
|
const { detector, servicesStateManager } = makeDetector();
|
||||||
|
servicesStateManager.read.mockResolvedValue({ services: [] });
|
||||||
|
const report = await detector.detect();
|
||||||
|
expect(report).toBeDefined();
|
||||||
|
expect(report.hasDrift).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tolerates Docker listContainers failure (logs and continues)', async () => {
|
||||||
|
const { detector, docker, ctx } = makeDetector();
|
||||||
|
docker.client.listContainers.mockRejectedValue(new Error('docker daemon down'));
|
||||||
|
const report = await detector.detect();
|
||||||
|
expect(report).toBeDefined();
|
||||||
|
expect(report.hasDrift).toBe(false);
|
||||||
|
expect(ctx.log.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autoFix()', () => {
|
||||||
|
test('removes stale records via servicesStateManager.update', async () => {
|
||||||
|
const services = [
|
||||||
|
{ id: 'svc-good', name: 'svc-good', containerId: 'liveid0000000000000000000000000000' },
|
||||||
|
{ id: 'svc-stale', name: 'svc-stale', containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef00000000' },
|
||||||
|
];
|
||||||
|
const containers = [makeContainer({
|
||||||
|
Id: 'liveid0000000000000000000000000000000000000000000000000000000000',
|
||||||
|
})];
|
||||||
|
|
||||||
|
const { detector, servicesStateManager, docker } = makeDetector();
|
||||||
|
servicesStateManager.read.mockResolvedValue(services);
|
||||||
|
servicesStateManager.update.mockImplementation(async (updater) => {
|
||||||
|
const next = updater(services);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
docker.client.listContainers.mockResolvedValue(containers);
|
||||||
|
|
||||||
|
const result = await detector.autoFix();
|
||||||
|
expect(result.staleRemoved).toBe(1);
|
||||||
|
expect(result.unknownFlagged).toBe(0);
|
||||||
|
expect(servicesStateManager.update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('polling', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startPolling/stopPolling toggles isPolling', () => {
|
||||||
|
const { detector } = makeDetector();
|
||||||
|
expect(detector.isPolling()).toBe(false);
|
||||||
|
detector.startPolling(60000);
|
||||||
|
expect(detector.isPolling()).toBe(true);
|
||||||
|
detector.stopPolling();
|
||||||
|
expect(detector.isPolling()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startPolling clears any existing timer before starting a new one', () => {
|
||||||
|
const { detector } = makeDetector();
|
||||||
|
detector.startPolling(60000);
|
||||||
|
const firstTimer = detector._pollTimer;
|
||||||
|
detector.startPolling(120000);
|
||||||
|
expect(detector._pollTimer).not.toBe(firstTimer);
|
||||||
|
detector.stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stopPolling is a safe no-op when not started', () => {
|
||||||
|
const { detector } = makeDetector();
|
||||||
|
expect(() => detector.stopPolling()).not.toThrow();
|
||||||
|
expect(detector.isPolling()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runs detect on the polling interval', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const { detector } = makeDetector();
|
||||||
|
const detectSpy = jest.spyOn(detector, 'detect').mockResolvedValue({
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
missingContainers: [],
|
||||||
|
unknownContainers: [],
|
||||||
|
portMismatch: [],
|
||||||
|
stateMismatch: [],
|
||||||
|
staleRecords: [],
|
||||||
|
hasDrift: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
detector.startPolling(1000);
|
||||||
|
jest.advanceTimersByTime(3500);
|
||||||
|
// 3 intervals should have fired (1000, 2000, 3000)
|
||||||
|
expect(detectSpy.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||||
|
detector.stopPolling();
|
||||||
|
detectSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_extractContainerPorts', () => {
|
||||||
|
test('returns mapped public ports', () => {
|
||||||
|
const { detector } = makeDetector();
|
||||||
|
const ports = detector._extractContainerPorts({
|
||||||
|
Ports: [
|
||||||
|
{ PublicPort: 8080, PrivatePort: 80, Type: 'tcp' },
|
||||||
|
{ PublicPort: 8443, PrivatePort: 443, Type: 'tcp' },
|
||||||
|
{ PrivatePort: 53, Type: 'udp' }, // No PublicPort → not exposed
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(ports).toEqual([8080, 8443]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns [] when container has no Ports field', () => {
|
||||||
|
const { detector } = makeDetector();
|
||||||
|
expect(detector._extractContainerPorts({})).toEqual([]);
|
||||||
|
expect(detector._extractContainerPorts({ Ports: null })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_sendDriftNotification', () => {
|
||||||
|
test('returns early when no notification manager is present', async () => {
|
||||||
|
const { detector } = makeDetector({ notification: null });
|
||||||
|
// Replace the field with null/undefined to simulate missing
|
||||||
|
detector.notification = null;
|
||||||
|
const result = await detector._sendDriftNotification({ hasDrift: true });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/no-notification-manager/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats message with one line per drift category', async () => {
|
||||||
|
const { detector, notification } = makeDetector();
|
||||||
|
const report = {
|
||||||
|
missingContainers: [{ name: 'app-a' }],
|
||||||
|
unknownContainers: [{ name: 'app-b' }],
|
||||||
|
portMismatch: [{ name: 'app-c' }],
|
||||||
|
stateMismatch: [],
|
||||||
|
staleRecords: [{ name: 'app-d' }],
|
||||||
|
hasDrift: true,
|
||||||
|
};
|
||||||
|
await detector._sendDriftNotification(report);
|
||||||
|
expect(notification.send).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = notification.send.mock.calls[0][1];
|
||||||
|
expect(payload.text).toMatch(/Missing containers: app-a/);
|
||||||
|
expect(payload.text).toMatch(/Unknown managed containers: app-b/);
|
||||||
|
expect(payload.text).toMatch(/Port mismatches: app-c/);
|
||||||
|
expect(payload.text).toMatch(/Stale records: app-d/);
|
||||||
|
expect(payload.report).toBe(report);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// Mock dependencies before requiring the module
|
// Mock dependencies before requiring the module
|
||||||
jest.mock('../keychain-manager', () => ({
|
jest.mock('../src/security/keychain-manager', () => ({
|
||||||
available: false,
|
available: false,
|
||||||
store: jest.fn().mockResolvedValue(false),
|
store: jest.fn().mockResolvedValue(false),
|
||||||
retrieve: jest.fn().mockResolvedValue(null),
|
retrieve: jest.fn().mockResolvedValue(null),
|
||||||
delete: jest.fn().mockResolvedValue(true),
|
delete: jest.fn().mockResolvedValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../crypto-utils', () => ({
|
jest.mock('../src/security/crypto-utils', () => ({
|
||||||
encrypt: jest.fn(data => `enc:tag:${Buffer.from(String(data)).toString('base64')}`),
|
encrypt: jest.fn(data => `enc:tag:${Buffer.from(String(data)).toString('base64')}`),
|
||||||
decrypt: jest.fn(data => {
|
decrypt: jest.fn(data => {
|
||||||
const parts = data.split(':');
|
const parts = data.split(':');
|
||||||
@@ -40,8 +40,8 @@ describe('CredentialManager', () => {
|
|||||||
// Re-get mocked modules
|
// Re-get mocked modules
|
||||||
fs = require('fs');
|
fs = require('fs');
|
||||||
lockfile = require('proper-lockfile');
|
lockfile = require('proper-lockfile');
|
||||||
keychainManager = require('../keychain-manager');
|
keychainManager = require('../src/security/keychain-manager');
|
||||||
cryptoUtils = require('../crypto-utils');
|
cryptoUtils = require('../src/security/crypto-utils');
|
||||||
|
|
||||||
// Reset mock implementations
|
// Reset mock implementations
|
||||||
fs.existsSync.mockReturnValue(true);
|
fs.existsSync.mockReturnValue(true);
|
||||||
@@ -50,7 +50,7 @@ describe('CredentialManager', () => {
|
|||||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||||
keychainManager.available = false;
|
keychainManager.available = false;
|
||||||
|
|
||||||
credentialManager = require('../credential-manager');
|
credentialManager = require('../src/managers/credential-manager');
|
||||||
credentialManager.cache.clear();
|
credentialManager.cache.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,10 +72,10 @@ describe('CredentialManager', () => {
|
|||||||
fs.writeFileSync.mockImplementation(() => {});
|
fs.writeFileSync.mockImplementation(() => {});
|
||||||
lockfile = require('proper-lockfile');
|
lockfile = require('proper-lockfile');
|
||||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||||
keychainManager = require('../keychain-manager');
|
keychainManager = require('../src/security/keychain-manager');
|
||||||
keychainManager.available = true;
|
keychainManager.available = true;
|
||||||
keychainManager.store.mockResolvedValue(true);
|
keychainManager.store.mockResolvedValue(true);
|
||||||
credentialManager = require('../credential-manager');
|
credentialManager = require('../src/managers/credential-manager');
|
||||||
|
|
||||||
const result = await credentialManager.store('test.key', 'value');
|
const result = await credentialManager.store('test.key', 'value');
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
@@ -91,11 +91,11 @@ describe('CredentialManager', () => {
|
|||||||
fs.writeFileSync.mockImplementation(() => {});
|
fs.writeFileSync.mockImplementation(() => {});
|
||||||
lockfile = require('proper-lockfile');
|
lockfile = require('proper-lockfile');
|
||||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||||
keychainManager = require('../keychain-manager');
|
keychainManager = require('../src/security/keychain-manager');
|
||||||
keychainManager.available = true;
|
keychainManager.available = true;
|
||||||
keychainManager.store.mockResolvedValue(false);
|
keychainManager.store.mockResolvedValue(false);
|
||||||
cryptoUtils = require('../crypto-utils');
|
cryptoUtils = require('../src/security/crypto-utils');
|
||||||
credentialManager = require('../credential-manager');
|
credentialManager = require('../src/managers/credential-manager');
|
||||||
|
|
||||||
const result = await credentialManager.store('test.key', 'value');
|
const result = await credentialManager.store('test.key', 'value');
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TEST_KEY_HEX = TEST_KEY.toString('hex');
|
|||||||
// Load the module once — no jest.resetModules() needed
|
// Load the module once — no jest.resetModules() needed
|
||||||
// We control key state via clearCachedKey() + env vars
|
// We control key state via clearCachedKey() + env vars
|
||||||
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
|
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||||
const cryptoUtils = require('../crypto-utils');
|
const cryptoUtils = require('../src/security/crypto-utils');
|
||||||
|
|
||||||
describe('Crypto Utils', () => {
|
describe('Crypto Utils', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const crypto = require('crypto');
|
|||||||
|
|
||||||
// Mock crypto-utils to provide a predictable signing key
|
// Mock crypto-utils to provide a predictable signing key
|
||||||
const mockFixedKey = Buffer.alloc(32, 'test-key-material');
|
const mockFixedKey = Buffer.alloc(32, 'test-key-material');
|
||||||
jest.mock('../crypto-utils', () => ({
|
jest.mock('../src/security/crypto-utils', () => ({
|
||||||
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ const {
|
|||||||
csrfCookieMiddleware,
|
csrfCookieMiddleware,
|
||||||
csrfValidationMiddleware,
|
csrfValidationMiddleware,
|
||||||
renewCSRFToken
|
renewCSRFToken
|
||||||
} = require('../csrf-protection');
|
} = require('../src/security/csrf-protection');
|
||||||
const { createMockReqRes } = require('./helpers/test-utils');
|
const { createMockReqRes } = require('./helpers/test-utils');
|
||||||
|
|
||||||
describe('CSRF Protection', () => {
|
describe('CSRF Protection', () => {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for dns-propagation.js
|
||||||
|
* Verifies DNS propagation checker module loads, exposes the expected
|
||||||
|
* interface, and basic methods (verifyRecord, startVerification,
|
||||||
|
* getVerificationStatus, getAllVerifications, cleanup) work without throwing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The module does `const dns = require('dns').promises;` then `new dns.Resolver()`.
|
||||||
|
// We mock the dns module so that .promises exposes our Resolver class.
|
||||||
|
jest.mock('dns', () => {
|
||||||
|
class MockResolver {
|
||||||
|
setServers() { return this; }
|
||||||
|
setTimeout() { return this; }
|
||||||
|
resolve4(domain) {
|
||||||
|
if (domain === 'propagated.sami') {
|
||||||
|
return Promise.resolve(['1.2.3.4']);
|
||||||
|
}
|
||||||
|
return Promise.resolve(['9.9.9.9']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
promises: { Resolver: MockResolver },
|
||||||
|
Resolver: MockResolver,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const DNSPropagationChecker = require('../src/dns/dns-propagation');
|
||||||
|
|
||||||
|
describe('DNSPropagationChecker', () => {
|
||||||
|
let checker;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const ctx = {
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
notification: { send: jest.fn().mockResolvedValue({ success: true }) },
|
||||||
|
};
|
||||||
|
checker = new DNSPropagationChecker(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is an EventEmitter', () => {
|
||||||
|
expect(typeof checker.on).toBe('function');
|
||||||
|
expect(typeof checker.emit).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts with an empty verifications map', () => {
|
||||||
|
expect(checker.verifications).toBeInstanceOf(Map);
|
||||||
|
expect(checker.verifications.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyRecord returns expected shape and detects propagated domain', async () => {
|
||||||
|
const result = await checker.verifyRecord('propagated.sami', '1.2.3.4', {
|
||||||
|
timeout: 5000,
|
||||||
|
interval: 100,
|
||||||
|
resolvers: ['1.1.1.1'],
|
||||||
|
});
|
||||||
|
expect(result).toHaveProperty('domain', 'propagated.sami');
|
||||||
|
expect(result).toHaveProperty('expectedIp', '1.2.3.4');
|
||||||
|
expect(result).toHaveProperty('propagated', true);
|
||||||
|
expect(Array.isArray(result.results)).toBe(true);
|
||||||
|
expect(result.results.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof result.totalTime).toBe('number');
|
||||||
|
expect(typeof result.checkedAt).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyRecord reports not-propagated when IP does not match', async () => {
|
||||||
|
const result = await checker.verifyRecord('notpropagated.sami', '5.6.7.8', {
|
||||||
|
timeout: 200,
|
||||||
|
interval: 50,
|
||||||
|
resolvers: ['1.1.1.1'],
|
||||||
|
});
|
||||||
|
expect(result.propagated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startVerification returns a job object with running status', () => {
|
||||||
|
const job = checker.startVerification('job.sami', '1.1.1.1', {
|
||||||
|
timeout: 100,
|
||||||
|
interval: 50,
|
||||||
|
resolvers: ['1.1.1.1'],
|
||||||
|
});
|
||||||
|
expect(job).toMatchObject({
|
||||||
|
domain: 'job.sami',
|
||||||
|
expectedIp: '1.1.1.1',
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
expect(job.startedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startVerification returns the same job when called twice for one domain', () => {
|
||||||
|
const a = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 });
|
||||||
|
const b = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 });
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVerificationStatus returns null for unknown domain', () => {
|
||||||
|
expect(checker.getVerificationStatus('nope.sami')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAllVerifications returns an array', () => {
|
||||||
|
expect(Array.isArray(checker.getAllVerifications())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanup is a no-op on empty verifications', () => {
|
||||||
|
expect(() => checker.cleanup()).not.toThrow();
|
||||||
|
expect(checker.verifications.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ describe('DockerSecurity Module', () => {
|
|||||||
|
|
||||||
// Reset modules to get fresh instance
|
// Reset modules to get fresh instance
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
dockerSecurity = require('../docker-security');
|
dockerSecurity = require('../src/security/docker-security');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -58,7 +58,7 @@ describe('DockerSecurity Module', () => {
|
|||||||
|
|
||||||
// Force module reload
|
// Force module reload
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
const freshInstance = require('../docker-security');
|
const freshInstance = require('../src/security/docker-security');
|
||||||
const status = freshInstance.getStatus();
|
const status = freshInstance.getStatus();
|
||||||
|
|
||||||
expect(status.trustedImagesCount).toBe(1);
|
expect(status.trustedImagesCount).toBe(1);
|
||||||
@@ -77,7 +77,7 @@ describe('DockerSecurity Module', () => {
|
|||||||
fs.writeFileSync(TEST_CONFIG_FILE, 'INVALID JSON{{{');
|
fs.writeFileSync(TEST_CONFIG_FILE, 'INVALID JSON{{{');
|
||||||
|
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
const freshInstance = require('../docker-security');
|
const freshInstance = require('../src/security/docker-security');
|
||||||
const status = freshInstance.getStatus();
|
const status = freshInstance.getStatus();
|
||||||
|
|
||||||
// Should fall back to default config
|
// Should fall back to default config
|
||||||
@@ -89,7 +89,7 @@ describe('DockerSecurity Module', () => {
|
|||||||
process.env.DOCKER_SECURITY_CONFIG = '/nonexistent/path/config.json';
|
process.env.DOCKER_SECURITY_CONFIG = '/nonexistent/path/config.json';
|
||||||
|
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
const freshInstance = require('../docker-security');
|
const freshInstance = require('../src/security/docker-security');
|
||||||
const status = freshInstance.getStatus();
|
const status = freshInstance.getStatus();
|
||||||
|
|
||||||
// Should fall back to default config
|
// Should fall back to default config
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
jest.mock('../error-logger', () => ({
|
// Mock the unified logging module so we can verify logError is called
|
||||||
logError: jest.fn(),
|
// without writing to the actual error.log file
|
||||||
|
jest.mock('../src/utils/logging', () => ({
|
||||||
|
logError: jest.fn().mockResolvedValue(),
|
||||||
|
safeErrorMessage: jest.fn((err) => {
|
||||||
|
if (!err) return 'An internal error occurred';
|
||||||
|
return err.message || String(err);
|
||||||
|
}),
|
||||||
|
createLogger: jest.fn(() => ({
|
||||||
|
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn()
|
||||||
|
})),
|
||||||
|
LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler');
|
const { errorMiddleware, notFoundHandler } = require('../src/utilities/error-handler');
|
||||||
const {
|
const {
|
||||||
AppError,
|
AppError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -10,7 +20,7 @@ const {
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
DockerError,
|
DockerError,
|
||||||
} = require('../errors');
|
} = require('../src/utilities/errors');
|
||||||
|
|
||||||
describe('Error Handler', () => {
|
describe('Error Handler', () => {
|
||||||
let req, res, next;
|
let req, res, next;
|
||||||
@@ -30,23 +40,6 @@ describe('Error Handler', () => {
|
|||||||
next = jest.fn();
|
next = jest.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('asyncHandler', () => {
|
|
||||||
it('calls the wrapped function', async () => {
|
|
||||||
const fn = jest.fn().mockResolvedValue();
|
|
||||||
const wrapped = asyncHandler(fn);
|
|
||||||
await wrapped(req, res, next);
|
|
||||||
expect(fn).toHaveBeenCalledWith(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls next(err) on rejected promise', async () => {
|
|
||||||
const error = new Error('async fail');
|
|
||||||
const fn = jest.fn().mockRejectedValue(error);
|
|
||||||
const wrapped = asyncHandler(fn);
|
|
||||||
await wrapped(req, res, next);
|
|
||||||
expect(next).toHaveBeenCalledWith(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('errorMiddleware', () => {
|
describe('errorMiddleware', () => {
|
||||||
it('returns 400 for ValidationError', () => {
|
it('returns 400 for ValidationError', () => {
|
||||||
const err = new ValidationError('bad input', 'email');
|
const err = new ValidationError('bad input', 'email');
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const {
|
|||||||
CaddyError,
|
CaddyError,
|
||||||
DNSError,
|
DNSError,
|
||||||
ServiceUnavailableError
|
ServiceUnavailableError
|
||||||
} = require('../errors');
|
} = require('../src/utilities/errors');
|
||||||
|
|
||||||
describe('Error Classes', () => {
|
describe('Error Classes', () => {
|
||||||
describe('AppError', () => {
|
describe('AppError', () => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('HealthChecker', () => {
|
|||||||
fs.writeFileSync.mockImplementation(() => {});
|
fs.writeFileSync.mockImplementation(() => {});
|
||||||
|
|
||||||
// Fresh instance each test
|
// Fresh instance each test
|
||||||
HealthChecker = require('../health-checker').constructor;
|
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||||
healthChecker = new HealthChecker();
|
healthChecker = new HealthChecker();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ describe('HealthChecker', () => {
|
|||||||
services: { svc1: { url: 'http://test.local', enabled: true } }
|
services: { svc1: { url: 'http://test.local', enabled: true } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
HealthChecker = require('../health-checker').constructor;
|
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||||
const hc = new HealthChecker();
|
const hc = new HealthChecker();
|
||||||
expect(hc.config.services.svc1).toBeDefined();
|
expect(hc.config.services.svc1).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ describe('HealthChecker', () => {
|
|||||||
fs.existsSync.mockReturnValue(true);
|
fs.existsSync.mockReturnValue(true);
|
||||||
fs.readFileSync.mockReturnValue('invalid json');
|
fs.readFileSync.mockReturnValue('invalid json');
|
||||||
|
|
||||||
HealthChecker = require('../health-checker').constructor;
|
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||||
const hc = new HealthChecker();
|
const hc = new HealthChecker();
|
||||||
expect(hc.config).toEqual({ services: {} });
|
expect(hc.config).toEqual({ services: {} });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Health endpoint tests
|
||||||
|
*
|
||||||
|
* Verifies:
|
||||||
|
* - /health/live always returns 200
|
||||||
|
* - /health/ready returns 200 with valid structure when all deps OK
|
||||||
|
* - /health/ready returns 503 when a critical dep is down
|
||||||
|
* - /health/ready does NOT crash with "res.status is not a function"
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
// Mock dockerode BEFORE anything else
|
||||||
|
jest.mock('dockerode', () => {
|
||||||
|
return jest.fn().mockImplementation(() => ({
|
||||||
|
ping: jest.fn().mockImplementation(() => {
|
||||||
|
if (process.env.MOCK_DOCKER_DOWN === '1') {
|
||||||
|
return Promise.reject(new Error('docker unreachable'));
|
||||||
|
}
|
||||||
|
return Promise.resolve('OK');
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a minimal Express app with the same health handlers as src/app.js
|
||||||
|
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||||
|
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const config = {
|
||||||
|
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||||
|
SERVICES_FILE: '/tmp/dc-test-services.json',
|
||||||
|
CADDY_ADMIN_URL: 'http://localhost:2019'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock fs
|
||||||
|
const fs = require('fs');
|
||||||
|
const realExistsSync = fs.existsSync;
|
||||||
|
const realReadFileSync = fs.readFileSync;
|
||||||
|
fs.existsSync = (p) => {
|
||||||
|
if (p === config.CONFIG_FILE) return configOk;
|
||||||
|
if (p === config.SERVICES_FILE) return servicesOk;
|
||||||
|
return realExistsSync(p);
|
||||||
|
};
|
||||||
|
fs.readFileSync = (p, ...args) => {
|
||||||
|
if (p === config.CONFIG_FILE) {
|
||||||
|
if (!configOk) throw new Error('config not found');
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
if (p === config.SERVICES_FILE) {
|
||||||
|
if (!servicesOk) throw new Error('services not found');
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
return realReadFileSync(p, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// /health/live (matches src/app.js exactly)
|
||||||
|
app.get('/health/live', (req, res) => {
|
||||||
|
res.json({ status: 'alive', uptime: process.uptime() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern)
|
||||||
|
const { asyncHandler } = require('../src/utils/async-handler');
|
||||||
|
const logError = async () => {}; // noop logger
|
||||||
|
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
|
||||||
|
|
||||||
|
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
|
||||||
|
const checks = {};
|
||||||
|
let allOk = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(config.CONFIG_FILE)) {
|
||||||
|
fs.readFileSync(config.CONFIG_FILE, 'utf8');
|
||||||
|
checks.configFile = { ok: true };
|
||||||
|
} else {
|
||||||
|
checks.configFile = { ok: false, error: 'Config file not found' };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
checks.configFile = { ok: false, error: e.message };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(config.SERVICES_FILE)) {
|
||||||
|
fs.readFileSync(config.SERVICES_FILE, 'utf8');
|
||||||
|
checks.servicesFile = { ok: true };
|
||||||
|
} else {
|
||||||
|
checks.servicesFile = { ok: false, error: 'Services file not found' };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
checks.servicesFile = { ok: false, error: e.message };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docker = require('dockerode')();
|
||||||
|
await docker.ping();
|
||||||
|
checks.docker = { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
checks.docker = { ok: false, error: e.message };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||||
|
const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
|
||||||
|
clearTimeout(timeout);
|
||||||
|
checks.caddy = { ok: response.ok, status: response.status };
|
||||||
|
if (!response.ok) allOk = false;
|
||||||
|
} catch (e) {
|
||||||
|
checks.caddy = { ok: false, error: e.message };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
status: allOk ? 'ready' : 'not-ready',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks
|
||||||
|
};
|
||||||
|
res.status(allOk ? 200 : 503).json(body);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Health Endpoints', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.MOCK_DOCKER_DOWN;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /health/live', () => {
|
||||||
|
it('always returns 200 with status: alive', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const res = await request(app).get('/health/live');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('alive');
|
||||||
|
expect(typeof res.body.uptime).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => {
|
||||||
|
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false });
|
||||||
|
const res = await request(app).get('/health/live');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /health/ready', () => {
|
||||||
|
it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const res = await request(app).get('/health/ready');
|
||||||
|
// config + services + docker should all be OK
|
||||||
|
expect(res.body.checks.configFile.ok).toBe(true);
|
||||||
|
expect(res.body.checks.servicesFile.ok).toBe(true);
|
||||||
|
expect(res.body.checks.docker.ok).toBe(true);
|
||||||
|
// caddy is tested in sandbox — may be 403 or 200
|
||||||
|
expect(res.body).toHaveProperty('checks');
|
||||||
|
expect(res.body).toHaveProperty('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when config file is missing', async () => {
|
||||||
|
const app = buildApp({ configOk: false });
|
||||||
|
const res = await request(app).get('/health/ready');
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.status).toBe('not-ready');
|
||||||
|
expect(res.body.checks.configFile.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when services file is missing', async () => {
|
||||||
|
const app = buildApp({ servicesOk: false });
|
||||||
|
const res = await request(app).get('/health/ready');
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.checks.servicesFile.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when Docker is unreachable', async () => {
|
||||||
|
const app = buildApp({ dockerOk: false });
|
||||||
|
const res = await request(app).get('/health/ready');
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.checks.docker.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT crash with "res.status is not a function" when dependencies fail', async () => {
|
||||||
|
const app = buildApp({ dockerOk: false });
|
||||||
|
const res = await request(app).get('/health/ready');
|
||||||
|
const bodyStr = JSON.stringify(res.body);
|
||||||
|
expect(bodyStr).not.toMatch(/res\.status is not a function/);
|
||||||
|
// Should always be a valid response object
|
||||||
|
expect(res.body).toHaveProperty('checks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with all 4 expected check keys', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const res = await request(app).get('/health/ready');
|
||||||
|
expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -90,7 +90,7 @@ function buildTestApp(routeFactory, deps, prefix = '/api') {
|
|||||||
const router = routeFactory(deps);
|
const router = routeFactory(deps);
|
||||||
app.use(prefix, router);
|
app.use(prefix, router);
|
||||||
// Error handler
|
// Error handler
|
||||||
const { errorMiddleware } = require('../../error-handler');
|
const { errorMiddleware } = require('../../../src/utilities/error-handler');
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const {
|
|||||||
isValidPort,
|
isValidPort,
|
||||||
isPrivateIP,
|
isPrivateIP,
|
||||||
validateSecurePath
|
validateSecurePath
|
||||||
} = require('../input-validator');
|
} = require('../src/security/input-validator');
|
||||||
|
|
||||||
describe('Input Validator', () => {
|
describe('Input Validator', () => {
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
@@ -480,7 +480,7 @@ describe('Input Validator', () => {
|
|||||||
|
|
||||||
// Re-require after mocking fs
|
// Re-require after mocking fs
|
||||||
function getValidateSecurePath() {
|
function getValidateSecurePath() {
|
||||||
return require('../input-validator').validateSecurePath;
|
return require('../src/security/input-validator').validateSecurePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('resolves valid path within allowed roots', async () => {
|
it('resolves valid path within allowed roots', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for log-digest.js
|
||||||
|
* Verifies the singleton LogDigest exposes the expected interface, parses
|
||||||
|
* Docker multiplexed log streams, formats digests, and supports on-demand
|
||||||
|
* daily digest generation with mocked Docker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fsReal = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
jest.mock('dockerode', () => {
|
||||||
|
const listContainers = jest.fn().mockResolvedValue([]);
|
||||||
|
const getContainer = jest.fn(() => ({
|
||||||
|
logs: jest.fn().mockResolvedValue(Buffer.from([])),
|
||||||
|
}));
|
||||||
|
function Docker() {}
|
||||||
|
Docker.prototype.listContainers = listContainers;
|
||||||
|
Docker.prototype.getContainer = getContainer;
|
||||||
|
return Docker;
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('fs', () => {
|
||||||
|
const actual = jest.requireActual('fs');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
existsSync: jest.fn().mockReturnValue(true),
|
||||||
|
mkdirSync: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../src/docker/docker-maintenance', () => ({
|
||||||
|
getDiskUsage: jest.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const Docker = require('dockerode');
|
||||||
|
const fs = require('fs');
|
||||||
|
const logDigest = require('../src/security/log-digest');
|
||||||
|
|
||||||
|
describe('LogDigest (singleton)', () => {
|
||||||
|
let dockerInstance;
|
||||||
|
let tempDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Each test gets a fresh Docker() mock instance
|
||||||
|
jest.clearAllMocks();
|
||||||
|
fs.existsSync.mockReturnValue(true);
|
||||||
|
// Use a real, writable temp directory so writeFile inside generateDailyDigest
|
||||||
|
// does not blow up. Each test gets a fresh dir to avoid cross-test pollution.
|
||||||
|
tempDir = fsReal.mkdtempSync(path.join(os.tmpdir(), 'dc-digest-test-'));
|
||||||
|
logDigest.hourlySummaries = [];
|
||||||
|
logDigest.lastCollect = null;
|
||||||
|
logDigest.running = false;
|
||||||
|
logDigest.digestDir = null;
|
||||||
|
if (logDigest.collectInterval) {
|
||||||
|
clearInterval(logDigest.collectInterval);
|
||||||
|
logDigest.collectInterval = null;
|
||||||
|
}
|
||||||
|
if (logDigest.digestTimeout) {
|
||||||
|
clearTimeout(logDigest.digestTimeout);
|
||||||
|
logDigest.digestTimeout = null;
|
||||||
|
}
|
||||||
|
dockerInstance = new Docker();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logDigest.stop();
|
||||||
|
if (tempDir && fsReal.existsSync(tempDir)) {
|
||||||
|
fsReal.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is an EventEmitter and exposes the documented API', () => {
|
||||||
|
expect(typeof logDigest.on).toBe('function');
|
||||||
|
expect(typeof logDigest.emit).toBe('function');
|
||||||
|
expect(typeof logDigest.start).toBe('function');
|
||||||
|
expect(typeof logDigest.stop).toBe('function');
|
||||||
|
expect(typeof logDigest.generateDailyDigest).toBe('function');
|
||||||
|
expect(typeof logDigest.getLatestDigest).toBe('function');
|
||||||
|
expect(typeof logDigest.getDigestByDate).toBe('function');
|
||||||
|
expect(typeof logDigest.getDigestText).toBe('function');
|
||||||
|
expect(typeof logDigest.listDigests).toBe('function');
|
||||||
|
expect(typeof logDigest.getLiveData).toBe('function');
|
||||||
|
expect(typeof logDigest.getStatus).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStatus returns current state', () => {
|
||||||
|
const status = logDigest.getStatus();
|
||||||
|
expect(status).toEqual({
|
||||||
|
running: false,
|
||||||
|
lastCollect: null,
|
||||||
|
hourlySummaries: 0,
|
||||||
|
digestDir: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start sets running and digestDir', () => {
|
||||||
|
logDigest.start(tempDir);
|
||||||
|
expect(logDigest.running).toBe(true);
|
||||||
|
expect(logDigest.digestDir).toBe(tempDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start is idempotent — second call does nothing new', () => {
|
||||||
|
logDigest.start(tempDir);
|
||||||
|
const firstInterval = logDigest.collectInterval;
|
||||||
|
logDigest.start(tempDir);
|
||||||
|
expect(logDigest.collectInterval).toBe(firstInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_parseDockerLogs decodes multiplexed log frames into lines', () => {
|
||||||
|
// Stream type byte: 0=stdin, 1=stdout, 2=stderr
|
||||||
|
// Header: [type, 0, 0, 0, size-BE-uint32]
|
||||||
|
function frame(streamType, text) {
|
||||||
|
const buf = Buffer.from(text, 'utf8');
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header[0] = streamType;
|
||||||
|
header.writeUInt32BE(buf.length, 4);
|
||||||
|
return Buffer.concat([header, buf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiplexed = Buffer.concat([
|
||||||
|
frame(1, 'hello world\n'),
|
||||||
|
frame(2, '2026-03-13T12:00:00.000Z an error happened\n'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lines = logDigest._parseDockerLogs(multiplexed);
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
expect(lines[0]).toEqual({
|
||||||
|
stream: 'stdout',
|
||||||
|
text: 'hello world',
|
||||||
|
timestamp: null,
|
||||||
|
});
|
||||||
|
expect(lines[1].stream).toBe('stderr');
|
||||||
|
expect(lines[1].text).toBe('an error happened');
|
||||||
|
expect(lines[1].timestamp).toBe('2026-03-13T12:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateDailyDigest with empty summaries produces minimal digest', async () => {
|
||||||
|
logDigest.start(tempDir);
|
||||||
|
const digest = await logDigest.generateDailyDigest('2099-01-01');
|
||||||
|
expect(digest.date).toBe('2099-01-01');
|
||||||
|
expect(digest.services).toEqual({});
|
||||||
|
expect(digest.summary.totalServices).toBe(0);
|
||||||
|
expect(digest.summary.totalErrors).toBe(0);
|
||||||
|
expect(Array.isArray(digest.notableEvents)).toBe(true);
|
||||||
|
|
||||||
|
// Confirm the file was actually written
|
||||||
|
const writtenPath = path.join(tempDir, 'digest-2099-01-01.log');
|
||||||
|
expect(fsReal.existsSync(writtenPath)).toBe(true);
|
||||||
|
const jsonPath = path.join(tempDir, 'digest-2099-01-01.json');
|
||||||
|
expect(fsReal.existsSync(jsonPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLiveData returns shape with date, hoursCollected, services', () => {
|
||||||
|
const data = logDigest.getLiveData();
|
||||||
|
expect(data).toHaveProperty('date');
|
||||||
|
expect(data).toHaveProperty('hoursCollected');
|
||||||
|
expect(data).toHaveProperty('services');
|
||||||
|
expect(data).toHaveProperty('lastCollect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLatestDigest returns null when digestDir is null', async () => {
|
||||||
|
logDigest.digestDir = null;
|
||||||
|
const result = await logDigest.getLatestDigest();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDigestByDate returns null when no file exists', async () => {
|
||||||
|
logDigest.digestDir = '/nonexistent/path';
|
||||||
|
const result = await logDigest.getDigestByDate('2020-01-01');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listDigests returns empty array when digestDir is null', async () => {
|
||||||
|
logDigest.digestDir = null;
|
||||||
|
const result = await logDigest.listDigests();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop clears intervals and timeouts', () => {
|
||||||
|
logDigest.start(tempDir);
|
||||||
|
logDigest.stop();
|
||||||
|
expect(logDigest.running).toBe(false);
|
||||||
|
expect(logDigest.collectInterval).toBeNull();
|
||||||
|
expect(logDigest.digestTimeout).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for metrics.js
|
||||||
|
* Verifies the Metrics singleton exposes the expected interface, accumulates
|
||||||
|
* request/error/business counters, normalizes paths, formats uptime, and resets.
|
||||||
|
*
|
||||||
|
* The module exports a singleton instance, so we import it once and mutate its
|
||||||
|
* state in beforeEach.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const metrics = require('../src/monitoring/metrics');
|
||||||
|
|
||||||
|
describe('Metrics (singleton)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
metrics.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes the documented public API', () => {
|
||||||
|
expect(typeof metrics.recordRequest).toBe('function');
|
||||||
|
expect(typeof metrics.recordError).toBe('function');
|
||||||
|
expect(typeof metrics.recordBusinessEvent).toBe('function');
|
||||||
|
expect(typeof metrics.normalizePath).toBe('function');
|
||||||
|
expect(typeof metrics.getSummary).toBe('function');
|
||||||
|
expect(typeof metrics.formatUptime).toBe('function');
|
||||||
|
expect(typeof metrics.reset).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordRequest', () => {
|
||||||
|
test('increments total request count', () => {
|
||||||
|
metrics.recordRequest('GET', '/api/services', 200, 12);
|
||||||
|
metrics.recordRequest('GET', '/api/services', 200, 8);
|
||||||
|
expect(metrics.requests.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates by status code', () => {
|
||||||
|
metrics.recordRequest('GET', '/a', 200, 5);
|
||||||
|
metrics.recordRequest('GET', '/b', 200, 5);
|
||||||
|
metrics.recordRequest('POST', '/c', 500, 5);
|
||||||
|
expect(metrics.requests.byStatus[200]).toBe(2);
|
||||||
|
expect(metrics.requests.byStatus[500]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates by HTTP method', () => {
|
||||||
|
metrics.recordRequest('GET', '/a', 200, 1);
|
||||||
|
metrics.recordRequest('GET', '/b', 200, 1);
|
||||||
|
metrics.recordRequest('DELETE', '/c', 200, 1);
|
||||||
|
expect(metrics.requests.byMethod.GET).toBe(2);
|
||||||
|
expect(metrics.requests.byMethod.DELETE).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates by normalized path with totalDuration', () => {
|
||||||
|
// Real-looking UUID and long hex hash; both should normalize to /:id
|
||||||
|
const id1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const id2 = 'abcdef0123456789abcdef0123456789';
|
||||||
|
metrics.recordRequest('GET', `/api/services/${id1}`, 200, 10);
|
||||||
|
metrics.recordRequest('GET', `/api/services/${id2}`, 200, 20);
|
||||||
|
const entry = metrics.requests.byPath['/api/services/:id'];
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry.count).toBe(2);
|
||||||
|
expect(entry.totalDuration).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordError', () => {
|
||||||
|
test('increments total error count and per-type counts', () => {
|
||||||
|
metrics.recordError('ValidationError');
|
||||||
|
metrics.recordError('ValidationError');
|
||||||
|
metrics.recordError('DockerError');
|
||||||
|
expect(metrics.errors.total).toBe(3);
|
||||||
|
expect(metrics.errors.byType.ValidationError).toBe(2);
|
||||||
|
expect(metrics.errors.byType.DockerError).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordBusinessEvent', () => {
|
||||||
|
test('increments known business counters', () => {
|
||||||
|
metrics.recordBusinessEvent('containersDeployed');
|
||||||
|
metrics.recordBusinessEvent('containersDeployed');
|
||||||
|
metrics.recordBusinessEvent('dnsRecordsCreated');
|
||||||
|
expect(metrics.business.containersDeployed).toBe(2);
|
||||||
|
expect(metrics.business.dnsRecordsCreated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores unknown event types without throwing', () => {
|
||||||
|
expect(() => metrics.recordBusinessEvent('not-a-real-event')).not.toThrow();
|
||||||
|
expect(metrics.business.notARealEvent).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizePath', () => {
|
||||||
|
test('replaces UUIDs with /:id', () => {
|
||||||
|
const normalized = metrics.normalizePath('/api/services/550e8400-e29b-41d4-a716-446655440000');
|
||||||
|
expect(normalized).toBe('/api/services/:id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces long hex segments with /:id', () => {
|
||||||
|
expect(metrics.normalizePath('/api/containers/abc123def4567890'))
|
||||||
|
.toBe('/api/containers/:id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces numeric path segments with /:n', () => {
|
||||||
|
expect(metrics.normalizePath('/api/services/42/edit'))
|
||||||
|
.toBe('/api/services/:n/edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves static paths unchanged', () => {
|
||||||
|
expect(metrics.normalizePath('/api/health')).toBe('/api/health');
|
||||||
|
expect(metrics.normalizePath('/')).toBe('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSummary', () => {
|
||||||
|
test('returns an object with the documented top-level shape', () => {
|
||||||
|
const summary = metrics.getSummary();
|
||||||
|
expect(summary).toHaveProperty('uptime');
|
||||||
|
expect(summary.uptime).toHaveProperty('ms');
|
||||||
|
expect(summary.uptime).toHaveProperty('human');
|
||||||
|
expect(summary).toHaveProperty('requests');
|
||||||
|
expect(summary.requests).toHaveProperty('total');
|
||||||
|
expect(summary.requests).toHaveProperty('perSecond');
|
||||||
|
expect(summary.requests).toHaveProperty('byStatus');
|
||||||
|
expect(summary.requests).toHaveProperty('byMethod');
|
||||||
|
expect(summary.requests).toHaveProperty('topEndpoints');
|
||||||
|
expect(Array.isArray(summary.requests.topEndpoints)).toBe(true);
|
||||||
|
expect(summary).toHaveProperty('errors');
|
||||||
|
expect(summary.errors).toHaveProperty('total');
|
||||||
|
expect(summary.errors).toHaveProperty('rate');
|
||||||
|
expect(summary.errors).toHaveProperty('byType');
|
||||||
|
expect(summary).toHaveProperty('business');
|
||||||
|
expect(summary).toHaveProperty('process');
|
||||||
|
expect(summary.process).toHaveProperty('pid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reflects recorded activity', () => {
|
||||||
|
metrics.recordRequest('GET', '/api/foo', 200, 10);
|
||||||
|
metrics.recordError('BoomError');
|
||||||
|
const summary = metrics.getSummary();
|
||||||
|
expect(summary.requests.total).toBe(1);
|
||||||
|
expect(summary.requests.byStatus[200]).toBe(1);
|
||||||
|
expect(summary.errors.total).toBe(1);
|
||||||
|
expect(summary.errors.byType.BoomError).toBe(1);
|
||||||
|
// 1 error / 1 request = 100% error rate
|
||||||
|
expect(summary.errors.rate).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('topEndpoints is sorted by count descending and capped at 15', () => {
|
||||||
|
// /a gets 3 hits, /b gets 1, /c gets 2
|
||||||
|
metrics.recordRequest('GET', '/a', 200, 1);
|
||||||
|
metrics.recordRequest('GET', '/a', 200, 2);
|
||||||
|
metrics.recordRequest('GET', '/a', 200, 3);
|
||||||
|
metrics.recordRequest('GET', '/b', 200, 1);
|
||||||
|
metrics.recordRequest('GET', '/c', 200, 1);
|
||||||
|
metrics.recordRequest('GET', '/c', 200, 2);
|
||||||
|
const top = metrics.getSummary().requests.topEndpoints;
|
||||||
|
expect(top[0].path).toBe('/a');
|
||||||
|
expect(top[0].count).toBe(3);
|
||||||
|
expect(top[0].avgMs).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatUptime', () => {
|
||||||
|
test('formats seconds-only when under a minute', () => {
|
||||||
|
expect(metrics.formatUptime(0)).toBe('0s');
|
||||||
|
expect(metrics.formatUptime(45)).toBe('45s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats minutes and seconds when under an hour', () => {
|
||||||
|
expect(metrics.formatUptime(60)).toBe('1m 0s');
|
||||||
|
expect(metrics.formatUptime(125)).toBe('2m 5s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats hours/minutes/seconds when under a day', () => {
|
||||||
|
expect(metrics.formatUptime(3600)).toBe('1h 0m 0s');
|
||||||
|
expect(metrics.formatUptime(3725)).toBe('1h 2m 5s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats days/hours/minutes when over a day', () => {
|
||||||
|
expect(metrics.formatUptime(86400)).toBe('1d 0h 0m');
|
||||||
|
// 1 day, 2 hours, 5 minutes, 0 seconds
|
||||||
|
expect(metrics.formatUptime(86400 + 2 * 3600 + 5 * 60)).toBe('1d 2h 5m');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
test('clears request counters and error counters', () => {
|
||||||
|
metrics.recordRequest('GET', '/x', 200, 1);
|
||||||
|
metrics.recordError('E');
|
||||||
|
metrics.reset();
|
||||||
|
expect(metrics.requests.total).toBe(0);
|
||||||
|
expect(metrics.errors.total).toBe(0);
|
||||||
|
expect(metrics.requests.byStatus).toEqual({});
|
||||||
|
expect(metrics.requests.byMethod).toEqual({});
|
||||||
|
expect(metrics.requests.byPath).toEqual({});
|
||||||
|
expect(metrics.errors.byType).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets startTime so uptime is small after reset', () => {
|
||||||
|
const before = metrics.startTime;
|
||||||
|
// Sleep a tick so Date.now() moves forward
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < 5) {} // ~5ms busy-wait
|
||||||
|
metrics.reset();
|
||||||
|
expect(metrics.startTime).toBeGreaterThanOrEqual(before);
|
||||||
|
const summary = metrics.getSummary();
|
||||||
|
expect(summary.uptime.ms).toBeLessThan(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for notification-manager.js
|
||||||
|
* Verifies the NotificationManager loads, exposes the expected interface,
|
||||||
|
* handles config loading/saving, sends notifications via providers, and
|
||||||
|
* correctly tracks history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock('fs', () => ({
|
||||||
|
existsSync: jest.fn().mockReturnValue(false),
|
||||||
|
readFileSync: jest.fn().mockReturnValue('{}'),
|
||||||
|
writeFileSync: jest.fn(),
|
||||||
|
mkdirSync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('nodemailer', () => ({
|
||||||
|
createTransport: jest.fn(() => ({
|
||||||
|
sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const NotificationManager = require('../src/managers/notification-manager');
|
||||||
|
|
||||||
|
describe('NotificationManager', () => {
|
||||||
|
let nm;
|
||||||
|
const NOTIF_FILE = '/tmp/dc-notif-test.json';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
fs.existsSync.mockReturnValue(false);
|
||||||
|
fs.readFileSync.mockReturnValue('{}');
|
||||||
|
fs.writeFileSync.mockReturnValue(undefined);
|
||||||
|
fs.mkdirSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
nm = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
fetchT: jest.fn(),
|
||||||
|
docker: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nm.stopHealthDaemon();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializes with default config', () => {
|
||||||
|
const cfg = nm.getConfig();
|
||||||
|
expect(cfg.enabled).toBe(true);
|
||||||
|
expect(cfg.providers).toHaveProperty('discord');
|
||||||
|
expect(cfg.providers).toHaveProperty('telegram');
|
||||||
|
expect(cfg.providers).toHaveProperty('ntfy');
|
||||||
|
expect(cfg.providers).toHaveProperty('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts with empty history and null lastSent', () => {
|
||||||
|
expect(nm.getHistory()).toEqual([]);
|
||||||
|
expect(nm.lastSent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveConfig writes the config to disk and creates parent dir', async () => {
|
||||||
|
fs.existsSync.mockReturnValue(false);
|
||||||
|
await nm.saveConfig();
|
||||||
|
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||||
|
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||||
|
const callArgs = fs.writeFileSync.mock.calls[0];
|
||||||
|
expect(callArgs[0]).toBe(NOTIF_FILE);
|
||||||
|
expect(callArgs[1]).toContain('enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadConfig merges file content with defaults', () => {
|
||||||
|
fs.existsSync.mockReturnValue(true);
|
||||||
|
fs.readFileSync.mockReturnValue(JSON.stringify({ enabled: false }));
|
||||||
|
const loaded = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
expect(loaded.getConfig().enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearHistory empties the history array', () => {
|
||||||
|
nm.history.push({ event: 'test', timestamp: new Date().toISOString() });
|
||||||
|
expect(nm.getHistory().length).toBe(1);
|
||||||
|
nm.clearHistory();
|
||||||
|
expect(nm.getHistory().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send returns disabled when notifications are off', async () => {
|
||||||
|
nm.config.enabled = false;
|
||||||
|
const result = await nm.send('alert', { text: 'hi' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toMatch(/disabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send returns event-not-enabled for unknown events', async () => {
|
||||||
|
nm.config.events['some-disabled-event'] = false;
|
||||||
|
const result = await nm.send('some-disabled-event', { text: 'hi' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toMatch(/not enabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send with no providers enabled records history and returns success:false', async () => {
|
||||||
|
const result = await nm.send('alert', { text: 'hello' });
|
||||||
|
expect(result).toHaveProperty('results');
|
||||||
|
expect(Array.isArray(result.results)).toBe(true);
|
||||||
|
expect(nm.getHistory().length).toBe(1);
|
||||||
|
expect(nm.getHistory()[0].event).toBe('alert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendDiscord calls ctx.fetchT and returns success on 2xx', async () => {
|
||||||
|
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
||||||
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
||||||
|
const result = await nm.sendDiscord('msg', { title: 'T' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||||
|
'https://hook.test/x',
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendDiscord throws on non-2xx response', async () => {
|
||||||
|
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
||||||
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||||
|
await expect(nm.sendDiscord('msg', null)).rejects.toThrow(/Discord/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendTelegram calls Telegram API', async () => {
|
||||||
|
nm.config.providers.telegram = { enabled: true, botToken: 'TOK', chatId: '123' };
|
||||||
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ ok: true }) });
|
||||||
|
const result = await nm.sendTelegram('hello');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('api.telegram.org'),
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendNtfy posts to the configured serverUrl + topic', async () => {
|
||||||
|
nm.config.providers.ntfy = { enabled: true, topic: 'dashcaddy', serverUrl: 'https://ntfy.sh' };
|
||||||
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
||||||
|
const result = await nm.sendNtfy('body', 'title');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||||
|
'https://ntfy.sh/dashcaddy',
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendEmail uses nodemailer transporter', async () => {
|
||||||
|
nm.config.providers.email = {
|
||||||
|
enabled: true,
|
||||||
|
host: 'smtp.test',
|
||||||
|
port: 587,
|
||||||
|
to: 'me@test',
|
||||||
|
from: 'from@test',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
};
|
||||||
|
const result = await nm.sendEmail('subject', 'body');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendAlert, sendBackupComplete, sendServiceEvent do not throw', async () => {
|
||||||
|
const alertResult = await nm.sendAlert({
|
||||||
|
containerName: 'web',
|
||||||
|
alerts: [{ type: 'cpu', severity: 'warning', message: 'high' }],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
expect(alertResult).toBeDefined();
|
||||||
|
|
||||||
|
const backupResult = await nm.sendBackupComplete({
|
||||||
|
name: 'daily',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
expect(backupResult).toBeDefined();
|
||||||
|
|
||||||
|
const serviceResult = await nm.sendServiceEvent('container-down', {
|
||||||
|
name: 'web',
|
||||||
|
containerName: 'sami-web',
|
||||||
|
});
|
||||||
|
expect(serviceResult).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkHealth returns checked:false when no docker client', async () => {
|
||||||
|
nm.ctx.docker = null;
|
||||||
|
const r = await nm.checkHealth();
|
||||||
|
expect(r.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkHealth with mocked docker returns checked:true', async () => {
|
||||||
|
nm.ctx.docker = {
|
||||||
|
listContainers: jest.fn().mockResolvedValue([
|
||||||
|
{ Id: 'aaaabbbbcccc', Names: ['/web'], State: 'running', Status: 'Up' },
|
||||||
|
{ Id: 'ddddeeeeffff', Names: ['/api'], State: 'exited', Status: 'Exited' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
nm.config.healthCheck = { enabled: true, intervalMinutes: 5 };
|
||||||
|
const r = await nm.checkHealth();
|
||||||
|
expect(r.checked).toBe(true);
|
||||||
|
expect(r.containersMonitored).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatTitle returns a string for known events', () => {
|
||||||
|
expect(typeof nm._formatTitle('alert')).toBe('string');
|
||||||
|
expect(typeof nm._formatTitle('unknown')).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startHealthDaemon and stopHealthDaemon are idempotent', () => {
|
||||||
|
nm.startHealthDaemon();
|
||||||
|
nm.startHealthDaemon(); // should not double-schedule
|
||||||
|
nm.stopHealthDaemon();
|
||||||
|
nm.stopHealthDaemon();
|
||||||
|
expect(nm.healthDaemonInterval).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../pagination');
|
const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../src/utilities/pagination');
|
||||||
|
|
||||||
describe('Pagination — DashCaddy list endpoints', () => {
|
describe('Pagination — DashCaddy list endpoints', () => {
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ fs.unlinkSync.mockReturnValue(undefined);
|
|||||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||||
lockfile.check.mockResolvedValue(false);
|
lockfile.check.mockResolvedValue(false);
|
||||||
|
|
||||||
const portLockManager = require('../port-lock-manager');
|
const portLockManager = require('../src/managers/port-lock-manager');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ fs.existsSync.mockReturnValue(false);
|
|||||||
fs.readFileSync.mockReturnValue('{}');
|
fs.readFileSync.mockReturnValue('{}');
|
||||||
fs.writeFileSync.mockReturnValue(undefined);
|
fs.writeFileSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
const resourceMonitor = require('../resource-monitor');
|
const resourceMonitor = require('../src/managers/resource-monitor');
|
||||||
|
|
||||||
function makeStat(overrides = {}) {
|
function makeStat(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for routes/auth/totp.js — the full TOTP auth flow.
|
||||||
|
*
|
||||||
|
* Covers the BACKLOG.md DC-006 acceptance criteria:
|
||||||
|
* - no code → 400 (ValidationError)
|
||||||
|
* - wrong code → 401 (AuthenticationError)
|
||||||
|
* - valid TOTP → 200 + session cookie + CSRF token
|
||||||
|
* - check-session with valid session → 200 { authenticated: true }
|
||||||
|
* - check-session without session → 401 (AuthenticationError)
|
||||||
|
*
|
||||||
|
* Uses real otplib for code generation (so we exercise the actual TOTP math)
|
||||||
|
* but mocks credentialManager, session, totpConfig, and saveTotpConfig —
|
||||||
|
* because those modules own their own state machines (disk, cookies, file)
|
||||||
|
* that don't belong in a routes-level test.
|
||||||
|
*
|
||||||
|
* NOTE: this test exercises the src/ refactored module layout (DC-005).
|
||||||
|
* It depends on routes/auth/totp.js requiring ../../src/utilities/errors and
|
||||||
|
* ../../src/utils/responses — fix the relative paths in totp.js if they
|
||||||
|
* regress (see commit log for DC-006).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { authenticator } = require('otplib');
|
||||||
|
|
||||||
|
// Quiet otplib's "Unescaped left brace" warning on Node 20+
|
||||||
|
const origWarn = console.warn;
|
||||||
|
beforeAll(() => {
|
||||||
|
console.warn = (...args) => {
|
||||||
|
const msg = args.join(' ');
|
||||||
|
if (msg.includes('Unescaped left brace')) return;
|
||||||
|
origWarn.apply(console, args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
console.warn = origWarn;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal asyncHandler that catches errors into the express error chain
|
||||||
|
function asyncHandler(fn) {
|
||||||
|
return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApp(depsOverride = {}) {
|
||||||
|
// In-memory secret store so credentialManager stays deterministic
|
||||||
|
const storedSecrets = new Map();
|
||||||
|
const credentialManager = {
|
||||||
|
store: jest.fn((key, value) => {
|
||||||
|
storedSecrets.set(key, value);
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}),
|
||||||
|
retrieve: jest.fn((key) => Promise.resolve(storedSecrets.has(key) ? storedSecrets.get(key) : null)),
|
||||||
|
delete: jest.fn((key) => {
|
||||||
|
storedSecrets.delete(key);
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}),
|
||||||
|
list: jest.fn(() => Promise.resolve(Array.from(storedSecrets.keys()))),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mutable TOTP config — tests mutate this to model setup → enable → disable
|
||||||
|
const totpConfig = {
|
||||||
|
enabled: false,
|
||||||
|
isSetUp: false,
|
||||||
|
sessionDuration: '24h',
|
||||||
|
secret: null, // matches main's optional backup-secret field
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock session context mirroring src/context/session.js
|
||||||
|
// isValid() is the knob — toggle it to test the auth-gate behavior
|
||||||
|
const sessionStore = new Map(); // ip → { expiresAt }
|
||||||
|
const session = {
|
||||||
|
create: jest.fn((req, duration) => {
|
||||||
|
const ip = session.getClientIP(req);
|
||||||
|
sessionStore.set(ip, { expiresAt: Date.now() + (duration === 'never' ? Number.MAX_SAFE_INTEGER : 3600000) });
|
||||||
|
}),
|
||||||
|
setCookie: jest.fn(),
|
||||||
|
clear: jest.fn((req) => {
|
||||||
|
const ip = session.getClientIP(req);
|
||||||
|
sessionStore.delete(ip);
|
||||||
|
}),
|
||||||
|
clearCookie: jest.fn(),
|
||||||
|
isValid: jest.fn((req) => {
|
||||||
|
const ip = session.getClientIP(req);
|
||||||
|
const entry = sessionStore.get(ip);
|
||||||
|
if (!entry) return false;
|
||||||
|
return entry.expiresAt > Date.now();
|
||||||
|
}),
|
||||||
|
// Test helper — pretend an IP has a valid session, regardless of req.ip
|
||||||
|
_grantSession: (ip = '127.0.0.1') => sessionStore.set(ip, { expiresAt: Date.now() + 3600000 }),
|
||||||
|
getClientIP: jest.fn((req) => req.ip || req.connection?.remoteAddress || '127.0.0.1'),
|
||||||
|
ipSessions: sessionStore,
|
||||||
|
durations: { '1h': 3600000, '24h': 86400000, '7d': 604800000, 'never': 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTotpConfig = jest.fn(() => Promise.resolve(true));
|
||||||
|
const renewCSRFToken = jest.fn(() => 'mock-csrf-token');
|
||||||
|
const log = {
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
authManager: {}, // unused by totp.js but required by the factory signature
|
||||||
|
credentialManager,
|
||||||
|
totpConfig,
|
||||||
|
saveTotpConfig,
|
||||||
|
session,
|
||||||
|
asyncHandler,
|
||||||
|
errorResponse: jest.fn(),
|
||||||
|
log,
|
||||||
|
renewCSRFToken,
|
||||||
|
...depsOverride,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear store between tests
|
||||||
|
deps._resetStore = () => {
|
||||||
|
storedSecrets.clear();
|
||||||
|
sessionStore.clear();
|
||||||
|
totpConfig.enabled = false;
|
||||||
|
totpConfig.isSetUp = false;
|
||||||
|
totpConfig.sessionDuration = '24h';
|
||||||
|
delete totpConfig.secret;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totpRoutes = require('../../routes/auth/totp');
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', true); // so req.ip populates from X-Forwarded-For
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api', totpRoutes(deps));
|
||||||
|
// Express error handler — surface status from thrown AppError
|
||||||
|
app.use((err, req, res, _next) => {
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
res.status(status).json({ success: false, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, deps };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||||
|
let app;
|
||||||
|
let deps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
({ app, deps } = createApp());
|
||||||
|
authenticator.options = { window: 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: derive a fresh secret + a valid current TOTP code for it
|
||||||
|
function freshSecret() {
|
||||||
|
const secret = authenticator.generateSecret();
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
return { secret, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// GET /api/totp/config
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('GET /api/totp/config', () => {
|
||||||
|
it('returns current config (enabled=false, isSetUp=false by default)', async () => {
|
||||||
|
const res = await request(app).get('/api/totp/config');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.config).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
sessionDuration: '24h',
|
||||||
|
isSetUp: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects state changes after setup completes', async () => {
|
||||||
|
deps.totpConfig.isSetUp = true;
|
||||||
|
deps.totpConfig.enabled = true;
|
||||||
|
const res = await request(app).get('/api/totp/config');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.config.isSetUp).toBe(true);
|
||||||
|
expect(res.body.config.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// POST /api/totp/setup
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('POST /api/totp/setup', () => {
|
||||||
|
it('generates a fresh secret + QR code when none is provided', async () => {
|
||||||
|
const res = await request(app).post('/api/totp/setup').send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.qrCode).toMatch(/^data:image\/png;base64,/);
|
||||||
|
expect(res.body.manualKey).toMatch(/^[A-Z2-7]{16,}$/);
|
||||||
|
expect(res.body.issuer).toBe('DashCaddy');
|
||||||
|
expect(res.body.imported).toBe(false);
|
||||||
|
// pending_secret should be stashed but totp.secret should NOT be active yet
|
||||||
|
expect(deps.credentialManager.store).toHaveBeenCalledWith('totp.pending_secret', res.body.manualKey);
|
||||||
|
expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts and normalizes a user-provided Base32 secret (0→O, 1→L, 8→B, lowercase→uppercase)', async () => {
|
||||||
|
const raw = 'JBSWY3DPEHPK3PXP'; // canonical example
|
||||||
|
const userInput = ' jbswy3dpehpk3pxp '; // spaces + lowercase
|
||||||
|
const res = await request(app).post('/api/totp/setup').send({ secret: userInput });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.manualKey).toBe(raw);
|
||||||
|
expect(res.body.imported).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an obviously invalid secret (wrong alphabet)', async () => {
|
||||||
|
const res = await request(app).post('/api/totp/setup').send({ secret: 'NOT-VALID-BASE32!' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
expect(res.body.error).toMatch(/Invalid secret key format/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// POST /api/totp/verify-setup (activates TOTP after setup)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('POST /api/totp/verify-setup', () => {
|
||||||
|
it('returns 400 when code is missing or malformed', async () => {
|
||||||
|
const res = await request(app).post('/api/totp/verify-setup').send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Invalid code format/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when no pending setup exists', async () => {
|
||||||
|
const { token } = freshSecret();
|
||||||
|
const res = await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/No pending TOTP setup/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when code is wrong', async () => {
|
||||||
|
const { secret } = freshSecret();
|
||||||
|
await request(app).post('/api/totp/setup').send({ secret });
|
||||||
|
const res = await request(app).post('/api/totp/verify-setup').send({ code: '000000' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toMatch(/DC-111/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 + activates TOTP + creates session on valid code', async () => {
|
||||||
|
const { secret, token } = freshSecret();
|
||||||
|
await request(app).post('/api/totp/setup').send({ secret });
|
||||||
|
const res = await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.message).toMatch(/TOTP enabled successfully/);
|
||||||
|
|
||||||
|
// TOTP config activated + persisted
|
||||||
|
expect(deps.totpConfig.isSetUp).toBe(true);
|
||||||
|
expect(deps.totpConfig.enabled).toBe(true);
|
||||||
|
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// pending_secret → totp.secret promotion, pending cleared
|
||||||
|
expect(await deps.credentialManager.retrieve('totp.secret')).toBe(secret);
|
||||||
|
expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull();
|
||||||
|
|
||||||
|
// Session established
|
||||||
|
expect(deps.session.create).toHaveBeenCalled();
|
||||||
|
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||||
|
// Note: renewCSRFToken is only called on /totp/verify (login), not /totp/verify-setup
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// POST /api/totp/verify (login flow — TOTP already configured)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('POST /api/totp/verify (login)', () => {
|
||||||
|
async function setupTOTP() {
|
||||||
|
const { secret, token } = freshSecret();
|
||||||
|
await request(app).post('/api/totp/setup').send({ secret });
|
||||||
|
await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||||
|
// Reset mocks but keep config/secret state for the test
|
||||||
|
jest.clearAllMocks();
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns 400 when code is missing', async () => {
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Invalid code format/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when TOTP is not enabled', async () => {
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: '123456' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/TOTP is not enabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when code is wrong (TOTP active)', async () => {
|
||||||
|
await setupTOTP();
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: '000000' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toMatch(/DC-111/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
||||||
|
const secret = await setupTOTP();
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||||
|
expect(res.body.csrfToken).toBe('mock-csrf-token');
|
||||||
|
expect(deps.session.create).toHaveBeenCalled();
|
||||||
|
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||||
|
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// GET /api/totp/check-session (the auth gate Caddy calls)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('GET /api/totp/check-session', () => {
|
||||||
|
it('always returns 200 when TOTP is not enabled (passthrough)', async () => {
|
||||||
|
const res = await request(app).get('/api/totp/check-session');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ authenticated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always returns 200 when sessionDuration is "never" (passthrough)', async () => {
|
||||||
|
deps.totpConfig.enabled = true;
|
||||||
|
deps.totpConfig.isSetUp = true;
|
||||||
|
deps.totpConfig.sessionDuration = 'never';
|
||||||
|
const res = await request(app).get('/api/totp/check-session');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ authenticated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when no session exists (BACKLOG: "no token → 401")', async () => {
|
||||||
|
deps.totpConfig.enabled = true;
|
||||||
|
deps.totpConfig.isSetUp = true;
|
||||||
|
deps.totpConfig.sessionDuration = '24h';
|
||||||
|
// session.isValid returns false because sessionStore is empty
|
||||||
|
const res = await request(app).get('/api/totp/check-session');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toMatch(/Session expired or invalid/);
|
||||||
|
// Cache-control headers must be set to avoid Caddy auth loops
|
||||||
|
expect(res.headers['cache-control']).toMatch(/no-store/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { authenticated: true } when session is valid (BACKLOG: "authenticated request succeeds")', async () => {
|
||||||
|
deps.totpConfig.enabled = true;
|
||||||
|
deps.totpConfig.isSetUp = true;
|
||||||
|
deps.totpConfig.sessionDuration = '24h';
|
||||||
|
// Pre-populate the session store as if verify already ran
|
||||||
|
deps.session._grantSession('127.0.0.1');
|
||||||
|
const res = await request(app).get('/api/totp/check-session').set('X-Forwarded-For', '127.0.0.1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ authenticated: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// POST /api/totp/disable
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('POST /api/totp/disable', () => {
|
||||||
|
async function setupTOTP() {
|
||||||
|
const { secret, token } = freshSecret();
|
||||||
|
await request(app).post('/api/totp/setup').send({ secret });
|
||||||
|
await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||||
|
jest.clearAllMocks();
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns 400 when TOTP is active but no code is provided', async () => {
|
||||||
|
await setupTOTP();
|
||||||
|
const res = await request(app).post('/api/totp/disable').send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/valid TOTP code is required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when code is wrong', async () => {
|
||||||
|
await setupTOTP();
|
||||||
|
const res = await request(app).post('/api/totp/disable').send({ code: '000000' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toMatch(/DC-111/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 + clears TOTP state on valid code', async () => {
|
||||||
|
const secret = await setupTOTP();
|
||||||
|
const code = authenticator.generate(secret);
|
||||||
|
const res = await request(app).post('/api/totp/disable').send({ code });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
// TOTP disabled, secrets cleared, session cleared
|
||||||
|
expect(deps.totpConfig.enabled).toBe(false);
|
||||||
|
expect(deps.totpConfig.isSetUp).toBe(false);
|
||||||
|
expect(deps.totpConfig.sessionDuration).toBe('never');
|
||||||
|
expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull();
|
||||||
|
expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull();
|
||||||
|
expect(deps.session.clear).toHaveBeenCalled();
|
||||||
|
expect(deps.session.clearCookie).toHaveBeenCalled();
|
||||||
|
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// POST /api/totp/config (session duration change)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('POST /api/totp/config (update settings)', () => {
|
||||||
|
it('updates sessionDuration with a valid value', async () => {
|
||||||
|
const res = await request(app).post('/api/totp/config').send({ sessionDuration: '7d' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.config.sessionDuration).toBe('7d');
|
||||||
|
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid sessionDuration', async () => {
|
||||||
|
const res = await request(app).post('/api/totp/config').send({ sessionDuration: '99y' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Invalid session duration/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setting sessionDuration to "never" disables TOTP', async () => {
|
||||||
|
deps.totpConfig.enabled = true;
|
||||||
|
deps.totpConfig.isSetUp = true;
|
||||||
|
const res = await request(app).post('/api/totp/config').send({ sessionDuration: 'never' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(deps.totpConfig.sessionDuration).toBe('never');
|
||||||
|
expect(deps.totpConfig.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// End-to-end flow (BACKLOG: "Cover the full /api/auth/check → session → endpoint flow")
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('End-to-end: setup → login → check-session → disable', () => {
|
||||||
|
it('walks the full BACKLOG DC-006 flow', async () => {
|
||||||
|
// 1. Setup — generate a fresh secret
|
||||||
|
const setupRes = await request(app).post('/api/totp/setup').send({});
|
||||||
|
expect(setupRes.status).toBe(200);
|
||||||
|
const secret = setupRes.body.manualKey;
|
||||||
|
const setupCode = authenticator.generate(secret);
|
||||||
|
|
||||||
|
// 2. Verify-setup — activate TOTP
|
||||||
|
const verifySetupRes = await request(app).post('/api/totp/verify-setup').send({ code: setupCode });
|
||||||
|
expect(verifySetupRes.status).toBe(200);
|
||||||
|
expect(deps.totpConfig.isSetUp).toBe(true);
|
||||||
|
|
||||||
|
// 3. Simulate session expiry by clearing the store
|
||||||
|
deps.session.ipSessions.clear();
|
||||||
|
|
||||||
|
// 4. Re-login via /totp/verify (the "login" path)
|
||||||
|
const loginCode = authenticator.generate(secret);
|
||||||
|
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
||||||
|
expect(loginRes.status).toBe(200);
|
||||||
|
expect(loginRes.body.csrfToken).toBeDefined();
|
||||||
|
|
||||||
|
// 5. Check-session — should now be authenticated (the BACKLOG "→ endpoint succeeds" step)
|
||||||
|
const checkRes = await request(app).get('/api/totp/check-session');
|
||||||
|
expect(checkRes.status).toBe(200);
|
||||||
|
expect(checkRes.body).toEqual({ authenticated: true });
|
||||||
|
|
||||||
|
// 6. Logout / disable
|
||||||
|
const disableCode = authenticator.generate(secret);
|
||||||
|
const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode });
|
||||||
|
expect(disableRes.status).toBe(200);
|
||||||
|
|
||||||
|
// 7. After disable, check-session should be passthrough (TOTP off)
|
||||||
|
const afterRes = await request(app).get('/api/totp/check-session');
|
||||||
|
expect(afterRes.status).toBe(200);
|
||||||
|
expect(afterRes.body).toEqual({ authenticated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proves otplib is real (not stubbed) by using a totally bogus code', async () => {
|
||||||
|
// Sanity check that the test harness is using real otplib, not a stub.
|
||||||
|
// otplib 12.0.1's authenticator.generate(secret) does not accept a {time} option
|
||||||
|
// (the signature is fixed to current-time TOTP), so a "stale code" test isn't
|
||||||
|
// reproducible across runs. Instead, we verify otplib rejects a code that is
|
||||||
|
// syntactically valid (6 digits) but doesn't match the live TOTP slot.
|
||||||
|
const secret = authenticator.generateSecret();
|
||||||
|
await request(app).post('/api/totp/setup').send({ secret });
|
||||||
|
// Generate the real current code, then mutate it — must be rejected
|
||||||
|
const realCode = authenticator.generate(secret);
|
||||||
|
const tampered = realCode === '000000' ? '111111' : '000000';
|
||||||
|
const res = await request(app).post('/api/totp/verify-setup').send({ code: tampered });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ function buildApp(mockDeps) {
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
const { errorMiddleware } = require('../../error-handler');
|
const { errorMiddleware } = require('../../src/utilities/error-handler');
|
||||||
const containersRouteFactory = require('../../routes/containers');
|
const containersRouteFactory = require('../../routes/containers');
|
||||||
app.use('/api/containers', containersRouteFactory(mockDeps));
|
app.use('/api/containers', containersRouteFactory(mockDeps));
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
@@ -52,21 +52,21 @@ jest.mock('../../platform-paths', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock fs-helpers.exists
|
// Mock fs-helpers.exists
|
||||||
jest.mock('../../fs-helpers', () => ({
|
jest.mock('../../src/utilities/fs-helpers', () => ({
|
||||||
exists: jest.fn().mockResolvedValue(true),
|
exists: jest.fn().mockResolvedValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../url-resolver', () => ({
|
jest.mock('../../src/utilities/url-resolver', () => ({
|
||||||
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../pagination', () => ({
|
jest.mock('../../src/utilities/pagination', () => ({
|
||||||
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
||||||
parsePaginationParams: jest.fn(() => null),
|
parsePaginationParams: jest.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../src/utilities/fs-helpers');
|
||||||
const { resolveServiceUrl } = require('../../url-resolver');
|
const { resolveServiceUrl } = require('../../src/utilities/url-resolver');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
describe('Health Routes', () => {
|
describe('Health Routes', () => {
|
||||||
@@ -538,7 +538,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('healthy');
|
expect(res.body.caStatus).toBe('healthy');
|
||||||
expect(res.body.daysUntilExpiration).toBeGreaterThan(90);
|
expect(res.body.daysUntilExpiration).toBeGreaterThan(90);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -551,7 +551,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('warning');
|
expect(res.body.caStatus).toBe('warning');
|
||||||
expect(res.body.daysUntilExpiration).toBeLessThan(90);
|
expect(res.body.daysUntilExpiration).toBeLessThan(90);
|
||||||
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30);
|
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30);
|
||||||
});
|
});
|
||||||
@@ -565,7 +565,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('critical');
|
expect(res.body.caStatus).toBe('critical');
|
||||||
expect(res.body.daysUntilExpiration).toBeLessThan(30);
|
expect(res.body.daysUntilExpiration).toBeLessThan(30);
|
||||||
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0);
|
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
@@ -579,7 +579,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('critical');
|
expect(res.body.caStatus).toBe('critical');
|
||||||
expect(res.body.daysUntilExpiration).toBeLessThan(7);
|
expect(res.body.daysUntilExpiration).toBeLessThan(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -592,7 +592,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('critical');
|
expect(res.body.caStatus).toBe('critical');
|
||||||
expect(res.body.daysUntilExpiration).toBeLessThan(0);
|
expect(res.body.daysUntilExpiration).toBeLessThan(0);
|
||||||
expect(res.body.message).toMatch(/EXPIRED/);
|
expect(res.body.message).toMatch(/EXPIRED/);
|
||||||
});
|
});
|
||||||
@@ -601,9 +601,9 @@ describe('Health Routes', () => {
|
|||||||
exists.mockResolvedValue(false);
|
exists.mockResolvedValue(false);
|
||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(404);
|
||||||
expect(res.body.status).toBe('error');
|
expect(res.body.caStatus).toBe('error');
|
||||||
expect(res.body.message).toMatch(/not found/);
|
expect(res.body.error).toMatch(/not found/);
|
||||||
expect(res.body.daysUntilExpiration).toBeNull();
|
expect(res.body.daysUntilExpiration).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -612,9 +612,9 @@ describe('Health Routes', () => {
|
|||||||
execSync.mockImplementation(() => { throw new Error('openssl not found'); });
|
execSync.mockImplementation(() => { throw new Error('openssl not found'); });
|
||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.status).toBe('error');
|
expect(res.body.caStatus).toBe('error');
|
||||||
expect(res.body.message).toBe('openssl not found');
|
expect(res.body.error).toBe('openssl not found');
|
||||||
expect(res.body.daysUntilExpiration).toBeNull();
|
expect(res.body.daysUntilExpiration).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,32 +9,32 @@ function asyncHandler(fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mock modules that services.js requires at top-level
|
// Mock modules that services.js requires at top-level
|
||||||
jest.mock('../../constants', () => ({
|
jest.mock('../../src/utilities/constants', () => ({
|
||||||
APP: { USER_AGENTS: { PROBE: 'DashCaddy/1.0' } },
|
APP: { USER_AGENTS: { PROBE: 'DashCaddy/1.0' } },
|
||||||
REGEX: { SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ },
|
REGEX: { SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ },
|
||||||
TIMEOUTS: { DEFAULT: 10000 },
|
TIMEOUTS: { DEFAULT: 10000 },
|
||||||
HTTP_STATUS: { OK: 200, CREATED: 201, NO_CONTENT: 204, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, INTERNAL_ERROR: 500 }
|
HTTP_STATUS: { OK: 200, CREATED: 201, NO_CONTENT: 204, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, INTERNAL_ERROR: 500 }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../input-validator', () => ({
|
jest.mock('../../src/security/input-validator', () => ({
|
||||||
validateServiceConfig: jest.fn(),
|
validateServiceConfig: jest.fn(),
|
||||||
isValidPort: jest.fn(p => p >= 1 && p <= 65535),
|
isValidPort: jest.fn(p => p >= 1 && p <= 65535),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../fs-helpers', () => ({
|
jest.mock('../../src/utilities/fs-helpers', () => ({
|
||||||
exists: jest.fn().mockResolvedValue(true),
|
exists: jest.fn().mockResolvedValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../url-resolver', () => ({
|
jest.mock('../../src/utilities/url-resolver', () => ({
|
||||||
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../pagination', () => ({
|
jest.mock('../../src/utilities/pagination', () => ({
|
||||||
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
||||||
parsePaginationParams: jest.fn(() => null),
|
parsePaginationParams: jest.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../response-helpers', () => ({
|
jest.mock('../../src/utils/responses', () => ({
|
||||||
success: jest.fn((res, data, statusCode = 200) => {
|
success: jest.fn((res, data, statusCode = 200) => {
|
||||||
return res.status(statusCode).json({ success: true, ...data });
|
return res.status(statusCode).json({ success: true, ...data });
|
||||||
}),
|
}),
|
||||||
@@ -45,8 +45,8 @@ jest.mock('../../response-helpers', () => ({
|
|||||||
|
|
||||||
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
||||||
|
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../src/utilities/fs-helpers');
|
||||||
const { validateServiceConfig } = require('../../input-validator');
|
const { validateServiceConfig } = require('../../src/security/input-validator');
|
||||||
|
|
||||||
function createApp(depsOverride = {}) {
|
function createApp(depsOverride = {}) {
|
||||||
const defaultDeps = {
|
const defaultDeps = {
|
||||||
@@ -103,12 +103,12 @@ describe('Services Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/services', () => {
|
describe('GET /api/services', () => {
|
||||||
it('returns empty array when no services file', async () => {
|
it('returns empty services array (enveloped) when no services file', async () => {
|
||||||
exists.mockResolvedValue(false);
|
exists.mockResolvedValue(false);
|
||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/services');
|
const res = await request(app).get('/api/services');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual([]);
|
expect(res.body).toEqual({ success: true, services: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns services list', async () => {
|
it('returns services list', async () => {
|
||||||
@@ -450,7 +450,7 @@ describe('Services Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid port', async () => {
|
it('rejects invalid port', async () => {
|
||||||
const { isValidPort } = require('../../input-validator');
|
const { isValidPort } = require('../../src/security/input-validator');
|
||||||
isValidPort.mockReturnValue(false);
|
isValidPort.mockReturnValue(false);
|
||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for ssl-monitor.js
|
||||||
|
* Verifies SSLMonitor loads, exposes the expected interface, can check
|
||||||
|
* certificates via mocked TLS, manage state, and persist cache.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock('tls', () => ({
|
||||||
|
connect: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../src/utilities/fs-helpers', () => ({
|
||||||
|
readJsonFile: jest.fn().mockResolvedValue(null),
|
||||||
|
writeJsonFile: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tls = require('tls');
|
||||||
|
const fsHelpers = require('../src/utilities/fs-helpers');
|
||||||
|
const SSLMonitor = require('../src/monitoring/ssl-monitor');
|
||||||
|
|
||||||
|
function makeSocket({ cert = null, error = null } = {}) {
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const socket = new EventEmitter();
|
||||||
|
socket.destroy = jest.fn();
|
||||||
|
socket.getPeerCertificate = jest.fn(() => cert);
|
||||||
|
socket.setTimeout = jest.fn();
|
||||||
|
|
||||||
|
// Simulate 'connect' on next tick (or 'error')
|
||||||
|
process.nextTick(() => {
|
||||||
|
if (error) socket.emit('error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SSLMonitor', () => {
|
||||||
|
let monitor;
|
||||||
|
const fakeStateManager = {
|
||||||
|
read: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
fsHelpers.readJsonFile.mockResolvedValue(null);
|
||||||
|
fsHelpers.writeJsonFile.mockResolvedValue(undefined);
|
||||||
|
fakeStateManager.read.mockResolvedValue([]);
|
||||||
|
|
||||||
|
monitor = new SSLMonitor({
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
servicesStateManager: fakeStateManager,
|
||||||
|
siteConfig: {},
|
||||||
|
buildServiceUrl: id => `https://${id}.sami`,
|
||||||
|
notification: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
monitor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializes with empty maps and default config', () => {
|
||||||
|
expect(monitor.certStatus).toBeInstanceOf(Map);
|
||||||
|
expect(monitor.notifiedThresholds).toBeInstanceOf(Map);
|
||||||
|
expect(monitor.hostnameToServiceId).toBeInstanceOf(Map);
|
||||||
|
expect(monitor.intervalHandle).toBeNull();
|
||||||
|
expect(monitor.config.enabled).toBe(true);
|
||||||
|
expect(typeof monitor.config.intervalMs).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getConfig returns a copy of the current config', () => {
|
||||||
|
const cfg = monitor.getConfig();
|
||||||
|
expect(cfg).toEqual(monitor.config);
|
||||||
|
cfg.enabled = false;
|
||||||
|
// The internal config must not be mutated
|
||||||
|
expect(monitor.config.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateConfig updates enabled and intervalMs', () => {
|
||||||
|
monitor.updateConfig({ enabled: false, intervalMs: 60000 });
|
||||||
|
expect(monitor.config.enabled).toBe(false);
|
||||||
|
expect(monitor.config.intervalMs).toBe(60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateConfig rejects intervalMs below 60000', () => {
|
||||||
|
const original = monitor.config.intervalMs;
|
||||||
|
monitor.updateConfig({ intervalMs: 1000 });
|
||||||
|
expect(monitor.config.intervalMs).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStatus returns an empty object when no checks have run', () => {
|
||||||
|
expect(monitor.getStatus()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getServiceCertStatus returns null for unknown service', () => {
|
||||||
|
expect(monitor.getServiceCertStatus('unknown-svc')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkCert rejects when peer cert is empty', async () => {
|
||||||
|
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||||
|
const sock = makeSocket({ cert: {} });
|
||||||
|
// Simulate immediate 'connect'
|
||||||
|
setImmediate(() => onConnect && onConnect());
|
||||||
|
return sock;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(monitor.checkCert('empty.sami')).rejects.toThrow(/No certificate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkCert resolves with cert details on success', async () => {
|
||||||
|
const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); // +60d
|
||||||
|
const validTo = futureDate.toUTCString();
|
||||||
|
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||||
|
const sock = makeSocket({
|
||||||
|
cert: {
|
||||||
|
subject: { CN: 'test.sami' },
|
||||||
|
issuer: { O: "Sami's CA" },
|
||||||
|
valid_from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toUTCString(),
|
||||||
|
valid_to: validTo,
|
||||||
|
fingerprint: 'AA:BB:CC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setImmediate(() => onConnect && onConnect());
|
||||||
|
return sock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await monitor.checkCert('test.sami', 443);
|
||||||
|
expect(result.hostname).toBe('test.sami');
|
||||||
|
expect(result.port).toBe(443);
|
||||||
|
expect(result.subject).toBe('test.sami');
|
||||||
|
expect(result.daysRemaining).toBeGreaterThan(0);
|
||||||
|
expect(typeof result.isExpiring).toBe('boolean');
|
||||||
|
expect(typeof result.checkedAt).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkCert rejects with TLS error event', async () => {
|
||||||
|
tls.connect.mockImplementation(() => {
|
||||||
|
const sock = makeSocket({ error: new Error('TLS boom') });
|
||||||
|
return sock;
|
||||||
|
});
|
||||||
|
await expect(monitor.checkCert('broken.sami')).rejects.toThrow(/TLS/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkAll returns empty status when no services configured', async () => {
|
||||||
|
const status = await monitor.checkAll();
|
||||||
|
expect(status).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkAll handles HTTPS services and stores results', async () => {
|
||||||
|
fakeStateManager.read.mockResolvedValue([
|
||||||
|
{ id: 'web', name: 'Web', url: 'https://web.sami' },
|
||||||
|
]);
|
||||||
|
const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000);
|
||||||
|
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||||
|
const sock = makeSocket({
|
||||||
|
cert: {
|
||||||
|
subject: { CN: 'web.sami' },
|
||||||
|
issuer: { O: "Sami's CA" },
|
||||||
|
valid_from: new Date().toUTCString(),
|
||||||
|
valid_to: futureDate.toUTCString(),
|
||||||
|
fingerprint: 'AA:BB:CC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setImmediate(() => onConnect && onConnect());
|
||||||
|
return sock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await monitor.checkAll();
|
||||||
|
expect(status['web.sami']).toBeDefined();
|
||||||
|
expect(status['web.sami'].hostname).toBe('web.sami');
|
||||||
|
expect(monitor.getServiceCertStatus('web')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start() schedules periodic checks and stop() clears them', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const originalCheckAll = monitor.checkAll.bind(monitor);
|
||||||
|
monitor.checkAll = jest.fn().mockResolvedValue(undefined);
|
||||||
|
monitor.start(120000);
|
||||||
|
expect(monitor.intervalHandle).not.toBeNull();
|
||||||
|
monitor.stop();
|
||||||
|
expect(monitor.intervalHandle).toBeNull();
|
||||||
|
monitor.checkAll = originalCheckAll;
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_saveCache and _loadCache round-trip via fs-helpers', async () => {
|
||||||
|
await monitor._saveCache();
|
||||||
|
expect(fsHelpers.writeJsonFile).toHaveBeenCalled();
|
||||||
|
|
||||||
|
fsHelpers.readJsonFile.mockResolvedValue({
|
||||||
|
lastChecked: new Date().toISOString(),
|
||||||
|
certs: { 'a.sami': { hostname: 'a.sami', daysRemaining: 30 } },
|
||||||
|
hostnameToServiceId: { 'a.sami': 'svc-a' },
|
||||||
|
});
|
||||||
|
const fresh = new SSLMonitor({
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
servicesStateManager: fakeStateManager,
|
||||||
|
siteConfig: {},
|
||||||
|
buildServiceUrl: id => `https://${id}.sami`,
|
||||||
|
});
|
||||||
|
await fresh._loadCache();
|
||||||
|
expect(fresh.certStatus.get('a.sami')).toBeDefined();
|
||||||
|
expect(fresh.hostnameToServiceId.get('a.sami')).toBe('svc-a');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ jest.mock('fs', () => ({
|
|||||||
|
|
||||||
const lockfile = require('proper-lockfile');
|
const lockfile = require('proper-lockfile');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const StateManager = require('../state-manager');
|
const StateManager = require('../src/managers/state-manager');
|
||||||
|
|
||||||
describe('StateManager', () => {
|
describe('StateManager', () => {
|
||||||
let sm;
|
let sm;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ fs.existsSync.mockReturnValue(false);
|
|||||||
fs.readFileSync.mockReturnValue('{}');
|
fs.readFileSync.mockReturnValue('{}');
|
||||||
fs.writeFileSync.mockReturnValue(undefined);
|
fs.writeFileSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
const updateManager = require('../update-manager');
|
const updateManager = require('../src/managers/update-manager');
|
||||||
|
|
||||||
// Helper to create a fake https request that responds with a given statusCode/headers/body
|
// Helper to create a fake https request that responds with a given statusCode/headers/body
|
||||||
function mockHttpsResponse({ statusCode = 200, headers = {}, body = '' } = {}) {
|
function mockHttpsResponse({ statusCode = 200, headers = {}, body = '' } = {}) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { resolveServiceUrl } = require('../url-resolver');
|
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
||||||
|
|
||||||
describe('URL Resolver — DashCaddy service URL resolution', () => {
|
describe('URL Resolver — DashCaddy service URL resolution', () => {
|
||||||
const buildServiceUrl = jest.fn(id => `https://${id}.sami`);
|
const buildServiceUrl = jest.fn(id => `https://${id}.sami`);
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* DashCaddy Error Handler Middleware
|
|
||||||
* Centralizes error handling logic to eliminate duplicate catch blocks
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { AppError } = require('./errors');
|
|
||||||
const { logError } = require('./error-logger');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async route handler wrapper
|
|
||||||
* Automatically catches errors and passes to error middleware
|
|
||||||
* Usage: app.get('/route', asyncHandler(async (req, res) => { ... }))
|
|
||||||
*/
|
|
||||||
function asyncHandler(fn) {
|
|
||||||
return (req, res, next) => {
|
|
||||||
Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global error handling middleware
|
|
||||||
* MUST be registered after all routes in server.js
|
|
||||||
*/
|
|
||||||
function errorMiddleware(err, req, res, next) {
|
|
||||||
// Log all errors with request context
|
|
||||||
logError(req.path, err, {
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip,
|
|
||||||
userId: req.user?.id,
|
|
||||||
body: req.body
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine if this is an operational error (AppError) or programming error
|
|
||||||
const isOperational = err.isOperational || err instanceof AppError;
|
|
||||||
|
|
||||||
// Status code
|
|
||||||
const statusCode = err.statusCode || 500;
|
|
||||||
|
|
||||||
// Error code (DC-XXX format)
|
|
||||||
const code = err.code || `DC-${statusCode}`;
|
|
||||||
|
|
||||||
// Build response
|
|
||||||
const response = {
|
|
||||||
success: false,
|
|
||||||
error: isOperational ? err.message : 'Internal server error',
|
|
||||||
code
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add optional fields if present
|
|
||||||
if (err.requiresTotp) response.requiresTotp = true;
|
|
||||||
if (err.retryAfter) response.retryAfter = err.retryAfter;
|
|
||||||
if (err.field) response.field = err.field;
|
|
||||||
if (err.resource) response.resource = err.resource;
|
|
||||||
if (err.details && Object.keys(err.details).length > 0) response.details = err.details;
|
|
||||||
|
|
||||||
// Development mode: include stack trace
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
response.stack = err.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send response
|
|
||||||
res.status(statusCode).json(response);
|
|
||||||
|
|
||||||
// For non-operational errors, log as fatal
|
|
||||||
if (!isOperational) {
|
|
||||||
console.error('FATAL: Non-operational error detected', {
|
|
||||||
error: err.message,
|
|
||||||
stack: err.stack,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 404 handler for routes not found
|
|
||||||
* Register this before the global error handler
|
|
||||||
*/
|
|
||||||
function notFoundHandler(req, res, next) {
|
|
||||||
const { NotFoundError } = require('./errors');
|
|
||||||
next(new NotFoundError(`Route ${req.method} ${req.path}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
asyncHandler,
|
|
||||||
errorMiddleware,
|
|
||||||
notFoundHandler
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// Error Logger Utility
|
|
||||||
// Centralized error logging with rotation and request context tracking
|
|
||||||
|
|
||||||
const fsp = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const { LIMITS } = require('./constants');
|
|
||||||
|
|
||||||
const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
|
|
||||||
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if file exists
|
|
||||||
*/
|
|
||||||
async function exists(filepath) {
|
|
||||||
try {
|
|
||||||
await fsp.access(filepath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log error with context and rotation
|
|
||||||
* @param {string} context - Where the error occurred
|
|
||||||
* @param {Error|string} error - The error to log
|
|
||||||
* @param {Object} additionalInfo - Additional context (req, etc.)
|
|
||||||
*/
|
|
||||||
async function logError(context, error, additionalInfo = {}) {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
// Extract request context if a request object is provided
|
|
||||||
const requestContext = extractRequestContext(additionalInfo.req);
|
|
||||||
if (additionalInfo.req) {
|
|
||||||
delete additionalInfo.req; // Remove req to avoid circular refs
|
|
||||||
}
|
|
||||||
|
|
||||||
const logEntry = {
|
|
||||||
timestamp,
|
|
||||||
context,
|
|
||||||
...requestContext,
|
|
||||||
error: {
|
|
||||||
message: error.message || error,
|
|
||||||
stack: error.stack,
|
|
||||||
code: error.code
|
|
||||||
},
|
|
||||||
...additionalInfo
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format log line with request context
|
|
||||||
const contextInfo = Object.keys(requestContext).length > 0
|
|
||||||
? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}`
|
|
||||||
: '';
|
|
||||||
const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Rotate log if it exceeds max size
|
|
||||||
await rotateLogIfNeeded();
|
|
||||||
await fsp.appendFile(ERROR_LOG_FILE, logLine);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to write to error log', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract request context from Express request object
|
|
||||||
*/
|
|
||||||
function extractRequestContext(req) {
|
|
||||||
if (!req) return {};
|
|
||||||
|
|
||||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestId: req.id,
|
|
||||||
ip: clientIP,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
method: req.method,
|
|
||||||
path: req.path
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotate log file if it exceeds max size
|
|
||||||
*/
|
|
||||||
async function rotateLogIfNeeded() {
|
|
||||||
try {
|
|
||||||
const stats = await fsp.stat(ERROR_LOG_FILE);
|
|
||||||
if (stats.size > MAX_ERROR_LOG_SIZE) {
|
|
||||||
const rotated = ERROR_LOG_FILE + '.1';
|
|
||||||
if (await exists(rotated)) {
|
|
||||||
await fsp.unlink(rotated);
|
|
||||||
}
|
|
||||||
await fsp.rename(ERROR_LOG_FILE, rotated);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// File may not exist yet, that's fine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a safe error message to the client without leaking internals
|
|
||||||
*/
|
|
||||||
function safeErrorMessage(error) {
|
|
||||||
const msg = error.message || String(error);
|
|
||||||
|
|
||||||
// Detect port conflict errors from Docker
|
|
||||||
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
|
|
||||||
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
|
|
||||||
const port = portMatch ? portMatch[1] : 'requested';
|
|
||||||
return `Port ${port} is already in use. Please choose a different port or stop the conflicting service.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect container not found errors
|
|
||||||
if (msg.includes('No such container')) {
|
|
||||||
return 'Container not found';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect network errors
|
|
||||||
if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) {
|
|
||||||
return 'Service unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic safe message for unknown errors
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
return 'An error occurred. Please try again or contact support.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// In development, show the actual error
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
logError,
|
|
||||||
safeErrorMessage
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashcaddy-api",
|
"name": "dashcaddy-api",
|
||||||
"version": "1.7.8",
|
"version": "1.13.4",
|
||||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// All paths can be overridden via environment variables.
|
// All paths can be overridden via environment variables.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
// Base directories
|
// Base directories
|
||||||
@@ -34,6 +35,8 @@ const paths = {
|
|||||||
caCertDir: path.join(CADDY_SITES, 'ca'),
|
caCertDir: path.join(CADDY_SITES, 'ca'),
|
||||||
pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
|
pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
|
||||||
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
|
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
|
||||||
|
generatedCertsDir: path.join(CADDY_SITES, 'generated-certs'),
|
||||||
|
pkiDir: CADDY_PKI,
|
||||||
|
|
||||||
// Static site base path
|
// Static site base path
|
||||||
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
|
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
|
||||||
@@ -41,6 +44,24 @@ const paths = {
|
|||||||
// Docker data path for app volumes
|
// Docker data path for app volumes
|
||||||
appData: (appName) => path.join(DOCKER_DATA, appName),
|
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
|
// Log digest directory
|
||||||
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'),
|
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'),
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,23 @@ const server = http.createServer(async (req, res) => {
|
|||||||
json(res, 404, { error: 'Not found' });
|
json(res, 404, { error: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
const PYLON_PORT = parseInt(process.env.PYLON_PORT, 10) || 7842;
|
||||||
console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`);
|
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}`);
|
||||||
if (API_KEY) console.log('[Pylon] API key authentication enabled');
|
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'));
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
// Response Helpers
|
|
||||||
// Standardize API response format across all routes
|
|
||||||
|
|
||||||
const { HTTP_STATUS } = require('./constants');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success response with data
|
|
||||||
*/
|
|
||||||
function success(res, data, statusCode = HTTP_STATUS.OK) {
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
success: true,
|
|
||||||
...data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success response with message
|
|
||||||
*/
|
|
||||||
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
success: true,
|
|
||||||
message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created response (201)
|
|
||||||
*/
|
|
||||||
function created(res, data) {
|
|
||||||
return res.status(HTTP_STATUS.CREATED).json({
|
|
||||||
success: true,
|
|
||||||
...data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No content response (204)
|
|
||||||
*/
|
|
||||||
function noContent(res) {
|
|
||||||
return res.status(HTTP_STATUS.NO_CONTENT).send();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error response
|
|
||||||
*/
|
|
||||||
function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation error response (400)
|
|
||||||
*/
|
|
||||||
function validationError(res, message) {
|
|
||||||
return res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unauthorized response (401)
|
|
||||||
*/
|
|
||||||
function unauthorized(res, message = 'Unauthorized') {
|
|
||||||
return res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forbidden response (403)
|
|
||||||
*/
|
|
||||||
function forbidden(res, message = 'Forbidden') {
|
|
||||||
return res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Not found response (404)
|
|
||||||
*/
|
|
||||||
function notFound(res, message = 'Not found') {
|
|
||||||
return res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conflict response (409)
|
|
||||||
*/
|
|
||||||
function conflict(res, message) {
|
|
||||||
return res.status(HTTP_STATUS.CONFLICT).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
success,
|
|
||||||
successMessage,
|
|
||||||
created,
|
|
||||||
noContent,
|
|
||||||
error,
|
|
||||||
validationError,
|
|
||||||
unauthorized,
|
|
||||||
forbidden,
|
|
||||||
notFound,
|
|
||||||
conflict
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const yaml = require('js-yaml');
|
const yaml = require('js-yaml');
|
||||||
const { DOCKER, REGEX } = require('../../constants');
|
const { DOCKER, REGEX } = require('../../../src/utilities/constants');
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../../src/utilities/errors');
|
||||||
const platformPaths = require('../../platform-paths');
|
const platformPaths = require('../../platform-paths');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Docker Compose import routes
|
* Docker Compose import routes
|
||||||
@@ -162,7 +163,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
|
|||||||
}
|
}
|
||||||
const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack';
|
const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack';
|
||||||
const result = parseCompose(yamlStr, name);
|
const result = parseCompose(yamlStr, name);
|
||||||
res.json({ success: true, ...result });
|
ok(res, { ...result });
|
||||||
}, 'compose-import'));
|
}, 'compose-import'));
|
||||||
|
|
||||||
// POST /deploy-compose — deploy parsed services
|
// POST /deploy-compose — deploy parsed services
|
||||||
@@ -300,7 +301,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
|
|||||||
results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason });
|
results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, results, stackName: stackName || prefix });
|
ok(res, { results, stackName: stackName || prefix });
|
||||||
}, 'compose-deploy'));
|
}, 'compose-deploy'));
|
||||||
|
|
||||||
// DELETE /compose-stack/:stackName — remove an entire stack
|
// DELETE /compose-stack/:stackName — remove an entire stack
|
||||||
@@ -329,7 +330,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
|
|||||||
});
|
});
|
||||||
await servicesStateManager.update(data => { data.services = updated; });
|
await servicesStateManager.update(data => { data.services = updated; });
|
||||||
|
|
||||||
res.json({ success: true, removed, count: removed.length });
|
ok(res, { removed, count: removed.length });
|
||||||
}, 'compose-stack-delete'));
|
}, 'compose-stack-delete'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ const express = require('express');
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const validatorLib = require('validator');
|
const validatorLib = require('validator');
|
||||||
const { REGEX, DOCKER } = require('../../constants');
|
const { REGEX, DOCKER } = require('../../../src/utilities/constants');
|
||||||
const { isValidPort } = require('../../input-validator');
|
const { isValidPort } = require('../../../src/security/input-validator');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../../src/utilities/fs-helpers');
|
||||||
const platformPaths = require('../../platform-paths');
|
const platformPaths = require('../../platform-paths');
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../../src/utilities/errors');
|
||||||
const { logError } = require('../../src/utils/logging');
|
const { logError } = require('../src/utils/logging');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
/**
|
/**
|
||||||
* Apps deployment routes factory
|
* Apps deployment routes factory
|
||||||
* @param {Object} deps - Explicit dependencies
|
* @param {Object} deps - Explicit dependencies
|
||||||
@@ -197,8 +198,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = await docker.client.createContainer(containerConfig);
|
let container;
|
||||||
await container.start();
|
try {
|
||||||
|
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
|
// Prune dangling images to prevent disk bloat
|
||||||
try {
|
try {
|
||||||
@@ -233,9 +244,9 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
if (!template) throw new ValidationError('Invalid app template');
|
if (!template) throw new ValidationError('Invalid app template');
|
||||||
const existingContainer = await helpers.findExistingContainerByImage(template);
|
const existingContainer = await helpers.findExistingContainerByImage(template);
|
||||||
if (existingContainer) {
|
if (existingContainer) {
|
||||||
res.json({ success: true, exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` });
|
ok(res, { exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` });
|
||||||
} else {
|
} else {
|
||||||
res.json({ success: true, exists: false, message: `No existing ${template.name} container found` });
|
ok(res, { exists: false, message: `No existing ${template.name} container found` });
|
||||||
}
|
}
|
||||||
}, 'check-existing'));
|
}, 'check-existing'));
|
||||||
|
|
||||||
@@ -306,7 +317,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
} else {
|
} else {
|
||||||
containerId = await deployContainer(appId, config, template);
|
containerId = await deployContainer(appId, config, template);
|
||||||
log.info('deploy', 'Container deployed', { containerId });
|
log.info('deploy', 'Container deployed', { containerId });
|
||||||
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
|
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort, 30);
|
||||||
log.info('deploy', 'Container is healthy', { containerId });
|
log.info('deploy', 'Container is healthy', { containerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +327,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
let dnsWarning = null;
|
let dnsWarning = null;
|
||||||
if (config.createDns && !isSubdirectoryMode) {
|
if (config.createDns && !isSubdirectoryMode) {
|
||||||
try {
|
try {
|
||||||
await ctx.dns.createRecord(config.subdomain, config.ip);
|
await ctx.dns.universalCreateRecord(config.subdomain, config.ip);
|
||||||
log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
|
log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
|
||||||
} catch (dnsError) {
|
} catch (dnsError) {
|
||||||
await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip });
|
await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip });
|
||||||
@@ -420,10 +431,11 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logError('app-deploy', error, { appId, config });
|
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
||||||
log.error('deploy', 'Deployment failed', { appId, error: error.message });
|
const msg = error?.message || String(error || 'Unknown error');
|
||||||
|
log.error('deploy', 'Deployment failed', { appId, error: msg });
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error');
|
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
||||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||||
}
|
}
|
||||||
}, 'apps-deploy'));
|
}, 'apps-deploy'));
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ const fs = require('fs');
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { REGEX, DOCKER } = require('../../constants');
|
const { REGEX, DOCKER } = require('../../../src/utilities/constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../../src/utilities/fs-helpers');
|
||||||
const platformPaths = require('../../platform-paths');
|
const platformPaths = require('../../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -379,9 +379,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success && result.error !== 'No changes to apply') {
|
||||||
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
|
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. */
|
/** Remove a subpath config block from between its markers in the Caddyfile. */
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ module.exports = function(ctx) {
|
|||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
errorResponse: ctx.errorResponse,
|
errorResponse: ctx.errorResponse,
|
||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
// Additional context properties needed by routes
|
|
||||||
APP_TEMPLATES: ctx.APP_TEMPLATES,
|
APP_TEMPLATES: ctx.APP_TEMPLATES,
|
||||||
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
|
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
|
||||||
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
|
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
|
||||||
@@ -40,26 +39,27 @@ module.exports = function(ctx) {
|
|||||||
ctx: ctx
|
ctx: ctx
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize helpers with dependencies (ctx is the Koa context)
|
|
||||||
const helpers = initHelpers({ ...deps, ctx });
|
const helpers = initHelpers({ ...deps, ctx });
|
||||||
|
|
||||||
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
|
|
||||||
const subCtx = Object.assign({}, ctx, { helpers });
|
const subCtx = Object.assign({}, ctx, { helpers });
|
||||||
|
|
||||||
try { router.use('/deploy', initDeploy(subCtx)); }
|
// Mount sub-routers at their prefix paths.
|
||||||
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
|
// 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('/remove', initRemoval(subCtx)); }
|
try { router.use('/apps', initDeploy(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); }
|
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('/apps', initTemplates(subCtx)); }
|
try { router.use('/apps', initTemplates(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
try { router.use('/apps', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
try { router.use('/compose', initCompose(subCtx)); }
|
try { router.use('/apps', initCompose(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../../src/utilities/fs-helpers');
|
||||||
const { logError } = require('../../src/utils/logging');
|
const { logError } = require('../src/utils/logging');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
module.exports = function({
|
module.exports = function({
|
||||||
docker, caddy, servicesStateManager, asyncHandler, log, helpers,
|
docker, caddy, servicesStateManager, asyncHandler, log, helpers,
|
||||||
@@ -71,18 +72,13 @@ module.exports = function({
|
|||||||
if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) {
|
if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) {
|
||||||
try {
|
try {
|
||||||
const domain = ctx.buildDomain(subdomain);
|
const domain = ctx.buildDomain(subdomain);
|
||||||
const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', {
|
const resolveResult = await ctx.dns.universalResolveRecord(domain, 'A');
|
||||||
token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true'
|
|
||||||
});
|
|
||||||
let recordIp = ip || 'localhost';
|
let recordIp = ip || 'localhost';
|
||||||
if (getResult.status === 'ok' && getResult.response?.records) {
|
if (resolveResult) {
|
||||||
const aRecord = getResult.response.records.find(r => r.type === 'A');
|
recordIp = resolveResult;
|
||||||
if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress;
|
|
||||||
}
|
}
|
||||||
const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
|
await ctx.dns.universalDeleteRecord(domain, recordIp);
|
||||||
token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp
|
results.dns = 'deleted';
|
||||||
});
|
|
||||||
results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed');
|
|
||||||
log.info('dns', 'DNS record removal', { result: results.dns });
|
log.info('dns', 'DNS record removal', { result: results.dns });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.dns = error.message;
|
results.dns = error.message;
|
||||||
@@ -140,7 +136,7 @@ module.exports = function({
|
|||||||
results.service = error.message;
|
results.service = error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, message: `App ${appId} removal completed`, results });
|
ok(res, { message: `App ${appId} removal completed`, results });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logError('app-removal', error);
|
await logError('app-removal', error);
|
||||||
errorResponse(res, 500, ctx.safeErrorMessage(error), { results });
|
errorResponse(res, 500, ctx.safeErrorMessage(error), { results });
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { DOCKER } = require('../../constants');
|
const { DOCKER } = require('../../../src/utilities/constants');
|
||||||
|
const { ok, validationError, notFound, errorResponse } = require('../../../src/utilities/responses');
|
||||||
|
|
||||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await restoreService(service);
|
const result = await restoreService(service);
|
||||||
res.json({ success: true, result });
|
ok(res, { result });
|
||||||
}, 'apps-restore'));
|
}, 'apps-restore'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,8 +60,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
const restoreable = services.filter(s => s.deploymentManifest);
|
const restoreable = services.filter(s => s.deploymentManifest);
|
||||||
|
|
||||||
if (restoreable.length === 0) {
|
if (restoreable.length === 0) {
|
||||||
return res.json({
|
return ok(res, {
|
||||||
success: true,
|
|
||||||
message: 'No services have deployment manifests to restore',
|
message: 'No services have deployment manifests to restore',
|
||||||
results: []
|
results: []
|
||||||
});
|
});
|
||||||
@@ -85,8 +85,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
const skipped = results.filter(r => r.status === 'skipped').length;
|
const skipped = results.filter(r => r.status === 'skipped').length;
|
||||||
const failed = results.filter(r => r.status === 'failed').length;
|
const failed = results.filter(r => r.status === 'failed').length;
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`,
|
message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`,
|
||||||
results
|
results
|
||||||
});
|
});
|
||||||
@@ -123,7 +122,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
status.push(entry);
|
status.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, services: status });
|
ok(res, { services: status });
|
||||||
}, 'apps-restore-status'));
|
}, 'apps-restore-status'));
|
||||||
|
|
||||||
// ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ====================
|
// ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ====================
|
||||||
@@ -174,8 +173,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
// Sort by timestamp descending (newest first)
|
// Sort by timestamp descending (newest first)
|
||||||
files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
appId,
|
appId,
|
||||||
isBackupFile: true,
|
isBackupFile: true,
|
||||||
files,
|
files,
|
||||||
@@ -190,12 +188,12 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
|
|
||||||
// Security: prevent path traversal
|
// Security: prevent path traversal
|
||||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
return res.status(400).json({ success: false, error: 'Invalid filename' });
|
return validationError(res, 'Invalid filename');
|
||||||
}
|
}
|
||||||
|
|
||||||
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
|
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
|
||||||
if (!fs.existsSync(filepath)) {
|
if (!fs.existsSync(filepath)) {
|
||||||
return res.status(404).json({ success: false, error: `Backup file not found: ${filename}` });
|
return notFound(res, `Backup file not found: ${filename}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -207,7 +205,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
try {
|
try {
|
||||||
fileData = await backupManager.decryptBackup(fileData, encryptionKey);
|
fileData = await backupManager.decryptBackup(fileData, encryptionKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ success: false, error: 'Failed to decrypt backup: ' + err.message });
|
return validationError(res, 'Failed to decrypt backup: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,8 +262,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
// Cleanup temp dir
|
// Cleanup temp dir
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
isBackupFile: true,
|
isBackupFile: true,
|
||||||
restored: {
|
restored: {
|
||||||
services: !!restoreData.services,
|
services: !!restoreData.services,
|
||||||
@@ -277,8 +274,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
} else {
|
} else {
|
||||||
// Preview mode
|
// Preview mode
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
isBackupFile: true,
|
isBackupFile: true,
|
||||||
preview: true,
|
preview: true,
|
||||||
filename,
|
filename,
|
||||||
@@ -296,7 +292,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ success: false, error: err.message });
|
errorResponse(res, 500, err.message);
|
||||||
}
|
}
|
||||||
}, 'apps-revert'));
|
}, 'apps-revert'));
|
||||||
|
|
||||||
@@ -458,7 +454,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
// DNS record
|
// DNS record
|
||||||
if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') {
|
if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') {
|
||||||
try {
|
try {
|
||||||
await ctx.dns.createRecord(manifest.config.subdomain, manifest.config.ip);
|
await ctx.dns.universalCreateRecord(manifest.config.subdomain, manifest.config.ip);
|
||||||
log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain });
|
log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('restore', `DNS recreation failed: ${e.message}`);
|
log.warn('restore', `DNS recreation failed: ${e.message}`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../../src/utilities/fs-helpers');
|
||||||
/**
|
/**
|
||||||
* Apps templates routes factory
|
* Apps templates routes factory
|
||||||
* @param {Object} deps - Explicit dependencies
|
* @param {Object} deps - Explicit dependencies
|
||||||
@@ -19,7 +19,8 @@ const { exists } = require('../../fs-helpers');
|
|||||||
* @param {string} deps.SERVICES_FILE - Services file path
|
* @param {string} deps.SERVICES_FILE - Services file path
|
||||||
* @returns {express.Router}
|
* @returns {express.Router}
|
||||||
*/
|
*/
|
||||||
const { REGEX } = require('../../constants');
|
const { REGEX } = require('../../../src/utilities/constants');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
module.exports = function({
|
module.exports = function({
|
||||||
servicesStateManager, asyncHandler, helpers,
|
servicesStateManager, asyncHandler, helpers,
|
||||||
@@ -42,8 +43,7 @@ module.exports = function({
|
|||||||
|
|
||||||
// Get available app templates
|
// Get available app templates
|
||||||
router.get('/templates', asyncHandler(async (req, res) => {
|
router.get('/templates', asyncHandler(async (req, res) => {
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
templates: ctx.APP_TEMPLATES,
|
templates: ctx.APP_TEMPLATES,
|
||||||
categories: ctx.TEMPLATE_CATEGORIES,
|
categories: ctx.TEMPLATE_CATEGORIES,
|
||||||
difficultyLevels: ctx.DIFFICULTY_LEVELS
|
difficultyLevels: ctx.DIFFICULTY_LEVELS
|
||||||
@@ -55,10 +55,10 @@ module.exports = function({
|
|||||||
const { appId } = req.params;
|
const { appId } = req.params;
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
if (!template) {
|
if (!template) {
|
||||||
const { NotFoundError } = require('../../errors');
|
const { NotFoundError } = require('../../../src/utilities/errors');
|
||||||
throw new NotFoundError('App template');
|
throw new NotFoundError('App template');
|
||||||
}
|
}
|
||||||
res.json({ success: true, template });
|
ok(res, { template });
|
||||||
}, 'apps-template-detail'));
|
}, 'apps-template-detail'));
|
||||||
|
|
||||||
// Check port availability
|
// Check port availability
|
||||||
@@ -80,7 +80,7 @@ module.exports = function({
|
|||||||
const usedPorts = await docker.getUsedPorts();
|
const usedPorts = await docker.getUsedPorts();
|
||||||
for (let port = basePort; port < basePort + maxAttempts; port++) {
|
for (let port = basePort; port < basePort + maxAttempts; port++) {
|
||||||
if (!usedPorts.has(port)) {
|
if (!usedPorts.has(port)) {
|
||||||
res.json({ success: true, suggestedPort: port, basePort });
|
ok(res, { suggestedPort: port, basePort });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ module.exports = function({
|
|||||||
// Update subdomain for deployed app
|
// Update subdomain for deployed app
|
||||||
router.post('/update-subdomain', asyncHandler(async (req, res) => {
|
router.post('/update-subdomain', asyncHandler(async (req, res) => {
|
||||||
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
|
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../../src/utilities/errors');
|
||||||
|
|
||||||
if (!oldSubdomain || typeof oldSubdomain !== 'string') {
|
if (!oldSubdomain || typeof oldSubdomain !== 'string') {
|
||||||
throw new ValidationError('oldSubdomain is required');
|
throw new ValidationError('oldSubdomain is required');
|
||||||
@@ -107,10 +107,8 @@ module.exports = function({
|
|||||||
if (oldSubdomain && ctx.dns.getToken()) {
|
if (oldSubdomain && ctx.dns.getToken()) {
|
||||||
try {
|
try {
|
||||||
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
|
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
|
||||||
const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
|
await ctx.dns.universalDeleteRecord(oldDomain, ip || 'localhost');
|
||||||
token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost'
|
results.oldDns = 'deleted';
|
||||||
});
|
|
||||||
results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage;
|
|
||||||
log.info('dns', 'Old DNS record deleted', { domain: oldDomain });
|
log.info('dns', 'Old DNS record deleted', { domain: oldDomain });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.oldDns = `failed: ${error.message}`;
|
results.oldDns = `failed: ${error.message}`;
|
||||||
@@ -120,7 +118,7 @@ module.exports = function({
|
|||||||
|
|
||||||
if (newSubdomain && ctx.dns.getToken()) {
|
if (newSubdomain && ctx.dns.getToken()) {
|
||||||
try {
|
try {
|
||||||
await ctx.dns.createRecord(newSubdomain, ip || 'localhost');
|
await ctx.dns.universalCreateRecord(newSubdomain, ip || 'localhost');
|
||||||
results.newDns = 'created';
|
results.newDns = 'created';
|
||||||
log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) });
|
log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,8 +170,7 @@ module.exports = function({
|
|||||||
log.warn('deploy', 'Service update warning', { error: error.message || String(error) });
|
log.warn('deploy', 'Service update warning', { error: error.message || String(error) });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`,
|
message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`,
|
||||||
newUrl: `https://${ctx.buildDomain(newSubdomain)}`,
|
newUrl: `https://${ctx.buildDomain(newSubdomain)}`,
|
||||||
results
|
results
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { APP_PORTS, ARR_SERVICES } = require('../../constants');
|
const { APP_PORTS, ARR_SERVICES } = require('../../../src/utilities/constants');
|
||||||
const { validateURL, validateToken } = require('../../input-validator');
|
const { validateURL, validateToken } = require('../../../src/security/input-validator');
|
||||||
const { ValidationError, AuthenticationError, NotFoundError } = require('../../errors');
|
const { ValidationError, AuthenticationError, NotFoundError } = require('../../../src/utilities/errors');
|
||||||
const { logError } = require('../../src/utils/logging');
|
const { logError } = require('../src/utils/logging');
|
||||||
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arr configuration routes factory
|
* Arr configuration routes factory
|
||||||
@@ -258,11 +259,7 @@ module.exports = function(ctx) {
|
|||||||
const version = service === 'plex' ? data.MediaContainer?.version : data.version;
|
const version = service === 'plex' ? data.MediaContainer?.version : data.version;
|
||||||
const appName = service === 'plex' ? 'Plex' : data.appName;
|
const appName = service === 'plex' ? 'Plex' : data.appName;
|
||||||
log.info('arr', 'Service connection successful', { service, appName, version });
|
log.info('arr', 'Service connection successful', { service, appName, version });
|
||||||
return res.json({
|
return ok(res, { version, appName });
|
||||||
success: true,
|
|
||||||
version,
|
|
||||||
appName
|
|
||||||
});
|
|
||||||
} else if (response.status === 401) {
|
} else if (response.status === 401) {
|
||||||
throw new AuthenticationError('Invalid API key');
|
throw new AuthenticationError('Invalid API key');
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
@@ -553,7 +550,7 @@ module.exports = function(ctx) {
|
|||||||
const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
|
const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
|
||||||
const storedProfileId = metadata?.qualityProfileId || null;
|
const storedProfileId = metadata?.qualityProfileId || null;
|
||||||
|
|
||||||
res.json({ success: true, profiles: mapped, storedProfileId });
|
ok(res, { profiles: mapped, storedProfileId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.cause?.code === 'ECONNREFUSED') {
|
if (e.cause?.code === 'ECONNREFUSED') {
|
||||||
return errorResponse(res, 502, 'Connection refused — is the service running?');
|
return errorResponse(res, 502, 'Connection refused — is the service running?');
|
||||||
@@ -588,7 +585,7 @@ module.exports = function(ctx) {
|
|||||||
existing.qualityProfileName = qualityProfileName || null;
|
existing.qualityProfileName = qualityProfileName || null;
|
||||||
await credentialManager.storeMetadata(credKey, existing);
|
await credentialManager.storeMetadata(credKey, existing);
|
||||||
|
|
||||||
res.json({ success: true, message: `Quality profile updated for ${service}` });
|
successMessage(res, `Quality profile updated for ${service}`);
|
||||||
}, 'arr-quality-profile-save'));
|
}, 'arr-quality-profile-save'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { validateURL, validateToken } = require('../../input-validator');
|
const { validateURL, validateToken } = require('../../../src/security/input-validator');
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../../src/utilities/errors');
|
||||||
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arr credentials routes factory
|
* Arr credentials routes factory
|
||||||
@@ -101,12 +102,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
|
|||||||
|
|
||||||
log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false });
|
log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false });
|
||||||
|
|
||||||
res.json({
|
ok(res, { message: `${service} API key stored`, connectionTest, url: resolvedUrl });
|
||||||
success: true,
|
|
||||||
message: `${service} API key stored`,
|
|
||||||
connectionTest,
|
|
||||||
url: resolvedUrl
|
|
||||||
});
|
|
||||||
}, 'arr-credentials-store'));
|
}, 'arr-credentials-store'));
|
||||||
|
|
||||||
// List stored arr credentials (keys only, not values)
|
// List stored arr credentials (keys only, not values)
|
||||||
@@ -131,7 +127,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
|
|||||||
// Get seedbox base URL
|
// Get seedbox base URL
|
||||||
const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl');
|
const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl');
|
||||||
|
|
||||||
res.json({ success: true, credentials, seedboxBaseUrl: seedboxBaseUrl || null });
|
ok(res, { credentials, seedboxBaseUrl: seedboxBaseUrl || null });
|
||||||
}, 'arr-credentials-list'));
|
}, 'arr-credentials-list'));
|
||||||
|
|
||||||
// Delete stored arr credentials
|
// Delete stored arr credentials
|
||||||
@@ -140,7 +136,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
|
|||||||
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
|
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
|
||||||
await credentialManager.delete(credKey);
|
await credentialManager.delete(credKey);
|
||||||
log.info('arr', 'Deleted credentials', { service });
|
log.info('arr', 'Deleted credentials', { service });
|
||||||
res.json({ success: true, message: `${service} credentials removed` });
|
successMessage(res, `${service} credentials removed`);
|
||||||
}, 'arr-credentials-delete'));
|
}, 'arr-credentials-delete'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { APP_PORTS, ARR_SERVICES } = require('../../constants');
|
const { APP_PORTS, ARR_SERVICES } = require('../../../src/utilities/constants');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arr service detection routes factory
|
* Arr service detection routes factory
|
||||||
@@ -62,8 +63,7 @@ module.exports = function({ docker, servicesStateManager, credentialManager, fet
|
|||||||
detected.plex.token = await helpers.getPlexToken(detected.plex.containerName);
|
detected.plex.token = await helpers.getPlexToken(detected.plex.containerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
services: detected,
|
services: detected,
|
||||||
summary: {
|
summary: {
|
||||||
plexReady: !!(detected.plex?.token),
|
plexReady: !!(detected.plex?.token),
|
||||||
@@ -287,7 +287,7 @@ module.exports = function({ docker, servicesStateManager, credentialManager, fet
|
|||||||
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2
|
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary });
|
ok(res, { services: result, seedboxBaseUrl: detectedSeedboxUrl, summary });
|
||||||
}, 'smart-detect'));
|
}, 'smart-detect'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { APP_PORTS } = require('../../constants');
|
const { APP_PORTS } = require('../../../src/utilities/constants');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arr helpers factory
|
* Arr helpers factory
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { APP_PORTS } = require('../../constants');
|
const { APP_PORTS } = require('../../../src/utilities/constants');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plex routes factory
|
* Plex routes factory
|
||||||
@@ -86,7 +87,7 @@ module.exports = function({ fetchT, asyncHandler, errorResponse, log: _log, help
|
|||||||
lastVerified: new Date().toISOString()
|
lastVerified: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, serverName, version, libraries });
|
ok(res, { serverName, version, libraries });
|
||||||
}, 'plex-libraries'));
|
}, 'plex-libraries'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { APP_PORTS } = require('../../constants');
|
const { APP_PORTS } = require('../../../src/utilities/constants');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arr smart-connect routes factory
|
* Arr smart-connect routes factory
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors');
|
const { ValidationError, ForbiddenError, NotFoundError } = require('../../../src/utilities/errors');
|
||||||
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
/**
|
/**
|
||||||
* Auth API keys routes factory
|
* Auth API keys routes factory
|
||||||
* @param {Object} deps - Explicit dependencies
|
* @param {Object} deps - Explicit dependencies
|
||||||
@@ -39,7 +40,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keys = await authManager.listAPIKeys();
|
const keys = await authManager.listAPIKeys();
|
||||||
res.json({ success: true, keys });
|
ok(res, { keys });
|
||||||
}, 'auth-keys-list'));
|
}, 'auth-keys-list'));
|
||||||
|
|
||||||
// Generate new API key
|
// Generate new API key
|
||||||
@@ -66,8 +67,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
|
|||||||
scopes || ['read', 'write']
|
scopes || ['read', 'write']
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
key: keyData.key,
|
key: keyData.key,
|
||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
@@ -93,7 +93,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
|
|||||||
const success = await authManager.revokeAPIKey(keyId);
|
const success = await authManager.revokeAPIKey(keyId);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
res.json({ success: true, message: 'API key revoked successfully' });
|
successMessage(res, 'API key revoked successfully');
|
||||||
} else {
|
} else {
|
||||||
throw new NotFoundError(`API key ${keyId}`);
|
throw new NotFoundError(`API key ${keyId}`);
|
||||||
}
|
}
|
||||||
@@ -126,8 +126,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
|
|||||||
const expiresInMs = parseExpiration(expiresIn || '24h');
|
const expiresInMs = parseExpiration(expiresIn || '24h');
|
||||||
const expiresAt = new Date(Date.now() + expiresInMs).toISOString();
|
const expiresAt = new Date(Date.now() + expiresInMs).toISOString();
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
token,
|
token,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
usage: 'Include in Authorization header as: Bearer <token>'
|
usage: 'Include in Authorization header as: Bearer <token>'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
|
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../../src/utilities/constants');
|
||||||
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
|
const { createCache, CACHE_CONFIGS } = require('../../../src/utilities/cache-config');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth session handlers routes factory
|
* Auth session handlers routes factory
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
|
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../../src/utilities/constants');
|
||||||
const { AuthenticationError, NotFoundError } = require('../../errors');
|
const { AuthenticationError, NotFoundError } = require('../../../src/utilities/errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth SSO gate routes factory
|
* Auth SSO gate routes factory
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ValidationError, AuthenticationError } = require('../../errors');
|
const { ValidationError, AuthenticationError } = require('../../src/utilities/errors');
|
||||||
|
const { ok, successMessage } = require('../../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth TOTP routes factory
|
* Auth TOTP routes factory
|
||||||
@@ -27,8 +28,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
|
|
||||||
// Get current TOTP config (public route)
|
// Get current TOTP config (public route)
|
||||||
router.get('/totp/config', asyncHandler(async (req, res) => {
|
router.get('/totp/config', asyncHandler(async (req, res) => {
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
config: {
|
config: {
|
||||||
enabled: ctx.totpConfig.enabled,
|
enabled: ctx.totpConfig.enabled,
|
||||||
sessionDuration: ctx.totpConfig.sessionDuration,
|
sessionDuration: ctx.totpConfig.sessionDuration,
|
||||||
@@ -122,7 +122,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
color: { dark: '#ffffff', light: '#00000000' }
|
color: { dark: '#ffffff', light: '#00000000' }
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret });
|
ok(res, { qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret });
|
||||||
}, 'totp-setup'));
|
}, 'totp-setup'));
|
||||||
|
|
||||||
// Verify first code to confirm setup, then activate TOTP
|
// Verify first code to confirm setup, then activate TOTP
|
||||||
@@ -159,7 +159,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
ctx.session.create(req, ctx.totpConfig.sessionDuration);
|
ctx.session.create(req, ctx.totpConfig.sessionDuration);
|
||||||
ctx.session.setCookie(res, ctx.totpConfig.sessionDuration);
|
ctx.session.setCookie(res, ctx.totpConfig.sessionDuration);
|
||||||
|
|
||||||
res.json({ success: true, message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration });
|
ok(res, { message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration });
|
||||||
}, 'totp-verify-setup'));
|
}, 'totp-verify-setup'));
|
||||||
|
|
||||||
// Login: verify TOTP code and set session cookie
|
// Login: verify TOTP code and set session cookie
|
||||||
@@ -193,7 +193,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
|
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
|
||||||
|
|
||||||
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
||||||
res.json({ success: true, message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
|
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
|
||||||
}, 'totp-verify'));
|
}, 'totp-verify'));
|
||||||
|
|
||||||
// Check session validity (used by Caddy forward_auth)
|
// Check session validity (used by Caddy forward_auth)
|
||||||
@@ -245,7 +245,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
|
|
||||||
ctx.session.clear(req);
|
ctx.session.clear(req);
|
||||||
ctx.session.clearCookie(res);
|
ctx.session.clearCookie(res);
|
||||||
res.json({ success: true, message: 'TOTP disabled' });
|
successMessage(res, 'TOTP disabled');
|
||||||
}, 'totp-disable'));
|
}, 'totp-disable'));
|
||||||
|
|
||||||
// Update TOTP settings (session duration)
|
// Update TOTP settings (session duration)
|
||||||
@@ -264,8 +264,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ctx.saveTotpConfig();
|
await ctx.saveTotpConfig();
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }
|
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }
|
||||||
});
|
});
|
||||||
}, 'totp-config'));
|
}, 'totp-config'));
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 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('../src/utils/responses');
|
||||||
|
const { ValidationError, NotFoundError } = require('../src/utilities/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,8 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { success } = require('../response-helpers');
|
const path = require('path');
|
||||||
|
const { success } = require('../src/utils/responses');
|
||||||
|
|
||||||
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
|
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
|
||||||
@@ -60,7 +60,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
||||||
|
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
throw new ValidationError('appId is required');
|
throw new ValidationError('appId is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
const config = backupManager.getConfig();
|
const config = backupManager.getConfig();
|
||||||
|
|
||||||
if (!config.backups || !config.backups[appId]) {
|
if (!config.backups || !config.backups[appId]) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404');
|
throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
|
|
||||||
const backupConfig = config.backups && config.backups[appId];
|
const backupConfig = config.backups && config.backups[appId];
|
||||||
if (!backupConfig) {
|
if (!backupConfig) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404');
|
throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,13 +240,13 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
|
|
||||||
// Security: prevent path traversal
|
// Security: prevent path traversal
|
||||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
throw new ValidationError('Invalid filename');
|
throw new ValidationError('Invalid filename');
|
||||||
}
|
}
|
||||||
|
|
||||||
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
|
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
|
||||||
if (!fs.existsSync(filepath)) {
|
if (!fs.existsSync(filepath)) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404');
|
throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,13 +376,13 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
|
|
||||||
// Security: prevent path traversal
|
// Security: prevent path traversal
|
||||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
throw new ValidationError('Invalid filename');
|
throw new ValidationError('Invalid filename');
|
||||||
}
|
}
|
||||||
|
|
||||||
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
|
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
|
||||||
if (!fs.existsSync(filepath)) {
|
if (!fs.existsSync(filepath)) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404');
|
throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +546,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
router.post('/backups/test-destination', asyncHandler(async (req, res) => {
|
router.post('/backups/test-destination', asyncHandler(async (req, res) => {
|
||||||
const destination = req.body;
|
const destination = req.body;
|
||||||
if (!destination || !destination.type) {
|
if (!destination || !destination.type) {
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
throw new ValidationError('destination.type is required');
|
throw new ValidationError('destination.type is required');
|
||||||
}
|
}
|
||||||
const result = await backupManager.testDestination(destination);
|
const result = await backupManager.testDestination(destination);
|
||||||
@@ -556,10 +556,10 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
// Get cloud credentials (masked) for a provider
|
// Get cloud credentials (masked) for a provider
|
||||||
// Provider: dropbox | webdav | sftp
|
// Provider: dropbox | webdav | sftp
|
||||||
router.get('/backups/credentials/:provider', asyncHandler(async (req, res) => {
|
router.get('/backups/credentials/:provider', asyncHandler(async (req, res) => {
|
||||||
const credentialManager = require('../credential-manager');
|
const credentialManager = require('../src/managers/credential-manager');
|
||||||
const provider = req.params.provider;
|
const provider = req.params.provider;
|
||||||
if (!['dropbox', 'webdav', 'sftp'].includes(provider)) {
|
if (!['dropbox', 'webdav', 'sftp'].includes(provider)) {
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
throw new ValidationError('Invalid provider');
|
throw new ValidationError('Invalid provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,8 +588,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
|
|
||||||
// Save cloud credentials for a provider
|
// Save cloud credentials for a provider
|
||||||
router.post('/backups/credentials/:provider', asyncHandler(async (req, res) => {
|
router.post('/backups/credentials/:provider', asyncHandler(async (req, res) => {
|
||||||
const credentialManager = require('../credential-manager');
|
const credentialManager = require('../src/managers/credential-manager');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
const provider = req.params.provider;
|
const provider = req.params.provider;
|
||||||
|
|
||||||
if (!['dropbox', 'webdav', 'sftp'].includes(provider)) {
|
if (!['dropbox', 'webdav', 'sftp'].includes(provider)) {
|
||||||
@@ -629,8 +629,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
|
|
||||||
// Delete cloud credentials for a provider
|
// Delete cloud credentials for a provider
|
||||||
router.delete('/backups/credentials/:provider', asyncHandler(async (req, res) => {
|
router.delete('/backups/credentials/:provider', asyncHandler(async (req, res) => {
|
||||||
const credentialManager = require('../credential-manager');
|
const credentialManager = require('../src/managers/credential-manager');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
const provider = req.params.provider;
|
const provider = req.params.provider;
|
||||||
|
|
||||||
if (!['dropbox', 'webdav', 'sftp'].includes(provider)) {
|
if (!['dropbox', 'webdav', 'sftp'].includes(provider)) {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ const express = require('express');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { exists, isAccessible } = require('../fs-helpers');
|
const { exists, isAccessible } = require('../src/utilities/fs-helpers');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { ValidationError, ForbiddenError } = require('../errors');
|
const { ValidationError, ForbiddenError } = require('../src/utilities/errors');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse route factory
|
* Browse route factory
|
||||||
@@ -44,7 +45,7 @@ module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, d
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ok(res, { roots });
|
return ok(res, { roots });
|
||||||
}, 'browse-roots'));
|
}, 'browse-roots'));
|
||||||
|
|
||||||
// Browse directory contents
|
// Browse directory contents
|
||||||
@@ -98,7 +99,7 @@ module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!await exists(resolvedPath)) {
|
if (!await exists(resolvedPath)) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Path');
|
throw new NotFoundError('Path');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +125,7 @@ module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, d
|
|||||||
|
|
||||||
const paginationParams = parsePaginationParams(req.query);
|
const paginationParams = parsePaginationParams(req.query);
|
||||||
const result = paginate(folders, paginationParams);
|
const result = paginate(folders, paginationParams);
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
path: requestedPath,
|
path: requestedPath,
|
||||||
parent: path.dirname(requestedPath).replace(/\\/g, '/') || null,
|
parent: path.dirname(requestedPath).replace(/\\/g, '/') || null,
|
||||||
items: result.data,
|
items: result.data,
|
||||||
@@ -190,8 +190,7 @@ module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, d
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
mounts: detectedMounts,
|
mounts: detectedMounts,
|
||||||
message: detectedMounts.length > 0
|
message: detectedMounts.length > 0
|
||||||
? `Found ${detectedMounts.length} media mount(s) from existing containers`
|
? `Found ${detectedMounts.length} media mount(s) from existing containers`
|
||||||
|
|||||||
+20
-26
@@ -3,8 +3,9 @@ const fs = require('fs');
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../src/utilities/fs-helpers');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
const platformPaths = require('../platform-paths');
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
@@ -12,16 +13,13 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// Get CA certificate information
|
// Get CA certificate information
|
||||||
router.get('/info', ctx.asyncHandler(async (req, res) => {
|
router.get('/info', ctx.asyncHandler(async (req, res) => {
|
||||||
const certInfoPath = '/app/ca/cert-info.json';
|
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||||
const fallbackCertInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
|
||||||
|
|
||||||
let certInfoFile;
|
let certInfoFile;
|
||||||
if (await exists(certInfoPath)) {
|
if (await exists(certInfoPath)) {
|
||||||
certInfoFile = certInfoPath;
|
certInfoFile = certInfoPath;
|
||||||
} else if (await exists(fallbackCertInfoPath)) {
|
|
||||||
certInfoFile = fallbackCertInfoPath;
|
|
||||||
} else {
|
} else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('CA certificate information');
|
throw new NotFoundError('CA certificate information');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +27,7 @@ module.exports = function(ctx) {
|
|||||||
const expirationDate = new Date(certInfo.validUntil);
|
const expirationDate = new Date(certInfo.validUntil);
|
||||||
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
certificate: {
|
certificate: {
|
||||||
name: certInfo.name,
|
name: certInfo.name,
|
||||||
fingerprint: certInfo.fingerprint,
|
fingerprint: certInfo.fingerprint,
|
||||||
@@ -46,16 +43,14 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// Serve root CA certificate directly (works even without DashCA deployed)
|
// Serve root CA certificate directly (works even without DashCA deployed)
|
||||||
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
|
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
|
||||||
const pkiCertPath = '/app/pki/root.crt';
|
|
||||||
const hostCertPath = platformPaths.pkiRootCert;
|
const hostCertPath = platformPaths.pkiRootCert;
|
||||||
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
|
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
|
||||||
|
|
||||||
let certPath;
|
let certPath;
|
||||||
if (await exists(pkiCertPath)) certPath = pkiCertPath;
|
if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
||||||
else if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
|
||||||
else if (await exists(hostCertPath)) certPath = hostCertPath;
|
else if (await exists(hostCertPath)) certPath = hostCertPath;
|
||||||
else {
|
else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Root CA certificate');
|
throw new NotFoundError('Root CA certificate');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,14 +67,13 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load cert info to get the fingerprint
|
// Load cert info to get the fingerprint
|
||||||
const certInfoPath = '/app/ca/cert-info.json';
|
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||||
const fallbackCertInfoPath2 = path.join(platformPaths.caCertDir, 'cert-info.json');
|
|
||||||
|
|
||||||
let certInfoFile;
|
let certInfoFile;
|
||||||
if (await exists(certInfoPath)) certInfoFile = certInfoPath;
|
if (await exists(certInfoPath)) {
|
||||||
else if (await exists(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2;
|
certInfoFile = certInfoPath;
|
||||||
else {
|
} else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
|
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +94,7 @@ module.exports = function(ctx) {
|
|||||||
// Look for template in multiple locations (packaged app vs dev)
|
// Look for template in multiple locations (packaged app vs dev)
|
||||||
const templatePaths = [
|
const templatePaths = [
|
||||||
path.join(__dirname, '..', 'scripts', templateName),
|
path.join(__dirname, '..', 'scripts', templateName),
|
||||||
path.join('/app', 'scripts', templateName)
|
path.join(platformPaths.caddyBase, 'scripts', templateName)
|
||||||
];
|
];
|
||||||
|
|
||||||
let templateContent;
|
let templateContent;
|
||||||
@@ -112,7 +106,7 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!templateContent) {
|
if (!templateContent) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError(`Install script template (${templateName})`);
|
throw new NotFoundError(`Install script template (${templateName})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +136,8 @@ module.exports = function(ctx) {
|
|||||||
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkiPath = '/app/pki';
|
const pkiPath = platformPaths.pkiDir;
|
||||||
const certsDir = '/app/generated-certs';
|
const certsDir = platformPaths.generatedCertsDir;
|
||||||
const domainDir = path.join(certsDir, domain);
|
const domainDir = path.join(certsDir, domain);
|
||||||
|
|
||||||
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
|
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
|
||||||
@@ -246,10 +240,10 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
|||||||
|
|
||||||
// List generated certificates
|
// List generated certificates
|
||||||
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
||||||
const certsDir = '/app/generated-certs';
|
const certsDir = platformPaths.generatedCertsDir;
|
||||||
|
|
||||||
if (!await exists(certsDir)) {
|
if (!await exists(certsDir)) {
|
||||||
return res.json({ success: true, certificates: [] });
|
return ok(res, { certificates: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirEntries = await fsp.readdir(certsDir);
|
const dirEntries = await fsp.readdir(certsDir);
|
||||||
@@ -284,7 +278,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
|||||||
}
|
}
|
||||||
}))).filter(Boolean);
|
}))).filter(Boolean);
|
||||||
|
|
||||||
res.json({ success: true, certificates });
|
ok(res, { certificates });
|
||||||
}, 'ca-certs'));
|
}, 'ca-certs'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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('../src/utils/responses');
|
||||||
|
const { ValidationError, NotFoundError } = require('../src/utilities/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;
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { LIMITS } = require('../../constants');
|
const { LIMITS } = require('../../../src/utilities/constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../../src/utilities/fs-helpers');
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../../src/utilities/errors');
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
/**
|
/**
|
||||||
* Config assets routes factory
|
* Config assets routes factory
|
||||||
* @param {Object} deps - Explicit dependencies
|
* @param {Object} deps - Explicit dependencies
|
||||||
@@ -51,7 +53,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
// Determine assets path (mounted volume)
|
// Determine assets path (mounted volume)
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
@@ -62,8 +64,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const filePath = path.join(assetsPath, safeFilename);
|
const filePath = path.join(assetsPath, safeFilename);
|
||||||
await fsp.writeFile(filePath, buffer);
|
await fsp.writeFile(filePath, buffer);
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
path: `/assets/${safeFilename}`,
|
path: `/assets/${safeFilename}`,
|
||||||
message: `Logo saved to ${filePath}`
|
message: `Logo saved to ${filePath}`
|
||||||
});
|
});
|
||||||
@@ -75,8 +76,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
// Get current logo path, position, and title
|
// Get current logo path, position, and title
|
||||||
router.get('/logo', asyncHandler(async (req, res) => {
|
router.get('/logo', asyncHandler(async (req, res) => {
|
||||||
const config = await ctx.readConfig();
|
const config = await ctx.readConfig();
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
// Dark/light variants (new)
|
// Dark/light variants (new)
|
||||||
customLogoDark: config.customLogoDark || null,
|
customLogoDark: config.customLogoDark || null,
|
||||||
customLogoLight: config.customLogoLight || null,
|
customLogoLight: config.customLogoLight || null,
|
||||||
@@ -96,7 +96,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
||||||
const buffer = Buffer.from(matches[2], 'base64');
|
const buffer = Buffer.from(matches[2], 'base64');
|
||||||
|
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
await fsp.mkdir(assetsPath, { recursive: true });
|
await fsp.mkdir(assetsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -155,8 +155,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
config.updatedAt = new Date().toISOString();
|
config.updatedAt = new Date().toISOString();
|
||||||
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
pathDark: pathDark,
|
pathDark: pathDark,
|
||||||
pathLight: pathLight,
|
pathLight: pathLight,
|
||||||
// Legacy compat
|
// Legacy compat
|
||||||
@@ -170,7 +169,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
// Reset all branding to defaults
|
// Reset all branding to defaults
|
||||||
router.delete('/logo', asyncHandler(async (req, res) => {
|
router.delete('/logo', asyncHandler(async (req, res) => {
|
||||||
const config = await ctx.readConfig();
|
const config = await ctx.readConfig();
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
|
|
||||||
// Delete all custom logo files
|
// Delete all custom logo files
|
||||||
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
|
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
|
||||||
@@ -194,10 +193,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
config.updatedAt = new Date().toISOString();
|
config.updatedAt = new Date().toISOString();
|
||||||
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
|
||||||
res.json({
|
successMessage(res, 'Branding reset to defaults');
|
||||||
success: true,
|
|
||||||
message: 'Branding reset to defaults'
|
|
||||||
});
|
|
||||||
}, 'logo-delete'));
|
}, 'logo-delete'));
|
||||||
|
|
||||||
// ===== FAVICON ENDPOINTS =====
|
// ===== FAVICON ENDPOINTS =====
|
||||||
@@ -206,8 +202,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
// Get current favicon
|
// Get current favicon
|
||||||
router.get('/favicon', asyncHandler(async (req, res) => {
|
router.get('/favicon', asyncHandler(async (req, res) => {
|
||||||
const config = await ctx.readConfig();
|
const config = await ctx.readConfig();
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
customFavicon: config.customFavicon || null,
|
customFavicon: config.customFavicon || null,
|
||||||
isDefault: !config.customFavicon
|
isDefault: !config.customFavicon
|
||||||
});
|
});
|
||||||
@@ -234,7 +229,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const base64Data = matches[2];
|
const base64Data = matches[2];
|
||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
await fsp.mkdir(assetsPath, { recursive: true });
|
await fsp.mkdir(assetsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -267,8 +262,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
// Update config
|
// Update config
|
||||||
await ctx.saveConfig({ customFavicon: '/assets/favicon.ico', updatedAt: new Date().toISOString() });
|
await ctx.saveConfig({ customFavicon: '/assets/favicon.ico', updatedAt: new Date().toISOString() });
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
success: true,
|
|
||||||
path: '/assets/favicon.ico',
|
path: '/assets/favicon.ico',
|
||||||
message: 'Favicon created successfully'
|
message: 'Favicon created successfully'
|
||||||
});
|
});
|
||||||
@@ -279,7 +273,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const config = await ctx.readConfig();
|
const config = await ctx.readConfig();
|
||||||
|
|
||||||
// Delete custom favicon files
|
// Delete custom favicon files
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
const filesToDelete = ['favicon.ico', 'favicon.png'];
|
const filesToDelete = ['favicon.ico', 'favicon.png'];
|
||||||
for (const file of filesToDelete) {
|
for (const file of filesToDelete) {
|
||||||
const filePath = `${assetsPath}/${file}`;
|
const filePath = `${assetsPath}/${file}`;
|
||||||
@@ -292,10 +286,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
config.updatedAt = new Date().toISOString();
|
config.updatedAt = new Date().toISOString();
|
||||||
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
|
||||||
res.json({
|
successMessage(res, 'Favicon reset to default');
|
||||||
success: true,
|
|
||||||
message: 'Favicon reset to default'
|
|
||||||
});
|
|
||||||
}, 'favicon-delete'));
|
}, 'favicon-delete'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { CADDY } = require('../../constants');
|
const { CADDY } = require('../../../src/utilities/constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../../src/utilities/fs-helpers');
|
||||||
const { ValidationError, AuthenticationError } = require('../../errors');
|
const { ValidationError, AuthenticationError } = require('../../../src/utilities/errors');
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config backup routes factory
|
* Config backup routes factory
|
||||||
@@ -115,7 +117,7 @@ module.exports = function(deps) {
|
|||||||
|
|
||||||
// Include custom assets (logo, favicon) as base64
|
// Include custom assets (logo, favicon) as base64
|
||||||
try {
|
try {
|
||||||
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
|
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
|
||||||
const configData = backup.files.config?.data || {};
|
const configData = backup.files.config?.data || {};
|
||||||
const assetFiles = [configData.customLogo, configData.customFavicon]
|
const assetFiles = [configData.customLogo, configData.customFavicon]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -209,7 +211,7 @@ module.exports = function(deps) {
|
|||||||
preview.browserStateCount = Object.keys(backup.browserState).length;
|
preview.browserStateCount = Object.keys(backup.browserState).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, preview });
|
ok(res, { preview });
|
||||||
}, 'backup-preview'));
|
}, 'backup-preview'));
|
||||||
|
|
||||||
// Restore configuration from backup
|
// Restore configuration from backup
|
||||||
@@ -346,7 +348,7 @@ module.exports = function(deps) {
|
|||||||
|
|
||||||
// Restore custom assets from base64
|
// Restore custom assets from base64
|
||||||
if (backup.assets && typeof backup.assets === 'object') {
|
if (backup.assets && typeof backup.assets === 'object') {
|
||||||
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
|
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
|
||||||
for (const [name, b64] of Object.entries(backup.assets)) {
|
for (const [name, b64] of Object.entries(backup.assets)) {
|
||||||
try {
|
try {
|
||||||
const safeName = path.basename(name); // prevent path traversal
|
const safeName = path.basename(name); // prevent path traversal
|
||||||
@@ -378,7 +380,7 @@ module.exports = function(deps) {
|
|||||||
if (results.restored.includes('encryptionKey')) {
|
if (results.restored.includes('encryptionKey')) {
|
||||||
try {
|
try {
|
||||||
// Clear the cached key so crypto-utils reloads from the new file on next use
|
// Clear the cached key so crypto-utils reloads from the new file on next use
|
||||||
const cryptoUtils = require('../../crypto-utils');
|
const cryptoUtils = require('../../../src/security/crypto-utils');
|
||||||
if (typeof cryptoUtils.clearCachedKey === 'function') {
|
if (typeof cryptoUtils.clearCachedKey === 'function') {
|
||||||
cryptoUtils.clearCachedKey();
|
cryptoUtils.clearCachedKey();
|
||||||
}
|
}
|
||||||
@@ -390,13 +392,17 @@ module.exports = function(deps) {
|
|||||||
|
|
||||||
const success = results.restored.length > 0 && results.errors.length === 0;
|
const success = results.restored.length > 0 && results.errors.length === 0;
|
||||||
|
|
||||||
res.json({
|
if (success) {
|
||||||
success,
|
ok(res, {
|
||||||
message: success
|
message: `Restored ${results.restored.length} file(s) successfully`,
|
||||||
? `Restored ${results.restored.length} file(s) successfully`
|
results
|
||||||
: `Restore completed with ${results.errors.length} error(s)`,
|
});
|
||||||
results
|
} else {
|
||||||
});
|
ok(res, {
|
||||||
|
message: `Restore completed with ${results.errors.length} error(s)`,
|
||||||
|
results
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });
|
log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });
|
||||||
}, 'backup-restore'));
|
}, 'backup-restore'));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const { validateConfig } = require('../../config-schema');
|
const { validateConfig } = require('../../../src/utilities/config-schema');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../../src/utilities/fs-helpers');
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../../src/utilities/errors');
|
||||||
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config settings routes factory
|
* Config settings routes factory
|
||||||
@@ -71,14 +72,14 @@ module.exports = function({ configStateManager: _configStateManager, asyncHandle
|
|||||||
}
|
}
|
||||||
log.info('config', 'Config saved', { path: ctx.CONFIG_FILE });
|
log.info('config', 'Config saved', { path: ctx.CONFIG_FILE });
|
||||||
|
|
||||||
res.json({ success: true, message: 'Configuration saved', config, warnings });
|
ok(res, { message: 'Configuration saved', config, warnings });
|
||||||
}, 'config-save'));
|
}, 'config-save'));
|
||||||
|
|
||||||
router.delete('/config', asyncHandler(async (req, res) => {
|
router.delete('/config', asyncHandler(async (req, res) => {
|
||||||
if (await exists(ctx.CONFIG_FILE)) {
|
if (await exists(ctx.CONFIG_FILE)) {
|
||||||
await fsp.unlink(ctx.CONFIG_FILE);
|
await fsp.unlink(ctx.CONFIG_FILE);
|
||||||
}
|
}
|
||||||
res.json({ success: true, message: 'Configuration reset' });
|
successMessage(res, 'Configuration reset');
|
||||||
}, 'config-delete'));
|
}, 'config-delete'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { DOCKER } = require('../constants');
|
const { DOCKER } = require('../src/utilities/constants');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Containers route factory
|
* Containers route factory
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credentials routes factory
|
* Credentials routes factory
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* 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('../src/utils/responses');
|
||||||
|
const { NotFoundError, ValidationError } = require('../src/utilities/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;
|
||||||
|
};
|
||||||
+236
-17
@@ -2,10 +2,10 @@ const express = require('express');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const validatorLib = require('validator');
|
const validatorLib = require('validator');
|
||||||
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
|
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../src/utilities/constants');
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../src/utilities/fs-helpers');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
|
const { ValidationError, AuthenticationError, NotFoundError } = require('../src/utilities/errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DNS routes factory
|
* DNS routes factory
|
||||||
@@ -26,7 +26,8 @@ module.exports = function({
|
|||||||
log,
|
log,
|
||||||
safeErrorMessage,
|
safeErrorMessage,
|
||||||
fetchT,
|
fetchT,
|
||||||
credentialManager
|
credentialManager,
|
||||||
|
dnsPropagationChecker
|
||||||
}) {
|
}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -41,7 +42,137 @@ module.exports = function({
|
|||||||
return serverIp;
|
return serverIp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /record — Delete a DNS record from Technitium
|
// ===== 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) =====
|
||||||
router.delete('/record', asyncHandler(async (req, res) => {
|
router.delete('/record', asyncHandler(async (req, res) => {
|
||||||
const { domain, type, token, server, ipAddress } = req.query;
|
const { domain, type, token, server, ipAddress } = req.query;
|
||||||
|
|
||||||
@@ -139,6 +270,14 @@ module.exports = function({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
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` });
|
success(res, { message: `DNS record ${domain} -> ${ip} created` });
|
||||||
} else {
|
} else {
|
||||||
// Error handled by middleware
|
// Error handled by middleware
|
||||||
@@ -194,8 +333,13 @@ module.exports = function({
|
|||||||
}
|
}
|
||||||
}, 'dns-resolve'));
|
}, 'dns-resolve'));
|
||||||
|
|
||||||
// GET /logs — Fetch DNS query logs from Technitium
|
// GET /logs — Fetch DNS query logs (Technitium only)
|
||||||
router.get('/logs', asyncHandler(async (req, res) => {
|
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;
|
const { server, limit } = req.query;
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -239,9 +383,8 @@ module.exports = function({
|
|||||||
|
|
||||||
const response = await fetchT(technitiumUrl, {
|
const response = await fetchT(technitiumUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Accept': 'text/plain' },
|
headers: { 'Accept': 'text/plain' }
|
||||||
timeout: 10000
|
}, 10000);
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@@ -409,7 +552,7 @@ module.exports = function({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return success(res, {
|
return ok(res, {
|
||||||
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
|
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
|
||||||
results
|
results
|
||||||
});
|
});
|
||||||
@@ -474,8 +617,13 @@ module.exports = function({
|
|||||||
success(res, { message: 'DNS credentials removed' });
|
success(res, { message: 'DNS credentials removed' });
|
||||||
}, 'dns-credentials-delete'));
|
}, 'dns-credentials-delete'));
|
||||||
|
|
||||||
// POST /restart/:dnsId — Restart a DNS server (proxied through backend for auth)
|
// POST /restart/:dnsId — Restart a DNS server (Technitium only)
|
||||||
router.post('/restart/:dnsId', asyncHandler(async (req, res) => {
|
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 { dnsId } = req.params;
|
||||||
const serverInfo = siteConfig.dnsServers?.[dnsId];
|
const serverInfo = siteConfig.dnsServers?.[dnsId];
|
||||||
if (!serverInfo?.ip) {
|
if (!serverInfo?.ip) {
|
||||||
@@ -490,7 +638,7 @@ module.exports = function({
|
|||||||
const dnsPort = siteConfig.dnsServerPort || '5380';
|
const dnsPort = siteConfig.dnsServerPort || '5380';
|
||||||
try {
|
try {
|
||||||
const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`;
|
const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`;
|
||||||
const response = await fetchT(url, { method: 'POST', timeout: 5000 });
|
const response = await fetchT(url, { method: 'POST' }, 5000);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
success(res, { message: 'Restart initiated' });
|
success(res, { message: 'Restart initiated' });
|
||||||
@@ -517,8 +665,13 @@ module.exports = function({
|
|||||||
}
|
}
|
||||||
}, 'dns-refresh-token'));
|
}, 'dns-refresh-token'));
|
||||||
|
|
||||||
// GET /check-update — Check for Technitium DNS server updates
|
// GET /check-update — Check for DNS server updates (Technitium only)
|
||||||
router.get('/check-update', asyncHandler(async (req, res) => {
|
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 {
|
try {
|
||||||
const { server } = req.query;
|
const { server } = req.query;
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -575,10 +728,13 @@ module.exports = function({
|
|||||||
}
|
}
|
||||||
}, 'dns-check-update'));
|
}, 'dns-check-update'));
|
||||||
|
|
||||||
// POST /update — Update Technitium DNS server
|
// POST /update — Update DNS server (Technitium only)
|
||||||
// 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) => {
|
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 {
|
try {
|
||||||
const { server } = req.query;
|
const { server } = req.query;
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -640,5 +796,68 @@ module.exports = function({
|
|||||||
}
|
}
|
||||||
}, 'dns-update'));
|
}, '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;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Docker resources route factory (volumes, networks, disk usage)
|
* Docker resources route factory (volumes, networks, disk usage)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../src/utilities/fs-helpers');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error logs routes factory
|
* Error logs routes factory
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-Sent Events route factory
|
* Server-Sent Events route factory
|
||||||
@@ -8,10 +9,14 @@ const express = require('express');
|
|||||||
* @param {Object} deps.healthChecker - Health checker
|
* @param {Object} deps.healthChecker - Health checker
|
||||||
* @param {Object} deps.updateManager - Update manager
|
* @param {Object} deps.updateManager - Update manager
|
||||||
* @param {Function} deps.logError - Error logging function
|
* @param {Function} deps.logError - Error logging function
|
||||||
* @param {Function} deps.ok - Success response helper
|
* @param {Object} deps.dependencyManager - Dependency manager for restart chain events
|
||||||
|
* @param {Object} deps.autoRestartManager - Auto-restart manager
|
||||||
|
* @param {Object} deps.driftDetector - Config drift detector
|
||||||
|
* @param {Object} deps.sslMonitor - SSL cert expiration monitor
|
||||||
|
* @param {Object} deps.dnsPropagationChecker - DNS propagation checker
|
||||||
* @returns {express.Router}
|
* @returns {express.Router}
|
||||||
*/
|
*/
|
||||||
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, ok }) {
|
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, dependencyManager, autoRestartManager, driftDetector, sslMonitor, dnsPropagationChecker }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const clients = new Set();
|
const clients = new Set();
|
||||||
|
|
||||||
@@ -75,6 +80,48 @@ 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
|
// SSE endpoint
|
||||||
router.get('/stream', (req, res) => {
|
router.get('/stream', (req, res) => {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ const express = require('express');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const { TIMEOUTS } = require('../constants');
|
const { TIMEOUTS } = require('../src/utilities/constants');
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../src/utilities/fs-helpers');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const platformPaths = require('../platform-paths');
|
const platformPaths = require('../platform-paths');
|
||||||
const { resolveServiceUrl } = require('../url-resolver');
|
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse, errorResponse: sendError, ok, notFound } = require('../src/utils/responses');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health routes factory
|
* Health routes factory
|
||||||
@@ -190,7 +190,7 @@ module.exports = function({
|
|||||||
|
|
||||||
// Load service config
|
// Load service config
|
||||||
if (!await exists(SERVICES_FILE)) {
|
if (!await exists(SERVICES_FILE)) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Services file');
|
throw new NotFoundError('Services file');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ module.exports = function({
|
|||||||
const service = services.find(s => (s.id || s.name?.toLowerCase()) === serviceId);
|
const service = services.find(s => (s.id || s.name?.toLowerCase()) === serviceId);
|
||||||
|
|
||||||
if (!service) {
|
if (!service) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Service');
|
throw new NotFoundError('Service');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,11 +273,7 @@ module.exports = function({
|
|||||||
try {
|
try {
|
||||||
// Check if certificate exists
|
// Check if certificate exists
|
||||||
if (!await exists(rootCertPath)) {
|
if (!await exists(rootCertPath)) {
|
||||||
return res.json({
|
return sendError(res, 404, 'Root CA certificate not found', { caStatus: 'error', daysUntilExpiration: null });
|
||||||
status: 'error',
|
|
||||||
message: 'Root CA certificate not found',
|
|
||||||
daysUntilExpiration: null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dates = execSync(`openssl x509 -in "${rootCertPath}" -noout -dates`).toString();
|
const dates = execSync(`openssl x509 -in "${rootCertPath}" -noout -dates`).toString();
|
||||||
@@ -286,36 +282,32 @@ module.exports = function({
|
|||||||
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
// Alert thresholds
|
// Alert thresholds
|
||||||
let status = 'healthy';
|
let caStatus = 'healthy';
|
||||||
let message = `CA certificate valid for ${daysUntilExpiration} days`;
|
let message = `CA certificate valid for ${daysUntilExpiration} days`;
|
||||||
|
|
||||||
if (daysUntilExpiration < 0) {
|
if (daysUntilExpiration < 0) {
|
||||||
status = 'critical';
|
caStatus = 'critical';
|
||||||
message = `CA certificate EXPIRED ${Math.abs(daysUntilExpiration)} days ago!`;
|
message = `CA certificate EXPIRED ${Math.abs(daysUntilExpiration)} days ago!`;
|
||||||
} else if (daysUntilExpiration < 7) {
|
} else if (daysUntilExpiration < 7) {
|
||||||
status = 'critical';
|
caStatus = 'critical';
|
||||||
message = `CA certificate expires in ${daysUntilExpiration} days!`;
|
message = `CA certificate expires in ${daysUntilExpiration} days!`;
|
||||||
} else if (daysUntilExpiration < 30) {
|
} else if (daysUntilExpiration < 30) {
|
||||||
status = 'critical';
|
caStatus = 'critical';
|
||||||
message = `CA certificate expires in ${daysUntilExpiration} days!`;
|
message = `CA certificate expires in ${daysUntilExpiration} days!`;
|
||||||
} else if (daysUntilExpiration < 90) {
|
} else if (daysUntilExpiration < 90) {
|
||||||
status = 'warning';
|
caStatus = 'warning';
|
||||||
message = `CA certificate expires in ${daysUntilExpiration} days`;
|
message = `CA certificate expires in ${daysUntilExpiration} days`;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
ok(res, {
|
||||||
status: status,
|
caStatus,
|
||||||
message: message,
|
message,
|
||||||
daysUntilExpiration: daysUntilExpiration,
|
daysUntilExpiration,
|
||||||
expiresAt: notAfter
|
expiresAt: notAfter
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logError('GET /api/health/ca', error);
|
await logError('GET /api/health/ca', error);
|
||||||
res.json({
|
sendError(res, 500, error.message, { caStatus: 'error', daysUntilExpiration: null });
|
||||||
status: 'error',
|
|
||||||
message: error.message,
|
|
||||||
daysUntilExpiration: null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 'health-ca'));
|
}, 'health-ca'));
|
||||||
|
|
||||||
@@ -349,7 +341,7 @@ module.exports = function({
|
|||||||
const hours = parseInt(req.query.hours) || 24;
|
const hours = parseInt(req.query.hours) || 24;
|
||||||
const stats = healthChecker.getServiceStats(req.params.serviceId, hours);
|
const stats = healthChecker.getServiceStats(req.params.serviceId, hours);
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Service');
|
throw new NotFoundError('Service');
|
||||||
}
|
}
|
||||||
success(res, { stats });
|
success(res, { stats });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* License routes factory
|
* License routes factory
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ const express = require('express');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../src/utilities/fs-helpers');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { NotFoundError, ValidationError, ForbiddenError } = require('../errors');
|
const { NotFoundError, ValidationError, ForbiddenError } = require('../src/utilities/errors');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs route factory
|
* Logs route factory
|
||||||
@@ -47,7 +48,7 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan
|
|||||||
info = await container.inspect();
|
info = await container.inspect();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) {
|
if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError(`Container ${containerId}`);
|
throw new NotFoundError(`Container ${containerId}`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -96,7 +97,7 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan
|
|||||||
await container.inspect();
|
await container.inspect();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) {
|
if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError(`Container ${containerId}`);
|
throw new NotFoundError(`Container ${containerId}`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -231,7 +232,7 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan
|
|||||||
try {
|
try {
|
||||||
resolvedPath = await fsp.realpath(normalizedPath);
|
resolvedPath = await fsp.realpath(normalizedPath);
|
||||||
} catch {
|
} catch {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Log file');
|
throw new NotFoundError('Log file');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +247,7 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!await exists(resolvedPath)) {
|
if (!await exists(resolvedPath)) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Log file');
|
throw new NotFoundError('Log file');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monitoring routes factory
|
* Monitoring routes factory
|
||||||
@@ -38,7 +38,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
|||||||
router.get('/monitoring/stats/:containerId', asyncHandler(async (req, res) => {
|
router.get('/monitoring/stats/:containerId', asyncHandler(async (req, res) => {
|
||||||
const stats = resourceMonitor.getCurrentStats(req.params.containerId);
|
const stats = resourceMonitor.getCurrentStats(req.params.containerId);
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Container');
|
throw new NotFoundError('Container');
|
||||||
}
|
}
|
||||||
success(res, { stats });
|
success(res, { stats });
|
||||||
@@ -54,7 +54,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
|||||||
const startTime = parseInt(req.query.startTime, 10);
|
const startTime = parseInt(req.query.startTime, 10);
|
||||||
const endTime = parseInt(req.query.endTime, 10);
|
const endTime = parseInt(req.query.endTime, 10);
|
||||||
if (Number.isNaN(startTime) || Number.isNaN(endTime) || startTime >= endTime) {
|
if (Number.isNaN(startTime) || Number.isNaN(endTime) || startTime >= endTime) {
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
throw new ValidationError('Invalid startTime/endTime');
|
throw new ValidationError('Invalid startTime/endTime');
|
||||||
}
|
}
|
||||||
const result = resourceMonitor.getHistoryByRange(containerId, startTime, endTime);
|
const result = resourceMonitor.getHistoryByRange(containerId, startTime, endTime);
|
||||||
@@ -73,7 +73,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
|||||||
const hours = parseInt(req.query.hours) || 24;
|
const hours = parseInt(req.query.hours) || 24;
|
||||||
const aggregated = resourceMonitor.getAggregatedStats(req.params.containerId, hours);
|
const aggregated = resourceMonitor.getAggregatedStats(req.params.containerId, hours);
|
||||||
if (!aggregated) {
|
if (!aggregated) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError('Monitoring data');
|
throw new NotFoundError('Monitoring data');
|
||||||
}
|
}
|
||||||
success(res, { aggregated, hours });
|
success(res, { aggregated, hours });
|
||||||
@@ -91,7 +91,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
|||||||
router.post('/monitoring/alerts/config', asyncHandler(async (req, res) => {
|
router.post('/monitoring/alerts/config', asyncHandler(async (req, res) => {
|
||||||
const { configs } = req.body;
|
const { configs } = req.body;
|
||||||
if (!configs || typeof configs !== 'object') {
|
if (!configs || typeof configs !== 'object') {
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
throw new ValidationError('configs object required');
|
throw new ValidationError('configs object required');
|
||||||
}
|
}
|
||||||
for (const [containerId, config] of Object.entries(configs)) {
|
for (const [containerId, config] of Object.entries(configs)) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { validateURL, validateToken } = require('../input-validator');
|
const { validateURL, validateToken } = require('../src/security/input-validator');
|
||||||
const validatorLib = require('validator');
|
const validatorLib = require('validator');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifications route factory
|
* Notifications route factory
|
||||||
@@ -151,7 +152,7 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await notification.saveConfig();
|
await notification.saveConfig();
|
||||||
ok(res, { message: 'Notification config updated' });
|
successMessage(res, 'Notification config updated');
|
||||||
}, 'notifications-config-update'));
|
}, 'notifications-config-update'));
|
||||||
|
|
||||||
// POST /test — Test notification delivery
|
// POST /test — Test notification delivery
|
||||||
@@ -206,7 +207,7 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
// DELETE /history — Clear notification history
|
// DELETE /history — Clear notification history
|
||||||
router.delete('/history', asyncHandler(async (req, res) => {
|
router.delete('/history', asyncHandler(async (req, res) => {
|
||||||
notification.clearHistory();
|
notification.clearHistory();
|
||||||
ok(res, { message: 'Notification history cleared' });
|
successMessage(res, 'Notification history cleared');
|
||||||
}, 'notifications-history-clear'));
|
}, 'notifications-history-clear'));
|
||||||
|
|
||||||
// POST /health-check — Manually trigger health check
|
// POST /health-check — Manually trigger health check
|
||||||
@@ -223,7 +224,7 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
router.get('/status', asyncHandler(async (req, res) => {
|
router.get('/status', asyncHandler(async (req, res) => {
|
||||||
const notificationConfig = notification.getConfig();
|
const notificationConfig = notification.getConfig();
|
||||||
const providers = notificationConfig.providers || {};
|
const providers = notificationConfig.providers || {};
|
||||||
|
|
||||||
ok(res, {
|
ok(res, {
|
||||||
enabled: notificationConfig.enabled,
|
enabled: notificationConfig.enabled,
|
||||||
providers: {
|
providers: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const { ok, errorResponse, notFound, conflict } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenClaw management routes
|
* OpenClaw management routes
|
||||||
@@ -94,8 +95,8 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
proxyRes.on('data', function(d) { res.write(d); });
|
proxyRes.on('data', function(d) { res.write(d); });
|
||||||
proxyRes.on('end', function() { res.end(); });
|
proxyRes.on('end', function() { res.end(); });
|
||||||
});
|
});
|
||||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); });
|
||||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); });
|
||||||
proxyReq.write(body);
|
proxyReq.write(body);
|
||||||
proxyReq.end();
|
proxyReq.end();
|
||||||
} else {
|
} else {
|
||||||
@@ -105,8 +106,8 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
proxyRes.on('data', function(d) { res.write(d); });
|
proxyRes.on('data', function(d) { res.write(d); });
|
||||||
proxyRes.on('end', function() { res.end(); });
|
proxyRes.on('end', function() { res.end(); });
|
||||||
});
|
});
|
||||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); });
|
||||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
router.post('/deploy', asyncHandler(async function(req, res) {
|
router.post('/deploy', asyncHandler(async function(req, res) {
|
||||||
const existing = await findOpenClawContainer();
|
const existing = await findOpenClawContainer();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
|
return conflict(res, 'OpenClaw is already deployed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = 'ghcr.io/nousresearch/openclaw:latest';
|
const image = 'ghcr.io/nousresearch/openclaw:latest';
|
||||||
@@ -170,7 +171,7 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
});
|
});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
log.error('OpenClaw pull failed: ' + e.message);
|
log.error('OpenClaw pull failed: ' + e.message);
|
||||||
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
|
return errorResponse(res, 500, 'Failed to pull image: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create + start container
|
// Create + start container
|
||||||
@@ -206,7 +207,7 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
});
|
});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
log.error('OpenClaw deploy failed: ' + e.message);
|
log.error('OpenClaw deploy failed: ' + e.message);
|
||||||
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
|
errorResponse(res, 500, 'Deploy failed: ' + e.message);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
|
|
||||||
router.get('/proxy/*', asyncHandler(async function(req, res) {
|
router.get('/proxy/*', asyncHandler(async function(req, res) {
|
||||||
const container = await findOpenClawContainer();
|
const container = await findOpenClawContainer();
|
||||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
if (!container) return notFound(res, 'OpenClaw not deployed');
|
||||||
|
|
||||||
const token = await getGatewayToken(container.Id);
|
const token = await getGatewayToken(container.Id);
|
||||||
const port = await getContainerPort(container.Id);
|
const port = await getContainerPort(container.Id);
|
||||||
@@ -228,7 +229,7 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
|
|
||||||
router.post('/proxy/*', asyncHandler(async function(req, res) {
|
router.post('/proxy/*', asyncHandler(async function(req, res) {
|
||||||
const container = await findOpenClawContainer();
|
const container = await findOpenClawContainer();
|
||||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
if (!container) return notFound(res, 'OpenClaw not deployed');
|
||||||
|
|
||||||
const token = await getGatewayToken(container.Id);
|
const token = await getGatewayToken(container.Id);
|
||||||
const port = await getContainerPort(container.Id);
|
const port = await getContainerPort(container.Id);
|
||||||
@@ -242,7 +243,7 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
|
|
||||||
router.delete('/', asyncHandler(async function(req, res) {
|
router.delete('/', asyncHandler(async function(req, res) {
|
||||||
const container = await findOpenClawContainer();
|
const container = await findOpenClawContainer();
|
||||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
if (!container) return notFound(res, 'OpenClaw not deployed');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const c = docker.client.container(container.Id);
|
const c = docker.client.container(container.Id);
|
||||||
@@ -252,7 +253,7 @@ module.exports = function openClawRoutes(ctx) {
|
|||||||
ok(res, { message: 'OpenClaw removed' });
|
ok(res, { message: 'OpenClaw removed' });
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
log.error('Failed to remove OpenClaw: ' + e.message);
|
log.error('Failed to remove OpenClaw: ' + e.message);
|
||||||
res.status(500).json({ success: false, error: e.message });
|
errorResponse(res, 500, e.message);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../../src/utilities/errors');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { DOCKER } = require('../../constants');
|
const { DOCKER } = require('../../../src/utilities/constants');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recipes deployment routes factory
|
* Recipes deployment routes factory
|
||||||
@@ -27,7 +28,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
|||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
router.post('/deploy', asyncHandler(async (req, res) => {
|
router.post('/deploy', asyncHandler(async (req, res) => {
|
||||||
const { recipeId, config } = req.body;
|
const { recipeId, config } = req.body;
|
||||||
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
const { RECIPE_TEMPLATES } = require('../../../src/recipes/recipe-templates');
|
||||||
|
|
||||||
const recipe = RECIPE_TEMPLATES[recipeId];
|
const recipe = RECIPE_TEMPLATES[recipeId];
|
||||||
if (!recipe) throw new ValidationError('Invalid recipe template', 'recipeId');
|
if (!recipe) throw new ValidationError('Invalid recipe template', 'recipeId');
|
||||||
@@ -146,7 +147,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
|||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(response);
|
ok(res, response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message });
|
log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message });
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const deployRoutes = require('./deploy');
|
const deployRoutes = require('./deploy');
|
||||||
const manageRoutes = require('./manage');
|
const manageRoutes = require('./manage');
|
||||||
const { NotFoundError } = require('../../errors');
|
const { NotFoundError } = require('../../../src/utilities/errors');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recipes routes aggregator
|
* Recipes routes aggregator
|
||||||
@@ -31,7 +32,7 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// GET /api/recipes/templates — list all recipe templates
|
// GET /api/recipes/templates — list all recipe templates
|
||||||
router.get('/templates', deps.asyncHandler(async (req, res) => {
|
router.get('/templates', deps.asyncHandler(async (req, res) => {
|
||||||
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../../recipe-templates');
|
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../../../src/recipes/recipe-templates');
|
||||||
const templates = Object.entries(RECIPE_TEMPLATES).map(([id, recipe]) => ({
|
const templates = Object.entries(RECIPE_TEMPLATES).map(([id, recipe]) => ({
|
||||||
id,
|
id,
|
||||||
name: recipe.name,
|
name: recipe.name,
|
||||||
@@ -55,16 +56,16 @@ module.exports = function(ctx) {
|
|||||||
setupInstructions: recipe.setupInstructions
|
setupInstructions: recipe.setupInstructions
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({ success: true, templates, categories: RECIPE_CATEGORIES });
|
ok(res, { templates, categories: RECIPE_CATEGORIES });
|
||||||
}, 'recipe-templates'));
|
}, 'recipe-templates'));
|
||||||
|
|
||||||
// GET /api/recipes/templates/:recipeId — get single recipe template detail
|
// GET /api/recipes/templates/:recipeId — get single recipe template detail
|
||||||
router.get('/templates/:recipeId', deps.asyncHandler(async (req, res) => {
|
router.get('/templates/:recipeId', deps.asyncHandler(async (req, res) => {
|
||||||
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
const { RECIPE_TEMPLATES } = require('../../../src/recipes/recipe-templates');
|
||||||
const recipe = RECIPE_TEMPLATES[req.params.recipeId];
|
const recipe = RECIPE_TEMPLATES[req.params.recipeId];
|
||||||
if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`);
|
if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`);
|
||||||
|
|
||||||
res.json({ success: true, recipe: { id: req.params.recipeId, ...recipe } });
|
ok(res, { recipe: { id: req.params.recipeId, ...recipe } });
|
||||||
}, 'recipe-template-detail'));
|
}, 'recipe-template-detail'));
|
||||||
|
|
||||||
// Mount deploy and manage sub-routes — pass full ctx for sub-routes that reference ctx.*
|
// Mount deploy and manage sub-routes — pass full ctx for sub-routes that reference ctx.*
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { DOCKER } = require('../../constants');
|
const { DOCKER } = require('../../../src/utilities/constants');
|
||||||
const { NotFoundError } = require('../../errors');
|
const { NotFoundError } = require('../../../src/utilities/errors');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) {
|
module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -98,7 +99,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, recipes: Object.values(recipeGroups) });
|
ok(res, { recipes: Object.values(recipeGroups) });
|
||||||
}, 'recipe-deployed'));
|
}, 'recipe-deployed'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,7 +130,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info('recipe', 'Recipe started', { recipeId, results });
|
log.info('recipe', 'Recipe started', { recipeId, results });
|
||||||
res.json({ success: true, recipeId, results });
|
ok(res, { recipeId, results });
|
||||||
}, 'recipe-start'));
|
}, 'recipe-start'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +162,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info('recipe', 'Recipe stopped', { recipeId, results });
|
log.info('recipe', 'Recipe stopped', { recipeId, results });
|
||||||
res.json({ success: true, recipeId, results });
|
ok(res, { recipeId, results });
|
||||||
}, 'recipe-stop'));
|
}, 'recipe-stop'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,7 +188,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info('recipe', 'Recipe restarted', { recipeId, results });
|
log.info('recipe', 'Recipe restarted', { recipeId, results });
|
||||||
res.json({ success: true, recipeId, results });
|
ok(res, { recipeId, results });
|
||||||
}, 'recipe-restart'));
|
}, 'recipe-restart'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,7 +260,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
|||||||
);
|
);
|
||||||
|
|
||||||
log.info('recipe', 'Recipe removed', { recipeId, results });
|
log.info('recipe', 'Recipe removed', { recipeId, results });
|
||||||
res.json({ success: true, recipeId, results });
|
ok(res, { recipeId, results });
|
||||||
}, 'recipe-remove'));
|
}, 'recipe-remove'));
|
||||||
|
|
||||||
// === Helper functions ===
|
// === Helper functions ===
|
||||||
@@ -268,7 +269,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
|||||||
* Find all Docker containers belonging to a recipe by label
|
* Find all Docker containers belonging to a recipe by label
|
||||||
*/
|
*/
|
||||||
async function findRecipeContainers(recipeId) {
|
async function findRecipeContainers(recipeId) {
|
||||||
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
const { RECIPE_TEMPLATES } = require('../../../src/recipes/recipe-templates');
|
||||||
const recipe = RECIPE_TEMPLATES[recipeId];
|
const recipe = RECIPE_TEMPLATES[recipeId];
|
||||||
const recipeLabel = recipe
|
const recipeLabel = recipe
|
||||||
? recipe.name.toLowerCase().replace(/\s+/g, '-')
|
? recipe.name.toLowerCase().replace(/\s+/g, '-')
|
||||||
@@ -292,7 +293,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
|||||||
* Find recipe ID by its label (name slug)
|
* Find recipe ID by its label (name slug)
|
||||||
*/
|
*/
|
||||||
function findRecipeIdByLabel(label) {
|
function findRecipeIdByLabel(label) {
|
||||||
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
const { RECIPE_TEMPLATES } = require('../../../src/recipes/recipe-templates');
|
||||||
for (const [id, recipe] of Object.entries(RECIPE_TEMPLATES)) {
|
for (const [id, recipe] of Object.entries(RECIPE_TEMPLATES)) {
|
||||||
if (recipe.name.toLowerCase().replace(/\s+/g, '-') === label) {
|
if (recipe.name.toLowerCase().replace(/\s+/g, '-') === label) {
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ const http = require('http');
|
|||||||
const https = require('https');
|
const https = require('https');
|
||||||
const tls = require('tls');
|
const tls = require('tls');
|
||||||
const validatorLib = require('validator');
|
const validatorLib = require('validator');
|
||||||
const { APP, REGEX, TIMEOUTS } = require('../constants');
|
const { APP, REGEX, TIMEOUTS } = require('../src/utilities/constants');
|
||||||
const { validateServiceConfig, isValidPort } = require('../input-validator');
|
const { validateServiceConfig, isValidPort } = require('../src/security/input-validator');
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../src/utilities/fs-helpers');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
|
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
|
||||||
const { resolveServiceUrl } = require('../url-resolver');
|
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Services route factory
|
* Services route factory
|
||||||
@@ -46,7 +47,7 @@ module.exports = function({
|
|||||||
dns
|
dns
|
||||||
}) {
|
}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
||||||
const PROBE_CONCURRENCY = 6;
|
const PROBE_CONCURRENCY = 6;
|
||||||
let probeHttpsAgent;
|
let probeHttpsAgent;
|
||||||
|
|
||||||
@@ -355,9 +356,11 @@ module.exports = function({
|
|||||||
}, 'services-status'));
|
}, 'services-status'));
|
||||||
|
|
||||||
// List all services
|
// List all services
|
||||||
|
// Always returns the standard envelope. The `services` field is the array
|
||||||
|
// (paginated if ?page=N&limit=M is in the query, otherwise the full list).
|
||||||
router.get('/services', asyncHandler(async (req, res) => {
|
router.get('/services', asyncHandler(async (req, res) => {
|
||||||
if (!await exists(SERVICES_FILE)) {
|
if (!await exists(SERVICES_FILE)) {
|
||||||
return res.json([]);
|
return success(res, { services: [] });
|
||||||
}
|
}
|
||||||
const services = await servicesStateManager.read();
|
const services = await servicesStateManager.read();
|
||||||
const paginationParams = parsePaginationParams(req.query);
|
const paginationParams = parsePaginationParams(req.query);
|
||||||
@@ -365,14 +368,14 @@ module.exports = function({
|
|||||||
if (paginationParams) {
|
if (paginationParams) {
|
||||||
success(res, { services: result.data, pagination: result.pagination });
|
success(res, { services: result.data, pagination: result.pagination });
|
||||||
} else {
|
} else {
|
||||||
res.json(result.data);
|
success(res, { services: result.data });
|
||||||
}
|
}
|
||||||
}, 'services-list'));
|
}, 'services-list'));
|
||||||
|
|
||||||
// Add a new service
|
// Add a new service
|
||||||
router.post('/services', asyncHandler(async (req, res) => {
|
router.post('/services', asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, name, logo } = req.body;
|
const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body;
|
||||||
|
|
||||||
if (!id || !name) {
|
if (!id || !name) {
|
||||||
throw new ValidationError('id and name are required');
|
throw new ValidationError('id and name are required');
|
||||||
@@ -391,7 +394,14 @@ module.exports = function({
|
|||||||
throw new ConflictError(`Service "${id}" already exists`, id);
|
throw new ConflictError(`Service "${id}" already exists`, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
services.push({ id, name, logo: logo || `/assets/${id}.png` });
|
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);
|
||||||
return services;
|
return services;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -513,9 +523,8 @@ module.exports = function({
|
|||||||
|
|
||||||
if (oldSubdomain !== newSubdomain) {
|
if (oldSubdomain !== newSubdomain) {
|
||||||
try {
|
try {
|
||||||
const dnsToken = dns.getToken();
|
await dns.universalDeleteRecord(oldDomain);
|
||||||
await dns.call(siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' });
|
await dns.universalCreateRecord(newSubdomain, ip || 'localhost');
|
||||||
await dns.createRecord(newSubdomain, ip || 'localhost');
|
|
||||||
results.dns = 'updated';
|
results.dns = 'updated';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.dns = `failed: ${e.message}`;
|
results.dns = `failed: ${e.message}`;
|
||||||
@@ -542,6 +551,8 @@ module.exports = function({
|
|||||||
};
|
};
|
||||||
if (name) services[serviceIndex].name = name;
|
if (name) services[serviceIndex].name = name;
|
||||||
if (logo) services[serviceIndex].logo = logo;
|
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';
|
results.services = 'updated';
|
||||||
} else {
|
} else {
|
||||||
results.services = 'not found';
|
results.services = 'not found';
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { CADDY, REGEX, LIMITS } = require('../constants');
|
const { CADDY, REGEX, LIMITS } = require('../src/utilities/constants');
|
||||||
const { ValidationError, ConflictError, NotFoundError } = require('../errors');
|
const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors');
|
||||||
const { validateURL } = require('../input-validator');
|
const { validateURL } = require('../src/security/input-validator');
|
||||||
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sites route factory
|
* Sites route factory
|
||||||
@@ -49,7 +50,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
|||||||
throw new Error('Caddy reload failed. Check server logs for details.');
|
throw new Error('Caddy reload failed. Check server logs for details.');
|
||||||
}
|
}
|
||||||
|
|
||||||
ok(res, { message: 'Caddy configuration reloaded successfully' });
|
successMessage(res, 'Caddy configuration reloaded successfully');
|
||||||
}, 'caddy-reload'));
|
}, 'caddy-reload'));
|
||||||
|
|
||||||
// Get Certificate Authorities from Caddyfile
|
// Get Certificate Authorities from Caddyfile
|
||||||
@@ -127,7 +128,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
|||||||
name: ca.name,
|
name: ca.name,
|
||||||
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name
|
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name
|
||||||
}));
|
}));
|
||||||
res.json({ status: 'success', data: { cas: caList } });
|
ok(res, { cas: caList });
|
||||||
}, 'caddy-get-cas'));
|
}, 'caddy-get-cas'));
|
||||||
|
|
||||||
// Remove a site from Caddyfile
|
// Remove a site from Caddyfile
|
||||||
@@ -152,7 +153,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
|||||||
throw new NotFoundError(`Site block for "" in Caddyfile`);
|
throw new NotFoundError(`Site block for "" in Caddyfile`);
|
||||||
}
|
}
|
||||||
|
|
||||||
ok(res, { message: `Site "${domain}" removed from Caddyfile and Caddy reloaded` });
|
successMessage(res, `Site "${domain}" removed from Caddyfile and Caddy reloaded`);
|
||||||
}, 'site-delete'));
|
}, 'site-delete'));
|
||||||
|
|
||||||
// Add a new site to Caddyfile and reload
|
// Add a new site to Caddyfile and reload
|
||||||
@@ -180,7 +181,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
|||||||
result.rolledBack ? { note: 'Caddyfile was rolled back to previous state' } : {});
|
result.rolledBack ? { note: 'Caddyfile was rolled back to previous state' } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
ok(res, { message: `Site "${domain}" added to Caddyfile and Caddy reloaded successfully` });
|
successMessage(res, `Site "${domain}" added to Caddyfile and Caddy reloaded successfully`);
|
||||||
}, 'site-add'));
|
}, 'site-add'));
|
||||||
|
|
||||||
// Add external service reverse proxy to Caddyfile
|
// Add external service reverse proxy to Caddyfile
|
||||||
@@ -205,7 +206,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
|||||||
|
|
||||||
if (createDns) {
|
if (createDns) {
|
||||||
try {
|
try {
|
||||||
await dns.createRecord(subdomain, siteConfig.dnsServerIp);
|
await dns.universalCreateRecord(subdomain, siteConfig.dnsServerIp);
|
||||||
log.info('dns', 'DNS record created for external proxy', { domain, ip: siteConfig.dnsServerIp });
|
log.info('dns', 'DNS record created for external proxy', { domain, ip: siteConfig.dnsServerIp });
|
||||||
} catch (dnsError) {
|
} catch (dnsError) {
|
||||||
dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`;
|
dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`;
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* SSL Monitor Routes
|
||||||
|
* REST API endpoints for SSL certificate monitoring.
|
||||||
|
*
|
||||||
|
* @module routes/ssl-monitor
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { success, error: errorResponse, notFound } = require('../src/utils/responses');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { TAILSCALE } = require('../constants');
|
const fs = require('fs');
|
||||||
const { exists } = require('../fs-helpers');
|
const { TAILSCALE } = require('../src/utilities/constants');
|
||||||
const { ValidationError, NotFoundError: _NotFoundError } = require('../errors');
|
const { exists } = require('../src/utilities/fs-helpers');
|
||||||
|
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||||
|
const { ok, successMessage, unauthorized } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tailscale route factory
|
* Tailscale route factory
|
||||||
@@ -156,7 +158,7 @@ module.exports = function({
|
|||||||
const match = content.match(blockRegex);
|
const match = content.match(blockRegex);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../src/utilities/errors');
|
||||||
throw new NotFoundError(`Service ${domain} in Caddyfile`);
|
throw new NotFoundError(`Service ${domain} in Caddyfile`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +267,7 @@ module.exports = function({
|
|||||||
|
|
||||||
tailscale.stopSync();
|
tailscale.stopSync();
|
||||||
|
|
||||||
ok(res, { message: 'Tailscale OAuth credentials removed' });
|
successMessage(res, 'Tailscale OAuth credentials removed');
|
||||||
}, 'tailscale-oauth-delete'));
|
}, 'tailscale-oauth-delete'));
|
||||||
|
|
||||||
// Get enriched device list from Tailscale API
|
// Get enriched device list from Tailscale API
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
const { ValidationError, NotFoundError } = require('../errors');
|
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Themes routes factory
|
* Themes routes factory
|
||||||
@@ -13,7 +14,7 @@ const { ValidationError, NotFoundError } = require('../errors');
|
|||||||
*/
|
*/
|
||||||
module.exports = function({ asyncHandler, log }) {
|
module.exports = function({ asyncHandler, log }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes');
|
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(platformPaths.servicesFile), 'themes');
|
||||||
|
|
||||||
// Ensure themes directory exists
|
// Ensure themes directory exists
|
||||||
if (!fs.existsSync(THEMES_DIR)) {
|
if (!fs.existsSync(THEMES_DIR)) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates route factory
|
* Updates route factory
|
||||||
@@ -41,7 +42,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError,
|
|||||||
// Rollback update
|
// Rollback update
|
||||||
router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => {
|
router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => {
|
||||||
await updateManager.rollbackUpdate(req.params.containerId);
|
await updateManager.rollbackUpdate(req.params.containerId);
|
||||||
ok(res, { message: 'Rollback completed' });
|
successMessage(res, 'Rollback completed');
|
||||||
}, 'updates-rollback'));
|
}, 'updates-rollback'));
|
||||||
|
|
||||||
// Get update history
|
// Get update history
|
||||||
@@ -57,7 +58,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError,
|
|||||||
// Configure auto-update
|
// Configure auto-update
|
||||||
router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => {
|
router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => {
|
||||||
updateManager.configureAutoUpdate(req.params.containerId, req.body);
|
updateManager.configureAutoUpdate(req.params.containerId, req.body);
|
||||||
ok(res, { message: 'Auto-update configured' });
|
successMessage(res, 'Auto-update configured');
|
||||||
}, 'updates-auto-update'));
|
}, 'updates-auto-update'));
|
||||||
|
|
||||||
// Get auto-update configuration
|
// Get auto-update configuration
|
||||||
@@ -87,14 +88,14 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError,
|
|||||||
// Check for DashCaddy update
|
// Check for DashCaddy update
|
||||||
router.get('/system/update-check', asyncHandler(async (req, res) => {
|
router.get('/system/update-check', asyncHandler(async (req, res) => {
|
||||||
const result = await selfUpdater.checkForUpdate();
|
const result = await selfUpdater.checkForUpdate();
|
||||||
ok(res, { ...result });
|
ok(res, result);
|
||||||
}, 'system-update-check'));
|
}, 'system-update-check'));
|
||||||
|
|
||||||
// Apply available update
|
// Apply available update
|
||||||
router.post('/system/update-apply', asyncHandler(async (req, res) => {
|
router.post('/system/update-apply', asyncHandler(async (req, res) => {
|
||||||
const check = await selfUpdater.checkForUpdate();
|
const check = await selfUpdater.checkForUpdate();
|
||||||
if (!check.available) {
|
if (!check.available) {
|
||||||
return ok(res, { message: 'Already up to date' });
|
return successMessage(res, 'Already up to date');
|
||||||
}
|
}
|
||||||
// Refuse same-version applies. The check.available flag can theoretically be
|
// Refuse same-version applies. The check.available flag can theoretically be
|
||||||
// true with equal versions (commit-mismatch path); applying anyway just
|
// true with equal versions (commit-mismatch path); applying anyway just
|
||||||
@@ -135,7 +136,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError,
|
|||||||
return res.status(401).json({ success: false, error: 'Invalid notify secret' });
|
return res.status(401).json({ success: false, error: 'Invalid notify secret' });
|
||||||
}
|
}
|
||||||
const result = selfUpdater.notifyAndApply('http-notify');
|
const result = selfUpdater.notifyAndApply('http-notify');
|
||||||
ok(res, { ...result });
|
ok(res, result);
|
||||||
}, 'system-update-notify'));
|
}, 'system-update-notify'));
|
||||||
|
|
||||||
// Get update status
|
// Get update status
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflows routes factory
|
* Workflows routes factory
|
||||||
@@ -27,14 +28,14 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
|
|||||||
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
|
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
|
||||||
const { workflowId } = req.params;
|
const { workflowId } = req.params;
|
||||||
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
|
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
|
||||||
ok(res, { ...result });
|
ok(res, result);
|
||||||
}, 'workflows-enable'));
|
}, 'workflows-enable'));
|
||||||
|
|
||||||
// Disable a workflow
|
// Disable a workflow
|
||||||
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
|
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
|
||||||
const { workflowId } = req.params;
|
const { workflowId } = req.params;
|
||||||
const result = workflowEngine.setWorkflowEnabled(workflowId, false);
|
const result = workflowEngine.setWorkflowEnabled(workflowId, false);
|
||||||
ok(res, { ...result });
|
ok(res, result);
|
||||||
}, 'workflows-disable'));
|
}, 'workflows-disable'));
|
||||||
|
|
||||||
// Manually trigger a workflow
|
// Manually trigger a workflow
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fix the remaining broken require paths after DC-005 refactor.
|
||||||
|
|
||||||
|
Two patterns to fix:
|
||||||
|
1. `require('./src/...')` and `require('../../src/...')` and `require('../../../src/...')`
|
||||||
|
in files inside `src/` directories → should be `require('../...')` (relative to src/)
|
||||||
|
2. `require('../../../src/...')` in test files in `__tests__/` → should be `require('../src/...')`
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DASHCADDY_API = Path('/root/dashcaddy-krystie/dashcaddy-api')
|
||||||
|
|
||||||
|
# Pattern to match require('../../../src/X/Y') and capture
|
||||||
|
# We need to detect the file's location and rewrite based on that
|
||||||
|
# A simple approach: find any require that contains 'src/' in the path,
|
||||||
|
# and rewrite it to be relative to the file's location.
|
||||||
|
|
||||||
|
def fix_file(filepath: Path) -> bool:
|
||||||
|
"""Returns True if file was changed."""
|
||||||
|
content = filepath.read_text()
|
||||||
|
original = content
|
||||||
|
|
||||||
|
# Find the file's directory relative to dashcaddy-api root
|
||||||
|
rel_dir = filepath.parent.relative_to(DASHCADDY_API)
|
||||||
|
depth = len(rel_dir.parts)
|
||||||
|
|
||||||
|
# If file is in src/X/Y/file.js, depth is 3 (src, X, Y)
|
||||||
|
# If file is in __tests__/file.js, depth is 1
|
||||||
|
# If file is in __tests__/routes/file.js, depth is 2
|
||||||
|
|
||||||
|
# Find all require() calls that contain 'src/'
|
||||||
|
# Pattern: require('(.....)*src/path')
|
||||||
|
def replacer(match):
|
||||||
|
quote = match.group(1) # the quote char
|
||||||
|
path = match.group(2) # the path inside quotes
|
||||||
|
# Calculate what the path SHOULD be
|
||||||
|
if 'src/' not in path:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
# Extract the part after 'src/'
|
||||||
|
idx = path.find('src/')
|
||||||
|
after_src = path[idx + 4:] # everything after 'src/'
|
||||||
|
|
||||||
|
if filepath.parts[-3] == 'src':
|
||||||
|
# File is in src/X/file.js - depth 3
|
||||||
|
# Should be '../<after_src>'
|
||||||
|
new_path = '../' + after_src
|
||||||
|
elif filepath.parts[-4] == 'src':
|
||||||
|
# File is in src/X/Y/file.js - depth 4
|
||||||
|
# Should be '../../<after_src>'
|
||||||
|
new_path = '../../' + after_src
|
||||||
|
elif filepath.parts[-2] == '__tests__' or filepath.parent.name == '__tests__':
|
||||||
|
# File is in __tests__/file.js - depth 1 (relative to api root)
|
||||||
|
# Should be '../src/<after_src>'
|
||||||
|
new_path = '../src/' + after_src
|
||||||
|
elif filepath.parts[-2] == 'routes' and filepath.parts[-3] == '__tests__':
|
||||||
|
# File is in __tests__/routes/file.js - depth 2
|
||||||
|
# Should be '../../src/<after_src>'
|
||||||
|
new_path = '../../src/' + after_src
|
||||||
|
elif 'src' in rel_dir.parts:
|
||||||
|
# Other src nested location
|
||||||
|
# Count how many .. we need
|
||||||
|
src_depth = len(rel_dir.parts) - list(rel_dir.parts).index('src') - 1
|
||||||
|
new_path = '../' * src_depth + after_src
|
||||||
|
else:
|
||||||
|
# Other location, leave it
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
return f"require({quote}{new_path}{quote})"
|
||||||
|
|
||||||
|
new_content = re.sub(
|
||||||
|
r"require\((['\"])([^'\"]*src/[^'\"]*)\1\)",
|
||||||
|
replacer,
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_content != original:
|
||||||
|
filepath.write_text(new_content)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
changed = []
|
||||||
|
for js_file in DASHCADDY_API.rglob('*.js'):
|
||||||
|
# Skip node_modules
|
||||||
|
if 'node_modules' in js_file.parts:
|
||||||
|
continue
|
||||||
|
if fix_file(js_file):
|
||||||
|
changed.append(str(js_file.relative_to(DASHCADDY_API)))
|
||||||
|
|
||||||
|
print(f"Changed {len(changed)} files:")
|
||||||
|
for f in changed:
|
||||||
|
print(f" {f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Refactor helper: rewrites require('./xxx') / require('../xxx') paths in
|
||||||
|
* dashcaddy-api to point to the new src/<subdir>/xxx.js locations.
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. For each require() call with a relative spec:
|
||||||
|
* 2. If the resolved file exists, leave it alone.
|
||||||
|
* 3. If the resolved file does NOT exist, the bare name of the spec
|
||||||
|
* (or the directory name 'dns-providers') might be one of the
|
||||||
|
* modules that was moved out of the repo root. In that case, rewrite
|
||||||
|
* the spec to the correct relative path to the new location.
|
||||||
|
* 4. Otherwise leave alone.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const REPO = process.cwd();
|
||||||
|
|
||||||
|
// Map: bare module name (no extension) -> new repo-relative path (no extension)
|
||||||
|
const NEW_LOCATIONS = {
|
||||||
|
'auth-manager': 'src/managers/auth-manager',
|
||||||
|
'credential-manager': 'src/managers/credential-manager',
|
||||||
|
'license-manager': 'src/managers/license-manager',
|
||||||
|
'port-lock-manager': 'src/managers/port-lock-manager',
|
||||||
|
'state-manager': 'src/managers/state-manager',
|
||||||
|
'notification-manager': 'src/managers/notification-manager',
|
||||||
|
'resource-monitor': 'src/managers/resource-monitor',
|
||||||
|
'config-drift-detector': 'src/managers/config-drift-detector',
|
||||||
|
'auto-restart-manager': 'src/managers/auto-restart-manager',
|
||||||
|
'update-manager': 'src/managers/update-manager',
|
||||||
|
'dependency-manager': 'src/managers/dependency-manager',
|
||||||
|
'csrf-protection': 'src/security/csrf-protection',
|
||||||
|
'crypto-utils': 'src/security/crypto-utils',
|
||||||
|
'docker-security': 'src/security/docker-security',
|
||||||
|
'input-validator': 'src/security/input-validator',
|
||||||
|
'keychain-manager': 'src/security/keychain-manager',
|
||||||
|
'log-digest': 'src/security/log-digest',
|
||||||
|
'audit-logger': 'src/security/audit-logger',
|
||||||
|
'docker-maintenance': 'src/docker/docker-maintenance',
|
||||||
|
'app-templates': 'src/docker/app-templates',
|
||||||
|
'self-updater': 'src/docker/self-updater',
|
||||||
|
'dns-propagation': 'src/dns/dns-propagation',
|
||||||
|
'recipe-templates': 'src/recipes/recipe-templates',
|
||||||
|
'bundled-workflows': 'src/recipes/bundled-workflows',
|
||||||
|
'health-checker': 'src/monitoring/health-checker',
|
||||||
|
'metrics': 'src/monitoring/metrics',
|
||||||
|
'ssl-monitor': 'src/monitoring/ssl-monitor',
|
||||||
|
'backup-manager': 'src/utilities/backup-manager',
|
||||||
|
'error-handler': 'src/utilities/error-handler',
|
||||||
|
'errors': 'src/utilities/errors',
|
||||||
|
'fs-helpers': 'src/utilities/fs-helpers',
|
||||||
|
'pagination': 'src/utilities/pagination',
|
||||||
|
'url-resolver': 'src/utilities/url-resolver',
|
||||||
|
'config-schema': 'src/utilities/config-schema',
|
||||||
|
'constants': 'src/utilities/constants',
|
||||||
|
'middleware': 'src/utilities/middleware',
|
||||||
|
'startup-validator': 'src/utilities/startup-validator',
|
||||||
|
'cache-config': 'src/utilities/cache-config',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKIP_DIRS = new Set(['node_modules', '.git']);
|
||||||
|
const SKIP_FILE_PATTERNS = [/\/scripts\/refactor-requires\.js$/];
|
||||||
|
|
||||||
|
function* walk(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) continue;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
yield* walk(full);
|
||||||
|
} else if (entry.name.endsWith('.js')) {
|
||||||
|
yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRelativeFromFile(filePath, targetRel) {
|
||||||
|
const fromDir = path.dirname(filePath);
|
||||||
|
const targetAbs = path.resolve(REPO, targetRel);
|
||||||
|
let rel = path.relative(fromDir, targetAbs);
|
||||||
|
if (!rel.startsWith('.')) rel = './' + rel;
|
||||||
|
return rel.split(path.sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExistsWithJsOrIndex(p) {
|
||||||
|
// exists if p is a file, or p is a dir with index.js
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p) && fs.statSync(p).isFile()) return true;
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p + '.js') && fs.statSync(p + '.js').isFile()) return true;
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
fs.existsSync(p) &&
|
||||||
|
fs.statSync(p).isDirectory() &&
|
||||||
|
fs.existsSync(path.join(p, 'index.js'))
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
} catch (_) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refactor(filePath) {
|
||||||
|
const relFile = path.relative(REPO, filePath);
|
||||||
|
if (SKIP_FILE_PATTERNS.some((re) => re.test(relFile))) return false;
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const requireRe = /require\(\s*(['"])([^'"]+)\1\s*\)/g;
|
||||||
|
const newContent = content.replace(requireRe, (full, quote, spec) => {
|
||||||
|
if (!spec.startsWith('.')) return full; // package require, leave alone
|
||||||
|
const fromDir = path.dirname(filePath);
|
||||||
|
const resolvedBase = path.resolve(fromDir, spec);
|
||||||
|
// If the resolved file exists, the require is correct as-is.
|
||||||
|
if (fileExistsWithJsOrIndex(resolvedBase)) {
|
||||||
|
// But — check for the special case: require to <REPO>/dns-providers/x
|
||||||
|
// which after move becomes <REPO>/src/dns/dns-providers/x — wait,
|
||||||
|
// that doesn't exist anymore. The dir was moved.
|
||||||
|
const dnsProvidersOld = path.resolve(REPO, 'dns-providers');
|
||||||
|
if (
|
||||||
|
resolvedBase === dnsProvidersOld ||
|
||||||
|
resolvedBase.startsWith(dnsProvidersOld + path.sep)
|
||||||
|
) {
|
||||||
|
const subPath = resolvedBase === dnsProvidersOld ? '' : resolvedBase.slice(dnsProvidersOld.length + 1);
|
||||||
|
const newResolved = path.resolve(REPO, 'src/dns/dns-providers', subPath);
|
||||||
|
let rel = path.relative(fromDir, newResolved);
|
||||||
|
if (!rel.startsWith('.')) rel = './' + rel;
|
||||||
|
const newSpec = rel.split(path.sep).join('/');
|
||||||
|
changed = true;
|
||||||
|
return `require(${quote}${newSpec}${quote})`;
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
// The file does not exist. Check if the bare name is a moved module.
|
||||||
|
const bare = path.basename(resolvedBase);
|
||||||
|
if (bare in NEW_LOCATIONS) {
|
||||||
|
const target = NEW_LOCATIONS[bare];
|
||||||
|
const newSpec = toRelativeFromFile(filePath, target);
|
||||||
|
changed = true;
|
||||||
|
return `require(${quote}${newSpec}${quote})`;
|
||||||
|
}
|
||||||
|
// Bare not in map. Check for the special case: the spec points into
|
||||||
|
// the OLD dns-providers dir (now src/dns/dns-providers). E.g. spec
|
||||||
|
// could be '../dns-providers/registry' or './dns-providers/registry'
|
||||||
|
// from somewhere else.
|
||||||
|
if (spec.includes('dns-providers')) {
|
||||||
|
const dnsProvidersOld = path.resolve(REPO, 'dns-providers');
|
||||||
|
if (
|
||||||
|
resolvedBase === dnsProvidersOld ||
|
||||||
|
resolvedBase.startsWith(dnsProvidersOld + path.sep)
|
||||||
|
) {
|
||||||
|
const subPath =
|
||||||
|
resolvedBase === dnsProvidersOld ? '' : resolvedBase.slice(dnsProvidersOld.length + 1);
|
||||||
|
const newResolved = path.resolve(REPO, 'src/dns/dns-providers', subPath);
|
||||||
|
let rel = path.relative(fromDir, newResolved);
|
||||||
|
if (!rel.startsWith('.')) rel = './' + rel;
|
||||||
|
const newSpec = rel.split(path.sep).join('/');
|
||||||
|
changed = true;
|
||||||
|
return `require(${quote}${newSpec}${quote})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
fs.writeFileSync(filePath, newContent);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const file of walk(REPO)) {
|
||||||
|
if (refactor(file)) {
|
||||||
|
count += 1;
|
||||||
|
console.log('rewrote', path.relative(REPO, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\nDone: rewrote ${count} file(s).`);
|
||||||
+31
-23
@@ -26,14 +26,15 @@ process.on('uncaughtException', (error) => {
|
|||||||
// Load license
|
// Load license
|
||||||
await licenseManager.load();
|
await licenseManager.load();
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = parseInt(process.env.PORT, 10) || 3001;
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
|
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
|
||||||
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
|
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
|
||||||
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
|
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
|
||||||
const CONFIG_FILE = process.env.CONFIG_FILE || platformPaths.servicesFile.replace('services.json', 'config.json');
|
const CONFIG_FILE = process.env.CONFIG_FILE || platformPaths.servicesFile.replace('services.json', 'config.json');
|
||||||
|
|
||||||
// Validate startup configuration
|
// Validate startup configuration
|
||||||
const { validateStartupConfig } = require('./startup-validator');
|
const { validateStartupConfig } = require('../src/utilities/startup-validator');
|
||||||
await validateStartupConfig({
|
await validateStartupConfig({
|
||||||
log,
|
log,
|
||||||
CADDYFILE_PATH,
|
CADDYFILE_PATH,
|
||||||
@@ -44,9 +45,10 @@ process.on('uncaughtException', (error) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start HTTP server
|
// Start HTTP server
|
||||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
const server = app.listen(PORT, HOST, () => {
|
||||||
log.info('server', 'DashCaddy API server started', {
|
log.info('server', 'DashCaddy API server started', {
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
host: HOST,
|
||||||
caddyfile: CADDYFILE_PATH,
|
caddyfile: CADDYFILE_PATH,
|
||||||
caddyAdmin: CADDY_ADMIN_URL,
|
caddyAdmin: CADDY_ADMIN_URL,
|
||||||
services: SERVICES_FILE,
|
services: SERVICES_FILE,
|
||||||
@@ -55,17 +57,17 @@ process.on('uncaughtException', (error) => {
|
|||||||
|
|
||||||
// Attach WebSocket exec handler (with auth)
|
// Attach WebSocket exec handler (with auth)
|
||||||
const attachExecWS = require('./routes/exec');
|
const attachExecWS = require('./routes/exec');
|
||||||
const authManager = require('./auth-manager');
|
const authManager = require('../src/managers/auth-manager');
|
||||||
attachExecWS(server, log, authManager);
|
attachExecWS(server, log, authManager);
|
||||||
log.info('server', 'WebSocket exec handler attached (auth enforced)');
|
log.info('server', 'WebSocket exec handler attached (auth enforced)');
|
||||||
|
|
||||||
// Start feature modules
|
// Start feature modules
|
||||||
const resourceMonitor = require('./resource-monitor');
|
const resourceMonitor = require('../src/managers/resource-monitor');
|
||||||
const backupManager = require('./backup-manager');
|
const backupManager = require('../src/utilities/backup-manager');
|
||||||
const healthChecker = require('./health-checker');
|
const healthChecker = require('../src/monitoring/health-checker');
|
||||||
const updateManager = require('./update-manager');
|
const updateManager = require('../src/managers/update-manager');
|
||||||
const selfUpdater = require('./self-updater');
|
const selfUpdater = require('../src/docker/self-updater');
|
||||||
const portLockManager = require('./port-lock-manager');
|
const portLockManager = require('../src/managers/port-lock-manager');
|
||||||
|
|
||||||
// Create servicesStateManager early — needed by workflow engine init
|
// Create servicesStateManager early — needed by workflow engine init
|
||||||
const StateManager = require('./state-manager');
|
const StateManager = require('./state-manager');
|
||||||
@@ -73,19 +75,22 @@ process.on('uncaughtException', (error) => {
|
|||||||
|
|
||||||
// Optional modules
|
// Optional modules
|
||||||
let dockerMaintenance, logDigest, bundledWorkflows;
|
let dockerMaintenance, logDigest, bundledWorkflows;
|
||||||
try { dockerMaintenance = require('./docker-maintenance'); } catch { /* optional */ }
|
try { dockerMaintenance = require('../src/docker/docker-maintenance'); } catch { /* optional */ }
|
||||||
try { logDigest = require('./log-digest'); } catch { /* optional */ }
|
try { logDigest = require('../src/security/log-digest'); } catch { /* optional */ }
|
||||||
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
|
try { bundledWorkflows = require('../src/recipes/bundled-workflows'); } catch { /* optional */ }
|
||||||
|
|
||||||
// Initialize workflow engine if bundled-workflows is available
|
// 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;
|
let workflowEngine = null;
|
||||||
if (bundledWorkflows) {
|
if (bundledWorkflows) {
|
||||||
try {
|
try {
|
||||||
|
const { fetchT } = require('./src/utils/http');
|
||||||
const { WorkflowEngine } = bundledWorkflows;
|
const { WorkflowEngine } = bundledWorkflows;
|
||||||
// Create a context with needed services
|
// Create a context with needed services
|
||||||
const workflowCtx = {
|
const workflowCtx = {
|
||||||
docker: { client: require('dockerode')() },
|
docker: { client: require('dockerode')() },
|
||||||
notification: new (require('./notification-manager'))({
|
notification: new (require('./src/managers/notification-manager'))({
|
||||||
NOTIFICATIONS_FILE: process.env.NOTIFICATIONS_FILE || require('./platform-paths').notificationsFile,
|
NOTIFICATIONS_FILE: process.env.NOTIFICATIONS_FILE || require('./platform-paths').notificationsFile,
|
||||||
fetchT,
|
fetchT,
|
||||||
log,
|
log,
|
||||||
@@ -137,7 +142,10 @@ process.on('uncaughtException', (error) => {
|
|||||||
// Health checker (with service sync)
|
// Health checker (with service sync)
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { syncHealthCheckerServices } = require('./startup-validator');
|
const { syncHealthCheckerServices } = require('../src/utilities/startup-validator');
|
||||||
|
const StateManager = require('../src/managers/state-manager');
|
||||||
|
const servicesStateManager = new StateManager(SERVICES_FILE);
|
||||||
|
|
||||||
|
|
||||||
await syncHealthCheckerServices({
|
await syncHealthCheckerServices({
|
||||||
log,
|
log,
|
||||||
@@ -148,7 +156,7 @@ process.on('uncaughtException', (error) => {
|
|||||||
? `https://${config.domain}/${subdomain}`
|
? `https://${config.domain}/${subdomain}`
|
||||||
: `https://${subdomain}${config.tld}`,
|
: `https://${subdomain}${config.tld}`,
|
||||||
siteConfig: config,
|
siteConfig: config,
|
||||||
APP: require('./constants').APP
|
APP: require('../src/utilities/constants').APP
|
||||||
});
|
});
|
||||||
|
|
||||||
healthChecker.start();
|
healthChecker.start();
|
||||||
@@ -230,11 +238,11 @@ process.on('uncaughtException', (error) => {
|
|||||||
const shutdown = (signal) => {
|
const shutdown = (signal) => {
|
||||||
log.info('shutdown', `${signal} received, draining connections...`);
|
log.info('shutdown', `${signal} received, draining connections...`);
|
||||||
|
|
||||||
const resourceMonitor = require('./resource-monitor');
|
const resourceMonitor = require('../src/managers/resource-monitor');
|
||||||
const backupManager = require('./backup-manager');
|
const backupManager = require('../src/utilities/backup-manager');
|
||||||
const healthChecker = require('./health-checker');
|
const healthChecker = require('../src/monitoring/health-checker');
|
||||||
const updateManager = require('./update-manager');
|
const updateManager = require('../src/managers/update-manager');
|
||||||
const selfUpdater = require('./self-updater');
|
const selfUpdater = require('../src/docker/self-updater');
|
||||||
|
|
||||||
resourceMonitor.stop();
|
resourceMonitor.stop();
|
||||||
backupManager.stop();
|
backupManager.stop();
|
||||||
@@ -243,12 +251,12 @@ process.on('uncaughtException', (error) => {
|
|||||||
selfUpdater.stop();
|
selfUpdater.stop();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dockerMaintenance = require('./docker-maintenance');
|
const dockerMaintenance = require('../src/docker/docker-maintenance');
|
||||||
dockerMaintenance.stop();
|
dockerMaintenance.stop();
|
||||||
} catch { /* optional */ }
|
} catch { /* optional */ }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const logDigest = require('./log-digest');
|
const logDigest = require('../src/security/log-digest');
|
||||||
logDigest.stop();
|
logDigest.stop();
|
||||||
} catch { /* optional */ }
|
} catch { /* optional */ }
|
||||||
|
|
||||||
|
|||||||
+263
-51
@@ -17,40 +17,41 @@ const { errorResponse, ok } = require('./utils/responses');
|
|||||||
const { asyncHandler } = require('./utils/async-handler');
|
const { asyncHandler } = require('./utils/async-handler');
|
||||||
|
|
||||||
// Managers and utilities
|
// Managers and utilities
|
||||||
const StateManager = require('../state-manager');
|
const StateManager = require('managers/state-manager');
|
||||||
const { LicenseManager } = require('../license-manager');
|
const platformPaths = require('../platform-paths');
|
||||||
const credentialManager = require('../credential-manager');
|
const { LicenseManager } = require('managers/license-manager');
|
||||||
const authManager = require('../auth-manager');
|
const credentialManager = require('managers/credential-manager');
|
||||||
const dockerSecurity = require('../docker-security');
|
const authManager = require('managers/auth-manager');
|
||||||
const auditLogger = require('../audit-logger');
|
const dockerSecurity = require('security/docker-security');
|
||||||
const portLockManager = require('../port-lock-manager');
|
const auditLogger = require('security/audit-logger');
|
||||||
const resourceMonitor = require('../resource-monitor');
|
const portLockManager = require('managers/port-lock-manager');
|
||||||
const backupManager = require('../backup-manager');
|
const resourceMonitor = require('managers/resource-monitor');
|
||||||
const healthChecker = require('../health-checker');
|
const backupManager = require('utilities/backup-manager');
|
||||||
const updateManager = require('../update-manager');
|
const healthChecker = require('monitoring/health-checker');
|
||||||
const selfUpdater = require('../self-updater');
|
const updateManager = require('managers/update-manager');
|
||||||
const configureMiddleware = require('../middleware');
|
const selfUpdater = require('docker/self-updater');
|
||||||
const { syncHealthCheckerServices } = require('../startup-validator');
|
const configureMiddleware = require('utilities/middleware');
|
||||||
const { CSRF_HEADER_NAME } = require('../csrf-protection');
|
const { validateStartupConfig: _validateStartupConfig, syncHealthCheckerServices } = require('utilities/startup-validator');
|
||||||
const { resolveServiceUrl } = require('../url-resolver');
|
const { CSRF_HEADER_NAME } = require('security/csrf-protection');
|
||||||
const metrics = require('../metrics');
|
const { resolveServiceUrl } = require('utilities/url-resolver');
|
||||||
const { validateURL } = require('../input-validator');
|
const metrics = require('monitoring/metrics');
|
||||||
|
const { validateURL } = require('security/input-validator');
|
||||||
|
|
||||||
// Optional modules
|
// Optional modules
|
||||||
let dockerMaintenance, logDigest;
|
let dockerMaintenance, logDigest;
|
||||||
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
|
try { dockerMaintenance = require('docker/docker-maintenance'); } catch (_) { /* optional module */ }
|
||||||
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
|
try { logDigest = require('security/log-digest'); } catch (_) { /* optional module */ }
|
||||||
|
|
||||||
// Workflow engine (bundled workflows)
|
// Workflow engine (bundled workflows)
|
||||||
let bundledWorkflowsModule;
|
let bundledWorkflowsModule;
|
||||||
let workflowEngine = null;
|
let workflowEngine = null;
|
||||||
try {
|
try {
|
||||||
bundledWorkflowsModule = require('../bundled-workflows');
|
bundledWorkflowsModule = require('recipes/bundled-workflows');
|
||||||
} catch (_) { /* optional module */ }
|
} catch (_) { /* optional module */ }
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
|
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('docker/app-templates');
|
||||||
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
|
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('recipes/recipe-templates');
|
||||||
|
|
||||||
// Route modules
|
// Route modules
|
||||||
const healthRoutes = require('../routes/health');
|
const healthRoutes = require('../routes/health');
|
||||||
@@ -79,16 +80,39 @@ const themesRoutes = require('../routes/themes');
|
|||||||
const dockerResourcesRoutes = require('../routes/docker-resources');
|
const dockerResourcesRoutes = require('../routes/docker-resources');
|
||||||
const eventsRoutes = require('../routes/events');
|
const eventsRoutes = require('../routes/events');
|
||||||
const workflowsRoutes = require('../routes/workflows');
|
const workflowsRoutes = require('../routes/workflows');
|
||||||
|
const dependenciesRoutes = require('../routes/dependencies');
|
||||||
|
const DependencyManager = require('managers/dependency-manager');
|
||||||
|
const autoRestartRoutes = require('../routes/auto-restart');
|
||||||
|
const configDriftRoutes = require('../routes/config-drift');
|
||||||
|
const sslMonitorRoutes = require('../routes/ssl-monitor');
|
||||||
|
const { AutoRestartManager } = require('managers/auto-restart-manager');
|
||||||
|
const { ConfigDriftDetector } = require('managers/config-drift-detector');
|
||||||
|
const SSLMonitor = require('monitoring/ssl-monitor');
|
||||||
|
const DNSPropagationChecker = require('dns/dns-propagation');
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const { APP } = require('../constants');
|
const { APP } = require('utilities/constants');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create and configure the Express application
|
* Create and configure the Express application
|
||||||
*/
|
*/
|
||||||
function createApp() {
|
// eslint-disable-next-line require-await -- kept async for API consistency with other factory functions
|
||||||
|
async function createApp() {
|
||||||
const app = express();
|
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
|
// Initialize logging
|
||||||
const log = createLogger(config.LOG_LEVEL);
|
const log = createLogger(config.LOG_LEVEL);
|
||||||
|
|
||||||
@@ -104,7 +128,7 @@ function createApp() {
|
|||||||
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
||||||
|
|
||||||
// HTTPS agent for internal CA
|
// HTTPS agent for internal CA
|
||||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
||||||
let httpsAgent;
|
let httpsAgent;
|
||||||
try {
|
try {
|
||||||
const caCert = fs.readFileSync(CA_CERT_PATH);
|
const caCert = fs.readFileSync(CA_CERT_PATH);
|
||||||
@@ -161,7 +185,26 @@ function createApp() {
|
|||||||
return first === 100 && second >= 64 && second <= 127;
|
return first === 100 && second >= 64 && second <= 127;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTailscaleStatus() {
|
function isPrivateLan(ip) {
|
||||||
|
if (!ip) return false;
|
||||||
|
if (ip.startsWith('192.168.') || ip.startsWith('10.')) return true;
|
||||||
|
return /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNetworkInterfaces(osModule) {
|
||||||
|
const out = [];
|
||||||
|
const interfaces = osModule.networkInterfaces();
|
||||||
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||||
|
for (const addr of addrs) {
|
||||||
|
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||||
|
out.push({ name, ip: addr.address });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line require-await -- stub for now, will gain await when wired into context
|
||||||
|
async function getTailscaleStatus() {
|
||||||
// Stub for now - will be populated by context
|
// Stub for now - will be populated by context
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -190,15 +233,15 @@ function createApp() {
|
|||||||
auditLogger,
|
auditLogger,
|
||||||
authManager,
|
authManager,
|
||||||
log,
|
log,
|
||||||
cryptoUtils: require('../crypto-utils'),
|
cryptoUtils: require('security/crypto-utils'),
|
||||||
isValidContainerId,
|
isValidContainerId,
|
||||||
isTailscaleIP,
|
isTailscaleIP,
|
||||||
getTailscaleStatus,
|
getTailscaleStatus,
|
||||||
RATE_LIMITS: require('../constants').RATE_LIMITS,
|
RATE_LIMITS: require('utilities/constants').RATE_LIMITS,
|
||||||
LIMITS: require('../constants').LIMITS,
|
LIMITS: require('utilities/constants').LIMITS,
|
||||||
APP: require('../constants').APP,
|
APP: require('utilities/constants').APP,
|
||||||
CACHE_CONFIGS: require('../cache-config').CACHE_CONFIGS,
|
CACHE_CONFIGS: require('utilities/cache-config').CACHE_CONFIGS,
|
||||||
createCache: require('../cache-config').createCache,
|
createCache: require('utilities/cache-config').createCache,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { strictLimiter } = middlewareResult;
|
const { strictLimiter } = middlewareResult;
|
||||||
@@ -209,9 +252,10 @@ function createApp() {
|
|||||||
return services.find(s => s.id === serviceId) || null;
|
return services.find(s => s.id === serviceId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line require-await -- may grow awaits as config loading evolves
|
||||||
async function readConfig() {
|
async function readConfig() {
|
||||||
const { readJsonFile } = require('../fs-helpers');
|
const { readJsonFile } = require('utilities/fs-helpers');
|
||||||
return await readJsonFile(config.CONFIG_FILE, {});
|
return readJsonFile(config.CONFIG_FILE, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig(updates) {
|
async function saveConfig(updates) {
|
||||||
@@ -233,7 +277,7 @@ function createApp() {
|
|||||||
|
|
||||||
async function saveTotpConfig() {
|
async function saveTotpConfig() {
|
||||||
try {
|
try {
|
||||||
const { writeJsonFile } = require('../fs-helpers');
|
const { writeJsonFile } = require('utilities/fs-helpers');
|
||||||
await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig);
|
await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('config', 'Could not save TOTP config', { error: e.message });
|
log.error('config', 'Could not save TOTP config', { error: e.message });
|
||||||
@@ -356,9 +400,64 @@ 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
|
// Build versioned API router
|
||||||
const apiRouter = express.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) => {
|
||||||
|
ok(res, {
|
||||||
|
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
|
// Wire up notification listeners for resourceMonitor and backupManager
|
||||||
if (ctx.notification && ctx.resourceMonitor) {
|
if (ctx.notification && ctx.resourceMonitor) {
|
||||||
ctx.resourceMonitor.on('alert', (alertData) => {
|
ctx.resourceMonitor.on('alert', (alertData) => {
|
||||||
@@ -396,7 +495,8 @@ function createApp() {
|
|||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
safeErrorMessage: ctx.safeErrorMessage,
|
safeErrorMessage: ctx.safeErrorMessage,
|
||||||
fetchT: ctx.fetchT,
|
fetchT: ctx.fetchT,
|
||||||
credentialManager: ctx.credentialManager
|
credentialManager: ctx.credentialManager,
|
||||||
|
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||||
}));
|
}));
|
||||||
apiRouter.use('/notifications', notificationRoutes({
|
apiRouter.use('/notifications', notificationRoutes({
|
||||||
notification: ctx.notification,
|
notification: ctx.notification,
|
||||||
@@ -516,26 +616,55 @@ function createApp() {
|
|||||||
healthChecker: ctx.healthChecker,
|
healthChecker: ctx.healthChecker,
|
||||||
updateManager: ctx.updateManager,
|
updateManager: ctx.updateManager,
|
||||||
logError: ctx.logError,
|
logError: ctx.logError,
|
||||||
ok: ctx.ok
|
ok: ctx.ok,
|
||||||
|
dependencyManager: ctx.dependencyManager,
|
||||||
|
autoRestartManager: ctx.autoRestartManager,
|
||||||
|
driftDetector: ctx.driftDetector,
|
||||||
|
sslMonitor: ctx.sslMonitor,
|
||||||
|
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||||
}));
|
}));
|
||||||
apiRouter.use(workflowsRoutes({
|
apiRouter.use('/workflows', workflowsRoutes({
|
||||||
workflowEngine: ctx.workflowEngine,
|
workflowEngine: ctx.workflowEngine,
|
||||||
licenseManager: ctx.licenseManager,
|
licenseManager: ctx.licenseManager,
|
||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
ok: ctx.ok
|
ok: ctx.ok
|
||||||
}));
|
}));
|
||||||
|
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
|
// Inline API routes
|
||||||
apiRouter.get('/health', (req, res) => {
|
apiRouter.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
ok(res, { status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
apiRouter.get('/csrf-token', (req, res) => {
|
apiRouter.get('/csrf-token', (req, res) => {
|
||||||
res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME });
|
ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME });
|
||||||
});
|
});
|
||||||
|
|
||||||
apiRouter.get('/metrics', (req, res) => {
|
apiRouter.get('/metrics', (req, res) => {
|
||||||
res.json({ success: true, metrics: metrics.getSummary() });
|
ok(res, { metrics: metrics.getSummary() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount at /api/v1 (canonical, single version)
|
// Mount at /api/v1 (canonical, single version)
|
||||||
@@ -543,13 +672,93 @@ function createApp() {
|
|||||||
|
|
||||||
// Root-level health check
|
// Root-level health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
ok(res, { status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Liveness probe — "is the process alive?"
|
||||||
|
// Always returns 200 unless the Node.js event loop is completely blocked.
|
||||||
|
// Used by k8s/Docker to decide whether to RESTART the container.
|
||||||
|
// DO NOT add dependency checks here — those belong in /health/ready.
|
||||||
|
app.get('/health/live', (req, res) => {
|
||||||
|
ok(res, { status: 'alive', uptime: process.uptime() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Readiness probe — "is the app ready to serve traffic?"
|
||||||
|
// Checks critical dependencies: Docker daemon, Caddy admin API, config file.
|
||||||
|
// Returns 200 with details if all OK, 503 with failed components otherwise.
|
||||||
|
// Used by k8s/Docker to decide whether to ROUTE TRAFFIC to this instance.
|
||||||
|
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
|
||||||
|
const checks = {};
|
||||||
|
let allOk = true;
|
||||||
|
|
||||||
|
// Check 1: Config file readable
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
if (fs.existsSync(config.CONFIG_FILE)) {
|
||||||
|
fs.readFileSync(config.CONFIG_FILE, 'utf8');
|
||||||
|
checks.configFile = { ok: true };
|
||||||
|
} else {
|
||||||
|
checks.configFile = { ok: false, error: 'Config file not found' };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
checks.configFile = { ok: false, error: e.message };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: Services file readable
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
if (fs.existsSync(config.SERVICES_FILE)) {
|
||||||
|
fs.readFileSync(config.SERVICES_FILE, 'utf8');
|
||||||
|
checks.servicesFile = { ok: true };
|
||||||
|
} else {
|
||||||
|
checks.servicesFile = { ok: false, error: 'Services file not found' };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
checks.servicesFile = { ok: false, error: e.message };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: Docker daemon reachable
|
||||||
|
try {
|
||||||
|
const docker = require('dockerode')();
|
||||||
|
await docker.ping();
|
||||||
|
checks.docker = { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
checks.docker = { ok: false, error: e.message };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 4: Caddy admin API reachable
|
||||||
|
try {
|
||||||
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||||
|
const response = await fetch(`${caddyUrl}/config/`, {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
checks.caddy = { ok: response.ok, status: response.status };
|
||||||
|
if (!response.ok) allOk = false;
|
||||||
|
} catch (e) {
|
||||||
|
checks.caddy = { ok: false, error: e.message };
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
status: allOk ? 'ready' : 'not-ready',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks
|
||||||
|
};
|
||||||
|
ok(res, body, allOk ? 200 : 503);
|
||||||
|
}));
|
||||||
|
|
||||||
// Lightweight probe endpoint
|
// Lightweight probe endpoint
|
||||||
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('utilities/fs-helpers');
|
||||||
|
|
||||||
let service = null;
|
let service = null;
|
||||||
if (id !== 'internet' && await exists(config.SERVICES_FILE)) {
|
if (id !== 'internet' && await exists(config.SERVICES_FILE)) {
|
||||||
@@ -676,13 +885,16 @@ function createApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!envLan || !envTailscale) {
|
if (!envLan || !envTailscale) {
|
||||||
const detected = detectInterfaceIps();
|
result.all = collectNetworkInterfaces(os);
|
||||||
if (!result.lan) result.lan = detected.lan;
|
if (!result.tailscale) {
|
||||||
if (!result.tailscale) result.tailscale = detected.tailscale;
|
result.tailscale = result.all.find(i => isTailscaleIP(i.ip))?.ip || null;
|
||||||
result.all = detected.all;
|
}
|
||||||
|
if (!result.lan) {
|
||||||
|
result.lan = result.all.find(i => isPrivateLan(i.ip))?.ip || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(result);
|
ok(res, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, 500, safeErrorMessage(error));
|
errorResponse(res, 500, safeErrorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -709,7 +921,7 @@ function createApp() {
|
|||||||
|
|
||||||
app.get('/api/v1/docs/spec', boundAsyncHandler(async (req, res) => {
|
app.get('/api/v1/docs/spec', boundAsyncHandler(async (req, res) => {
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('utilities/fs-helpers');
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
|
|
||||||
const specPath = path.join(__dirname, '../openapi.yaml');
|
const specPath = path.join(__dirname, '../openapi.yaml');
|
||||||
@@ -722,7 +934,7 @@ function createApp() {
|
|||||||
}, 'api-docs-spec'));
|
}, 'api-docs-spec'));
|
||||||
|
|
||||||
// Error handlers (MUST be last)
|
// Error handlers (MUST be last)
|
||||||
const { notFoundHandler, errorMiddleware } = require('../error-handler');
|
const { notFoundHandler, errorMiddleware } = require('utilities/error-handler');
|
||||||
app.use('/api', notFoundHandler);
|
app.use('/api', notFoundHandler);
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
const paths = require('./paths');
|
const paths = require('./paths');
|
||||||
const site = require('./site');
|
const site = require('./site');
|
||||||
const { APP, LIMITS, TIMEOUTS, RETRIES, CADDY } = require('../../constants');
|
const { APP, LIMITS, TIMEOUTS, RETRIES, CADDY } = require('../utilities/constants');
|
||||||
|
|
||||||
// Load logging level
|
// Load logging level
|
||||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 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,10 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Site configuration loader
|
* Site configuration loader
|
||||||
* Loads and manages site-wide settings from config.json
|
* 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('../utilities/config-schema');
|
||||||
const { validateConfig } = require('../../config-schema');
|
const { CADDY } = require('../utilities/constants');
|
||||||
const { CADDY } = require('../../constants');
|
const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
|
||||||
|
|
||||||
const siteConfig = {
|
const siteConfig = {
|
||||||
tld: '.home',
|
tld: '.home',
|
||||||
@@ -19,7 +24,7 @@ const siteConfig = {
|
|||||||
routingMode: 'subdomain'
|
routingMode: 'subdomain'
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyRawConfig(raw) {
|
function applyConfigFields(raw) {
|
||||||
siteConfig.tld = raw.tld || '.home';
|
siteConfig.tld = raw.tld || '.home';
|
||||||
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
|
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
|
||||||
siteConfig.caName = raw.caName || '';
|
siteConfig.caName = raw.caName || '';
|
||||||
@@ -33,24 +38,27 @@ function applyRawConfig(raw) {
|
|||||||
siteConfig.routingMode = raw.routingMode || 'subdomain';
|
siteConfig.routingMode = raw.routingMode || 'subdomain';
|
||||||
siteConfig.pylon = raw.pylon || null;
|
siteConfig.pylon = raw.pylon || null;
|
||||||
}
|
}
|
||||||
|
function validateAndLogConfig(raw, log) {
|
||||||
|
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
|
||||||
|
if (log && log.warn) {
|
||||||
|
if (!valid) {
|
||||||
|
log.warn('config', 'Config validation errors', { errors: configErrors });
|
||||||
|
}
|
||||||
|
for (const w of configWarnings) {
|
||||||
|
log.warn('config', w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadSiteConfig(CONFIG_FILE, log) {
|
function loadSiteConfig(CONFIG_FILE, log) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
// Run migrations first — this handles config.json files from older
|
||||||
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
// versions of DashCaddy and writes the migrated version back to disk.
|
||||||
|
const raw = loadAndMigrate(CONFIG_FILE, log);
|
||||||
|
|
||||||
// Validate config and log any issues
|
if (raw && Object.keys(raw).length > 0) {
|
||||||
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
|
validateAndLogConfig(raw, log);
|
||||||
if (log && log.warn) {
|
applyConfigFields(raw);
|
||||||
if (!valid) {
|
|
||||||
log.warn('config', 'Config validation errors', { errors: configErrors });
|
|
||||||
}
|
|
||||||
for (const w of configWarnings) {
|
|
||||||
log.warn('config', w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyRawConfig(raw);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (log && log.error) {
|
if (log && log.error) {
|
||||||
@@ -80,4 +88,5 @@ module.exports = {
|
|||||||
loadSiteConfig,
|
loadSiteConfig,
|
||||||
buildDomain,
|
buildDomain,
|
||||||
buildServiceUrl,
|
buildServiceUrl,
|
||||||
|
CURRENT_VERSION
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Caddy context - Caddyfile manipulation and reload
|
* Caddy context - Caddyfile manipulation and reload
|
||||||
*/
|
*/
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const { RETRIES } = require('../../constants');
|
const { RETRIES } = require('../utilities/constants');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically read-modify-write the Caddyfile and reload Caddy.
|
* Atomically read-modify-write the Caddyfile and reload Caddy.
|
||||||
@@ -43,6 +43,7 @@ async function modifyCaddyfile(CADDYFILE_PATH, reloadCaddy, modifyFn) {
|
|||||||
/**
|
/**
|
||||||
* Read the current Caddyfile content
|
* Read the current Caddyfile content
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line require-await -- fsp.readFile already returns a promise
|
||||||
async function readCaddyfile(CADDYFILE_PATH) {
|
async function readCaddyfile(CADDYFILE_PATH) {
|
||||||
return await fsp.readFile(CADDYFILE_PATH, 'utf8');
|
return await fsp.readFile(CADDYFILE_PATH, 'utf8');
|
||||||
}
|
}
|
||||||
@@ -93,9 +94,8 @@ async function verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts
|
|||||||
try {
|
try {
|
||||||
const response = await fetchT(`https://${domain}/`, {
|
const response = await fetchT(`https://${domain}/`, {
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
agent: httpsAgent,
|
agent: httpsAgent
|
||||||
timeout: 5000
|
}, 5000);
|
||||||
});
|
|
||||||
|
|
||||||
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user