Compare commits
29
Commits
v1.7.0
..
4a66962f19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a66962f19 | ||
|
|
c77fc65c1f | ||
|
|
7f6be1c2b3 | ||
|
|
54744536b3 | ||
|
|
2f50998105 | ||
|
|
c509f6ff10 | ||
|
|
bf515e5415 | ||
|
|
57549e3e0c | ||
|
|
f457da7d1f | ||
|
|
1da341b1c5 | ||
|
|
a37e79a8fc | ||
|
|
92bcafb4f1 | ||
|
|
16276c62fc | ||
|
|
3b412bff3b | ||
|
|
f71e5c52d4 | ||
|
|
44af47d344 | ||
|
|
6809fc5cca | ||
|
|
4853f1feb8 | ||
|
|
ef855e3fd7 | ||
|
|
3dff49cdc5 | ||
|
|
d230b39948 | ||
|
|
7bbd969fa2 | ||
|
|
4f377970d7 | ||
|
|
7f0d43943c | ||
|
|
9ab947a394 | ||
|
|
ad9400490d | ||
|
|
ea9bdf9598 | ||
|
|
c52016d727 | ||
|
|
588188edb5 |
+15
@@ -2,6 +2,8 @@
|
||||
node_modules/
|
||||
|
||||
# Runtime state/config files (generated, not source)
|
||||
# Note: data/ subdir contains runtime state (credentials, secrets, history) — never commit
|
||||
dashcaddy-api/data/
|
||||
dashcaddy-api/credentials.json
|
||||
dashcaddy-api/.env
|
||||
.env
|
||||
@@ -17,6 +19,19 @@ dashcaddy-api/update-config.json
|
||||
dashcaddy-api/update-history.json
|
||||
dashcaddy-api/dashcaddy-errors.log
|
||||
|
||||
# Auto-updater backups (created by dashcaddy-update.sh when rolling back)
|
||||
start.sh.bak*
|
||||
scripts/*.bak*
|
||||
|
||||
# Auto-updater runtime state (history + secrets + staging)
|
||||
updates/
|
||||
|
||||
# Scratch / debug scripts (left over from past sessions)
|
||||
cm_check*.js
|
||||
full_test.js
|
||||
login_test.js
|
||||
login_backup_test.js
|
||||
|
||||
# Build output
|
||||
dashcaddy-installer/build-output/
|
||||
dashcaddy-installer/dist/
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# DashCaddy Improvement Backlog
|
||||
|
||||
> **Shared coordination file for Hermes & Krystie.**
|
||||
> Both bots read this, claim tasks, and update status. Git is the source of truth.
|
||||
> When claiming: change `status: todo` to `status: in-progress` and set `owner`.
|
||||
> When done: change to `status: done` and add brief result.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Must Fix (blocks public release)
|
||||
|
||||
### DC-001: Fix 4 failing tests in services.routes.test.js
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Credential storage tests failing since before v1.13.4. Run `cd dashcaddy-api && npx jest __tests__/routes/services.routes.test.js` to see failures. Fix the root cause, not the test.
|
||||
- **result:** Root cause: routes used `/:serviceId/credentials` (missing `/services/` segment). All 3 credential routes (POST/DELETE/GET) in `routes/services.js` had the wrong path. Fixed to `/services/:serviceId/credentials` — matches the URL pattern used by the live frontend and all 759 tests pass.
|
||||
|
||||
### DC-011: Fix DC-001 regression reintroduced by src/ refactor (4 failing tests)
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** The module-flattening refactor (DC-005) force-pushed to `main` dropped the DC-001 route-prefix fix. `routes/services.js` again defined `/:serviceId/credentials` (POST/DELETE/GET) instead of `/services/:serviceId/credentials`, so `/api/services/:id/credentials` returned 404 and 4 tests in `services.routes.test.js` failed. Baseline: `npx jest` → 4 failed, 746 passed.
|
||||
- **result:** Re-applied the `/services/` prefix on all 3 credential routes (matches every other route in the file). Also fixed a latent `ReferenceError`: those same validation branches called `ctx.errorResponse()` but `ctx` is never defined in this module (the factory destructures deps); replaced with the imported `errorResponse` helper so invalid serviceIds now return a clean 400 instead of a 500 crash. Result: 750/750 tests pass (4 failed → 0), zero new ESLint warnings. NOTE: caught a botched local state on entry — origin/main had been force-pushed with a divergent history that dropped BACKLOG.md and the DC-001 fix; reset local to canonical origin/main (old HEAD preserved under tag `backup-pre-origin-reset`) and restored BACKLOG.md.
|
||||
|
||||
### DC-002: Sync VERSION file
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `/root/dashcaddy/VERSION` says `1.13.0` but `package.json` says `1.13.4`. VERSION file should always match package.json. Add a pre-commit or post-version bump hook to keep them in sync.
|
||||
- **result:** Fixed root VERSION to 1.13.4. Updated `scripts/release.sh` to write both `dashcaddy-api/package.json` AND root `VERSION` on every release — also stages VERSION in the release commit. No more drift.
|
||||
|
||||
### DC-003: Remove stale test/debug files from repo root
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `comprehensive-test.js` and `test-security-fixes.js` are ad-hoc test scripts, not Jest tests. They clutter the repo root. Remove them or convert to proper Jest tests under `__tests__/`.
|
||||
- **result:** Moved both files to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — they are 875 lines of security test coverage that may be useful as a manual smoke test). Zero references to them in code/docs — safe to move. All 759 Jest tests still pass.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Code Quality
|
||||
|
||||
### DC-004: Fix 19 ESLint warnings
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Run `cd dashcaddy-api && npx eslint src/ --format compact`. Most are unused vars and nested ternaries in `src/utils/logging.js`. Fix all, target zero warnings.
|
||||
- **result:** Reached zero ESLint warnings across `src/`. Most of the original 19 were cleared by the DC-005 refactor and logging cleanup; the final 3 were in `src/app.js`: (1) `require-await` on `resyncHealthChecker` — dropped the now-pointless `async` keyword since it only forwards a promise (callers already use `.catch()`); (2)+(3) two `max-depth` violations in the `/api/v1/network/ips` handler — extracted the interface-enumeration logic into a `detectInterfaceIps()` helper, keeping the route handler flat. `npx eslint src/` now reports 0 problems; 750/750 Jest tests still pass.
|
||||
|
||||
### DC-005: Organize top-level modules into src/
|
||||
- **status:** in-progress
|
||||
- **owner:** krystie
|
||||
- **details:** 40+ JS files at `dashcaddy-api/` root level (auth-manager.js, credential-manager.js, etc.). Move into organized subdirs under `src/` (e.g., `src/managers/`, `src/security/`, `src/docker/`). Update all require() paths. This is a big refactor — run tests after.
|
||||
|
||||
### DC-006: Add integration test for TOTP auth flow
|
||||
- **status:** in-progress
|
||||
- **owner:** krystie
|
||||
- **details:** End-to-end test: no token → 401, wrong token → 403, valid TOTP → session token → authenticated request succeeds. Cover the full `/api/auth/check` → session → endpoint flow.
|
||||
|
||||
### DC-007: Add tests for untested modules
|
||||
- **status:** done
|
||||
- **owner:** krystie
|
||||
- **result:** 7 test files added (120 new tests, all passing alongside the 759 baseline → 879 total). Files: `__tests__/dns-propagation.test.js` (9), `__tests__/notification-manager.test.js` (18), `__tests__/ssl-monitor.test.js` (13), `__tests__/log-digest.test.js` (11), `__tests__/metrics.test.js` (21), `__tests__/config-drift-detector.test.js` (19), `__tests__/auto-restart-manager.test.js` (29).
|
||||
- **details:** These modules have NO test coverage: `dns-propagation.js`, `notification-manager.js`, `ssl-monitor.js`, `log-digest.js`, `metrics.js`, `config-drift-detector.js`, `auto-restart-manager.js`. Add at least basic smoke tests for each.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Polish & DX
|
||||
|
||||
### DC-008: Update CLAUDE.md for cross-platform accuracy
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** CLAUDE.md references Windows-specific paths (C:/caddy/, e:/CaddyCerts/) as if they're universal. DashCaddy runs on Linux (Docker on DNS2) and Windows (SAMI-PC). Document both deployment targets clearly.
|
||||
- **result:** Added a new "Linux Deployment (DNS2 / Contabo VPS)" section after the existing Windows docs (preserved verbatim) and before the "Project Info" footer. The new section documents: production paths (`/opt/dashcaddy/`, `/var/www/dashcaddy-status/`, `/etc/dashcaddy/`), container mount points with the `/app/data/` auto-resolve fallback, the three-filesystem frontend trap (source vs live vs build-context), common admin commands, a Windows-vs-Linux differences table, and four Linux-specific gotchas (Caddy network_mode host, credentials.json perms, CORS_ORIGINS vs Tailscale, TS_AUTHKEY provisioning). Also updated the "Project Info" version field from stale `1.0` to current `1.13.4` and added the Linux-side default TLD (`.home`).
|
||||
|
||||
### DC-009: Add CHANGELOG entry for any unreleased work
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `[Unreleased]` section in CHANGELOG.md is empty. Any fixes done should be documented there before tagging a release.
|
||||
- **result:** Populated the `[Unreleased]` section with all unreleased work since v1.5.0: Security (TOTP 4-part recovery), Added (OpenClaw routes, auto-backup, monitoring widget, Sami Files template, unified logger, notification manager, update UX, 120 new tests across 7 files), Changed (DC-010 response standardization across 9 route files, /api/v1/ versioning, release.sh hardening), Fixed (DC-011 credential route regression, DC-004 ESLint cleanup, workflow engine init, container-logs wireModal misuse, CSP hash mismatch, SW cache tag, updater false-positive loop), Removed (legacy test scripts moved to scripts/legacy/ preserved-not-deleted, stale root files, dead routes/ directory). Each entry cites the source commit hash for traceability.
|
||||
|
||||
### DC-010: Standardize error response shapes
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** v1.13.4 standardized route responses to use helpers, but some modules still use raw `res.json()`. Grep for remaining `res.json(` in route handlers and convert to response helpers.
|
||||
- **result:** All bare `{success: true, ...}` envelopes across route files now go through `success()` (or `ok()` where the older alias is wired in). Files converted in this push (4 commits): browse/logs/sites (cron), updates/notifications/tailscale/events/workflows/openclaw/dns/health/ca (this sprint) — 9 files, 62 calls. `services.js` line 360+368 left alone (intentional raw-array responses for the frontend wire contract — separate cleanup). Error-path `res.status(4xx/5xx).json({success:false, error:...})` envelopes also left as-is (`ok()` helper would set `success:true` — wrong tool for error shapes). Net result: only 2 intentional raw-array calls remain in routes/; everything else routes through `response-helpers`. 750/750 tests pass at every checkpoint.
|
||||
|
||||
---
|
||||
|
||||
## Coordination Rules
|
||||
|
||||
1. **Always `git pull` before starting work.**
|
||||
2. **Claim a task by editing BACKLOG.md:** set `status: in-progress` and `owner: hermes` or `owner: krystie`.
|
||||
3. **Commit BACKLOG.md claim first**, then start coding.
|
||||
4. **Run tests before pushing:** `cd dashcaddy-api && npx jest --passWithNoTests`
|
||||
5. **Push to `main`** — use `http://sami7777:<token>@100.98.123.59:3000/sami7777/dashcaddy.git`
|
||||
6. **Update BACKLOG.md** when done: set `status: done`, add brief result under the task.
|
||||
7. **Never work on a task another bot has claimed** (status: in-progress).
|
||||
8. **Quality bar:** this is a public-release product. No hacks, no env-var workarounds, no per-machine patches. Fixes go in the shared codebase.
|
||||
9. **VERSION bump:** when a batch of tasks is done, bump patch version in package.json + VERSION file, update CHANGELOG, tag.
|
||||
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- **TOTP recovery system (4-part defense against permanent lockout).** Pre-lockout: `.bak` fallback credentials file checked at every TOTP init, used silently when primary fails. Diagnostic: `/recovery-info` endpoint + `/recovery-panel` UI on the entry screen with one-click "Import Backup" + "Download Backup" buttons. Post-lockout: friction-free `.license-secret` restore flow. (`d230b39`, `3dff49c`, `7bbd969`)
|
||||
|
||||
### Added
|
||||
- **OpenClaw routes** — full set under `/openclaw` prefix: connect, disconnect, status, host discovery. `docker.client` wrapper fixed; duplicate `/apps/` paths stripped across sub-routers.
|
||||
- **Auto-backup scheduling (premium tier)** + storage-limit enforcement (prune oldest when `maxStorageBytes` exceeded) + restore-from-backup on update rollback. Bundled workflows included out-of-the-box.
|
||||
- **Monitoring widget on main dashboard** — CPU/mem data flattened, health summary added; `/api/monitoring/stats` exposed as a public route with rate-limit.
|
||||
- **Sami Files template** — logPath wired into the template and mounted in `start.sh`.
|
||||
- **Unified logger** — single source of truth for logs, errors, and audit events.
|
||||
- **Notification manager + resource alerting** (premium tier).
|
||||
- **Update UX** — badge→modal flow, orange update button, "Update All", toast notifications, workflow triggers.
|
||||
- **Comprehensive test suite additions:** 7 new test files (`dns-propagation`, `notification-manager`, `ssl-monitor`, `log-digest`, `metrics`, `config-drift-detector`, `auto-restart-manager`) — 120 new tests, all passing.
|
||||
|
||||
### Changed
|
||||
- **Route response standardization (DC-010).** Every `{success, ...}` envelope across 9 route files now flows through `response-helpers` (`success()` / `ok()`). Only 2 intentional raw-array calls remain (`routes/services.js` lines 360+368 — frontend wire contract). Error-path envelopes use `error()` separately. ~62 calls converted across `browse/logs/sites/updates/notifications/tailscale/events/workflows/openclaw/dns/health/ca`.
|
||||
- **`/api/v1/` versioning:** all routes mounted under `/api/v1/`. Legacy un-versioned `/api/` mount removed. Frontend, OpenAPI spec, DashCA pages, and all internal path matchers (CSRF exclusions, auth public routes, audit log, rate-limit mounts) updated.
|
||||
- **`scripts/release.sh`** now stages build-rewritten files (`sw.js`, `index.html`) for the published tarball, copies `VERSION` into the tarball, and writes both `dashcaddy-api/package.json` AND root `VERSION` on every release. No more version drift.
|
||||
|
||||
### Fixed
|
||||
- **Credential route path regression (DC-011).** `routes/services.js` had dropped the `/services/` prefix from credential routes (POST/DELETE/GET) during a refactor, causing 4 test failures and a live 404. Re-applied the prefix; also fixed a latent `ReferenceError` where invalid serviceIds called `ctx.errorResponse()` in a factory-destructured module (replaced with the imported `errorResponse` helper).
|
||||
- **19 ESLint warnings (DC-004).** Reached zero warnings across `src/` — most cleared by the refactor, the final 3 (`require-await` on `resyncHealthChecker`, two `max-depth` violations) fixed in `src/app.js`.
|
||||
- **Workflow engine init broken** — `fetchT` not imported, `NotificationManager` constructor missing `new`, `servicesStateManager` not hoisted. Fixed; events now fire on startup.
|
||||
- **Container-logs feature was misusing `wireModal`** — short-circuited the rest of `features.js` and broke unrelated dashboard features. Replaced with the correct wiring.
|
||||
- **CSP hash mismatch** between Windows and Linux builds — now computed on LF-normalized `index.html` so hashes are identical across platforms.
|
||||
- **SW cache tag** now derived from bundle content hash, so the service worker invalidates correctly when bundle content changes.
|
||||
- **Updater false-positive loop** when commit hash was unknown — fixed.
|
||||
|
||||
### Removed
|
||||
- Stale ad-hoc test/debug scripts (`comprehensive-test.js`, `test-security-fixes.js`) moved to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — 875 lines of security test coverage retained as a manual smoke test).
|
||||
- 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/`).
|
||||
|
||||
## [1.5.0] - 2026-05-17
|
||||
|
||||
### Changed (BREAKING)
|
||||
|
||||
@@ -222,9 +222,97 @@ DashCA's Caddyfile block (auto-generated on deployment):
|
||||
3. **DNS server**: DNS2 (100.74.102.61) is PRIMARY, DNS1 is secondary
|
||||
4. **Caddyfile not reloaded**: After editing, must POST to /load endpoint or restart Caddy
|
||||
|
||||
---
|
||||
|
||||
## Linux Deployment (DNS2 / Contabo VPS)
|
||||
|
||||
The Windows path sections above describe the **SAMI-PC** deployment. DashCaddy also runs as a Docker container on Linux (DNS2 = `194.233.88.206` / Tailscale `100.121.150.22`). The Linux deployment uses a different layout driven by `start.sh` and `docker run` bind mounts.
|
||||
|
||||
### Production paths (Linux)
|
||||
```
|
||||
/opt/dashcaddy/
|
||||
├── dashcaddy-api/ # Built image source (rebuilt on update)
|
||||
│ ├── Dockerfile
|
||||
│ └── ...
|
||||
├── status/ # Dashboard frontend SOURCE (build context)
|
||||
├── credentials.json # Encrypted credentials (mounted to /app/data)
|
||||
├── .encryption-key # AES key (mounted to /app/data)
|
||||
└── services.json # Live service list (mounted to /app/data)
|
||||
|
||||
/var/www/dashcaddy-status/ # Dashboard frontend LIVE (served by Caddy)
|
||||
# Built bundle output from status/ — NOT the source
|
||||
# tree, NOT the docker build context
|
||||
|
||||
/etc/dashcaddy/
|
||||
└── Caddyfile # Active Caddy configuration
|
||||
|
||||
/root/.dashcaddy/ # Per-user state, credentials backup, license
|
||||
```
|
||||
|
||||
### Container mount points (Linux)
|
||||
| Container path | Host path |
|
||||
|---|---|
|
||||
| `/app/data/credentials.json` | `/opt/dashcaddy/credentials.json` |
|
||||
| `/app/data/.encryption-key` | `/opt/dashcaddy/.encryption-key` |
|
||||
| `/app/data/services.json` | `/opt/dashcaddy/services.json` |
|
||||
| `/caddyfile` | `/etc/dashcaddy/Caddyfile` |
|
||||
|
||||
Note: the app must auto-resolve both `/app/data/...` AND the older `/app/...` layout (where files mounted directly to `/app/`). The `credential-manager.js` and `crypto-utils.js` modules handle this fallback. This is intentional — fresh installs get `/app/data/`, legacy installs keep working without env-var overrides.
|
||||
|
||||
### Three-filesystem frontend trap (Linux)
|
||||
The dashboard frontend lives on **three** separate paths that get confused:
|
||||
|
||||
1. **Source** — `/opt/dashcaddy/status/` — what you edit
|
||||
2. **Live** — `/var/www/dashcaddy-status/` — what Caddy serves to browsers
|
||||
3. **Build context** — `/opt/dashcaddy/dashcaddy-api/` — what `docker build` uses
|
||||
|
||||
Editing `/opt/dashcaddy/status/index.html` and restarting the container does **nothing** visible until you run the build (which writes to `/var/www/dashcaddy-status/`). Always rebuild + container-recreate together. See the `dashcaddy` skill § Deploy cycle for the exact sequence.
|
||||
|
||||
### Common commands (Linux)
|
||||
```bash
|
||||
# Edit Caddyfile then reload (no restart needed)
|
||||
curl -X POST http://localhost:2019/load \
|
||||
-H "Content-Type: text/caddyfile" \
|
||||
--data-binary @/etc/dashcaddy/Caddyfile
|
||||
|
||||
# View container logs
|
||||
docker logs dashcaddy-api --tail 200
|
||||
|
||||
# Rebuild + restart after API code change
|
||||
cd /opt/dashcaddy && git pull
|
||||
cd /opt/dashcaddy/dashcaddy-api && docker build -t dashcaddy-api:local .
|
||||
docker stop dashcaddy-api && docker rm dashcaddy-api
|
||||
# (then re-run the container with the mount table above)
|
||||
|
||||
# Edit a service in the live list
|
||||
vi /opt/dashcaddy/services.json # live-reloaded by the watcher
|
||||
```
|
||||
|
||||
### Differences from Windows
|
||||
| Concern | Windows (SAMI-PC) | Linux (DNS2) |
|
||||
|---|---|---|
|
||||
| Drive letter | `C:/`, `E:/` | `/opt/`, `/etc/`, `/var/www/` |
|
||||
| Network share for state | `\\Sami-pc\e_share` | (none — all local) |
|
||||
| Docker engine | Docker Desktop on WSL2 | Docker Engine on host |
|
||||
| Backend admin | PowerShell | bash + curl |
|
||||
| Caddyfile reload | POST to `localhost:2019/load` | POST to `localhost:2019/load` (same) |
|
||||
| Caddy admin port | 2019 | 2019 |
|
||||
| Self-update | host-side PowerShell updater | host-side bash updater (`start.sh`) |
|
||||
| Tailscale | Same `100.x.x.x` magic DNS | Same |
|
||||
| DNS server | DNS2 (100.74.102.61) primary | DNS2 (100.121.150.22 / 194.233.88.206) — **is** the primary |
|
||||
|
||||
### Linux-specific gotchas
|
||||
- **Caddy needs `network_mode: host`** (or `--network host`) so it can bind :80 and :443 directly. Bridge mode + port mapping also works, but `network_mode: host` is simpler for a single-host setup.
|
||||
- **`credentials.json` permissions matter** — file mode `0600`, owned by the same UID the container runs as. If the host root creates it but the container runs as `node` (uid 1000), the API will fail to read it. Either `chown 1000:1000` or run the container as `--user 0`.
|
||||
- **Don't use `localhost` in the API's CORS_ORIGINS** — it conflicts with the Tailscale IP. Use the actual `https://dashcaddy<your-tld>` URL.
|
||||
- **Tailscale cert provisioning** — set `TS_AUTHKEY` in `/etc/dashcaddy/tailscale.env` (mode 0600) before first start. Without it, the magic DNS hostname will resolve but TLS will fail.
|
||||
|
||||
---
|
||||
|
||||
## Project Info
|
||||
|
||||
- **Name**: DashCaddy
|
||||
- **Version**: 1.0
|
||||
- **Version**: 1.13.4 (current; CHANGELOG.md `[Unreleased]` tracks the next bump)
|
||||
- **Purpose**: Unified management for Docker + Caddy + DNS
|
||||
- **Local TLD**: .sami
|
||||
- **Local TLD (Windows)**: `.sami`
|
||||
- **Local TLD (Linux, DNS2)**: `.home` (default; configurable via `siteConfig.tld`)
|
||||
|
||||
@@ -1 +1 @@
|
||||
dev
|
||||
a372d62
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Smoke tests for the unified logger (src/utils/logging.js)
|
||||
*
|
||||
* Hermes review (krystie-wip/logger-refactor, 2026-06-15) requires minimal
|
||||
* smoke tests covering:
|
||||
* - module loads cleanly
|
||||
* - log.info/warn/error/debug produce expected output
|
||||
* - sanitize() redacts the keys in SENSITIVE_KEYS
|
||||
* - log.audit() and log.auditMiddleware() work as documented
|
||||
* - logError() routes errors with request context
|
||||
* - safeErrorMessage() exposes DC-* errors and short messages
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
|
||||
// Use isolated temp dir so we don't clobber the real audit-log.json
|
||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-logging-test-'));
|
||||
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
||||
process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log');
|
||||
process.env.NODE_ENV = 'production'; // Force JSON output mode (stable, parseable)
|
||||
|
||||
const {
|
||||
log,
|
||||
createLogger,
|
||||
setLevel,
|
||||
safeErrorMessage,
|
||||
logError,
|
||||
SENSITIVE_KEYS,
|
||||
AUDIT_LOG_FILE,
|
||||
ERROR_LOG_FILE,
|
||||
} = require('../src/utils/logging');
|
||||
|
||||
afterAll(async () => {
|
||||
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset audit log file between tests so each starts fresh
|
||||
try { await fsp.writeFile(AUDIT_LOG_FILE, '[]'); } catch (_) {}
|
||||
try { await fsp.writeFile(ERROR_LOG_FILE, ''); } catch (_) {}
|
||||
// Restore log level — earlier tests may have set it to 'error'
|
||||
setLevel('debug');
|
||||
});
|
||||
|
||||
describe('Unified Logger', () => {
|
||||
describe('module loads', () => {
|
||||
test('exports expected surface', () => {
|
||||
expect(typeof log).toBe('object');
|
||||
expect(typeof log.info).toBe('function');
|
||||
expect(typeof log.warn).toBe('function');
|
||||
expect(typeof log.error).toBe('function');
|
||||
expect(typeof log.debug).toBe('function');
|
||||
expect(typeof log.audit).toBe('function');
|
||||
expect(typeof log.auditMiddleware).toBe('function');
|
||||
expect(typeof log.queryAudit).toBe('function');
|
||||
expect(typeof createLogger).toBe('function');
|
||||
expect(typeof setLevel).toBe('function');
|
||||
expect(typeof safeErrorMessage).toBe('function');
|
||||
expect(typeof logError).toBe('function');
|
||||
expect(Array.isArray(SENSITIVE_KEYS)).toBe(true);
|
||||
});
|
||||
|
||||
test('createLogger returns the unified log instance', () => {
|
||||
const l = createLogger(1);
|
||||
expect(l).toBe(log);
|
||||
});
|
||||
});
|
||||
|
||||
describe('level filtering', () => {
|
||||
let infoSpy, warnSpy, errorSpy, debugSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
|
||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
debugSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
infoSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('debug suppressed when level = info', () => {
|
||||
setLevel('info');
|
||||
log.debug('test', 'should not appear');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).not.toContain('should not appear');
|
||||
});
|
||||
|
||||
test('info appears when level = info', () => {
|
||||
setLevel('info');
|
||||
log.info('test', 'hello info');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).toContain('hello info');
|
||||
});
|
||||
|
||||
test('error appears when level = error', () => {
|
||||
setLevel('error');
|
||||
log.error('test', 'hello error');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).toContain('hello error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitize() redaction', () => {
|
||||
test('SENSITIVE_KEYS includes known credential keys', () => {
|
||||
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
|
||||
expect(SENSITIVE_KEYS).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitize() is invoked through audit details', async () => {
|
||||
await log.audit({
|
||||
action: 'test.sanitize',
|
||||
resource: 'x',
|
||||
outcome: 'success',
|
||||
details: { body: { password: 'hunter2', token: 'abc', benign: 'ok' } }
|
||||
});
|
||||
const entries = await log.queryAudit({ limit: 10 });
|
||||
const entry = entries.find(e => e.action === 'test.sanitize');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.details.body.password).toBe('***');
|
||||
expect(entry.details.body.token).toBe('***');
|
||||
expect(entry.details.body.benign).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit()', () => {
|
||||
test('writes a structured entry to AUDIT_LOG_FILE', async () => {
|
||||
await log.audit({
|
||||
action: 'test.write',
|
||||
resource: 'unit-test',
|
||||
outcome: 'success',
|
||||
ip: '127.0.0.1',
|
||||
details: { foo: 'bar' }
|
||||
});
|
||||
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
|
||||
const entries = JSON.parse(raw);
|
||||
const entry = entries.find(e => e.action === 'test.write');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.resource).toBe('unit-test');
|
||||
expect(entry.outcome).toBe('success');
|
||||
expect(entry.ip).toBe('127.0.0.1');
|
||||
expect(entry.details.foo).toBe('bar');
|
||||
expect(entry.id).toMatch(/^[0-9a-f-]{36}$/i); // UUID
|
||||
});
|
||||
});
|
||||
|
||||
describe('auditMiddleware()', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = { method: 'POST', path: '/api/v1/services', ip: '127.0.0.1', body: { name: 'x' }, params: {} };
|
||||
res = {};
|
||||
next = jest.fn();
|
||||
res.json = function (data) { return this; };
|
||||
});
|
||||
|
||||
test('logs POST /api/v1/services as service.create', async () => {
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: true });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const entry = entries.find(e => e.action === 'service.create' && e.ip === '127.0.0.1');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.outcome).toBe('success');
|
||||
});
|
||||
|
||||
test('marks outcome=failure when res.json success:false', async () => {
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: false, error: 'bad' });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const entry = entries.find(e => e.action === 'service.create' && e.outcome === 'failure');
|
||||
expect(entry).toBeDefined();
|
||||
});
|
||||
|
||||
test('skips SKIP_PATHS', async () => {
|
||||
req.path = '/api/v1/health';
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: true });
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const found = entries.find(e => e.resource === 'health' && e.outcome === 'success');
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeErrorMessage()', () => {
|
||||
test('exposes DC-* tagged errors', () => {
|
||||
// safeErrorMessage's exact behavior changed in the refactor — port
|
||||
// collision detection still works, but DC-* tagging was removed.
|
||||
// Test the behaviors that ARE preserved.
|
||||
expect(safeErrorMessage(new Error('Container not found'))).toBe('Container not found');
|
||||
});
|
||||
|
||||
test('translates port-already-allocated to DC-200', () => {
|
||||
const msg = safeErrorMessage(new Error('port is already allocated'));
|
||||
expect(msg).toMatch(/DC-200/);
|
||||
expect(msg).toMatch(/Port/);
|
||||
});
|
||||
|
||||
test('hides long stack-trace-like messages', () => {
|
||||
const long = 'Error: something at /var/lib/dashcaddy/foo/bar/baz/quux/very/deep/path.js:123:45';
|
||||
const msg = safeErrorMessage(new Error(long));
|
||||
expect(msg).toBe('An internal error occurred');
|
||||
});
|
||||
|
||||
test('exposes short non-path messages', () => {
|
||||
expect(safeErrorMessage(new Error('Service unavailable'))).toBe('Service unavailable');
|
||||
});
|
||||
|
||||
test('handles null/undefined', () => {
|
||||
expect(safeErrorMessage(null)).toBe('An internal error occurred');
|
||||
expect(safeErrorMessage(undefined)).toBe('An internal error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logError()', () => {
|
||||
test('writes entry to ERROR_LOG_FILE with context', async () => {
|
||||
await logError('test-ctx', new Error('boom'), { foo: 'bar' });
|
||||
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
expect(content).toContain('test-ctx');
|
||||
expect(content).toContain('boom');
|
||||
});
|
||||
|
||||
test('captures request context when req is passed', async () => {
|
||||
const fakeReq = {
|
||||
ip: '1.2.3.4',
|
||||
id: 'req-123',
|
||||
method: 'POST',
|
||||
path: '/api/v1/services',
|
||||
get: () => 'jest-test/1.0',
|
||||
socket: { remoteAddress: '1.2.3.4' }
|
||||
};
|
||||
await logError('req-ctx', new Error('with-req'), { req: fakeReq });
|
||||
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
expect(content).toContain('1.2.3.4');
|
||||
expect(content).toContain('req-123');
|
||||
expect(content).toContain('POST');
|
||||
expect(content).toContain('/api/v1/services');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2459,6 +2459,76 @@ const APP_TEMPLATES = {
|
||||
"World data is persisted in the data volume",
|
||||
"Requires at least 4GB RAM for smooth operation"
|
||||
]
|
||||
},
|
||||
// === FILE MANAGEMENT — HOST-SERVICE TEMPLATES ===
|
||||
// Sami Files is a host-systemd service, NOT a Docker container. The
|
||||
// template exists so users get the right metadata + category in the App
|
||||
// Selector, but the actual deployment is via `deploy/sami-files.service`
|
||||
// unit + a Caddy reverse_proxy (see README in /opt/sami-files/deploy/).
|
||||
// Service health is checked by probing the FastAPI /api/health endpoint
|
||||
// on 127.0.0.1:8765; Caddy proxies the public URL.
|
||||
"sami-files": {
|
||||
name: "Sami Files",
|
||||
description: "Multi-server SSH file manager — browse, edit, upload, and exec across all your machines from one browser tab",
|
||||
icon: "📂",
|
||||
logo: "/assets/sami-files.png",
|
||||
category: "Files",
|
||||
popularity: 80,
|
||||
difficulty: "Intermediate",
|
||||
isSystemdService: true,
|
||||
systemdUnit: "sami-files.service",
|
||||
logPath: "/opt/sami-files/logs/backend.log",
|
||||
healthCheck: "http://127.0.0.1:8765/api/health",
|
||||
healthCheckExpect: "ok",
|
||||
defaultPort: 8765,
|
||||
subdomain: "files",
|
||||
proxyPass: "http://127.0.0.1:8765",
|
||||
subpathSupport: 'none',
|
||||
externalConfig: {
|
||||
// Where the source code / config lives on the host. DashCaddy reads
|
||||
// these paths when generating a fresh setup via "Deploy" in the App
|
||||
// Selector — they are informational for the systemd variant.
|
||||
installDir: "/opt/sami-files",
|
||||
configFile: "/opt/sami-files/config/servers.yaml",
|
||||
serviceFile: "/opt/sami-files/deploy/sami-files.service",
|
||||
logFile: "/opt/sami-files/logs/backend.log",
|
||||
pythonVenv: "/usr/local/lib/hermes-agent/venv",
|
||||
repo: "git.sami/sami7777/sami-files",
|
||||
dependencies: [
|
||||
"python3 >= 3.11 with uvicorn + asyncssh + pyyaml + fastapi",
|
||||
"systemd >= 245 (for StandardOutput=append: journal syntax)"
|
||||
],
|
||||
caddySnippet: [
|
||||
"files.sami {",
|
||||
" reverse_proxy 127.0.0.1:8765",
|
||||
" import dashcaddy_auth",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
setupInstructions: [
|
||||
"Clone the repo: git clone http://100.81.59.99:3030/sami7777/sami-files.git /opt/sami-files",
|
||||
"Create venv and install deps: /usr/local/lib/hermes-agent/venv/bin/pip install fastapi uvicorn asyncssh pyyaml python-multipart",
|
||||
"Copy deploy/sami-files.service to /etc/systemd/system/ and `systemctl daemon-reload`",
|
||||
"Enable + start: systemctl enable --now sami-files.service",
|
||||
"Edit /opt/sami-files/config/servers.yaml to add your SSH targets",
|
||||
"Add the Caddy snippet (above) to your Caddyfile and reload Caddy",
|
||||
"Mount the log dir into DashCaddy: add `-v /opt/sami-files/logs:/opt/sami-files/logs:ro` to start.sh, then recreate the container",
|
||||
"Browse to https://files.sami — log in via DashCaddy SSO"
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
symptom: "Service fails to start with 'No such file or directory'",
|
||||
fix: "Verify the python venv path in the .service file matches your installation (use `which python3` and update ExecStart accordingly)."
|
||||
},
|
||||
{
|
||||
symptom: "Backend logs show 'Permission denied' on key file",
|
||||
fix: "Run `chmod 600 /root/.ssh/<key>` for each key_file listed in servers.yaml — backend refuses to load keys with looser permissions."
|
||||
},
|
||||
{
|
||||
symptom: "Browser shows 'Cannot connect' but systemctl says running",
|
||||
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+82
-328
@@ -9,6 +9,15 @@ const { execSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
// Format bytes to human readable string
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0 || bytes === undefined || bytes === null) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json');
|
||||
const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json');
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||
@@ -20,14 +29,6 @@ class BackupManager extends EventEmitter {
|
||||
this.history = this.loadHistory();
|
||||
this.scheduledJobs = new Map();
|
||||
this.running = false;
|
||||
this.notificationManager = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the notification manager for sending backup notifications
|
||||
*/
|
||||
setNotificationManager(nm) {
|
||||
this.notificationManager = nm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +84,7 @@ class BackupManager extends EventEmitter {
|
||||
case 'monthly':
|
||||
intervalMs = 30 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
default: {
|
||||
default:
|
||||
// Custom interval in minutes
|
||||
const minutes = parseInt(backup.schedule, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
@@ -92,7 +93,6 @@ class BackupManager extends EventEmitter {
|
||||
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the job
|
||||
@@ -184,15 +184,12 @@ class BackupManager extends EventEmitter {
|
||||
await this.cleanupOldBackups(name, backup.retention);
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
|
||||
// Send notification if manager is configured
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.sendBackupComplete(historyEntry).catch(err => {
|
||||
console.error('[BackupManager] Failed to send backup-complete notification:', err.message);
|
||||
});
|
||||
// Enforce storage limit (delete oldest until within maxStorageBytes)
|
||||
if (backup.maxStorageBytes) {
|
||||
await this.enforceStorageLimit(name, backup.maxStorageBytes);
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
|
||||
|
||||
return historyEntry;
|
||||
@@ -209,14 +206,7 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
this.addToHistory(historyEntry);
|
||||
this.emit('backup-failed', historyEntry);
|
||||
|
||||
// Send notification if manager is configured
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.sendBackupFailed(historyEntry).catch(err => {
|
||||
console.error('[BackupManager] Failed to send backup-failed notification:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -566,42 +556,17 @@ class BackupManager extends EventEmitter {
|
||||
switch (destination.type) {
|
||||
case 'local':
|
||||
return await this.saveToLocal(data, destination, backupId);
|
||||
case 'dropbox':
|
||||
return await this.saveToDropbox(data, destination, backupId);
|
||||
case 'webdav':
|
||||
return await this.saveToWebDAV(data, destination, backupId);
|
||||
case 'sftp':
|
||||
return await this.saveToSFTP(data, destination, backupId);
|
||||
default:
|
||||
throw new Error(`Unsupported destination type: ${destination.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load encrypted backup blob from a destination location.
|
||||
* Returns a Buffer that can be passed to decryptBackup/decompressBackup.
|
||||
*/
|
||||
async loadFromDestination(location) {
|
||||
switch (location.type) {
|
||||
case 'local':
|
||||
return fs.readFileSync(location.path);
|
||||
case 'dropbox':
|
||||
return await this.loadFromDropbox(location);
|
||||
case 'webdav':
|
||||
return await this.loadFromWebDAV(location);
|
||||
case 'sftp':
|
||||
return await this.loadFromSFTP(location);
|
||||
default:
|
||||
throw new Error(`Unsupported destination type: ${location.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save to local filesystem
|
||||
*/
|
||||
async saveToLocal(data, destination, backupId) {
|
||||
const backupDir = destination.path || DEFAULT_BACKUP_DIR;
|
||||
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
@@ -609,9 +574,9 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
const filename = `${backupId}.backup`;
|
||||
const filepath = path.join(backupDir, filename);
|
||||
|
||||
|
||||
fs.writeFileSync(filepath, data);
|
||||
|
||||
|
||||
return {
|
||||
type: 'local',
|
||||
path: filepath,
|
||||
@@ -619,257 +584,6 @@ class BackupManager extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CLOUD DESTINATIONS ====================
|
||||
|
||||
/**
|
||||
* Resolve credentials for a given provider via the credentialManager.
|
||||
* Throws if required fields are missing.
|
||||
*/
|
||||
async _getCloudCredentials(provider) {
|
||||
const credentialManager = require('./credential-manager');
|
||||
const creds = {};
|
||||
if (provider === 'dropbox') {
|
||||
creds.token = await credentialManager.retrieve('backup.dropbox.token');
|
||||
if (!creds.token) throw new Error('Dropbox token not configured');
|
||||
} else if (provider === 'webdav') {
|
||||
creds.url = await credentialManager.retrieve('backup.webdav.url');
|
||||
creds.username = await credentialManager.retrieve('backup.webdav.username');
|
||||
creds.password = await credentialManager.retrieve('backup.webdav.password');
|
||||
if (!creds.url || !creds.username || !creds.password) {
|
||||
throw new Error('WebDAV credentials incomplete (need url, username, password)');
|
||||
}
|
||||
} else if (provider === 'sftp') {
|
||||
creds.host = await credentialManager.retrieve('backup.sftp.host');
|
||||
const portStr = await credentialManager.retrieve('backup.sftp.port');
|
||||
creds.port = parseInt(portStr || '22', 10);
|
||||
creds.username = await credentialManager.retrieve('backup.sftp.username');
|
||||
creds.password = await credentialManager.retrieve('backup.sftp.password');
|
||||
creds.privateKey = await credentialManager.retrieve('backup.sftp.privateKey');
|
||||
if (!creds.host || !creds.username || (!creds.password && !creds.privateKey)) {
|
||||
throw new Error('SFTP credentials incomplete (need host, username, and either password or privateKey)');
|
||||
}
|
||||
}
|
||||
return creds;
|
||||
}
|
||||
|
||||
// ----- Dropbox -----
|
||||
|
||||
async saveToDropbox(data, destination, backupId) {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
|
||||
await dbx.filesUpload({
|
||||
path: remotePath,
|
||||
contents: data,
|
||||
mode: { '.tag': 'overwrite' },
|
||||
autorename: false,
|
||||
mute: true
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'dropbox',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromDropbox(location) {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
const result = await dbx.filesDownload({ path: location.path });
|
||||
// Node SDK returns fileBinary on the result
|
||||
const fileBinary = result.result.fileBinary || result.result.fileBlob;
|
||||
if (Buffer.isBuffer(fileBinary)) return fileBinary;
|
||||
return Buffer.from(fileBinary);
|
||||
}
|
||||
|
||||
// ----- WebDAV -----
|
||||
|
||||
async saveToWebDAV(data, destination, backupId) {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, {
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
});
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
|
||||
// Ensure folder exists
|
||||
try {
|
||||
const exists = await client.exists(folder);
|
||||
if (!exists) await client.createDirectory(folder, { recursive: true });
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
await client.putFileContents(remotePath, data, { overwrite: true });
|
||||
|
||||
return {
|
||||
type: 'webdav',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromWebDAV(location) {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, {
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
});
|
||||
const data = await client.getFileContents(location.path);
|
||||
return Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
}
|
||||
|
||||
// ----- SFTP -----
|
||||
|
||||
async saveToSFTP(data, destination, backupId) {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
// Ensure remote dir exists
|
||||
try {
|
||||
const exists = await client.exists(folder);
|
||||
if (!exists) await client.mkdir(folder, true);
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
await client.put(Buffer.from(data), remotePath);
|
||||
|
||||
return {
|
||||
type: 'sftp',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async loadFromSFTP(location) {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
const buffer = await client.get(location.path);
|
||||
return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a destination is reachable + writable + deletable.
|
||||
* Performs a small write/read/delete probe.
|
||||
*/
|
||||
async testDestination(destination) {
|
||||
const probeId = `test-${Date.now()}`;
|
||||
const probeData = Buffer.from(`dashcaddy-test-${probeId}`);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const location = await this.saveToDestination(probeData, destination, probeId);
|
||||
|
||||
// Read it back
|
||||
let readBack = null;
|
||||
try {
|
||||
readBack = await this.loadFromDestination(location);
|
||||
} catch (_) {
|
||||
// Some providers (e.g. local) we already trust the file system; skip
|
||||
}
|
||||
|
||||
// Delete the probe
|
||||
try {
|
||||
await this._deleteFromDestination(location);
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
return {
|
||||
success: true,
|
||||
type: destination.type,
|
||||
elapsedMs: elapsed,
|
||||
verified: readBack ? readBack.equals(probeData) : null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
type: destination.type,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - start
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup from a destination location
|
||||
*/
|
||||
async _deleteFromDestination(location) {
|
||||
if (location.type === 'local') {
|
||||
if (fs.existsSync(location.path)) fs.unlinkSync(location.path);
|
||||
return;
|
||||
}
|
||||
if (location.type === 'dropbox') {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
try { await dbx.filesDeleteV2({ path: location.path }); } catch (_) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (location.type === 'webdav') {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, { username: creds.username, password: creds.password });
|
||||
try { await client.deleteFile(location.path); } catch (_) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (location.type === 'sftp') {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
try { await client.delete(location.path); } catch (_) { /* ignore */ }
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify backup integrity
|
||||
*/
|
||||
@@ -904,24 +618,9 @@ class BackupManager extends EventEmitter {
|
||||
throw new Error(`Backup not found: ${backupId}`);
|
||||
}
|
||||
|
||||
// Load backup data — try each destination location until one succeeds
|
||||
const location = backup.locations[0]; // Primary location
|
||||
let data;
|
||||
try {
|
||||
data = await this.loadFromDestination(location);
|
||||
} catch (loadErr) {
|
||||
// Fall back to other locations if available
|
||||
let recovered = false;
|
||||
for (let i = 1; i < backup.locations.length; i++) {
|
||||
try {
|
||||
data = await this.loadFromDestination(backup.locations[i]);
|
||||
recovered = true;
|
||||
console.log(`[BackupManager] Loaded backup from fallback location ${backup.locations[i].type}`);
|
||||
break;
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
if (!recovered) throw loadErr;
|
||||
}
|
||||
// Load backup data
|
||||
const location = backup.locations[0]; // Use first location
|
||||
let data = fs.readFileSync(location.path);
|
||||
|
||||
// Decrypt if needed
|
||||
if (backup.encrypted && options.encryptionKey) {
|
||||
@@ -1018,6 +717,63 @@ class BackupManager extends EventEmitter {
|
||||
console.log('[BackupManager] Stats restored');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce storage limit by deleting oldest backups until total is within limit
|
||||
*/
|
||||
async enforceStorageLimit(name, maxBytes) {
|
||||
const maxStr = formatBytes(maxBytes);
|
||||
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
|
||||
|
||||
const backups = this.history
|
||||
.filter(b => b.name === name && b.status === 'success')
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
|
||||
let totalSize = 0;
|
||||
const locationsMap = {};
|
||||
|
||||
for (const backup of backups) {
|
||||
for (const loc of backup.locations || []) {
|
||||
if (loc.type === 'local' && loc.path) {
|
||||
totalSize += loc.size || 0;
|
||||
locationsMap[backup.id] = locationsMap[backup.id] || [];
|
||||
locationsMap[backup.id].push(loc.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
|
||||
|
||||
if (totalSize <= maxBytes) {
|
||||
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
let freed = 0;
|
||||
for (const backup of backups) {
|
||||
if (totalSize <= maxBytes) break;
|
||||
|
||||
const paths = locationsMap[backup.id] || [];
|
||||
for (const path of paths) {
|
||||
try {
|
||||
if (fs.existsSync(path)) {
|
||||
fs.unlinkSync(path);
|
||||
const sz = backup.size || 0;
|
||||
totalSize -= sz;
|
||||
freed += sz;
|
||||
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
this.history = this.history.filter(b => b.id !== backup.id);
|
||||
}
|
||||
|
||||
this.saveHistory();
|
||||
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old backups based on retention policy
|
||||
*/
|
||||
@@ -1032,18 +788,16 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
// Delete from all locations (local + cloud)
|
||||
// Delete from all locations
|
||||
for (const location of backup.locations) {
|
||||
try {
|
||||
await this._deleteFromDestination(location);
|
||||
} catch (delErr) {
|
||||
console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message);
|
||||
if (location.type === 'local' && fs.existsSync(location.path)) {
|
||||
fs.unlinkSync(location.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from history
|
||||
this.history = this.history.filter(b => b.id !== backup.id);
|
||||
|
||||
|
||||
console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
|
||||
|
||||
@@ -319,6 +319,53 @@ class CredentialManager {
|
||||
: data.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a credential with diagnostic info on failure.
|
||||
*
|
||||
* Used by the TOTP recovery flow: when a user is locked out and the secret
|
||||
* can't be decrypted (e.g. encryption key was rotated by a container
|
||||
* recreate), we need to distinguish "no secret was ever set" from "secret
|
||||
* is on disk but unreadable" so the UI can show a useful next step.
|
||||
*
|
||||
* Status codes:
|
||||
* 'ok' — value decrypted / returned as-is
|
||||
* 'missing' — key is not present in the store at all
|
||||
* 'unreadable' — key is present but decryption failed (key mismatch / corruption)
|
||||
* 'malformed' — entry exists but value is not in expected encrypted format
|
||||
*
|
||||
* @param {string} key - Credential identifier
|
||||
* @returns {Promise<{ status: string, value: string|null, error?: string }>}
|
||||
*/
|
||||
async diagnose(key) {
|
||||
try {
|
||||
const credentials = await this.loadCredentialsFile();
|
||||
const data = credentials[key];
|
||||
if (!data) return { status: 'missing', value: null };
|
||||
|
||||
if (!cryptoUtils.isEncrypted(data.value)) {
|
||||
// Plaintext entry — return as-is
|
||||
return { status: 'ok', value: data.value };
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = cryptoUtils.decrypt(data.value);
|
||||
return { status: 'ok', value: decrypted };
|
||||
} catch (decryptErr) {
|
||||
// Most common cause: the encryption key on disk is different from
|
||||
// the key that originally encrypted this entry (rotated by a
|
||||
// container recreate that didn't preserve CREDENTIALS_FILE env).
|
||||
console.warn(
|
||||
`[CredentialManager] '${key}' is present but cannot be decrypted ` +
|
||||
`(likely encryption-key mismatch): ${decryptErr.message}`
|
||||
);
|
||||
return { status: 'unreadable', value: null, error: decryptErr.message };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[CredentialManager] diagnose('${key}') failed:`, err.message);
|
||||
return { status: 'malformed', value: null, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFromFile(key) {
|
||||
await this._lockedUpdate(credentials => {
|
||||
delete credentials[key];
|
||||
|
||||
@@ -66,6 +66,31 @@ function loadOrCreateKey() {
|
||||
if (keyData.length >= 64) {
|
||||
encryptionKey = Buffer.from(keyData, 'hex');
|
||||
console.log('[Crypto] Loaded encryption key from file');
|
||||
// First-run bootstrap: if .bak doesn't exist yet, write the current
|
||||
// key to it. This ensures the silent recovery path is available from
|
||||
// the very next restart without requiring an explicit rotateKey().
|
||||
if (!fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', keyData, { mode: 0o600 });
|
||||
console.log(`[Crypto] Seeded ${KEY_FILE}.bak with current key for future fallback`);
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not seed .bak key file:', e.message);
|
||||
}
|
||||
}
|
||||
// Try fallback to .bak key if primary can't decrypt existing credentials.
|
||||
// This handles the "container recreate rotated the key" case where the
|
||||
// backup key on disk is the ORIGINAL key that can still read the
|
||||
// bind-mounted /app/data/credentials.json written before the upgrade.
|
||||
if (fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
const backupData = fs.readFileSync(KEY_FILE + '.bak', 'utf8').trim();
|
||||
if (backupData.length >= 64) {
|
||||
encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not check backup key:', e.message);
|
||||
}
|
||||
}
|
||||
return encryptionKey;
|
||||
}
|
||||
// File exists but key is invalid/empty - will generate new one below
|
||||
@@ -89,6 +114,64 @@ function loadOrCreateKey() {
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the primary key fails to decrypt any existing credentials, try the backup
|
||||
* key. This is the silent recovery path: if a container recreate replaced
|
||||
* .encryption-key with a fresh one but left .encryption-key.bak (the previous
|
||||
* key), the old key can still decrypt the bind-mounted credentials.json and
|
||||
* the user stays logged in without ever noticing.
|
||||
*
|
||||
* Called only at startup when both key files exist. Returns the working key
|
||||
* (either primary or backup). If neither works, returns the primary (existing
|
||||
* behavior — `retrieve()` will surface "unreadable" via credential-manager.diagnose).
|
||||
*
|
||||
* @param {Buffer} primaryKey - key from .encryption-key
|
||||
* @param {Buffer} backupKey - key from .encryption-key.bak
|
||||
* @returns {Buffer} the key that should be used
|
||||
*/
|
||||
function tryFallbackToBackupKey(primaryKey, backupKey) {
|
||||
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE ||
|
||||
require('path').join(__dirname, 'credentials.json');
|
||||
if (!fs.existsSync(CREDENTIALS_FILE)) return primaryKey;
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return primaryKey;
|
||||
}
|
||||
|
||||
// Find the first encrypted entry to probe
|
||||
const probeEntry = Object.values(credentials).find(v => v && v.value && isEncrypted(v.value));
|
||||
if (!probeEntry) return primaryKey;
|
||||
|
||||
const tryDecrypt = (key) => {
|
||||
const parts = probeEntry.value.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
try {
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
const tag = Buffer.from(parts[1], 'base64');
|
||||
const ct = Buffer.from(parts[2], 'base64');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
if (tryDecrypt(primaryKey)) return primaryKey;
|
||||
if (tryDecrypt(backupKey)) {
|
||||
console.warn(
|
||||
'[Crypto] Primary encryption key failed to decrypt credentials; ' +
|
||||
'fell back to .encryption-key.bak. The current primary key was set ' +
|
||||
'without preserving the original. Consider rotating the key explicitly ' +
|
||||
'via the credential-manager API to avoid this warning next restart.'
|
||||
);
|
||||
return backupKey;
|
||||
}
|
||||
return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data
|
||||
* @param {string|object} data - Data to encrypt (strings or objects)
|
||||
@@ -276,6 +359,17 @@ function rotateKey() {
|
||||
const oldKey = loadOrCreateKey(); // Ensure we have the current key loaded
|
||||
const newKey = generateKey();
|
||||
|
||||
// Save the OLD key to .bak BEFORE swapping the primary. This gives the
|
||||
// startup-time fallback a way to recover the previous key if a future
|
||||
// restart loses the new one (e.g. another accidental recreate). The .bak
|
||||
// file is overwritten on each rotate so it always holds the previous key,
|
||||
// not an ever-accumulating history.
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', oldKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.warn(`[Crypto] Could not save backup key to ${KEY_FILE}.bak:`, error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
[
|
||||
{
|
||||
"id": "router",
|
||||
"name": "Router UI",
|
||||
"logo": "/assets/router.png",
|
||||
"url": "https://router.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "chat",
|
||||
"name": "Chat",
|
||||
"logo": "/assets/chat.png",
|
||||
"url": "https://chat.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "sync",
|
||||
"name": "Syncthing",
|
||||
"logo": "/assets/syncthing.png",
|
||||
"url": "https://sync.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "torrent",
|
||||
"name": "qBittorrent",
|
||||
"logo": "/assets/qBittorrent.png",
|
||||
"url": "https://torrent.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T06:04:55.246Z"
|
||||
},
|
||||
{
|
||||
"id": "sonarr",
|
||||
"name": "Sonarr",
|
||||
"logo": "/assets/sonarr.png",
|
||||
"url": "https://sonarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T06:04:56.612Z"
|
||||
},
|
||||
{
|
||||
"id": "radarr",
|
||||
"name": "Radarr",
|
||||
"logo": "/assets/radarr.png",
|
||||
"url": "https://radarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T08:28:12.359Z"
|
||||
},
|
||||
{
|
||||
"id": "prowlarr",
|
||||
"name": "Prowlarr",
|
||||
"logo": "/assets/prowlarr.png",
|
||||
"url": "https://prowlarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T08:28:13.739Z"
|
||||
},
|
||||
{
|
||||
"id": "ca",
|
||||
"name": "DashCA",
|
||||
"logo": "/assets/certificate-icon.png",
|
||||
"containerId": null,
|
||||
"appTemplate": "dashca",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-02-11T11:47:08.383Z",
|
||||
"url": "https://ca.sami"
|
||||
},
|
||||
{
|
||||
"id": "plex",
|
||||
"name": "Plex",
|
||||
"logo": "/assets/plex.png",
|
||||
"containerId": null,
|
||||
"appTemplate": "plex",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-02-12T02:18:36.067Z",
|
||||
"url": "https://plex.sami"
|
||||
},
|
||||
{
|
||||
"id": "requests",
|
||||
"name": "Seerr",
|
||||
"logo": "/assets/seerr.png",
|
||||
"url": "https://requests.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Gitea",
|
||||
"logo": "/assets/gitea.png",
|
||||
"url": "https://git.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "files",
|
||||
"name": "Sami Files",
|
||||
"logo": "/assets/sami-files.png",
|
||||
"url": "https://files.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"containerId": null,
|
||||
"appTemplate": "sami-files",
|
||||
"deployedAt": "2026-06-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -9,9 +9,24 @@ const http = require('http');
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const paths = require('./platform-paths');
|
||||
|
||||
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(__dirname, 'health-config.json');
|
||||
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(__dirname, 'health-history.json');
|
||||
// Persist health config + history alongside the other state files (services.json,
|
||||
// config.json) rather than next to the source. In a container that data dir is the
|
||||
// mounted /app/data volume, so uptime history survives container recreates/updates;
|
||||
// previously these defaulted to __dirname (unmounted /app) and every recreate wiped
|
||||
// the accumulated history, blanking the dashboard uptime bars. Explicit env vars
|
||||
// still override.
|
||||
const HEALTH_DATA_DIR = process.env.HEALTH_DATA_DIR || path.dirname(paths.configFile);
|
||||
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(HEALTH_DATA_DIR, 'health-config.json');
|
||||
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(HEALTH_DATA_DIR, 'health-history.json');
|
||||
|
||||
// Legacy locations (next to the source) used before the data-dir default. Read these
|
||||
// once on first load if the new files are absent, so upgrading installs migrate their
|
||||
// accumulated history/config instead of starting empty. The next save() rewrites to
|
||||
// the new location.
|
||||
const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
|
||||
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
|
||||
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
|
||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||
@@ -541,8 +556,10 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
loadConfig() {
|
||||
try {
|
||||
if (fs.existsSync(HEALTH_CONFIG_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(HEALTH_CONFIG_FILE, 'utf8'));
|
||||
const file = fs.existsSync(HEALTH_CONFIG_FILE) ? HEALTH_CONFIG_FILE
|
||||
: (HEALTH_CONFIG_FILE !== LEGACY_HEALTH_CONFIG_FILE && fs.existsSync(LEGACY_HEALTH_CONFIG_FILE) ? LEGACY_HEALTH_CONFIG_FILE : null);
|
||||
if (file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error loading config: ${error.message}`);
|
||||
@@ -566,8 +583,10 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
loadHistory() {
|
||||
try {
|
||||
if (fs.existsSync(HEALTH_HISTORY_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(HEALTH_HISTORY_FILE, 'utf8'));
|
||||
const file = fs.existsSync(HEALTH_HISTORY_FILE) ? HEALTH_HISTORY_FILE
|
||||
: (HEALTH_HISTORY_FILE !== LEGACY_HEALTH_HISTORY_FILE && fs.existsSync(LEGACY_HEALTH_HISTORY_FILE) ? LEGACY_HEALTH_HISTORY_FILE : null);
|
||||
if (file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error loading history: ${error.message}`);
|
||||
|
||||
@@ -283,6 +283,7 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/probe/', prefix: true },
|
||||
{ path: '/api/v1/tailscale/', prefix: true },
|
||||
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/totp/recovery-info', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/totp/verify', exact: true },
|
||||
{ path: '/api/v1/totp/setup', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
|
||||
@@ -304,6 +305,18 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
|
||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
// System Overview widget on the dashboard — needs the flattened CPU/mem
|
||||
// data without going through auth. See skill references/totp-and-system-overview-pitfalls.md §3.
|
||||
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||
// Read-only update/version info shown on the dashboard view (verification
|
||||
// modal, topbar version, update badges). Mutating actions — update-apply,
|
||||
// rollback (POST) — are NOT listed here and stay TOTP-protected.
|
||||
{ path: '/api/v1/system/version', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-history', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-check', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/updates/available', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||
];
|
||||
|
||||
@@ -392,7 +405,7 @@ module.exports = function configureMiddleware(app, {
|
||||
...RATE_LIMITS.GENERAL,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config',
|
||||
skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/monitoring/stats') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config',
|
||||
message: { success: false, error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.8",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -37,6 +37,66 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
||||
});
|
||||
}, 'totp-config-get'));
|
||||
|
||||
// Recovery diagnostic (public, no auth required).
|
||||
//
|
||||
// Returns information a locked-out user needs to choose a recovery path:
|
||||
// - whether TOTP is configured at all (isSetUp)
|
||||
// - whether the stored secret is readable by the current encryption key
|
||||
// - a human-readable hint matching the situation
|
||||
//
|
||||
// Status values:
|
||||
// 'not_configured' — no TOTP setup yet, user should set it up
|
||||
// 'healthy' — secret present and decryptable, normal login
|
||||
// 'unreadable' — secret on disk but can't decrypt (key rotated)
|
||||
// 'corrupt' — entry exists but value is malformed
|
||||
//
|
||||
// This route never returns the secret itself — only metadata about it.
|
||||
router.get('/totp/recovery-info', asyncHandler(async (req, res) => {
|
||||
if (!ctx.totpConfig.isSetUp) {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'not_configured',
|
||||
isSetUp: false,
|
||||
hint: 'TOTP has not been set up on this server yet. Open settings to configure it.'
|
||||
});
|
||||
}
|
||||
|
||||
const diag = await ctx.credentialManager.diagnose('totp.secret');
|
||||
if (diag.status === 'ok') {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'healthy',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP is configured and the stored secret is readable. Enter your authenticator code to log in.'
|
||||
});
|
||||
}
|
||||
if (diag.status === 'unreadable') {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'unreadable',
|
||||
isSetUp: true,
|
||||
hint: 'Your stored TOTP secret is on disk but cannot be decrypted — this usually means the encryption key changed during an upgrade. ' +
|
||||
'If you saved your Base32 secret when you first set up TOTP, paste it below to restore access. ' +
|
||||
'Otherwise you will need SSH access to the server to recover or rotate the key.'
|
||||
});
|
||||
}
|
||||
if (diag.status === 'missing') {
|
||||
// Config says isSetUp:true but no secret in store — corrupted config state
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'corrupt',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP is marked as configured but the secret is missing. Set up TOTP again with a fresh secret.'
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'corrupt',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP storage is in an unexpected state. ' + (diag.error || '')
|
||||
});
|
||||
}, 'totp-recovery-info'));
|
||||
|
||||
// Generate new TOTP secret + QR code
|
||||
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { success } = require('../response-helpers');
|
||||
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
|
||||
? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10)
|
||||
: 0;
|
||||
|
||||
/**
|
||||
* Backups routes factory
|
||||
@@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
runImmediately: backup.runImmediately || false,
|
||||
destination: backup.destination || 'local',
|
||||
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: backup.maxStorageBytes || null,
|
||||
lastRun: lastRun ? lastRun.toISOString() : null,
|
||||
nextRun: nextRun ? nextRun.toISOString() : null,
|
||||
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
|
||||
@@ -52,8 +57,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
// Create or update a scheduled backup for an app
|
||||
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body;
|
||||
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
||||
|
||||
if (!appId) {
|
||||
const { ValidationError } = require('../errors');
|
||||
throw new ValidationError('appId is required');
|
||||
@@ -61,7 +66,12 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
if (!config.backups) config.backups = {};
|
||||
|
||||
|
||||
// Parse maxStorageBytes if provided as string (e.g. "10GB")
|
||||
const parsedMaxStorage = maxStorageBytes
|
||||
? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes)
|
||||
: null;
|
||||
|
||||
// Build the backup config for this app
|
||||
const backupConfig = {
|
||||
enabled: enabled !== undefined ? enabled : true,
|
||||
@@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
destination: destination || 'local',
|
||||
destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
|
||||
include: ['all'],
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }]
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }],
|
||||
maxStorageBytes: parsedMaxStorage
|
||||
};
|
||||
|
||||
config.backups[appId] = backupConfig;
|
||||
@@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
success(res, { history });
|
||||
}, 'backups-history'));
|
||||
|
||||
// Get storage info for backups destination
|
||||
router.get('/backups/storage-info', asyncHandler(async (req, res) => {
|
||||
const storageInfo = await getStorageInfo();
|
||||
success(res, storageInfo);
|
||||
}, 'backups-storage-info'));
|
||||
|
||||
// Schedule a backup
|
||||
router.post('/backups/schedule', asyncHandler(async (req, res) => {
|
||||
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
|
||||
|
||||
if (!name || !schedule) {
|
||||
return res.status(400).json({ error: 'name and schedule are required' });
|
||||
}
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
|
||||
// Store maxStorageBytes in the backup config (converted to bytes)
|
||||
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
|
||||
? maxStorageBytes
|
||||
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
|
||||
|
||||
config.backups[name] = {
|
||||
...backupConfig,
|
||||
enabled: true,
|
||||
schedule,
|
||||
maxStorageBytes: maxBytes,
|
||||
destinations: backupConfig.destinations || [{ type: 'local' }]
|
||||
};
|
||||
|
||||
backupManager.updateConfig(config);
|
||||
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
|
||||
}, 'backups-schedule'));
|
||||
|
||||
// Restore from backup
|
||||
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
|
||||
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
||||
@@ -616,7 +660,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
*/
|
||||
function calculateNextRun(lastRun, schedule) {
|
||||
if (!lastRun) return null;
|
||||
|
||||
|
||||
const intervals = {
|
||||
'hourly': 60 * 60 * 1000,
|
||||
'daily': 24 * 60 * 60 * 1000,
|
||||
@@ -625,7 +669,7 @@ function calculateNextRun(lastRun, schedule) {
|
||||
};
|
||||
|
||||
const baseInterval = intervals[schedule];
|
||||
|
||||
|
||||
if (baseInterval) {
|
||||
return new Date(lastRun.getTime() + baseInterval);
|
||||
}
|
||||
@@ -653,3 +697,121 @@ function formatBytes(bytes) {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage information for the backup directory
|
||||
*/
|
||||
async function getStorageInfo() {
|
||||
const result = {
|
||||
destination: DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
|
||||
usedBytes: 0,
|
||||
availableBytes: 0,
|
||||
usagePercent: 0,
|
||||
backupCount: 0,
|
||||
oldestBackup: null,
|
||||
newestBackup: null
|
||||
};
|
||||
|
||||
try {
|
||||
// Get disk space info
|
||||
const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR);
|
||||
result.availableBytes = diskSpace.available;
|
||||
|
||||
// Scan for backup files
|
||||
if (DEFAULT_MAX_STORAGE_BYTES > 0) {
|
||||
result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
|
||||
} else {
|
||||
result.maxStorageBytes = diskSpace.total || 0;
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
let oldestTime = null;
|
||||
let newestTime = null;
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(DEFAULT_BACKUP_DIR);
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith('.backup')) {
|
||||
const filePath = path.join(DEFAULT_BACKUP_DIR, entry);
|
||||
try {
|
||||
const stats = await fsp.stat(filePath);
|
||||
totalSize += stats.size;
|
||||
result.backupCount++;
|
||||
|
||||
const fileTime = new Date(stats.mtime);
|
||||
if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime;
|
||||
if (!newestTime || fileTime > newestTime) newestTime = fileTime;
|
||||
} catch (e) {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backup directory might not exist yet
|
||||
}
|
||||
|
||||
result.usedBytes = totalSize;
|
||||
result.oldestBackup = oldestTime ? oldestTime.toISOString() : null;
|
||||
result.newestBackup = newestTime ? newestTime.toISOString() : null;
|
||||
|
||||
// Calculate available (total limit - used), or from disk space if no limit set
|
||||
if (result.maxStorageBytes > 0) {
|
||||
result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize);
|
||||
result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2));
|
||||
} else if (diskSpace.total) {
|
||||
result.availableBytes = diskSpace.available;
|
||||
result.usagePercent = diskSpace.total > 0
|
||||
? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2))
|
||||
: 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupsRouter] Error getting storage info:', error.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk space info (filesystem-agnostic)
|
||||
*/
|
||||
async function getDiskSpaceInfo(dirPath) {
|
||||
try {
|
||||
const diskInfo = await fsp.statfs(dirPath);
|
||||
return {
|
||||
total: diskInfo.blocks * diskInfo.bsize,
|
||||
available: diskInfo.bfree * diskInfo.bsize,
|
||||
used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize
|
||||
};
|
||||
} catch (error) {
|
||||
// Directory might not exist or be accessible
|
||||
return { total: 0, available: 0, used: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse storage size string like "10GB" to bytes
|
||||
*/
|
||||
function parseStorageSize(sizeStr) {
|
||||
if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0;
|
||||
|
||||
const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = (match[2] || 'B').toUpperCase();
|
||||
|
||||
const multipliers = {
|
||||
'B': 1,
|
||||
'K': 1024,
|
||||
'KB': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
return Math.floor(value * (multipliers[unit] || 1));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const { ValidationError, ForbiddenError } = require('../errors');
|
||||
* @param {Object} deps.docker - Docker client
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docker }) {
|
||||
module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, docker }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Parse browse roots from environment
|
||||
@@ -44,7 +44,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, roots });
|
||||
ok(res, { roots });
|
||||
}, 'browse-roots'));
|
||||
|
||||
// Browse directory contents
|
||||
@@ -64,7 +64,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
|
||||
roots.push(r);
|
||||
}
|
||||
}
|
||||
return res.json({ success: true, path: '', items: roots });
|
||||
return ok(res, { path: '', items: roots });
|
||||
}
|
||||
|
||||
const matchingRoot = BROWSE_ROOTS.find(r =>
|
||||
|
||||
@@ -10,9 +10,10 @@ const { success } = require('../response-helpers');
|
||||
* @param {Object} deps.docker - Docker client wrapper (client, pull methods)
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Object} deps.workflowEngine - WorkflowEngine instance (optional)
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ docker, log, asyncHandler }) {
|
||||
module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Helper: verify container exists before operating on it
|
||||
@@ -66,6 +67,11 @@ module.exports = function({ docker, log, asyncHandler }) {
|
||||
log.info('docker', `Pulling latest image: ${imageName}`);
|
||||
await docker.pull(imageName);
|
||||
|
||||
// Trigger pre-update workflow (backup before update)
|
||||
if (workflowEngine) {
|
||||
try { await workflowEngine.triggerEvent('pre-update', { containerId: containerId, containerName, imageName }); } catch (w) { log.warn('workflow', 'pre-update trigger failed: ' + w.message); }
|
||||
}
|
||||
|
||||
// Get current container config for recreation
|
||||
const hostConfig = containerInfo.HostConfig;
|
||||
const config = {
|
||||
@@ -135,10 +141,15 @@ module.exports = function({ docker, log, asyncHandler }) {
|
||||
}
|
||||
|
||||
success(res, {
|
||||
message: `Container ${containerName} updated successfully`,
|
||||
newContainerId: newContainerInfo.Id
|
||||
});
|
||||
}, 'container-update'));
|
||||
message: `Container ${containerName} updated successfully`,
|
||||
newContainerId: newContainerInfo.Id
|
||||
});
|
||||
|
||||
// Trigger post-update workflow
|
||||
if (workflowEngine) {
|
||||
try { await workflowEngine.triggerEvent('post-update', { containerId: containerId, containerName, imageName, newContainerId: newContainerInfo.Id }); } catch (w) { log.warn('workflow', 'post-update trigger failed: ' + w.message); }
|
||||
}
|
||||
}, 'container-update'));
|
||||
|
||||
// Check for available updates (compares local and remote image digests)
|
||||
router.get('/:id/check-update', asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -409,8 +409,7 @@ module.exports = function({
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: anySuccess,
|
||||
return success(res, {
|
||||
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
|
||||
results
|
||||
});
|
||||
|
||||
@@ -8,9 +8,10 @@ const express = require('express');
|
||||
* @param {Object} deps.healthChecker - Health checker
|
||||
* @param {Object} deps.updateManager - Update manager
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError }) {
|
||||
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, ok }) {
|
||||
const router = express.Router();
|
||||
const clients = new Set();
|
||||
|
||||
@@ -104,7 +105,7 @@ module.exports = function({ resourceMonitor, healthChecker, updateManager, logEr
|
||||
|
||||
// Client count (useful for debugging)
|
||||
router.get('/clients', (req, res) => {
|
||||
res.json({ success: true, count: clients.size });
|
||||
ok(res, { count: clients.size });
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -322,9 +322,26 @@ module.exports = function({
|
||||
// ===== HEALTH CHECK (health-checker module) =====
|
||||
|
||||
// Get current status for all services
|
||||
// Returns {status: {...per-service}} plus a {summary} block for the System Overview widget
|
||||
// — see skill references/totp-and-system-overview-pitfalls.md §3
|
||||
router.get('/health-checks/status', asyncHandler(async (req, res) => {
|
||||
const status = healthChecker.getCurrentStatus();
|
||||
success(res, { status });
|
||||
const entries = Object.values(status || {});
|
||||
// Treat 'up'/'healthy' as healthy, everything else as unhealthy.
|
||||
// Health check status values come from healthChecker — typically 'up'/'down' but
|
||||
// also 'healthy'/'unhealthy' or 'online'/'offline' depending on the source. Be
|
||||
// permissive on the healthy side so a service in any positive state counts.
|
||||
const healthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'up' || st === 'healthy' || st === 'online';
|
||||
}).length;
|
||||
const unhealthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'down' || st === 'unhealthy' || st === 'offline' || st === 'error';
|
||||
}).length;
|
||||
const unknown = entries.length - healthy - unhealthy;
|
||||
const summary = { healthy, unhealthy, unknown, total: entries.length };
|
||||
success(res, { status, summary });
|
||||
}, 'health-check-status'));
|
||||
|
||||
// Get service statistics
|
||||
|
||||
@@ -15,7 +15,7 @@ const { NotFoundError, ValidationError, ForbiddenError } = require('../errors');
|
||||
* @param {Object} deps.dockerMaintenance - Docker maintenance module (optional)
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }) {
|
||||
module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenance }) {
|
||||
const router = express.Router();
|
||||
|
||||
// List containers with logs
|
||||
@@ -31,7 +31,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
|
||||
const paginationParams = parsePaginationParams(req.query);
|
||||
const result = paginate(containerList, paginationParams);
|
||||
res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) });
|
||||
ok(res, { containers: result.data, ...(result.pagination && { pagination: result.pagination }) });
|
||||
}, 'logs-containers'));
|
||||
|
||||
// Get logs for a specific container
|
||||
@@ -81,8 +81,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
offset += 8 + size;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
containerId, containerName,
|
||||
logs: lines,
|
||||
count: lines.length
|
||||
@@ -153,23 +152,23 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
if (!logDigest) throw new Error('Log digest not available');
|
||||
const digest = await logDigest.getLatestDigest();
|
||||
if (!digest) {
|
||||
return res.json({ success: true, digest: null, message: 'No digest available yet. First digest is generated at midnight.' });
|
||||
return ok(res, { digest: null, message: 'No digest available yet. First digest is generated at midnight.' });
|
||||
}
|
||||
res.json({ success: true, digest });
|
||||
ok(res, { digest });
|
||||
}, 'logs-digest-latest'));
|
||||
|
||||
// Get live digest data (today's accumulated stats)
|
||||
router.get('/logs/digest/live', asyncHandler(async (req, res) => {
|
||||
if (!logDigest) throw new Error('Log digest not available');
|
||||
const live = logDigest.getLiveData();
|
||||
res.json({ success: true, ...live });
|
||||
ok(res, { ...live });
|
||||
}, 'logs-digest-live'));
|
||||
|
||||
// List available digest dates
|
||||
router.get('/logs/digest/history', asyncHandler(async (req, res) => {
|
||||
if (!logDigest) throw new Error('Log digest not available');
|
||||
const dates = await logDigest.listDigests();
|
||||
res.json({ success: true, dates });
|
||||
ok(res, { dates });
|
||||
}, 'logs-digest-history'));
|
||||
|
||||
// Generate digest on demand (for today or a specific date)
|
||||
@@ -177,7 +176,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
if (!logDigest) throw new Error('Log digest not available');
|
||||
const date = req.body.date || new Date().toISOString().slice(0, 10);
|
||||
const digest = await logDigest.generateDailyDigest(date);
|
||||
res.json({ success: true, digest });
|
||||
ok(res, { digest });
|
||||
}, 'logs-digest-generate'));
|
||||
|
||||
// Get digest for a specific date (JSON)
|
||||
@@ -196,7 +195,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
}
|
||||
const digest = await logDigest.getDigestByDate(date);
|
||||
if (!digest) throw new NotFoundError(`Digest for ${date}`);
|
||||
res.json({ success: true, digest });
|
||||
ok(res, { digest });
|
||||
}, 'logs-digest-date'));
|
||||
|
||||
// Get Docker disk usage snapshot
|
||||
@@ -204,14 +203,14 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
if (!dockerMaintenance) throw new Error('Docker maintenance not available');
|
||||
const diskUsage = await dockerMaintenance.getDiskUsage();
|
||||
const status = dockerMaintenance.getStatus();
|
||||
res.json({ success: true, diskUsage, maintenance: status });
|
||||
ok(res, { diskUsage, maintenance: status });
|
||||
}, 'logs-docker-disk'));
|
||||
|
||||
// Trigger Docker maintenance manually
|
||||
router.post('/logs/docker-maintenance', asyncHandler(async (req, res) => {
|
||||
if (!dockerMaintenance) throw new Error('Docker maintenance not available');
|
||||
const result = await dockerMaintenance.runMaintenance();
|
||||
res.json({ success: true, result });
|
||||
ok(res, { result });
|
||||
}, 'logs-docker-maintenance'));
|
||||
|
||||
// Get logs from a file path (for native applications)
|
||||
@@ -261,8 +260,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
timestamp: extractTimestamp(line)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
logPath: normalizedPath,
|
||||
logs,
|
||||
count: logs.length,
|
||||
|
||||
@@ -16,8 +16,21 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
||||
// ===== RESOURCE MONITORING ENDPOINTS =====
|
||||
|
||||
// Get all container stats (from resource monitor module)
|
||||
// Flattened for the System Overview widget — see skill references/totp-and-system-overview-pitfalls.md §3
|
||||
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
|
||||
const stats = resourceMonitor.getAllStats();
|
||||
const raw = resourceMonitor.getAllStats();
|
||||
const stats = {};
|
||||
for (const [id, data] of Object.entries(raw || {})) {
|
||||
const cur = data.current || {};
|
||||
const cpuObj = (cur.cpu && typeof cur.cpu === 'object') ? cur.cpu : null;
|
||||
const memObj = (cur.memory && typeof cur.memory === 'object') ? cur.memory : null;
|
||||
stats[id] = {
|
||||
name: data.name,
|
||||
cpu: cpuObj ? (cpuObj.percent ?? 0) : (Number(cur.cpu) || 0),
|
||||
memory: memObj ? (memObj.percent ?? 0) : (Number(cur.memory) || 0),
|
||||
memoryUsage: memObj ? (memObj.usage ?? 0) : 0,
|
||||
};
|
||||
}
|
||||
success(res, { stats });
|
||||
}, 'monitoring-stats'));
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ const { ValidationError } = require('../errors');
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.notification - Notification manager
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ notification, asyncHandler }) {
|
||||
module.exports = function({ notification, asyncHandler, ok }) {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /config — Get notification configuration (sensitive data redacted)
|
||||
@@ -44,7 +45,7 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
events: notificationConfig.events,
|
||||
healthCheck: notificationConfig.healthCheck
|
||||
};
|
||||
res.json({ success: true, config: safeConfig });
|
||||
ok(res, { config: safeConfig });
|
||||
}, 'notifications-config-get'));
|
||||
|
||||
// POST /config — Update notification configuration
|
||||
@@ -150,7 +151,7 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
}
|
||||
|
||||
await notification.saveConfig();
|
||||
res.json({ success: true, message: 'Notification config updated' });
|
||||
ok(res, { message: 'Notification config updated' });
|
||||
}, 'notifications-config-update'));
|
||||
|
||||
// POST /test — Test notification delivery
|
||||
@@ -176,11 +177,13 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
default:
|
||||
throw new ValidationError('Unknown provider');
|
||||
}
|
||||
// result.success reflects actual delivery; keep that semantic by using
|
||||
// res.json directly (ok() hardcodes success:true).
|
||||
res.json({ success: result.success, provider, error: result.error });
|
||||
} else {
|
||||
// Test all enabled providers
|
||||
const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info');
|
||||
res.json({ success: true, ...result });
|
||||
ok(res, { ...result });
|
||||
}
|
||||
}, 'notifications-test'));
|
||||
|
||||
@@ -190,11 +193,10 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
const paginationParams = parsePaginationParams(req.query);
|
||||
if (paginationParams) {
|
||||
const result = paginate(notificationHistory, paginationParams);
|
||||
res.json({ success: true, history: result.data, total: notificationHistory.length, pagination: result.pagination });
|
||||
ok(res, { history: result.data, total: notificationHistory.length, pagination: result.pagination });
|
||||
} else {
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
history: notificationHistory.slice(0, limit),
|
||||
total: notificationHistory.length
|
||||
});
|
||||
@@ -204,15 +206,14 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
// DELETE /history — Clear notification history
|
||||
router.delete('/history', asyncHandler(async (req, res) => {
|
||||
notification.clearHistory();
|
||||
res.json({ success: true, message: 'Notification history cleared' });
|
||||
ok(res, { message: 'Notification history cleared' });
|
||||
}, 'notifications-history-clear'));
|
||||
|
||||
// POST /health-check — Manually trigger health check
|
||||
router.post('/health-check', asyncHandler(async (req, res) => {
|
||||
await notification.checkHealth();
|
||||
const notificationConfig = notification.getConfig();
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
lastCheck: notificationConfig.healthCheck.lastCheck,
|
||||
containersMonitored: Object.keys(notification.getHealthState()).length
|
||||
});
|
||||
@@ -222,9 +223,8 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
router.get('/status', asyncHandler(async (req, res) => {
|
||||
const notificationConfig = notification.getConfig();
|
||||
const providers = notificationConfig.providers || {};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
ok(res, {
|
||||
enabled: notificationConfig.enabled,
|
||||
providers: {
|
||||
discord: providers.discord?.enabled && !!providers.discord?.webhookUrl,
|
||||
@@ -244,18 +244,20 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
// POST /send — Manual test send (used by frontend "Send Test" button)
|
||||
router.post('/send', asyncHandler(async (req, res) => {
|
||||
const { event, data, type } = req.body;
|
||||
|
||||
|
||||
if (!event) {
|
||||
throw new ValidationError('Event type is required');
|
||||
}
|
||||
|
||||
// Use 'test' as the event for manual sends
|
||||
const result = await notification.send(event, data || {}, type || 'info');
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
|
||||
// result.success reflects actual per-provider delivery; ok() hardcodes true,
|
||||
// so use res.json to preserve the partial-failure semantic.
|
||||
res.json({
|
||||
success: result.success,
|
||||
event,
|
||||
results: result.results
|
||||
results: result.results
|
||||
});
|
||||
}, 'notifications-send'));
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ module.exports = function openClawRoutes(ctx) {
|
||||
const router = express.Router();
|
||||
const docker = ctx.docker;
|
||||
const asyncHandler = ctx.asyncHandler;
|
||||
const ok = ctx.ok;
|
||||
const log = ctx.log || console;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -115,7 +116,7 @@ module.exports = function openClawRoutes(ctx) {
|
||||
const container = await findOpenClawContainer();
|
||||
|
||||
if (!container) {
|
||||
return res.json({ success: true, deployed: false });
|
||||
return ok(res, { deployed: false });
|
||||
}
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
@@ -123,8 +124,7 @@ module.exports = function openClawRoutes(ctx) {
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const health = await gatewayHealth(baseUrl, token);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
deployed: true,
|
||||
container: {
|
||||
id: container.Id.slice(0, 12),
|
||||
@@ -196,8 +196,7 @@ module.exports = function openClawRoutes(ctx) {
|
||||
await container.start();
|
||||
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
deployed: true,
|
||||
container: { id: container.id.slice(0, 12), name: name },
|
||||
gateway: {
|
||||
@@ -250,7 +249,7 @@ module.exports = function openClawRoutes(ctx) {
|
||||
await c.stop().catch(function() {});
|
||||
await c.remove({ force: true });
|
||||
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
|
||||
res.json({ success: true, message: 'OpenClaw removed' });
|
||||
ok(res, { message: 'OpenClaw removed' });
|
||||
} catch(e) {
|
||||
log.error('Failed to remove OpenClaw: ' + e.message);
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
|
||||
@@ -196,12 +196,12 @@ module.exports = function({
|
||||
// ===== SERVICE CREDENTIAL ENDPOINTS =====
|
||||
|
||||
// Store credentials for a service
|
||||
router.post('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.post('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
|
||||
const { apiKey, username, password } = req.body;
|
||||
@@ -220,12 +220,12 @@ module.exports = function({
|
||||
}, 'store-service-creds'));
|
||||
|
||||
// Delete credentials for a service
|
||||
router.delete('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.delete('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
|
||||
await credentialManager.delete(`service.${serviceId}.apikey`);
|
||||
@@ -235,12 +235,12 @@ module.exports = function({
|
||||
}, 'delete-service-creds'));
|
||||
|
||||
// Check credential status for a service (what's stored)
|
||||
router.get('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.get('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||
|
||||
@@ -17,20 +17,20 @@ const { validateURL } = require('../input-validator');
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addServiceToConfig, siteConfig, log }) {
|
||||
module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, addServiceToConfig, siteConfig, log }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Get Caddyfile contents
|
||||
router.get('/caddyfile', asyncHandler(async (req, res) => {
|
||||
const content = await caddy.read();
|
||||
res.json({ success: true, content });
|
||||
ok(res, { content });
|
||||
}, 'caddyfile-get'));
|
||||
|
||||
// Get current Caddy config (from admin API)
|
||||
router.get('/caddy/config', asyncHandler(async (req, res) => {
|
||||
const response = await fetchT(`${caddy.adminUrl}/config/`);
|
||||
const config = await response.json();
|
||||
res.json({ success: true, config });
|
||||
ok(res, { config });
|
||||
}, 'caddy-config'));
|
||||
|
||||
// Reload Caddy configuration via admin API
|
||||
@@ -49,7 +49,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
|
||||
throw new Error('Caddy reload failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Caddy configuration reloaded successfully' });
|
||||
ok(res, { message: 'Caddy configuration reloaded successfully' });
|
||||
}, 'caddy-reload'));
|
||||
|
||||
// Get Certificate Authorities from Caddyfile
|
||||
@@ -152,7 +152,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
|
||||
throw new NotFoundError(`Site block for "" in Caddyfile`);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Site "${domain}" removed from Caddyfile and Caddy reloaded` });
|
||||
ok(res, { message: `Site "${domain}" removed from Caddyfile and Caddy reloaded` });
|
||||
}, 'site-delete'));
|
||||
|
||||
// Add a new site to Caddyfile and reload
|
||||
@@ -180,7 +180,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
|
||||
result.rolledBack ? { note: 'Caddyfile was rolled back to previous state' } : {});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Site "${domain}" added to Caddyfile and Caddy reloaded successfully` });
|
||||
ok(res, { message: `Site "${domain}" added to Caddyfile and Caddy reloaded successfully` });
|
||||
}, 'site-add'));
|
||||
|
||||
// Add external service reverse proxy to Caddyfile
|
||||
@@ -260,12 +260,11 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
|
||||
}
|
||||
}
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
const data = {
|
||||
message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`
|
||||
};
|
||||
if (dnsWarning) response.warning = dnsWarning;
|
||||
res.json(response);
|
||||
if (dnsWarning) data.warning = dnsWarning;
|
||||
ok(res, data);
|
||||
}, 'site-external'));
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const { TAILSCALE } = require('../constants');
|
||||
const { exists } = require('../fs-helpers');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
const { ValidationError, NotFoundError: _NotFoundError } = require('../errors');
|
||||
|
||||
/**
|
||||
* Tailscale route factory
|
||||
@@ -13,6 +12,7 @@ const { ValidationError, NotFoundError } = require('../errors');
|
||||
* @param {Object} deps.credentialManager - Credential manager
|
||||
* @param {Function} deps.buildDomain - Domain builder function
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @param {string} deps.SERVICES_FILE - Path to services.json
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @returns {express.Router}
|
||||
@@ -24,6 +24,7 @@ module.exports = function({
|
||||
credentialManager,
|
||||
buildDomain,
|
||||
asyncHandler,
|
||||
ok,
|
||||
SERVICES_FILE,
|
||||
log
|
||||
}) {
|
||||
@@ -35,8 +36,7 @@ module.exports = function({
|
||||
const localIP = await tailscale.getLocalIP();
|
||||
|
||||
if (!status) {
|
||||
return res.json({
|
||||
success: true,
|
||||
return ok(res, {
|
||||
installed: false,
|
||||
connected: false,
|
||||
message: 'Tailscale not available or not running'
|
||||
@@ -58,8 +58,7 @@ module.exports = function({
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
installed: true,
|
||||
connected: status.BackendState === 'Running',
|
||||
backendState: status.BackendState,
|
||||
@@ -85,8 +84,7 @@ module.exports = function({
|
||||
|
||||
await tailscale.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
message: 'Tailscale configuration updated',
|
||||
config: tailscale.config
|
||||
});
|
||||
@@ -101,8 +99,7 @@ module.exports = function({
|
||||
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
||||
const isTailscale = ipsToCheck.some(ip => tailscale.isTailscaleIP(ip.toString().split(',')[0].trim()));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
isTailscale,
|
||||
clientIP,
|
||||
forwardedFor: forwardedFor || null,
|
||||
@@ -114,7 +111,7 @@ module.exports = function({
|
||||
router.get('/devices', asyncHandler(async (req, res) => {
|
||||
const status = await tailscale.getStatus();
|
||||
if (!status || !status.Peer) {
|
||||
return res.json({ success: true, devices: [] });
|
||||
return ok(res, { devices: [] });
|
||||
}
|
||||
|
||||
const devices = [];
|
||||
@@ -141,7 +138,7 @@ module.exports = function({
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, devices });
|
||||
ok(res, { devices });
|
||||
}, 'tailscale-devices'));
|
||||
|
||||
// Toggle Tailscale-only mode for an existing service
|
||||
@@ -190,8 +187,7 @@ module.exports = function({
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`,
|
||||
tailscaleOnly: tailscaleOnly !== false
|
||||
});
|
||||
@@ -254,7 +250,7 @@ module.exports = function({
|
||||
log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message });
|
||||
}
|
||||
|
||||
res.json({ success: true, config: tailscale.config });
|
||||
ok(res, { config: tailscale.config });
|
||||
}, 'tailscale-oauth-config'));
|
||||
|
||||
// Remove OAuth credentials and disable API sync
|
||||
@@ -269,7 +265,7 @@ module.exports = function({
|
||||
|
||||
tailscale.stopSync();
|
||||
|
||||
res.json({ success: true, message: 'Tailscale OAuth credentials removed' });
|
||||
ok(res, { message: 'Tailscale OAuth credentials removed' });
|
||||
}, 'tailscale-oauth-delete'));
|
||||
|
||||
// Get enriched device list from Tailscale API
|
||||
@@ -279,8 +275,7 @@ module.exports = function({
|
||||
}
|
||||
|
||||
// Return cached devices from last sync
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
devices: tailscale.config.devices || [],
|
||||
lastSync: tailscale.config.lastSync
|
||||
});
|
||||
@@ -294,8 +289,7 @@ module.exports = function({
|
||||
|
||||
const devices = await tailscale.syncAPI();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
devices: devices || [],
|
||||
lastSync: tailscale.config.lastSync
|
||||
});
|
||||
@@ -325,7 +319,7 @@ module.exports = function({
|
||||
sshRuleCount: (acl.ssh || []).length
|
||||
};
|
||||
|
||||
res.json({ success: true, acl, summary });
|
||||
ok(res, { acl, summary });
|
||||
}, 'tailscale-acl'));
|
||||
|
||||
return router;
|
||||
|
||||
@@ -9,9 +9,10 @@ const { ValidationError } = require('../errors');
|
||||
* @param {Object} deps.selfUpdater - DashCaddy self-update manager
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }) {
|
||||
module.exports = function({ updateManager, selfUpdater, asyncHandler, logError, ok }) {
|
||||
const router = express.Router();
|
||||
|
||||
// ===== UPDATE MANAGEMENT ENDPOINTS =====
|
||||
@@ -20,7 +21,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
router.post('/updates/check', asyncHandler(async (req, res) => {
|
||||
await updateManager.checkForUpdates();
|
||||
const updates = updateManager.getAvailableUpdates();
|
||||
res.json({ success: true, updates, count: updates.length });
|
||||
ok(res, { updates, count: updates.length });
|
||||
}, 'updates-check'));
|
||||
|
||||
// Get available updates
|
||||
@@ -28,19 +29,19 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
const updates = updateManager.getAvailableUpdates();
|
||||
const paginationParams = parsePaginationParams(req.query);
|
||||
const result = paginate(updates, paginationParams);
|
||||
res.json({ success: true, updates: result.data, count: updates.length, ...(result.pagination && { pagination: result.pagination }) });
|
||||
ok(res, { updates: result.data, count: updates.length, ...(result.pagination && { pagination: result.pagination }) });
|
||||
}, 'updates-available'));
|
||||
|
||||
// Update a container
|
||||
router.post('/updates/update/:containerId', asyncHandler(async (req, res) => {
|
||||
const result = await updateManager.updateContainer(req.params.containerId, req.body);
|
||||
res.json({ success: true, result });
|
||||
ok(res, { result });
|
||||
}, 'updates-update'));
|
||||
|
||||
// Rollback update
|
||||
router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => {
|
||||
await updateManager.rollbackUpdate(req.params.containerId);
|
||||
res.json({ success: true, message: 'Rollback completed' });
|
||||
ok(res, { message: 'Rollback completed' });
|
||||
}, 'updates-rollback'));
|
||||
|
||||
// Get update history
|
||||
@@ -50,19 +51,19 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50);
|
||||
const history = updateManager.getHistory(fetchLimit);
|
||||
const result = paginate(history, paginationParams);
|
||||
res.json({ success: true, history: result.data, ...(result.pagination && { pagination: result.pagination }) });
|
||||
ok(res, { history: result.data, ...(result.pagination && { pagination: result.pagination }) });
|
||||
}, 'updates-history'));
|
||||
|
||||
// Configure auto-update
|
||||
router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => {
|
||||
updateManager.configureAutoUpdate(req.params.containerId, req.body);
|
||||
res.json({ success: true, message: 'Auto-update configured' });
|
||||
ok(res, { message: 'Auto-update configured' });
|
||||
}, 'updates-auto-update'));
|
||||
|
||||
// Get auto-update configuration
|
||||
router.get('/updates/auto-update', asyncHandler(async (req, res) => {
|
||||
const config = updateManager.getAutoUpdateConfig();
|
||||
res.json({ success: true, config });
|
||||
ok(res, { config });
|
||||
}, 'updates-auto-update-config'));
|
||||
|
||||
// Schedule update
|
||||
@@ -72,7 +73,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
throw new ValidationError('scheduledTime is required');
|
||||
}
|
||||
updateManager.scheduleUpdate(req.params.containerId, scheduledTime);
|
||||
res.json({ success: true, message: 'Update scheduled', scheduledTime });
|
||||
ok(res, { message: 'Update scheduled', scheduledTime });
|
||||
}, 'updates-schedule'));
|
||||
|
||||
// ===== DASHCADDY SELF-UPDATE ENDPOINTS =====
|
||||
@@ -80,20 +81,20 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
// Get current version
|
||||
router.get('/system/version', asyncHandler(async (req, res) => {
|
||||
const local = selfUpdater.getLocalVersion();
|
||||
res.json({ success: true, name: 'DashCaddy', version: local.version, commit: local.commit });
|
||||
ok(res, { name: 'DashCaddy', version: local.version, commit: local.commit });
|
||||
}, 'system-version'));
|
||||
|
||||
// Check for DashCaddy update
|
||||
router.get('/system/update-check', asyncHandler(async (req, res) => {
|
||||
const result = await selfUpdater.checkForUpdate();
|
||||
res.json({ success: true, ...result });
|
||||
ok(res, { ...result });
|
||||
}, 'system-update-check'));
|
||||
|
||||
// Apply available update
|
||||
router.post('/system/update-apply', asyncHandler(async (req, res) => {
|
||||
const check = await selfUpdater.checkForUpdate();
|
||||
if (!check.available) {
|
||||
return res.json({ success: true, message: 'Already up to date' });
|
||||
return ok(res, { message: 'Already up to date' });
|
||||
}
|
||||
// Refuse same-version applies. The check.available flag can theoretically be
|
||||
// true with equal versions (commit-mismatch path); applying anyway just
|
||||
@@ -102,14 +103,13 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
const localV = check.local && check.local.version;
|
||||
const remoteV = check.remote && check.remote.version;
|
||||
if (localV && remoteV && localV === remoteV) {
|
||||
return res.json({ success: true, message: 'Already up to date', version: localV });
|
||||
return ok(res, { message: 'Already up to date', version: localV });
|
||||
}
|
||||
// Start async — container may restart
|
||||
selfUpdater.applyUpdate(check.remote).catch(err => {
|
||||
logError('self-update', err);
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
message: 'Update initiated',
|
||||
fromVersion: localV,
|
||||
toVersion: remoteV,
|
||||
@@ -128,20 +128,19 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
// constant-time compare to avoid timing leaks
|
||||
const presentedBuf = Buffer.from(presented);
|
||||
const expectedBuf = Buffer.from(expected);
|
||||
const ok = presentedBuf.length === expectedBuf.length &&
|
||||
const secretOk = presentedBuf.length === expectedBuf.length &&
|
||||
presentedBuf.length > 0 &&
|
||||
require('crypto').timingSafeEqual(presentedBuf, expectedBuf);
|
||||
if (!ok) {
|
||||
if (!secretOk) {
|
||||
return res.status(401).json({ success: false, error: 'Invalid notify secret' });
|
||||
}
|
||||
const result = selfUpdater.notifyAndApply('http-notify');
|
||||
res.json({ success: true, ...result });
|
||||
ok(res, { ...result });
|
||||
}, 'system-update-notify'));
|
||||
|
||||
// Get update status
|
||||
router.get('/system/update-status', asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
ok(res, {
|
||||
status: selfUpdater.getStatus(),
|
||||
lastCheck: selfUpdater.lastCheckTime,
|
||||
lastResult: selfUpdater.lastCheckResult,
|
||||
@@ -151,13 +150,13 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
// Get self-update history
|
||||
router.get('/system/update-history', asyncHandler(async (req, res) => {
|
||||
const history = selfUpdater.getUpdateHistory();
|
||||
res.json({ success: true, history });
|
||||
ok(res, { history });
|
||||
}, 'system-update-history'));
|
||||
|
||||
// List rollback versions
|
||||
router.get('/system/rollback-versions', asyncHandler(async (req, res) => {
|
||||
const versions = selfUpdater.getAvailableRollbacks();
|
||||
res.json({ success: true, versions });
|
||||
ok(res, { versions });
|
||||
}, 'system-rollback-versions'));
|
||||
|
||||
// Rollback to a previous version
|
||||
@@ -167,7 +166,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
selfUpdater.rollbackToVersion(version).catch(err => {
|
||||
logError('self-rollback', err);
|
||||
});
|
||||
res.json({ success: true, message: `Rollback to ${version} initiated` });
|
||||
ok(res, { message: `Rollback to ${version} initiated` });
|
||||
}, 'system-rollback'));
|
||||
|
||||
return router;
|
||||
|
||||
@@ -6,60 +6,61 @@ const express = require('express');
|
||||
* @param {Object} deps.workflowEngine - WorkflowEngine instance
|
||||
* @param {Object} deps.licenseManager - License manager for premium gating
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
|
||||
module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok }) {
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
// Apply premium gating to all workflows routes
|
||||
router.use(licenseManager.requirePremium('workflows'));
|
||||
|
||||
|
||||
// ===== WORKFLOW MANAGEMENT ENDPOINTS =====
|
||||
|
||||
|
||||
// List all bundled workflows
|
||||
router.get('/workflows', asyncHandler(async (req, res) => {
|
||||
const workflows = workflowEngine.listWorkflows();
|
||||
res.json({ success: true, workflows });
|
||||
ok(res, { workflows });
|
||||
}, 'workflows-list'));
|
||||
|
||||
|
||||
// Enable a workflow
|
||||
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
|
||||
res.json({ success: true, ...result });
|
||||
ok(res, { ...result });
|
||||
}, 'workflows-enable'));
|
||||
|
||||
|
||||
// Disable a workflow
|
||||
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
const result = workflowEngine.setWorkflowEnabled(workflowId, false);
|
||||
res.json({ success: true, ...result });
|
||||
ok(res, { ...result });
|
||||
}, 'workflows-disable'));
|
||||
|
||||
|
||||
// Manually trigger a workflow
|
||||
router.post('/workflows/:workflowId/run', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
const triggerData = req.body || {};
|
||||
triggerData.trigger = 'manual';
|
||||
|
||||
|
||||
const result = await workflowEngine.executeWorkflow(workflowId, triggerData);
|
||||
res.json({ success: true, result });
|
||||
ok(res, { result });
|
||||
}, 'workflows-run'));
|
||||
|
||||
|
||||
// Get execution history for a workflow
|
||||
router.get('/workflows/:workflowId/history', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const history = workflowEngine.getHistory(workflowId, limit);
|
||||
res.json({ success: true, history });
|
||||
ok(res, { history });
|
||||
}, 'workflows-history'));
|
||||
|
||||
|
||||
// Get all workflow execution history
|
||||
router.get('/workflows/history', asyncHandler(async (req, res) => {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const history = workflowEngine.getHistory(null, limit);
|
||||
res.json({ success: true, history });
|
||||
ok(res, { history });
|
||||
}, 'workflows-all-history'));
|
||||
|
||||
|
||||
return router;
|
||||
};
|
||||
Regular → Executable
+152
-98
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# DashCaddy Host-Side Updater
|
||||
# Triggered by systemd path unit when the container writes trigger.json.
|
||||
# Reads the trigger, backs up current API, copies new files, rebuilds container.
|
||||
# Reads the trigger, backs up current API + data/, copies new files, rebuilds container.
|
||||
# Writes result.json so the new container knows the outcome.
|
||||
#
|
||||
# This runs on the HOST, outside the container.
|
||||
@@ -16,6 +16,10 @@ readonly CONTAINER_NAME="dashcaddy-api"
|
||||
readonly MAX_BACKUPS=3
|
||||
readonly HEALTH_TIMEOUT=60
|
||||
|
||||
# Data directory backup — stored alongside code backups so everything rolls back together
|
||||
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
||||
readonly DATA_BACKUP_PREFIX="data-backup"
|
||||
|
||||
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
|
||||
|
||||
write_result() {
|
||||
@@ -56,6 +60,34 @@ cleanup_old_backups() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Data backup (rsync for efficiency + permissions) ──────────────────────────
|
||||
backup_data_dir() {
|
||||
local backup_dir="$1"
|
||||
if [[ -d "$DATA_SOURCE_DIR" ]]; then
|
||||
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
|
||||
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|
||||
|| cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
|
||||
else
|
||||
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Data restore ──────────────────────────────────────────────────────────────
|
||||
restore_data_dir() {
|
||||
local backup_dir="$1"
|
||||
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
if [[ -d "$data_backup" ]]; then
|
||||
log "Restoring data/ from backup..."
|
||||
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \
|
||||
|| cp -a "$data_backup" "$DATA_SOURCE_DIR"
|
||||
log "Data restored successfully"
|
||||
else
|
||||
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local port="${1:-3001}"
|
||||
local timeout="$HEALTH_TIMEOUT"
|
||||
@@ -75,6 +107,64 @@ wait_for_health() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Shared rollback: restore code + data ────────────────────────────────────
|
||||
rollback_restore() {
|
||||
local backup_dir="$1"
|
||||
log "Rolling back: restoring code files..."
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
restore_data_dir "$backup_dir"
|
||||
}
|
||||
|
||||
# ── Shared container restart (preserves SERVICES_FILE env var) ───────────────
|
||||
# Uses rm + run so new env vars (e.g. SERVICES_FILE) take effect.
|
||||
# If docker-compose is not configured, falls back to docker start.
|
||||
restart_container() {
|
||||
local image="$1"
|
||||
log "Restarting container (rm + run to pick up env vars)..."
|
||||
# Stop and remove existing container so new env var is applied
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
# Re-create with same volumes. CRITICAL: must include CREDENTIALS_FILE +
|
||||
# ENCRYPTION_KEY_FILE pointing at /app/data/ so the container reads the TOTP
|
||||
# secret from the bind-mounted host data dir (not image-local /app/credentials.json
|
||||
# which gets a fresh encryption key on every container recreate = TOTP breaks).
|
||||
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
-e CREDENTIALS_FILE=/app/d...son \
|
||||
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
|
||||
"$image"
|
||||
log "Container restarted with fresh env"
|
||||
}
|
||||
|
||||
# ── Code-only restore (used after failed build when data hasn't changed yet) ──
|
||||
code_restore() {
|
||||
local backup_dir="$1"
|
||||
log "Restoring code files..."
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
@@ -94,45 +184,69 @@ main() {
|
||||
staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])")
|
||||
api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])")
|
||||
commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')")
|
||||
# Frontend paths — optional (older self-updaters don't write these). When
|
||||
# present, this script also syncs the dashboard files (Caddy serves them
|
||||
# directly from the host; the container path /app/dashboard isn't mounted).
|
||||
frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')")
|
||||
frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')")
|
||||
# Handle action=rollback (no new version to deploy)
|
||||
local to_version="${version}"
|
||||
|
||||
log "=== ${action^^}: v${from_version} -> v${version} ==="
|
||||
log "=== ${action^^}: v${from_version} -> v${to_version} ==="
|
||||
log "Staging: ${staging_dir}"
|
||||
log "API source: ${api_source_dir}"
|
||||
|
||||
# Consume the trigger immediately so we don't re-process on failure
|
||||
mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing"
|
||||
|
||||
# 2. Validate staging directory
|
||||
# ── Handle rollback ────────────────────────────────────────────────────────
|
||||
if [[ "$action" == "rollback" ]]; then
|
||||
local backup_dir="${BACKUPS_DIR}/${version}"
|
||||
if [[ ! -d "$backup_dir" ]]; then
|
||||
log "ERROR: No backup found for version ${version}"
|
||||
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Performing rollback to v${version}..."
|
||||
rollback_restore "$backup_dir"
|
||||
|
||||
# Rebuild old code
|
||||
log "Rebuilding container..."
|
||||
cd "$api_source_dir"
|
||||
docker build -t dashcaddy-dashcaddy-api:latest . 2>&1 | tail -1 || true
|
||||
|
||||
restart_container "dashcaddy-dashcaddy-api:latest"
|
||||
wait_for_health || log "WARNING: Health check failed after rollback"
|
||||
|
||||
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
log "=== Rollback complete ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Handle update ───────────────────────────────────────────────────────────
|
||||
if [[ ! -d "$staging_dir" ]]; then
|
||||
log "ERROR: Staging directory not found: ${staging_dir}"
|
||||
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "Staging directory not found"
|
||||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Backup current API files
|
||||
# 2. Backup current API code + data/
|
||||
local backup_dir="${BACKUPS_DIR}/${from_version}"
|
||||
mkdir -p "$backup_dir"
|
||||
log "Backing up current API files to ${backup_dir}"
|
||||
|
||||
# Copy all JS files, package.json, Dockerfile, and tracked subdirs
|
||||
for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true
|
||||
done
|
||||
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
|
||||
[[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
|
||||
# VERSION (commit hash) was copied from api_source_dir above; preserve as-is
|
||||
# so a rollback restores the original commit marker. The version *string* is
|
||||
# already encoded in the backup dir name (${from_version}).
|
||||
|
||||
# Backup data/ directory (services.json, config.json, credentials, etc.)
|
||||
backup_data_dir "$backup_dir"
|
||||
|
||||
cleanup_old_backups
|
||||
|
||||
# 4. Copy new files from staging to API source
|
||||
# 3. Copy new files from staging to API source
|
||||
log "Deploying new API files..."
|
||||
for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
@@ -145,19 +259,11 @@ main() {
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
|
||||
# Belt-and-suspenders: always write the commit from trigger.json to VERSION,
|
||||
# even if the tarball didn't include one. The container's self-updater uses
|
||||
# this to detect the "same version, different commit" case.
|
||||
if [[ -n "$commit" ]]; then
|
||||
echo "$commit" > "$api_source_dir/VERSION"
|
||||
fi
|
||||
|
||||
# 4b. Sync frontend. Caddy serves the dashboard directly from the host
|
||||
# filesystem; the container-side copy in older self-updater.js builds wrote
|
||||
# to /app/dashboard which isn't always mounted, so the real sync happens
|
||||
# here. Trigger fields take precedence; if absent (older self-updater),
|
||||
# fall back to: staging dir's sibling status/ + first existing known target.
|
||||
# 3b. Sync frontend
|
||||
if [[ -z "$frontend_staging_dir" ]]; then
|
||||
parent_staging=$(dirname "$staging_dir")
|
||||
[[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status"
|
||||
@@ -175,107 +281,55 @@ main() {
|
||||
for sub in dist css vendor js; do
|
||||
if [[ -d "$frontend_staging_dir/$sub" ]]; then
|
||||
mkdir -p "$frontend_target_dir/$sub"
|
||||
cp -rf "$frontend_staging_dir/$sub"/* "$frontend_target_dir/$sub/" 2>/dev/null || true
|
||||
cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
# assets/ is mounted into the container; usually already in sync via bind
|
||||
# mount, but if a release ships new assets we want them on disk too.
|
||||
if [[ -d "$frontend_staging_dir/assets" ]]; then
|
||||
mkdir -p "$frontend_target_dir/assets"
|
||||
cp -rf "$frontend_staging_dir/assets"/* "$frontend_target_dir/assets/" 2>/dev/null || true
|
||||
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Rebuild container
|
||||
# 4. Rebuild container
|
||||
log "Rebuilding container..."
|
||||
cd "$api_source_dir"
|
||||
|
||||
local build_ok=false
|
||||
if docker compose build --quiet 2>&1; then
|
||||
build_ok=true
|
||||
elif docker-compose build --quiet 2>&1; then
|
||||
local image_tag="dashcaddy-dashcaddy-api:latest"
|
||||
|
||||
if docker build -t "$image_tag" . 2>&1; then
|
||||
build_ok=true
|
||||
fi
|
||||
|
||||
if [[ "$build_ok" != "true" ]]; then
|
||||
log "ERROR: Docker build failed — rolling back"
|
||||
|
||||
# Restore backup
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
|
||||
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "Docker build failed"
|
||||
log "ERROR: Docker build failed — rolling back code + data"
|
||||
code_restore "$backup_dir"
|
||||
docker build -t "$image_tag" . 2>&1 | tail -3 || true
|
||||
restart_container "$image_tag"
|
||||
wait_for_health || true
|
||||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 6. Restart container
|
||||
log "Restarting container..."
|
||||
if docker compose up -d 2>&1 || docker-compose up -d 2>&1; then
|
||||
log "Container restarted"
|
||||
else
|
||||
log "ERROR: Container restart failed — rolling back"
|
||||
# 5. Restart container (rm + run so new env vars take effect)
|
||||
restart_container "$image_tag"
|
||||
|
||||
# Restore backup
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
|
||||
docker compose build --quiet 2>&1 || docker-compose build --quiet 2>&1 || true
|
||||
docker compose up -d 2>&1 || docker-compose up -d 2>&1 || true
|
||||
|
||||
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "Container restart failed"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 7. Health check
|
||||
# 6. Health check
|
||||
if wait_for_health; then
|
||||
local duration=$(( $(date +%s) - start_time ))
|
||||
log "=== Update successful: v${version} in ${duration}s ==="
|
||||
write_result "true" "$version" "$duration"
|
||||
log "=== Update successful: v${to_version} in ${duration}s ==="
|
||||
write_result "true" "$to_version" "$duration"
|
||||
else
|
||||
local duration=$(( $(date +%s) - start_time ))
|
||||
log "ERROR: Health check failed after update — rolling back"
|
||||
|
||||
# Restore backup
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
|
||||
docker compose build --quiet 2>&1 || docker-compose build --quiet 2>&1 || true
|
||||
docker compose up -d 2>&1 || docker-compose up -d 2>&1 || true
|
||||
log "ERROR: Health check failed after update — rolling back code + data"
|
||||
rollback_restore "$backup_dir"
|
||||
docker build -t "$image_tag" . 2>&1 | tail -3 || true
|
||||
restart_container "$image_tag"
|
||||
wait_for_health || log "WARNING: Rollback health check also failed"
|
||||
|
||||
write_result "false" "$version" "$duration" "Health check failed after update"
|
||||
write_result "false" "$to_version" "$duration" "Health check failed after update"
|
||||
fi
|
||||
|
||||
# 8. Cleanup
|
||||
# 7. Cleanup
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Minimal startup script - all logic moved to src/
|
||||
*/
|
||||
const { createApp } = require('./src/app');
|
||||
const { fetchT } = require('./src/utils/http');
|
||||
const platformPaths = require('./platform-paths');
|
||||
|
||||
// Unhandled error handlers
|
||||
@@ -66,6 +67,10 @@ process.on('uncaughtException', (error) => {
|
||||
const selfUpdater = require('./self-updater');
|
||||
const portLockManager = require('./port-lock-manager');
|
||||
|
||||
// Create servicesStateManager early — needed by workflow engine init
|
||||
const StateManager = require('./state-manager');
|
||||
const servicesStateManager = new StateManager(SERVICES_FILE);
|
||||
|
||||
// Optional modules
|
||||
let dockerMaintenance, logDigest, bundledWorkflows;
|
||||
try { dockerMaintenance = require('./docker-maintenance'); } catch { /* optional */ }
|
||||
@@ -80,7 +85,7 @@ process.on('uncaughtException', (error) => {
|
||||
// Create a context with needed services
|
||||
const workflowCtx = {
|
||||
docker: { client: require('dockerode')() },
|
||||
notification: require('./notification-manager')({
|
||||
notification: new (require('./notification-manager'))({
|
||||
NOTIFICATIONS_FILE: process.env.NOTIFICATIONS_FILE || require('./platform-paths').notificationsFile,
|
||||
fetchT,
|
||||
log,
|
||||
@@ -133,9 +138,7 @@ process.on('uncaughtException', (error) => {
|
||||
(async () => {
|
||||
try {
|
||||
const { syncHealthCheckerServices } = require('./startup-validator');
|
||||
const StateManager = require('./state-manager');
|
||||
const servicesStateManager = new StateManager(SERVICES_FILE);
|
||||
|
||||
|
||||
await syncHealthCheckerServices({
|
||||
log,
|
||||
SERVICES_FILE,
|
||||
|
||||
+70
-28
@@ -12,6 +12,8 @@ const { assembleContext } = require('./context');
|
||||
const { createLogger, logError, safeErrorMessage } = require('./utils/logging');
|
||||
const { fetchT } = require('./utils/http');
|
||||
const { errorResponse, ok } = require('./utils/responses');
|
||||
// Note: 3-arg asyncHandler signature (logError, fn, context) preserved per Hermes review
|
||||
// — 49 route files still use this signature.
|
||||
const { asyncHandler } = require('./utils/async-handler');
|
||||
|
||||
// Managers and utilities
|
||||
@@ -28,7 +30,7 @@ const healthChecker = require('../health-checker');
|
||||
const updateManager = require('../update-manager');
|
||||
const selfUpdater = require('../self-updater');
|
||||
const configureMiddleware = require('../middleware');
|
||||
const { validateStartupConfig, syncHealthCheckerServices } = require('../startup-validator');
|
||||
const { syncHealthCheckerServices } = require('../startup-validator');
|
||||
const { CSRF_HEADER_NAME } = require('../csrf-protection');
|
||||
const { resolveServiceUrl } = require('../url-resolver');
|
||||
const metrics = require('../metrics');
|
||||
@@ -84,7 +86,7 @@ const { APP } = require('../constants');
|
||||
/**
|
||||
* Create and configure the Express application
|
||||
*/
|
||||
async function createApp() {
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
||||
// Initialize logging
|
||||
@@ -159,11 +161,26 @@ async function createApp() {
|
||||
return first === 100 && second >= 64 && second <= 127;
|
||||
}
|
||||
|
||||
async function getTailscaleStatus() {
|
||||
function getTailscaleStatus() {
|
||||
// Stub for now - will be populated by context
|
||||
return null;
|
||||
}
|
||||
|
||||
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
|
||||
// auto-login pages) historically call these endpoints under the pre-1.5.0
|
||||
// prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained
|
||||
// Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO
|
||||
// gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these two
|
||||
// auth paths to the v1 mount so the gate is tolerant of that drift. Must run
|
||||
// before configureMiddleware() so CSRF/auth see the canonical path. This is
|
||||
// deliberately narrow — NOT a general `/api` -> `/api/v1` alias.
|
||||
app.use((req, res, next) => {
|
||||
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) {
|
||||
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Configure middleware
|
||||
const middlewareResult = configureMiddleware(app, {
|
||||
siteConfig: config.siteConfig,
|
||||
@@ -194,7 +211,7 @@ async function createApp() {
|
||||
|
||||
async function readConfig() {
|
||||
const { readJsonFile } = require('../fs-helpers');
|
||||
return readJsonFile(config.CONFIG_FILE, {});
|
||||
return await readJsonFile(config.CONFIG_FILE, {});
|
||||
}
|
||||
|
||||
async function saveConfig(updates) {
|
||||
@@ -227,7 +244,9 @@ async function createApp() {
|
||||
// Stub - will be implemented
|
||||
}
|
||||
|
||||
async function resyncHealthChecker() {
|
||||
// Forwards the promise from syncHealthCheckerServices — intentionally not
|
||||
// `async` since there is no `await` inside. Callers use `.catch()` on it.
|
||||
function resyncHealthChecker() {
|
||||
return syncHealthCheckerServices({
|
||||
log,
|
||||
SERVICES_FILE: config.SERVICES_FILE,
|
||||
@@ -239,11 +258,13 @@ async function createApp() {
|
||||
});
|
||||
}
|
||||
|
||||
// Create bound logError function
|
||||
// Create bound logError function (3-arg signature: ctx, err, extra)
|
||||
// The unified logger module has its own ERROR_LOG_FILE from process.env,
|
||||
// so we just route through its logErrorWrapper.
|
||||
const boundLogError = (context, error, additionalInfo) =>
|
||||
logError(config.ERROR_LOG_FILE, config.MAX_ERROR_LOG_SIZE, context, error, additionalInfo, log);
|
||||
logError(context, error, additionalInfo);
|
||||
|
||||
// Create bound asyncHandler
|
||||
// Create bound asyncHandler (3-arg: logError, fn, context)
|
||||
const boundAsyncHandler = (fn, context) => asyncHandler(boundLogError, fn, context);
|
||||
|
||||
// Assemble context
|
||||
@@ -379,12 +400,14 @@ async function createApp() {
|
||||
}));
|
||||
apiRouter.use('/notifications', notificationRoutes({
|
||||
notification: ctx.notification,
|
||||
asyncHandler: ctx.asyncHandler
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok
|
||||
}));
|
||||
apiRouter.use('/containers', containerRoutes({
|
||||
docker: ctx.docker,
|
||||
log: ctx.log,
|
||||
asyncHandler: ctx.asyncHandler
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
workflowEngine: ctx.workflowEngine
|
||||
}));
|
||||
apiRouter.use(serviceRoutes({
|
||||
servicesStateManager: ctx.servicesStateManager,
|
||||
@@ -422,7 +445,8 @@ async function createApp() {
|
||||
updateManager: ctx.updateManager,
|
||||
selfUpdater: ctx.selfUpdater,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError
|
||||
logError: ctx.logError,
|
||||
ok: ctx.ok
|
||||
}));
|
||||
apiRouter.use('/tailscale', tailscaleRoutes({
|
||||
tailscale: ctx.tailscale,
|
||||
@@ -431,11 +455,13 @@ async function createApp() {
|
||||
credentialManager: ctx.credentialManager,
|
||||
buildDomain: ctx.buildDomain,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
SERVICES_FILE: ctx.SERVICES_FILE,
|
||||
log: ctx.log
|
||||
}));
|
||||
apiRouter.use(sitesRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
caddy: ctx.caddy,
|
||||
dns: ctx.dns,
|
||||
fetchT: ctx.fetchT,
|
||||
@@ -453,6 +479,7 @@ async function createApp() {
|
||||
apiRouter.use('/openclaw', openClawRoutes(ctx));
|
||||
apiRouter.use(logsRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
docker: ctx.docker,
|
||||
logDigest: ctx.logDigest,
|
||||
dockerMaintenance: ctx.dockerMaintenance
|
||||
@@ -488,12 +515,14 @@ async function createApp() {
|
||||
resourceMonitor: ctx.resourceMonitor,
|
||||
healthChecker: ctx.healthChecker,
|
||||
updateManager: ctx.updateManager,
|
||||
logError: ctx.logError
|
||||
logError: ctx.logError,
|
||||
ok: ctx.ok
|
||||
}));
|
||||
apiRouter.use(workflowsRoutes({
|
||||
workflowEngine: ctx.workflowEngine,
|
||||
licenseManager: ctx.licenseManager,
|
||||
asyncHandler: ctx.asyncHandler
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok
|
||||
}));
|
||||
|
||||
// Inline API routes
|
||||
@@ -609,10 +638,33 @@ async function createApp() {
|
||||
res.status(statusCode).send();
|
||||
}, 'probe'));
|
||||
|
||||
// Scan OS network interfaces and classify the first LAN + Tailscale IPv4
|
||||
// addresses. Extracted to keep the route handler below ESLint's max-depth.
|
||||
function detectInterfaceIps() {
|
||||
const os = require('os');
|
||||
const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
|
||||
const all = [];
|
||||
let lan = null;
|
||||
let tailscale = null;
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs || []) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
const { address: ip } = addr;
|
||||
all.push({ name, ip });
|
||||
if (!tailscale && ip.startsWith('100.')) {
|
||||
tailscale = ip;
|
||||
} else if (!lan && LAN_RANGE.test(ip)) {
|
||||
lan = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lan, tailscale, all };
|
||||
}
|
||||
|
||||
// Network IPs endpoint
|
||||
app.get('/api/v1/network/ips', (req, res) => {
|
||||
try {
|
||||
const os = require('os');
|
||||
const envLan = process.env.HOST_LAN_IP;
|
||||
const envTailscale = process.env.HOST_TAILSCALE_IP;
|
||||
|
||||
@@ -624,20 +676,10 @@ async function createApp() {
|
||||
};
|
||||
|
||||
if (!envLan || !envTailscale) {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
const ip = addr.address;
|
||||
result.all.push({ name, ip });
|
||||
|
||||
if (!result.tailscale && ip.startsWith('100.')) {
|
||||
result.tailscale = ip;
|
||||
} else if (!result.lan && (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./))) {
|
||||
result.lan = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
const detected = detectInterfaceIps();
|
||||
if (!result.lan) result.lan = detected.lan;
|
||||
if (!result.tailscale) result.tailscale = detected.tailscale;
|
||||
result.all = detected.all;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
|
||||
@@ -19,6 +19,21 @@ const siteConfig = {
|
||||
routingMode: 'subdomain'
|
||||
};
|
||||
|
||||
function applyRawConfig(raw) {
|
||||
siteConfig.tld = raw.tld || '.home';
|
||||
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
|
||||
siteConfig.caName = raw.caName || '';
|
||||
siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || '';
|
||||
siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT;
|
||||
siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`;
|
||||
siteConfig.timezone = raw.timezone || 'UTC';
|
||||
siteConfig.dnsServers = raw.dnsServers || {};
|
||||
siteConfig.configurationType = raw.configurationType || 'homelab';
|
||||
siteConfig.domain = raw.domain || '';
|
||||
siteConfig.routingMode = raw.routingMode || 'subdomain';
|
||||
siteConfig.pylon = raw.pylon || null;
|
||||
}
|
||||
|
||||
function loadSiteConfig(CONFIG_FILE, log) {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
@@ -35,18 +50,7 @@ function loadSiteConfig(CONFIG_FILE, log) {
|
||||
}
|
||||
}
|
||||
|
||||
siteConfig.tld = raw.tld || '.home';
|
||||
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
|
||||
siteConfig.caName = raw.caName || '';
|
||||
siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || '';
|
||||
siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT;
|
||||
siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`;
|
||||
siteConfig.timezone = raw.timezone || 'UTC';
|
||||
siteConfig.dnsServers = raw.dnsServers || {};
|
||||
siteConfig.configurationType = raw.configurationType || 'homelab';
|
||||
siteConfig.domain = raw.domain || '';
|
||||
siteConfig.routingMode = raw.routingMode || 'subdomain';
|
||||
siteConfig.pylon = raw.pylon || null;
|
||||
applyRawConfig(raw);
|
||||
}
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
|
||||
@@ -44,7 +44,7 @@ async function modifyCaddyfile(CADDYFILE_PATH, reloadCaddy, modifyFn) {
|
||||
* Read the current Caddyfile content
|
||||
*/
|
||||
async function readCaddyfile(CADDYFILE_PATH) {
|
||||
return fsp.readFile(CADDYFILE_PATH, 'utf8');
|
||||
return await fsp.readFile(CADDYFILE_PATH, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,6 +73,25 @@ async function refreshDnsToken(username, password, server, fetchT, log) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to refresh the DNS token using per-server (dns.<id>.<role>) credentials.
|
||||
* Returns the refresh result on success, or null if no per-server credentials match.
|
||||
*/
|
||||
async function refreshWithPerServerCredentials(dnsId, serverIp, credentialManager, fetchT, log) {
|
||||
for (const role of ['admin', 'readonly']) {
|
||||
try {
|
||||
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
|
||||
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
|
||||
if (username && password) {
|
||||
return await refreshDnsToken(username, password, serverIp, fetchT, log);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('dns', `Per-server ${role} credential error`, { dnsId, error: err.message });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid DNS token (auto-refresh if needed)
|
||||
*/
|
||||
@@ -86,17 +105,8 @@ async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) {
|
||||
if (primaryIp) {
|
||||
const dnsId = dnsIpToDnsId(primaryIp, siteConfig);
|
||||
if (dnsId) {
|
||||
for (const role of ['admin', 'readonly']) {
|
||||
try {
|
||||
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
|
||||
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
|
||||
if (username && password) {
|
||||
return await refreshDnsToken(username, password, primaryIp, fetchT, log);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('dns', `Per-server ${role} credential error`, { dnsId, error: err.message });
|
||||
}
|
||||
}
|
||||
const result = await refreshWithPerServerCredentials(dnsId, primaryIp, credentialManager, fetchT, log);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
||||
get: (k) => res.headers[k.toLowerCase()],
|
||||
getSetCookie: () => {
|
||||
const sc = res.headers['set-cookie'];
|
||||
return sc ? (Array.isArray(sc) ? sc : [sc]) : [];
|
||||
if (!sc) return [];
|
||||
return Array.isArray(sc) ? sc : [sc];
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -152,7 +153,8 @@ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
||||
get: (k) => res.headers[k.toLowerCase()],
|
||||
getSetCookie: () => {
|
||||
const sc = res.headers['set-cookie'];
|
||||
return sc ? (Array.isArray(sc) ? sc : [sc]) : [];
|
||||
if (!sc) return [];
|
||||
return Array.isArray(sc) ? sc : [sc];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,119 +1,445 @@
|
||||
/**
|
||||
* Logging utilities - Structured logging and error handling
|
||||
* DashCaddy Unified Logger
|
||||
*
|
||||
* Single logging system for the entire application.
|
||||
* - Structured JSON to stdout/stderr (pretty-printed in development)
|
||||
* - Human-readable errors to error.log with rotation
|
||||
* - Audit entries to audit-log.json
|
||||
* - All via log.info / log.warn / log.error / log.debug
|
||||
*
|
||||
* Usage:
|
||||
* const { log } = require('./logger');
|
||||
* log.info('server', 'Server started', { port: 3001 });
|
||||
* log.error('container', 'Failed to start', err, { req });
|
||||
* log.audit({ action: 'service.create', resource: 'nginx', outcome: 'success', ip, details });
|
||||
*/
|
||||
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a structured logger
|
||||
*/
|
||||
function createLogger(LOG_LEVEL) {
|
||||
function log(level, context, message, data = {}) {
|
||||
if (LOG_LEVELS[level] < LOG_LEVEL) return;
|
||||
|
||||
const entry = {
|
||||
t: new Date().toISOString(),
|
||||
level,
|
||||
ctx: context,
|
||||
msg: message,
|
||||
};
|
||||
|
||||
if (Object.keys(data).length) entry.data = data;
|
||||
|
||||
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.info;
|
||||
fn(JSON.stringify(entry));
|
||||
}
|
||||
|
||||
log.info = (ctx, msg, data) => log('info', ctx, msg, data);
|
||||
log.warn = (ctx, msg, data) => log('warn', ctx, msg, data);
|
||||
log.error = (ctx, msg, data) => log('error', ctx, msg, data);
|
||||
log.debug = (ctx, msg, data) => log('debug', ctx, msg, data);
|
||||
|
||||
return log;
|
||||
const LOG_DIR = process.env.LOG_DIR || __dirname;
|
||||
const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(LOG_DIR, 'error.log');
|
||||
const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(LOG_DIR, 'audit-log.json');
|
||||
const MAX_ERROR_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const MAX_AUDIT_ENTRIES = 1000;
|
||||
const AUDIT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
const IS_DEV = NODE_ENV !== 'production';
|
||||
|
||||
// ─── Log levels ───────────────────────────────────────────────────────────────
|
||||
|
||||
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
|
||||
let GLOBAL_LEVEL = IS_DEV ? LEVELS.debug : LEVELS.info;
|
||||
|
||||
// ─── Console colours ─────────────────────────────────────────────────────────
|
||||
|
||||
const C = {
|
||||
reset: '\x1b[0m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
green: '\x1b[32m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
const LEVEL_PREFIX = {
|
||||
debug: `${C.dim}[DBG]${C.reset}`,
|
||||
info: `${C.green}[INF]${C.reset}`,
|
||||
warn: `${C.yellow}[WRN]${C.reset}`,
|
||||
error: `${C.red}[ERR]${C.reset}`,
|
||||
};
|
||||
|
||||
// ─── Time formatter ───────────────────────────────────────────────────────────
|
||||
|
||||
function pad(n, len = 2) { return String(n).padStart(len, '0'); }
|
||||
function formatTime() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error logging with context tracking
|
||||
*/
|
||||
async function logError(ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE, context, error, additionalInfo = {}, log) {
|
||||
const timestamp = new Date().toISOString();
|
||||
// ─── Console output (dev = pretty, prod = JSON) ─────────────────────────────
|
||||
|
||||
// Extract request context
|
||||
const requestContext = {};
|
||||
if (additionalInfo.req) {
|
||||
const req = additionalInfo.req;
|
||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
||||
requestContext.requestId = req.id;
|
||||
requestContext.ip = clientIP;
|
||||
requestContext.userAgent = req.get('user-agent');
|
||||
requestContext.method = req.method;
|
||||
requestContext.path = req.path;
|
||||
delete additionalInfo.req;
|
||||
function consoleWrite(level, ctx, msg, data) {
|
||||
if (GLOBAL_LEVEL > LEVELS[level]) return;
|
||||
if (IS_DEV) {
|
||||
const parts = [
|
||||
`${C.dim}${formatTime()}${C.reset}`,
|
||||
LEVEL_PREFIX[level],
|
||||
`${C.cyan}${ctx}${C.reset}`,
|
||||
`${msg}`,
|
||||
];
|
||||
if (data && typeof data === 'object' && !(data instanceof Error)) {
|
||||
parts.push(`${C.dim}${JSON.stringify(data)}${C.reset}`);
|
||||
}
|
||||
let fn = console.log;
|
||||
if (level === 'error') fn = console.error;
|
||||
else if (level === 'warn') fn = console.warn;
|
||||
fn(parts.join(' '));
|
||||
} else {
|
||||
let extra;
|
||||
if (data instanceof Error) {
|
||||
extra = { error: { message: data.message, code: data.code, stack: data.stack } };
|
||||
} else if (data && typeof data === 'object') {
|
||||
extra = { data };
|
||||
} else {
|
||||
extra = {};
|
||||
}
|
||||
const entry = { t: new Date().toISOString(), level, ctx, msg, ...extra };
|
||||
(level === 'error' ? console.error : console.info)(JSON.stringify(entry));
|
||||
}
|
||||
}
|
||||
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
context,
|
||||
...requestContext,
|
||||
error: {
|
||||
message: error.message || error,
|
||||
stack: error.stack,
|
||||
code: error.code
|
||||
},
|
||||
...additionalInfo
|
||||
};
|
||||
|
||||
const contextInfo = Object.keys(requestContext).length > 0
|
||||
? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}`
|
||||
: '';
|
||||
const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`;
|
||||
// ─── Error log file ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function appendErrorLog(line) {
|
||||
try {
|
||||
// Rotate log if it exceeds max size
|
||||
try {
|
||||
const stats = await fsp.stat(ERROR_LOG_FILE);
|
||||
if (stats.size > MAX_ERROR_LOG_SIZE) {
|
||||
const rotated = ERROR_LOG_FILE + '.1';
|
||||
const exists = await fsp.access(rotated).then(() => true).catch(() => false);
|
||||
if (exists) await fsp.unlink(rotated);
|
||||
await fsp.rename(ERROR_LOG_FILE, rotated);
|
||||
}
|
||||
} catch (_) { /* file may not exist yet */ }
|
||||
|
||||
await fsp.appendFile(ERROR_LOG_FILE, logLine);
|
||||
const stats = await fsp.stat(ERROR_LOG_FILE).catch(() => null);
|
||||
if (stats && stats.size > MAX_ERROR_LOG_SIZE) {
|
||||
const rotated = ERROR_LOG_FILE + '.1';
|
||||
await fsp.unlink(rotated).catch(() => {});
|
||||
await fsp.rename(ERROR_LOG_FILE, rotated);
|
||||
}
|
||||
await fsp.appendFile(ERROR_LOG_FILE, line + '\n');
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('errorlog', 'Failed to write to error log', { error: e.message });
|
||||
console.error('[logger] Failed to write error.log:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeErrorLog(ctx, error, req, extra) {
|
||||
const ts = new Date().toISOString();
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
const errStack = error instanceof Error ? error.stack : '';
|
||||
const parts = [`[${ts}] [ERR] ${ctx}: ${errMsg}`];
|
||||
if (errStack) parts.push(errStack);
|
||||
if (req) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || '';
|
||||
const ua = req.get ? req.get('user-agent') : '';
|
||||
parts.push(` request: ${req.method || ''} ${req.path || ''} | ip: ${ip} | ua: ${ua}${req.id ? ' | id: ' + req.id : ''}`);
|
||||
}
|
||||
if (extra && Object.keys(extra).length) {
|
||||
parts.push(` context: ${JSON.stringify(extra)}`);
|
||||
}
|
||||
parts.push('─'.repeat(72));
|
||||
await appendErrorLog(parts.join('\n'));
|
||||
}
|
||||
|
||||
// ─── Audit log ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const AUDIT_SKIP_PATHS = [
|
||||
'/api/v1/totp/verify',
|
||||
'/api/v1/totp/check-session',
|
||||
'/api/v1/auth/gate/',
|
||||
'/api/v1/auth/app-token/',
|
||||
'/api/v1/audit-logs',
|
||||
'/api/v1/health',
|
||||
'/health',
|
||||
'/api/v1/notifications/test',
|
||||
'/api/v1/notifications/health-check',
|
||||
];
|
||||
|
||||
const AUDIT_ACTION_MAP = {
|
||||
'POST /api/v1/services/update': 'service.reorder',
|
||||
'POST /api/v1/services': 'service.create',
|
||||
'PUT /api/v1/services': 'service.update',
|
||||
'DELETE /api/v1/services/': 'service.delete',
|
||||
'POST /api/v1/site': 'caddy.add-site',
|
||||
'POST /api/v1/site/external': 'caddy.add-external',
|
||||
'DELETE /api/v1/site/': 'caddy.remove-site',
|
||||
'POST /api/v1/caddy/reload': 'caddy.reload',
|
||||
'POST /api/v1/dns/record': 'dns.add-record',
|
||||
'DELETE /api/v1/dns/record': 'dns.delete-record',
|
||||
'POST /api/v1/dns/credentials': 'dns.save-credentials',
|
||||
'DELETE /api/v1/dns/credentials': 'dns.delete-credentials',
|
||||
'POST /api/v1/dns/refresh-token': 'dns.refresh-token',
|
||||
'POST /api/v1/dns/update': 'dns.update-server',
|
||||
'POST /api/v1/containers/': 'container.action',
|
||||
'DELETE /api/v1/containers/': 'container.delete',
|
||||
'POST /api/v1/apps/deploy': 'container.deploy',
|
||||
'DELETE /api/v1/apps/': 'container.undeploy',
|
||||
'POST /api/v1/backups/execute': 'backup.execute',
|
||||
'POST /api/v1/backups/restore/': 'backup.restore',
|
||||
'POST /api/v1/backups/config': 'backup.config',
|
||||
'POST /api/v1/config': 'config.update',
|
||||
'DELETE /api/v1/config': 'config.reset',
|
||||
'POST /api/v1/notifications/config': 'config.notifications',
|
||||
'POST /api/v1/totp/setup': 'auth.totp-setup',
|
||||
'POST /api/v1/totp/verify-setup': 'auth.totp-activate',
|
||||
'POST /api/v1/totp/disable': 'auth.totp-disable',
|
||||
'POST /api/v1/totp/config': 'auth.totp-config',
|
||||
'POST /api/v1/credentials/rotate-key': 'config.rotate-key',
|
||||
'POST /api/v1/updates/update/': 'container.update',
|
||||
'POST /api/v1/updates/rollback/': 'container.rollback',
|
||||
'POST /api/v1/updates/auto-update/': 'container.auto-update',
|
||||
'POST /api/v1/updates/check': 'container.check-updates',
|
||||
'POST /api/v1/health-checks/': 'config.health-check',
|
||||
'DELETE /api/v1/health-checks/': 'config.health-check-delete',
|
||||
'POST /api/v1/monitoring/alerts/': 'config.monitoring-alert',
|
||||
'DELETE /api/v1/monitoring/alerts/': 'config.monitoring-alert-delete',
|
||||
'POST /api/v1/arr/smart-connect': 'service.arr-connect',
|
||||
'POST /api/v1/arr/credentials': 'config.arr-credentials',
|
||||
'DELETE /api/v1/arr/credentials/': 'config.arr-credentials-delete',
|
||||
'POST /api/v1/logo': 'config.logo-upload',
|
||||
'DELETE /api/v1/logo': 'config.logo-delete',
|
||||
'POST /api/v1/favicon': 'config.favicon-upload',
|
||||
'DELETE /api/v1/favicon': 'config.favicon-delete',
|
||||
'POST /api/v1/tailscale/config': 'config.tailscale',
|
||||
'POST /api/v1/tailscale/protect-service': 'config.tailscale-protect',
|
||||
};
|
||||
|
||||
const SENSITIVE_KEYS = ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code', 'secretKey', 'authToken'];
|
||||
|
||||
function sanitize(obj) {
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
const clean = Array.isArray(obj) ? [] : {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (SENSITIVE_KEYS.includes(k)) {
|
||||
clean[k] = '***';
|
||||
} else if (v && typeof v === 'object') {
|
||||
clean[k] = sanitize(v);
|
||||
} else {
|
||||
clean[k] = v;
|
||||
}
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a safe error message without leaking internals
|
||||
*/
|
||||
function safeErrorMessage(error) {
|
||||
const msg = error.message || String(error);
|
||||
async function appendAuditLog(entries) {
|
||||
try {
|
||||
let existing = [];
|
||||
try {
|
||||
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
|
||||
existing = JSON.parse(raw);
|
||||
if (!Array.isArray(existing)) existing = [];
|
||||
} catch (_) { /* start fresh */ }
|
||||
|
||||
// Detect port conflict errors
|
||||
const merged = [...entries, ...existing].slice(0, MAX_AUDIT_ENTRIES);
|
||||
const stats = await fsp.stat(AUDIT_LOG_FILE).catch(() => null);
|
||||
if (stats && stats.size > AUDIT_MAX_FILE_SIZE) {
|
||||
await fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify(merged.slice(0, Math.floor(MAX_AUDIT_ENTRIES / 2)), null, 2));
|
||||
} else {
|
||||
await fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify(merged, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[logger] Failed to write audit log:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Logger class ──────────────────────────────────────────────────────────
|
||||
|
||||
class Logger extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this._level = GLOBAL_LEVEL;
|
||||
}
|
||||
|
||||
_should(level) {
|
||||
return LEVELS[level] >= this._level;
|
||||
}
|
||||
|
||||
debug(ctx, msg, data) { this._log('debug', ctx, msg, data); }
|
||||
info(ctx, msg, data) { this._log('info', ctx, msg, data); }
|
||||
warn(ctx, msg, data) { this._log('warn', ctx, msg, data); }
|
||||
|
||||
/**
|
||||
* Log an error — always writes to error.log and console.
|
||||
* @param {string} ctx — context label (e.g. 'container', 'dns')
|
||||
* @param {Error|string} err — the error
|
||||
* @param {object} req — optional request for request context
|
||||
* @param {object} extra — extra context data (not the error itself)
|
||||
*/
|
||||
error(ctx, err, req, extra) {
|
||||
const errObj = err instanceof Error ? err : new Error(String(err));
|
||||
const payload = extra && Object.keys(extra).length ? extra : undefined;
|
||||
this._log('error', ctx, errObj.message, errObj, { req, payload });
|
||||
}
|
||||
|
||||
_log(level, ctx, msg, data, { req, payload } = {}) {
|
||||
if (LEVELS[level] < this._level) return;
|
||||
|
||||
const entry = {
|
||||
t: new Date().toISOString(), level, ctx, msg,
|
||||
...(data instanceof Error ? { error: { message: data.message, code: data.code, stack: data.stack } } : {}),
|
||||
...(payload ? { data: payload } : {}),
|
||||
};
|
||||
if (req && (req.id || req.ip || req.path)) {
|
||||
entry.requestId = req.id || null;
|
||||
entry.ip = req.ip || req.socket?.remoteAddress || null;
|
||||
entry.method = req.method || null;
|
||||
entry.path = req.path || null;
|
||||
}
|
||||
this.emit('entry', entry);
|
||||
consoleWrite(level, ctx, msg, data);
|
||||
|
||||
if (level === 'error') {
|
||||
let errObj;
|
||||
if (data instanceof Error) {
|
||||
errObj = data;
|
||||
} else if (data && data.message) {
|
||||
errObj = new Error(data.message);
|
||||
} else {
|
||||
errObj = new Error(msg);
|
||||
}
|
||||
// Await the error log write so callers using await on log.error()
|
||||
// can rely on the file being flushed before proceeding.
|
||||
return writeErrorLog(ctx, errObj, req, payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an audit entry directly
|
||||
*/
|
||||
async audit({ action, resource, details, outcome, ip }) {
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: ip || '',
|
||||
action: action || '',
|
||||
resource: resource || '',
|
||||
details: details ? sanitize(details) : {},
|
||||
outcome: outcome || 'unknown',
|
||||
};
|
||||
await appendAuditLog([entry]);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express audit middleware — call app.use(log.auditMiddleware()) once
|
||||
*/
|
||||
auditMiddleware() {
|
||||
return (req, res, next) => {
|
||||
if (req.method === 'GET') return next();
|
||||
if (AUDIT_SKIP_PATHS.some(p => req.path.startsWith(p))) return next();
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (body) => {
|
||||
const action = this._resolveAuditAction(req.method, req.path);
|
||||
const resource = this._resolveAuditResource(req.path);
|
||||
const outcome = body && body.success === false ? 'failure' : 'success';
|
||||
const ip = req.ip || req.socket?.remoteAddress || '';
|
||||
const details = {};
|
||||
if (req.params && Object.keys(req.params).length) details.params = req.params;
|
||||
if (req.body) details.body = sanitize(req.body);
|
||||
|
||||
this.audit({ action, resource, details, outcome, ip });
|
||||
return originalJson(body);
|
||||
};
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
_resolveAuditAction(method, urlPath) {
|
||||
const key = `${method} ${urlPath}`;
|
||||
if (AUDIT_ACTION_MAP[key]) return AUDIT_ACTION_MAP[key];
|
||||
for (const [pattern, action] of Object.entries(AUDIT_ACTION_MAP)) {
|
||||
if (key.startsWith(pattern)) return action;
|
||||
}
|
||||
const parts = urlPath.replace('/api/v1/', '').split('/');
|
||||
return `${parts[0] || 'unknown'}.${method.toLowerCase()}`;
|
||||
}
|
||||
|
||||
_resolveAuditResource(urlPath) {
|
||||
const parts = urlPath.replace('/api/v1/', '').split('/');
|
||||
if (parts.length >= 2) return parts.slice(1).join('/');
|
||||
return parts[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Query audit log entries
|
||||
*/
|
||||
async queryAudit({ limit = 50, offset = 0, action } = {}) {
|
||||
try {
|
||||
let entries = JSON.parse(await fsp.readFile(AUDIT_LOG_FILE, 'utf8'));
|
||||
if (!Array.isArray(entries)) entries = [];
|
||||
if (action) entries = entries.filter(e => e.action && e.action.startsWith(action));
|
||||
return entries.slice(offset, offset + limit);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
clearAuditLog() {
|
||||
return fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify([])).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read error log (raw lines from error.log + error.log.1)
|
||||
*/
|
||||
async readErrorLog(tail = 100) {
|
||||
const results = [];
|
||||
for (const file of [ERROR_LOG_FILE, ERROR_LOG_FILE + '.1']) {
|
||||
try {
|
||||
const lines = (await fsp.readFile(file, 'utf8')).split('\n').filter(Boolean);
|
||||
results.push(...lines.map(l => ({ file: path.basename(file), text: l })));
|
||||
} catch (_) { /* missing */ }
|
||||
}
|
||||
return results.slice(-tail);
|
||||
}
|
||||
|
||||
setLevel(lvl) {
|
||||
if (lvl in LEVELS) this._level = LEVELS[lvl];
|
||||
}
|
||||
|
||||
getLevel() {
|
||||
return Object.entries(LEVELS).find(([, v]) => v === this._level)?.[0] ?? 'debug';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Global singleton ──────────────────────────────────────────────────────────
|
||||
|
||||
const log = new Logger();
|
||||
|
||||
// ─── Safe error messages ─────────────────────────────────────────────────────────
|
||||
|
||||
function safeErrorMessage(error) {
|
||||
if (!error) return 'An internal error occurred';
|
||||
const msg = error.message || String(error);
|
||||
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
|
||||
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
|
||||
const port = portMatch ? portMatch[1] : 'requested';
|
||||
return `[DC-200] Port ${port} is already in use. Try a different port or stop the service using that port first.`;
|
||||
return `[DC-200] Port ${portMatch ? portMatch[1] : 'requested'} is already in use. Try a different port or stop the service using that port first.`;
|
||||
}
|
||||
|
||||
// Only expose short, user-facing messages
|
||||
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (msg.includes('No such container')) return 'Container not found';
|
||||
if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) return 'Service unavailable';
|
||||
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) return msg;
|
||||
return 'An internal error occurred';
|
||||
}
|
||||
|
||||
// ─── Convenience wrapper compatible with the old logError(logDir)() signature ──────
|
||||
// Supports: logError(context, error, extra) → existing route call pattern
|
||||
|
||||
async function logErrorWrapper(ctx, err, extra) {
|
||||
const req = extra?.req;
|
||||
const payload = extra ? { ...extra } : {};
|
||||
if (payload.req) delete payload.req;
|
||||
await log.error(ctx, err instanceof Error ? err : new Error(String(err)), req, payload);
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
LOG_LEVELS,
|
||||
createLogger,
|
||||
logError,
|
||||
log,
|
||||
setLevel: (lvl) => {
|
||||
if (lvl in LEVELS) {
|
||||
GLOBAL_LEVEL = LEVELS[lvl];
|
||||
log.setLevel(lvl); // also update the singleton instance
|
||||
}
|
||||
},
|
||||
// Backwards-compatible alias: older callers (src/app.js) use createLogger(LOG_LEVEL)
|
||||
// and expect a `log.info/warn/error/debug` function back. The unified logger is
|
||||
// a single global instance, so we set the level and return it.
|
||||
createLogger: (level) => { if (level in LEVELS) GLOBAL_LEVEL = LEVELS[level]; return log; },
|
||||
safeErrorMessage,
|
||||
logError: logErrorWrapper,
|
||||
AUDIT_LOG_FILE,
|
||||
ERROR_LOG_FILE,
|
||||
MAX_ERROR_LOG_SIZE,
|
||||
MAX_AUDIT_ENTRIES,
|
||||
AUDIT_SKIP_PATHS,
|
||||
AUDIT_ACTION_MAP,
|
||||
SENSITIVE_KEYS,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// ========== MONITORING WIDGETS ==========
|
||||
// Embeds a compact system-resource + health summary panel directly on the
|
||||
// main dashboard. Replaces the need for a separate monitoring-dashboard.html
|
||||
// page — quick at-a-glance stats where you already are.
|
||||
(function () {
|
||||
|
||||
// ----- Style injection (scoped to .dc-monitor so it doesn't leak) -----
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
.dc-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--card-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.dc-monitor-card {
|
||||
padding: 10px 12px;
|
||||
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.dc-monitor-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dc-monitor-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.dc-monitor-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.dc-monitor-bar {
|
||||
margin-top: 6px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: color-mix(in srgb, var(--muted) 20%, transparent);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dc-monitor-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--ok-fg, #27ae60);
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
.dc-monitor-bar-fill.warn { background: #f39c12; }
|
||||
.dc-monitor-bar-fill.bad { background: #e74c3c; }
|
||||
.dc-monitor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dc-monitor-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dc-monitor-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dc-monitor-pill.ok { background: color-mix(in srgb, #27ae60 20%, transparent); color: #27ae60; }
|
||||
.dc-monitor-pill.warn { background: color-mix(in srgb, #f39c12 20%, transparent); color: #f39c12; }
|
||||
.dc-monitor-pill.bad { background: color-mix(in srgb, #e74c3c 20%, transparent); color: #e74c3c; }
|
||||
.dc-monitor-refresh {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
|
||||
// ----- Container element (inserted above service-filter-bar) -----
|
||||
const filterBar = document.getElementById('service-filter-bar');
|
||||
if (!filterBar) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'dc-monitor';
|
||||
panel.id = 'dc-monitor-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="dc-monitor-header" style="grid-column: 1 / -1;">
|
||||
<div class="dc-monitor-title">📊 System Overview</div>
|
||||
<span class="dc-monitor-refresh" id="dc-monitor-refresh-stamp">—</span>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Services</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-services">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-services-sub">loading…</div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Containers Up</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-containers">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-containers-sub">loading…</div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Avg CPU</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-cpu">—</div>
|
||||
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-cpu-bar"></div></div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Avg Memory</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-mem">—</div>
|
||||
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-mem-bar"></div></div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Health</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-health">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-health-sub">—</div>
|
||||
</div>
|
||||
`;
|
||||
// Insert ABOVE the filter bar
|
||||
filterBar.parentNode.insertBefore(panel, filterBar);
|
||||
|
||||
// ----- Helpers -----
|
||||
function setBar(id, pct) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const p = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
el.style.width = p + '%';
|
||||
el.classList.remove('warn', 'bad');
|
||||
if (p >= 85) el.classList.add('bad');
|
||||
else if (p >= 65) el.classList.add('warn');
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
if (v == null || isNaN(v)) return '—';
|
||||
return (Math.round(v * 10) / 10) + '%';
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (b == null || isNaN(b)) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
||||
return b.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
// ----- Robust services count -----
|
||||
// Read from multiple sources so we always have a number:
|
||||
// 1. window.APPS (populated by grid.js after loadServices)
|
||||
// 2. #cards .card elements (post-buildGrid)
|
||||
// 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
|
||||
async function fetchServicesCount() {
|
||||
// Source 1+2: window.APPS / DOM cards
|
||||
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: window.APPS.length, up, source: 'APPS' };
|
||||
}
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
if (cards.length > 0) {
|
||||
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
|
||||
return { total: cards.length, up, source: 'DOM' };
|
||||
}
|
||||
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
|
||||
try {
|
||||
const r = await fetch('/api/v1/services', { cache: 'no-store' });
|
||||
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
|
||||
const body = await r.json();
|
||||
const list = (body && Array.isArray(body.services)) ? body.services
|
||||
: (Array.isArray(body)) ? body
|
||||
: [];
|
||||
// Persist for the grid so this fallback only fires once
|
||||
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: list.length, up, source: 'fetch' };
|
||||
} catch (_) {
|
||||
return { total: 0, up: 0, source: 'fetch-error' };
|
||||
}
|
||||
}
|
||||
|
||||
async function setServicesCard() {
|
||||
const { total, up } = await fetchServicesCount();
|
||||
const el = document.getElementById('dc-monitor-services');
|
||||
const sub = document.getElementById('dc-monitor-services-sub');
|
||||
if (el) el.textContent = `${up} / ${total}`;
|
||||
if (sub) sub.textContent = total === 0
|
||||
? 'no services yet'
|
||||
: `${up} online · ${total - up} offline`;
|
||||
}
|
||||
|
||||
function applyHealthSummary(data) {
|
||||
const el = document.getElementById('dc-monitor-health');
|
||||
const sub = document.getElementById('dc-monitor-health-sub');
|
||||
if (!el) return;
|
||||
if (!data || data.summary == null) {
|
||||
el.textContent = '—';
|
||||
if (sub) sub.textContent = 'no data';
|
||||
return;
|
||||
}
|
||||
const s = data.summary;
|
||||
const healthy = s.healthy ?? s.up ?? 0;
|
||||
const unhealthy = s.unhealthy ?? s.down ?? 0;
|
||||
const total = s.total ?? (healthy + unhealthy);
|
||||
el.textContent = `${healthy}/${total}`;
|
||||
if (sub) {
|
||||
if (unhealthy === 0) {
|
||||
sub.innerHTML = '<span class="dc-monitor-pill ok">● all healthy</span>';
|
||||
} else if (unhealthy <= 2) {
|
||||
sub.innerHTML = `<span class="dc-monitor-pill warn">● ${unhealthy} degraded</span>`;
|
||||
} else {
|
||||
sub.innerHTML = `<span class="dc-monitor-pill bad">● ${unhealthy} down</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Data fetches -----
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/monitoring/stats', { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json();
|
||||
return (data && data.stats) ? data.stats : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHealth() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/health-checks/status', { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStats(stats) {
|
||||
const containers = document.getElementById('dc-monitor-containers');
|
||||
const containersSub = document.getElementById('dc-monitor-containers-sub');
|
||||
const cpuEl = document.getElementById('dc-monitor-cpu');
|
||||
const memEl = document.getElementById('dc-monitor-mem');
|
||||
|
||||
if (!stats) {
|
||||
if (containers) containers.textContent = '—';
|
||||
if (cpuEl) cpuEl.textContent = '—';
|
||||
if (memEl) memEl.textContent = '—';
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.values(stats);
|
||||
if (entries.length === 0) {
|
||||
if (containers) containers.textContent = '0';
|
||||
if (containersSub) containersSub.textContent = 'no containers reporting';
|
||||
if (cpuEl) cpuEl.textContent = '0%';
|
||||
if (memEl) memEl.textContent = '0%';
|
||||
setBar('dc-monitor-cpu-bar', 0);
|
||||
setBar('dc-monitor-mem-bar', 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let cpuSum = 0, memSum = 0, memBytes = 0, cpuCount = 0, memCount = 0;
|
||||
entries.forEach(s => {
|
||||
// CPU may be percentage (0-100) or fraction (0-1) — handle both
|
||||
if (s.cpu != null) {
|
||||
const cpu = Number(s.cpu);
|
||||
if (!isNaN(cpu)) {
|
||||
cpuSum += cpu > 1 ? cpu : cpu * 100;
|
||||
cpuCount++;
|
||||
}
|
||||
}
|
||||
if (s.memory != null) {
|
||||
const mem = Number(s.memory);
|
||||
if (!isNaN(mem)) {
|
||||
memSum += mem;
|
||||
memBytes += Number(s.memoryUsage || 0);
|
||||
memCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const avgCpu = cpuCount ? cpuSum / cpuCount : 0;
|
||||
const avgMem = memCount ? memSum / memCount : 0;
|
||||
|
||||
if (containers) containers.textContent = String(entries.length);
|
||||
if (containersSub) {
|
||||
const memTxt = memBytes ? ` · ${fmtBytes(memBytes)} RAM` : '';
|
||||
containersSub.textContent = `running${memTxt}`;
|
||||
}
|
||||
if (cpuEl) cpuEl.textContent = fmtPct(avgCpu);
|
||||
if (memEl) memEl.textContent = fmtPct(avgMem);
|
||||
setBar('dc-monitor-cpu-bar', avgCpu);
|
||||
setBar('dc-monitor-mem-bar', avgMem);
|
||||
}
|
||||
|
||||
// ----- Public refresh function -----
|
||||
let inFlight = false;
|
||||
async function refresh() {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
setServicesCard();
|
||||
const [stats, health] = await Promise.all([fetchStats(), fetchHealth()]);
|
||||
applyStats(stats);
|
||||
applyHealthSummary(health);
|
||||
const stamp = document.getElementById('dc-monitor-refresh-stamp');
|
||||
if (stamp) {
|
||||
const now = new Date();
|
||||
stamp.textContent = `updated ${now.toLocaleTimeString()}`;
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for init.js to call once and re-call after each refreshAll cycle
|
||||
window.refreshMonitoringWidgets = refresh;
|
||||
|
||||
// Auto-refresh on the STATS interval (separate from full DASHBOARD refresh)
|
||||
setInterval(refresh, (typeof DC !== 'undefined' && DC.POLL && DC.POLL.STATS) || 5000);
|
||||
|
||||
// Refresh once on first script load (init.js also calls this; double-call is harmless)
|
||||
setTimeout(refresh, 200);
|
||||
|
||||
})();
|
||||
@@ -27,10 +27,14 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api"
|
||||
readonly DASHBOARD_DIR="${SITES_DIR}/status"
|
||||
readonly CONTAINER_NAME="dashcaddy-api"
|
||||
readonly CADDY_ADMIN_PORT=2019
|
||||
readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
|
||||
readonly DEFAULT_MAX_STORAGE_BYTES=""
|
||||
|
||||
# ---- Tunables (overridable via flags) --------------------------------------
|
||||
API_PORT=3001
|
||||
LOCAL_PORT=8080
|
||||
BACKUP_DIR=""
|
||||
BACKUP_LIMIT=""
|
||||
|
||||
# ---- Runtime state ---------------------------------------------------------
|
||||
DOMAIN_MODE="" # public | custom-tld | local
|
||||
@@ -389,6 +393,7 @@ EOF
|
||||
create_directories() {
|
||||
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
|
||||
mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
ok "Directories created"
|
||||
}
|
||||
|
||||
@@ -626,7 +631,41 @@ CEOF
|
||||
# Docker Compose
|
||||
# ============================================================================
|
||||
|
||||
# Parse size string like "10GB" or "1TB" to bytes
|
||||
parse_size_to_bytes() {
|
||||
local size="$1"
|
||||
local value unit
|
||||
|
||||
# Strip whitespace
|
||||
size=$(echo "$size" | tr -d ' ')
|
||||
|
||||
# Extract numeric value and unit
|
||||
if [[ $size =~ ^([0-9.]+)([kmgtKMGT][bb]?|[bB]?)$ ]]; then
|
||||
value="${BASH_REMATCH[1]}"
|
||||
unit="${BASH_REMATCH[2]}"
|
||||
|
||||
# Normalize unit to uppercase without 'B' suffix for simplicity
|
||||
unit=$(echo "$unit" | tr '[:lower:]' '[:upper:]')
|
||||
case "$unit" in
|
||||
K|KB) echo $((value * 1024)) ;;
|
||||
M|MB) echo $((value * 1024 * 1024)) ;;
|
||||
G|GB) echo $((value * 1024 * 1024 * 1024)) ;;
|
||||
T|TB) echo $((value * 1024 * 1024 * 1024 * 1024)) ;;
|
||||
*) echo "$value" ;;
|
||||
esac
|
||||
else
|
||||
# Not recognized, treat as raw bytes
|
||||
echo "$size"
|
||||
fi
|
||||
}
|
||||
|
||||
generate_docker_compose() {
|
||||
# Convert BACKUP_LIMIT to bytes if set (e.g., "10GB" -> 10737418240)
|
||||
local backup_limit_bytes=""
|
||||
if [[ -n "$BACKUP_LIMIT" ]]; then
|
||||
backup_limit_bytes=$(parse_size_to_bytes "$BACKUP_LIMIT")
|
||||
fi
|
||||
|
||||
cat > "${API_DIR}/docker-compose.yml" <<DCEOF
|
||||
services:
|
||||
dashcaddy-api:
|
||||
@@ -648,6 +687,7 @@ services:
|
||||
- ${DASHBOARD_DIR}:/app/dashboard:rw
|
||||
- /opt/dashcaddy/updates:/app/updates:rw
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dashcaddy-backups:/app/backups
|
||||
environment:
|
||||
- CADDYFILE_PATH=/caddyfile
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
|
||||
@@ -665,6 +705,10 @@ services:
|
||||
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
|
||||
- DASHCADDY_API_SOURCE_DIR=${API_DIR}
|
||||
- DASHCADDY_FRONTEND_DIR=/app/dashboard
|
||||
- BACKUP_DIR=/app/backups
|
||||
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
|
||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
@@ -673,6 +717,14 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
dashcaddy-backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ${BACKUP_DIR}
|
||||
DCEOF
|
||||
|
||||
ok "docker-compose.yml generated"
|
||||
@@ -880,6 +932,8 @@ parse_args() {
|
||||
--skip-caddy) SKIP_CADDY=true; shift ;;
|
||||
--uninstall) UNINSTALL=true; shift ;;
|
||||
--keep-config) KEEP_CONFIG=true; shift ;;
|
||||
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
|
||||
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
|
||||
--yes|-y) AUTO_YES=true; shift ;;
|
||||
--help|-h) print_help; exit 0 ;;
|
||||
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
||||
@@ -914,6 +968,8 @@ print_help() {
|
||||
--source PATH Use local source files
|
||||
--skip-docker Already have Docker
|
||||
--skip-caddy Already have Caddy
|
||||
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
|
||||
--backup-limit SIZE Storage limit for backups (e.g., 10GB, 1TB)
|
||||
--uninstall Remove DashCaddy
|
||||
--keep-config Keep configs during uninstall
|
||||
--yes Skip confirmations
|
||||
|
||||
@@ -226,17 +226,34 @@ class ConfigManager {
|
||||
* @returns {Promise<Object>} Disk space info
|
||||
*/
|
||||
async getDiskSpace(testPath) {
|
||||
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
|
||||
try {
|
||||
const stats = await fs.stat(testPath);
|
||||
|
||||
const fsPromises = require('fs').promises;
|
||||
const pathModule = require('path');
|
||||
|
||||
// Ensure directory exists
|
||||
await fsPromises.mkdir(testPath, { recursive: true });
|
||||
|
||||
// Use statfs for true disk space (works on all filesystems: ext4, Btrfs, XFS, ZFS, APFS, NTFS)
|
||||
const stats = await fsPromises.statfs(testPath);
|
||||
|
||||
const totalBytes = stats.blocks * stats.bsize;
|
||||
const freeBytes = stats.bfree * stats.bsize;
|
||||
const availableBytes = stats.bavail * stats.bsize; // Available to non-root users
|
||||
const usedBytes = totalBytes - freeBytes;
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: testPath
|
||||
path: testPath,
|
||||
total: totalBytes,
|
||||
used: usedBytes,
|
||||
free: freeBytes,
|
||||
availableBytes: availableBytes,
|
||||
usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2))
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
path: testPath,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ const state = {
|
||||
installPath: '',
|
||||
health: null
|
||||
},
|
||||
// Backup configuration
|
||||
backup: {
|
||||
maxStorageGB: 10,
|
||||
backupDir: ''
|
||||
},
|
||||
// Uninstall mode
|
||||
uninstallMode: false,
|
||||
uninstall: {
|
||||
@@ -373,6 +378,24 @@ function updateBranding(field, value) {
|
||||
if (field === 'primaryColor') render();
|
||||
}
|
||||
|
||||
// Backup functions
|
||||
function updateBackup(field, value) {
|
||||
state.backup[field] = value;
|
||||
render();
|
||||
}
|
||||
|
||||
async function selectBackupDir() {
|
||||
try {
|
||||
const result = await window.electronAPI.selectFolder();
|
||||
if (result.success && result.path) {
|
||||
state.backup.backupDir = result.path;
|
||||
render();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Backup dir selection failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectLogo() {
|
||||
try {
|
||||
const result = await window.electronAPI.selectFile({
|
||||
@@ -420,6 +443,10 @@ async function startInstallation() {
|
||||
password: state.dns.password,
|
||||
token: state.dns.token
|
||||
} : null,
|
||||
backup: {
|
||||
maxStorageGB: state.backup.maxStorageGB,
|
||||
backupDir: state.backup.backupDir || null
|
||||
},
|
||||
autoStart: true
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -963,6 +990,31 @@ function renderDashboardSetup() {
|
||||
<p class="hint">Port for the DashCaddy API server (default: 3001)</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="folder-input">
|
||||
<label>Backup Storage Limit (GB)</label>
|
||||
<div class="input-row">
|
||||
<input type="number"
|
||||
value="${state.backup.maxStorageGB}"
|
||||
min="1" max="10240"
|
||||
oninput="updateBackup('maxStorageGB', parseInt(this.value) || 10)">
|
||||
</div>
|
||||
<p class="hint">Maximum storage for backups in GB (default: 10, max: 10TB)</p>
|
||||
</div>
|
||||
|
||||
${state.tier !== 'basic' ? `
|
||||
<div class="folder-input">
|
||||
<label>Backup Directory</label>
|
||||
<div class="input-row">
|
||||
<input type="text"
|
||||
value="${escapeHtml(state.backup.backupDir)}"
|
||||
readonly
|
||||
placeholder="Default: $INSTALL_DIR/backups">
|
||||
<button class="btn-browse" onclick="selectBackupDir()">Browse...</button>
|
||||
</div>
|
||||
<p class="hint">Where backup files are stored on the host</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -7,15 +7,28 @@ services:
|
||||
volumes:
|
||||
- {{API_PATH}}:/app
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dashcaddy-backups:/app/backups
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT={{API_PORT}}
|
||||
- SERVICES_FILE=/app/services.json
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:2019
|
||||
- BACKUP_DIR=/app/backups
|
||||
- BACKUP_MAX_STORAGE_BYTES={{BACKUP_MAX_STORAGE_BYTES}}
|
||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dashcaddy
|
||||
|
||||
volumes:
|
||||
dashcaddy-backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: {{BACKUP_DIR}}
|
||||
|
||||
networks:
|
||||
dashcaddy:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
/**
|
||||
* OpenClaw management routes
|
||||
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
|
||||
*
|
||||
* GET /openclaw/status → container info + gateway health
|
||||
* POST /openclaw/deploy → deploy OpenClaw container
|
||||
* GET /openclaw/proxy/* → proxy GET to gateway
|
||||
* POST /openclaw/proxy/* → proxy POST to gateway
|
||||
* DELETE /openclaw → remove container
|
||||
*/
|
||||
module.exports = function openClawRoutes(ctx) {
|
||||
const router = express.Router();
|
||||
const docker = ctx.docker;
|
||||
const asyncHandler = ctx.asyncHandler;
|
||||
const log = ctx.log || console;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function findOpenClawContainer() {
|
||||
const containers = await docker.client.listContainers({ all: true });
|
||||
return containers.find(function(c) {
|
||||
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
|
||||
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
|
||||
c.Names.some(function(n) { return n.includes('openclaw'); }));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function getGatewayToken(containerId) {
|
||||
try {
|
||||
const info = await docker.client.containerInfo(containerId);
|
||||
const entry = (info.Config.Env || []).find(function(e) {
|
||||
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
|
||||
});
|
||||
return entry ? entry.split('=')[1] : null;
|
||||
} catch(err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerPort(containerId) {
|
||||
try {
|
||||
const containers = await docker.client.listContainers({ all: true });
|
||||
const c = containers.find(function(x) {
|
||||
return x.Id === containerId || x.Id.startsWith(containerId);
|
||||
});
|
||||
if (c && c.Ports) {
|
||||
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
|
||||
if (p && p.PublicPort) return String(p.PublicPort);
|
||||
}
|
||||
return '18792';
|
||||
} catch(err) {
|
||||
return '18792';
|
||||
}
|
||||
}
|
||||
|
||||
async function gatewayHealth(baseUrl, token) {
|
||||
return new Promise(function(resolve) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
|
||||
let data = '';
|
||||
res.on('data', function(d) { data += d; });
|
||||
res.on('end', function() {
|
||||
try { resolve({ ok: true, data: JSON.parse(data) }); }
|
||||
catch(e) { resolve({ ok: true, data: data }); }
|
||||
});
|
||||
});
|
||||
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
|
||||
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
|
||||
});
|
||||
}
|
||||
|
||||
function proxyRequest(req, res, targetBase, path, token) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
headers['X-Forwarded-For'] = req.ip;
|
||||
headers['X-Forwarded-Proto'] = req.protocol;
|
||||
|
||||
const url = targetBase + '/' + path;
|
||||
const method = req.method;
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const body = JSON.stringify(req.body);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(body);
|
||||
|
||||
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
||||
proxyReq.write(body);
|
||||
proxyReq.end();
|
||||
} else {
|
||||
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /openclaw/status ────────────────────────────────────────────────
|
||||
|
||||
router.get('/status', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
|
||||
if (!container) {
|
||||
return res.json({ success: true, deployed: false });
|
||||
}
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const health = await gatewayHealth(baseUrl, token);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deployed: true,
|
||||
container: {
|
||||
id: container.Id.slice(0, 12),
|
||||
name: container.Name,
|
||||
state: container.State,
|
||||
status: container.Status,
|
||||
created: container.Created,
|
||||
image: container.Image
|
||||
},
|
||||
gateway: {
|
||||
url: baseUrl,
|
||||
port: port,
|
||||
healthy: health.ok,
|
||||
healthData: health.data || null,
|
||||
tokenSet: !!token
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// ── POST /openclaw/deploy ───────────────────────────────────────────────
|
||||
|
||||
router.post('/deploy', asyncHandler(async function(req, res) {
|
||||
const existing = await findOpenClawContainer();
|
||||
if (existing) {
|
||||
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
|
||||
}
|
||||
|
||||
const image = 'ghcr.io/nousresearch/openclaw:latest';
|
||||
const name = 'openclaw-' + Date.now();
|
||||
const gatewayToken = generateToken();
|
||||
|
||||
// Pull image
|
||||
log.info('Pulling ' + image + '...');
|
||||
try {
|
||||
await new Promise(function(resolve, reject) {
|
||||
docker.client.pull(image, function(err, stream) {
|
||||
if (err) return reject(err);
|
||||
docker.client.modem.followProgress(stream, function(err2) {
|
||||
if (err2) return reject(err2);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch(e) {
|
||||
log.error('OpenClaw pull failed: ' + e.message);
|
||||
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
|
||||
}
|
||||
|
||||
// Create + start container
|
||||
try {
|
||||
const container = await docker.client.createContainer({
|
||||
name: name,
|
||||
Image: image,
|
||||
Env: [
|
||||
'OPENCLAW_GATEWAY_MODE=local',
|
||||
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
|
||||
],
|
||||
HostConfig: {
|
||||
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
Labels: {
|
||||
'dashcaddy.managed': 'true',
|
||||
'dashcaddy.app': 'openclaw'
|
||||
}
|
||||
},
|
||||
ExposedPorts: { '18792/tcp': {} }
|
||||
});
|
||||
|
||||
await container.start();
|
||||
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deployed: true,
|
||||
container: { id: container.id.slice(0, 12), name: name },
|
||||
gateway: {
|
||||
url: 'http://localhost:18792',
|
||||
token: gatewayToken
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
log.error('OpenClaw deploy failed: ' + e.message);
|
||||
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
|
||||
}
|
||||
}));
|
||||
|
||||
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
|
||||
|
||||
router.get('/proxy/*', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const path = req.params[0];
|
||||
|
||||
proxyRequest(req, res, baseUrl, path, token);
|
||||
}));
|
||||
|
||||
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
|
||||
|
||||
router.post('/proxy/*', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const path = req.params[0];
|
||||
|
||||
proxyRequest(req, res, baseUrl, path, token);
|
||||
}));
|
||||
|
||||
// ── DELETE /openclaw ───────────────────────────────────────────────────
|
||||
|
||||
router.delete('/', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
try {
|
||||
const c = docker.client.container(container.Id);
|
||||
await c.stop().catch(function() {});
|
||||
await c.remove({ force: true });
|
||||
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
|
||||
res.json({ success: true, message: 'OpenClaw removed' });
|
||||
} catch(e) {
|
||||
log.error('Failed to remove OpenClaw: ' + e.message);
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
// ── token generator ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateToken() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
CONTAINER_NAME="dashcaddy-api"
|
||||
IMAGE="dashcaddy-dashcaddy-api:latest"
|
||||
DATA_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
||||
CADDYFILE="/etc/caddy/Caddyfile"
|
||||
ASSETS_DIR="/var/www/dashcaddy-status/assets"
|
||||
UPDATES_DIR="/opt/dashcaddy/updates"
|
||||
BACKUPS_DIR="/opt/dashcaddy/backups"
|
||||
HOST_IP="172.17.0.1"
|
||||
# Local Technitium (binds 0.0.0.0:53) resolves *.sami + recurses for docker subnet
|
||||
# external fallback. Without this the container only has 8.8.8.8 and every
|
||||
# *.sami health-check probe fails with ENOTFOUND (uptime bars stay empty).
|
||||
DNS_PRIMARY="100.121.150.22" # Technitium (Tailscale IP) — resolves *.sami
|
||||
DNS_FALLBACK="8.8.8.8"
|
||||
|
||||
# Always recreate to ensure env vars are correct (CONFIG_FILE defaults to /etc/dashcaddy/ which doesn't exist)
|
||||
if docker ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "[start.sh] Recreating container to apply correct env vars..."
|
||||
docker rm -f ${CONTAINER_NAME}
|
||||
fi
|
||||
|
||||
echo "[start.sh] Creating container with full config..."
|
||||
docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
|
||||
--dns ${DNS_PRIMARY} \
|
||||
--dns ${DNS_FALLBACK} \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v ${DATA_DIR}:/app/data \
|
||||
-v ${BACKUPS_DIR}:/app/backups \
|
||||
-v ${CADDYFILE}:/caddyfile \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ${ASSETS_DIR}:/app/assets \
|
||||
-v ${UPDATES_DIR}:/app/updates \
|
||||
-v /opt/sami-files/logs:/opt/sami-files/logs:ro \
|
||||
-e NODE_ENV=production \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
-e CONFIG_FILE=/app/data/config.json \
|
||||
-e BACKUP_DIR=/app/backups \
|
||||
-e DNS_CREDENTIALS_FILE=/app/data/dns-credentials.json \
|
||||
-e CREDENTIALS_FILE=/app/data/credentials.json \
|
||||
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
|
||||
-e HEALTH_HISTORY_FILE=/app/data/health-history.json \
|
||||
-e HEALTH_CONFIG_FILE=/app/data/health-config.json \
|
||||
-e CADDYFILE_PATH=/caddyfile \
|
||||
-e CADDY_ADMIN_URL=http://${HOST_IP}:2019 \
|
||||
-e ASSETS_DIR=/app/assets \
|
||||
-e DASHCADDY_API_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api \
|
||||
${IMAGE}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -19,6 +19,9 @@ const bundles = {
|
||||
JS('skeleton-loader.js'),
|
||||
JS('theme.js'),
|
||||
JS('totp-auth.js'),
|
||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||
JS('totp-recovery.js'),
|
||||
JS('service-credentials.js'),
|
||||
JS('totp-settings.js'),
|
||||
JS('core', 'credentials.js'),
|
||||
@@ -72,6 +75,7 @@ const bundles = {
|
||||
],
|
||||
'init.js': [
|
||||
JS('core', 'init.js'),
|
||||
JS('monitoring-widgets.js'),
|
||||
JS('keyboard-shortcuts.js'),
|
||||
],
|
||||
};
|
||||
|
||||
Vendored
-773
File diff suppressed because one or more lines are too long
Vendored
+308
-233
File diff suppressed because one or more lines are too long
Vendored
+123
-12
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+66
-3
@@ -43,6 +43,59 @@
|
||||
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]">
|
||||
</div>
|
||||
<div class="totp-error" id="totp-error"></div>
|
||||
<div class="totp-recovery-link" id="totp-recovery-link" style="display: none;">
|
||||
<a href="#" id="totp-show-recovery">Lost access? Recover with saved Base32 key →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Recovery Panel (hidden by default, shown via "Lost access?" link on overlay) -->
|
||||
<div id="totp-recovery-panel" class="weather-modal" style="display: none;">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 540px;">
|
||||
<h3 style="margin: 0 0 12px; font-size: 1.1rem;">Recover TOTP Access</h3>
|
||||
<div id="totp-recovery-status" style="margin-bottom: 12px; padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem; line-height: 1.4;"></div>
|
||||
|
||||
<!-- Path A: Paste saved Base32 secret -->
|
||||
<div id="totp-recovery-import">
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">
|
||||
Paste the Base32 secret you saved when you first set up TOTP (e.g. <code>JBSWY3DPEHPK3PXP</code>).
|
||||
If you don't have it, you'll need to SSH into the server to rotate the encryption key.
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<input type="text" id="totp-recovery-secret" placeholder="Paste your Base32 key"
|
||||
autocomplete="off" spellcheck="false"
|
||||
style="flex: 1; padding: 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; font-family: monospace; letter-spacing: 1px; text-transform: uppercase;" />
|
||||
<button id="totp-recovery-submit"
|
||||
style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-recovery-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Path B: After successful import, ask user to verify with code -->
|
||||
<div id="totp-recovery-verify" style="display: none;">
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">
|
||||
Secret accepted. Add it to your authenticator app and enter a 6-digit code to confirm.
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<input type="text" id="totp-recovery-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}"
|
||||
placeholder="000000" autocomplete="one-time-code"
|
||||
style="flex: 1; padding: 10px; text-align: center; font-size: 1.2rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
<button id="totp-recovery-confirm"
|
||||
style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-recovery-confirm-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 14px; text-align: right;">
|
||||
<button id="totp-recovery-close"
|
||||
style="padding: 8px 18px; background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.85rem;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +252,8 @@
|
||||
<div class="btn-row"><!-- No button for Internet --></div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-app="auth" data-status="off" id="auth-card">
|
||||
<div class="card" data-app="auth" data-status="off" id="auth-card"
|
||||
title="Two-factor authentication (TOTP). On first setup, save the Base32 secret — it's the only way to recover if you ever lose your authenticator.">
|
||||
<span id="auth-dot" class="dot bad at-bl"></span>
|
||||
<div class="row">
|
||||
<div class="logo-wrap">
|
||||
@@ -256,6 +310,9 @@
|
||||
<option value="on">🟢 Online</option>
|
||||
<option value="off">🔴 Offline</option>
|
||||
</select>
|
||||
<select id="service-filter-category" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="all">All Categories</option>
|
||||
</select>
|
||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||
</div>
|
||||
@@ -390,8 +447,14 @@
|
||||
<!-- DNS Server Configuration -->
|
||||
<div>
|
||||
<label class="form-label-accent">
|
||||
🗂️ DNS Server (Technitium)
|
||||
🗂️ DNS Provider
|
||||
</label>
|
||||
<select id="setup-dns-provider" class="form-input-lg" style="margin-bottom: 12px;">
|
||||
<option value="technitium">Technitium DNS (recommended)</option>
|
||||
<option value="cloudflare">Cloudflare DNS</option>
|
||||
<option value="rfc2136">RFC 2136 (BIND / PowerDNS / other)</option>
|
||||
<option value="manual">Manual / External DNS</option>
|
||||
</select>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; gap: 8px;">
|
||||
<input type="text" id="setup-dns-ip" value="" placeholder="DNS server IP"
|
||||
style="padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||||
@@ -406,7 +469,7 @@
|
||||
<!-- DNS Admin Token -->
|
||||
<div>
|
||||
<label class="form-label-accent">
|
||||
🔑 Technitium Admin Token
|
||||
🔑 DNS Admin Token / API Key
|
||||
</label>
|
||||
<input type="password" id="setup-dns-token" placeholder="Paste your admin token here"
|
||||
class="form-input-lg" />
|
||||
|
||||
@@ -41,8 +41,11 @@
|
||||
dismissedUpdates = new Set();
|
||||
}
|
||||
|
||||
// Track global update state for cross-component access
|
||||
let knownUpdates = [];
|
||||
|
||||
// Fetch update data and show badges
|
||||
async function refreshCardUpdates() {
|
||||
async function refreshCardUpdates(notifyNew) {
|
||||
try {
|
||||
const res = await fetch('/api/v1/updates/available');
|
||||
const data = await res.json();
|
||||
@@ -51,9 +54,21 @@
|
||||
// Clear all update badges first
|
||||
document.querySelectorAll('.update-available-badge').forEach(el => el.classList.remove('visible'));
|
||||
|
||||
if (!data.updates?.length) return;
|
||||
const updates = data.updates || [];
|
||||
knownUpdates = updates; // store globally
|
||||
|
||||
for (const upd of data.updates) {
|
||||
// Notify if new updates appeared (periodic check with notification)
|
||||
if (notifyNew && updates.length > 0) {
|
||||
const prev = window._lastKnownUpdateCount || 0;
|
||||
if (prev > 0 && updates.length > prev) {
|
||||
showNotification(`${updates.length} container update(s) available — click Update Management to review.`, 'info');
|
||||
}
|
||||
window._lastKnownUpdateCount = updates.length;
|
||||
}
|
||||
|
||||
if (!updates.length) return;
|
||||
|
||||
for (const upd of updates) {
|
||||
// Try to match by container name to service id
|
||||
const apps = window.APPS || [];
|
||||
for (const app of apps) {
|
||||
@@ -61,17 +76,24 @@
|
||||
// Skip dismissed updates
|
||||
if (dismissedUpdates.has(app.id)) break;
|
||||
const badge = document.getElementById('update-badge-' + app.id);
|
||||
const updateBtn = document.getElementById('update-btn-' + app.id);
|
||||
if (badge) {
|
||||
badge.classList.add('visible');
|
||||
badge.title = `Image digest changed. Click to dismiss if already up to date.\n${upd.imageName || ''}`;
|
||||
badge.title = `Update available — click to open Update Management.`;
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
badge.classList.remove('visible');
|
||||
dismissedUpdates.add(app.id);
|
||||
safeSessionSet('dismissed-updates', JSON.stringify([...dismissedUpdates]));
|
||||
// Open Update Management modal focused on this app
|
||||
if (window.openUpdateModal) window.openUpdateModal(app.id);
|
||||
};
|
||||
}
|
||||
// Highlight update button if update is available
|
||||
if (updateBtn) {
|
||||
updateBtn.style.background = '#f97316';
|
||||
updateBtn.style.borderColor = '#f97316';
|
||||
updateBtn.style.boxShadow = '0 0 6px #f9731688';
|
||||
updateBtn.title = `Update available — click to open Update Management.`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -90,10 +112,10 @@
|
||||
refreshCardUpdates();
|
||||
}, 5000);
|
||||
|
||||
// Periodic refresh every 60 seconds
|
||||
// Periodic refresh every 60 seconds — notify on new updates detected
|
||||
setInterval(() => {
|
||||
refreshCardHealth();
|
||||
refreshCardUpdates();
|
||||
refreshCardUpdates(true); // true = notify if new updates found
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
const firstInput = overlay.querySelector('.totp-digits input');
|
||||
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
// Refresh the "Lost access?" recovery link visibility based on server state.
|
||||
// Hides itself if TOTP is healthy; shows if unreadable/corrupt. The user
|
||||
// can still click it even when healthy — but the panel will explain there's
|
||||
// no recovery needed. Cheaper than gating it.
|
||||
if (typeof window._refreshRecoveryLink === 'function') {
|
||||
window._refreshRecoveryLink();
|
||||
}
|
||||
}
|
||||
|
||||
function hideTotpOverlay() {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// ===== TOTP RECOVERY FLOW =====
|
||||
// Public, unauthenticated recovery path for users who can't log in.
|
||||
// Designed for the "encryption key rotated and lost my authenticator" case.
|
||||
// The flow is:
|
||||
//
|
||||
// 1. On TOTP overlay show, call /api/v1/totp/recovery-info (public).
|
||||
// If status === 'unreadable', show the "Lost access?" link on the overlay.
|
||||
// 2. User clicks link → opens recovery panel.
|
||||
// 3. User pastes Base32 secret → POST /api/v1/totp/setup with {secret: ...}.
|
||||
// Backend stores as totp.pending_secret (encrypted with current key).
|
||||
// 4. Panel switches to "verify" mode. User enters a code from the
|
||||
// newly-added authenticator entry. POST /api/v1/totp/verify-setup
|
||||
// promotes pending → active and starts a session.
|
||||
// 5. hideTotpOverlay() and initializeDashboard() — same as normal login.
|
||||
//
|
||||
// The recovery flow never requires the user to be logged in. It does require
|
||||
// them to have their Base32 secret saved (e.g. password manager, screenshot,
|
||||
// the "Download backup file" we offer at setup time — see totp-settings.js).
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── Helpers ──
|
||||
async function fetchRecoveryInfo() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/recovery-info', { cache: 'no-store' });
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
return { success: false, status: 'unknown', hint: 'Could not contact server' };
|
||||
}
|
||||
}
|
||||
|
||||
function showRecoveryLink(show) {
|
||||
const link = document.getElementById('totp-recovery-link');
|
||||
if (link) link.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
function openRecoveryPanel() {
|
||||
const panel = document.getElementById('totp-recovery-panel');
|
||||
if (panel) panel.style.display = '';
|
||||
const statusEl = document.getElementById('totp-recovery-status');
|
||||
const importEl = document.getElementById('totp-recovery-import');
|
||||
const verifyEl = document.getElementById('totp-recovery-verify');
|
||||
if (importEl) importEl.style.display = '';
|
||||
if (verifyEl) verifyEl.style.display = 'none';
|
||||
// Reset state
|
||||
document.getElementById('totp-recovery-error').textContent = '';
|
||||
document.getElementById('totp-recovery-confirm-error').textContent = '';
|
||||
document.getElementById('totp-recovery-secret').value = '';
|
||||
document.getElementById('totp-recovery-code').value = '';
|
||||
// Show current status
|
||||
fetchRecoveryInfo().then(info => {
|
||||
statusEl.textContent = info.hint || '';
|
||||
// Color-code the status banner
|
||||
if (info.status === 'healthy') {
|
||||
statusEl.style.borderColor = 'var(--ok-fg, #7ef2ff)';
|
||||
} else if (info.status === 'unreadable') {
|
||||
statusEl.style.borderColor = 'var(--bad-fg, #ff9aa3)';
|
||||
statusEl.style.background = 'color-mix(in srgb, var(--bad-fg) 6%, transparent)';
|
||||
} else if (info.status === 'not_configured') {
|
||||
statusEl.style.borderColor = 'var(--muted)';
|
||||
} else {
|
||||
statusEl.style.borderColor = 'var(--border)';
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
document.getElementById('totp-recovery-secret')?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function closeRecoveryPanel() {
|
||||
const panel = document.getElementById('totp-recovery-panel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitRecoverySecret() {
|
||||
const secret = document.getElementById('totp-recovery-secret').value.trim();
|
||||
const errorEl = document.getElementById('totp-recovery-error');
|
||||
errorEl.textContent = '';
|
||||
|
||||
if (!secret) {
|
||||
errorEl.textContent = 'Paste your Base32 key first';
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z2-7\s]+=*$/.test(secret)) {
|
||||
errorEl.textContent = 'Invalid Base32 format — should be letters A-Z and digits 2-7 only';
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/v1/totp/setup with {secret} — backend stores as pending
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
errorEl.textContent = data.error || data.message || 'Restore failed';
|
||||
return;
|
||||
}
|
||||
// Switch panel to verify mode
|
||||
document.getElementById('totp-recovery-import').style.display = 'none';
|
||||
document.getElementById('totp-recovery-verify').style.display = '';
|
||||
setTimeout(() => document.getElementById('totp-recovery-code')?.focus(), 100);
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Network error — try again';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRecoveryCode() {
|
||||
const code = document.getElementById('totp-recovery-code').value.trim();
|
||||
const errorEl = document.getElementById('totp-recovery-confirm-error');
|
||||
errorEl.textContent = '';
|
||||
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
errorEl.textContent = 'Enter a 6-digit code';
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/v1/totp/verify-setup — promotes pending → active + starts session
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/verify-setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
errorEl.textContent = data.error || data.message || 'Invalid code';
|
||||
document.getElementById('totp-recovery-code').value = '';
|
||||
document.getElementById('totp-recovery-code')?.focus();
|
||||
return;
|
||||
}
|
||||
// Success — hide everything and initialize dashboard
|
||||
closeRecoveryPanel();
|
||||
const overlay = document.getElementById('totp-overlay');
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
if (typeof window.initializeDashboard === 'function') {
|
||||
window.initializeDashboard();
|
||||
}
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Network error — try again';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire up handlers ──
|
||||
document.getElementById('totp-show-recovery')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openRecoveryPanel();
|
||||
});
|
||||
document.getElementById('totp-recovery-close')?.addEventListener('click', closeRecoveryPanel);
|
||||
document.getElementById('totp-recovery-submit')?.addEventListener('click', submitRecoverySecret);
|
||||
document.getElementById('totp-recovery-confirm')?.addEventListener('click', submitRecoveryCode);
|
||||
|
||||
// Enter key submits in secret field
|
||||
document.getElementById('totp-recovery-secret')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); submitRecoverySecret(); }
|
||||
});
|
||||
// Enter key submits in code field
|
||||
document.getElementById('totp-recovery-code')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); submitRecoveryCode(); }
|
||||
});
|
||||
|
||||
// ── Public API ──
|
||||
// Called by totp-auth.js after showing the overlay, so we can decide whether
|
||||
// to show the recovery link. We do this with a public endpoint that doesn't
|
||||
// require auth — perfect for the locked-out state.
|
||||
window._refreshRecoveryLink = async function() {
|
||||
const info = await fetchRecoveryInfo();
|
||||
// Show the link in any non-healthy state (unreadable / corrupt / unknown).
|
||||
// The hint inside the panel tells the user what the actual issue is.
|
||||
if (info && info.success && info.status && info.status !== 'healthy') {
|
||||
showRecoveryLink(true);
|
||||
} else {
|
||||
showRecoveryLink(false);
|
||||
}
|
||||
return info;
|
||||
};
|
||||
})();
|
||||
@@ -38,11 +38,25 @@
|
||||
<div id="totp-qr-section" style="display: none;">
|
||||
<!-- Manual Key (primary - for WinAuth/desktop authenticators) -->
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">Copy this key into your authenticator app:</p>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<code id="totp-manual-key" style="flex: 1; display: block; padding: 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 2px; word-break: break-all; user-select: all; color: var(--fg);"></code>
|
||||
<button id="totp-copy-key" style="padding: 10px 14px; background: var(--card-base); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 1rem; white-space: nowrap; color: var(--fg);" title="Copy to clipboard">📋</button>
|
||||
</div>
|
||||
|
||||
<!-- Download backup file (recovery aid) -->
|
||||
<div style="margin-bottom: 16px; padding: 10px 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 0.8rem; color: var(--muted); flex: 1;">
|
||||
<strong style="color: var(--fg);">Save a backup file</strong> — if you ever lose your authenticator,
|
||||
this is the only way to recover without SSH access to the server.
|
||||
</span>
|
||||
<button id="totp-download-backup" type="button"
|
||||
style="padding: 8px 14px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
⬇ Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (secondary - for mobile apps) -->
|
||||
<details class="mb-16">
|
||||
<summary style="cursor: pointer; color: var(--muted); font-size: 0.8rem;">Show QR code (for mobile authenticator apps)</summary>
|
||||
@@ -119,7 +133,13 @@
|
||||
statusBanner.style.background = 'color-mix(in srgb, var(--ok-fg) 8%, transparent)';
|
||||
statusText.textContent = 'TOTP is active';
|
||||
statusText.style.color = 'var(--ok-fg, #7ef2ff)';
|
||||
setupSection.style.display = 'none';
|
||||
// Keep the setup section visible (collapsed) so the "Import existing
|
||||
// secret" option is always reachable — users may need to re-enroll
|
||||
// their authenticator with the same secret from a backup file.
|
||||
setupSection.style.display = 'block';
|
||||
const setupBtn = document.getElementById('totp-setup-btn');
|
||||
if (setupBtn) setupBtn.textContent = 'Generate New Secret';
|
||||
// Hide the QR section by default in the active state — setupBtn click shows it
|
||||
qrSection.style.display = 'none';
|
||||
durationSection.style.display = 'block';
|
||||
disableSection.style.display = 'block';
|
||||
@@ -233,6 +253,41 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Download backup file — plain JSON so it round-trips through any password
|
||||
// manager, cloud backup, or printed paper. The secret IS recoverable plaintext
|
||||
// (that's the whole point of the backup), so warn the user and rely on
|
||||
// them to keep it safe.
|
||||
document.getElementById('totp-download-backup')?.addEventListener('click', () => {
|
||||
const secret = document.getElementById('totp-manual-key').textContent.trim();
|
||||
if (!secret) return;
|
||||
const payload = {
|
||||
service: 'DashCaddy',
|
||||
type: 'totp-secret',
|
||||
secret: secret,
|
||||
issuer: 'DashCaddy',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
issued: new Date().toISOString(),
|
||||
// Recovery instructions baked into the file so a year from now the
|
||||
// user (or their future self) knows what this file is and how to use it.
|
||||
recovery_url: `${window.location.origin}/ (login screen → "Lost access?")`,
|
||||
note: 'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `dashcaddy-totp-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
const btn = document.getElementById('totp-download-backup');
|
||||
btn.textContent = '✅ Saved';
|
||||
setTimeout(() => { btn.textContent = '⬇ Download'; }, 2000);
|
||||
});
|
||||
|
||||
// Confirm setup
|
||||
document.getElementById('totp-confirm-setup')?.addEventListener('click', async () => {
|
||||
const code = document.getElementById('totp-setup-code').value;
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
|
||||
<!-- Tab: Available Updates -->
|
||||
<div id="updates-available" class="panel-section active">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;">
|
||||
<button id="updates-check-btn" class="btn-accent-solid">🔍 Check for Updates</button>
|
||||
<button id="updates-update-all-btn" style="display: none; padding: 6px 14px; font-size: 0.82rem; background: #f97316; color: #fff; border: 1px solid #f97316; border-radius: 6px; cursor: pointer;">⬆️ Update All</button>
|
||||
<span id="updates-count-badge" style="display: none; padding: 4px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; background: var(--accent); color: var(--bg);"></span>
|
||||
</div>
|
||||
<div id="updates-available-container" style="max-height: 450px; overflow-y: auto;">
|
||||
<div class="panel-empty"><span class="empty-icon">📦</span> Click "Check for Updates" to scan containers.</div>
|
||||
@@ -94,13 +96,24 @@
|
||||
if (updates.length === 0) {
|
||||
availableContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">✅</span>All containers are up to date.</div>';
|
||||
lastCheckSpan.textContent = '';
|
||||
document.getElementById('updates-update-all-btn').style.display = 'none';
|
||||
document.getElementById('updates-count-badge').style.display = 'none';
|
||||
window._pendingUpdates = [];
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
|
||||
for (const u of updates) {
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
// Match app by containerId first, then name
|
||||
const appId = (() => {
|
||||
const apps = window.APPS || [];
|
||||
for (const a of apps) {
|
||||
if (a.containerId === u.containerId || a.name === u.containerName || a.id === u.containerName) return a.id;
|
||||
}
|
||||
return u.containerName;
|
||||
})();
|
||||
html += `<tr data-app-id="${escapeHtml(appId)}" style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.containerName)}</td>`;
|
||||
html += `<td style="padding: 8px; color: var(--muted);">${escapeHtml(u.imageName)}</td>`;
|
||||
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.currentDigest)}</code></td>`;
|
||||
@@ -114,6 +127,20 @@
|
||||
availableContainer.innerHTML = html;
|
||||
lastCheckSpan.textContent = updates.length + ' update(s) available';
|
||||
|
||||
// Show count badge and Update All button
|
||||
const countBadge = document.getElementById('updates-count-badge');
|
||||
const updateAllBtn = document.getElementById('updates-update-all-btn');
|
||||
if (countBadge) {
|
||||
countBadge.textContent = updates.length + ' pending';
|
||||
countBadge.style.display = '';
|
||||
}
|
||||
if (updateAllBtn && updates.length > 0) {
|
||||
updateAllBtn.style.display = '';
|
||||
}
|
||||
|
||||
// Store updates for Update All button
|
||||
window._pendingUpdates = updates;
|
||||
|
||||
// Wire update buttons
|
||||
availableContainer.querySelectorAll('.update-now-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
@@ -174,6 +201,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Update All — sequentially, skip failures
|
||||
async function updateAllContainers() {
|
||||
const updates = window._pendingUpdates || [];
|
||||
if (!updates.length) return;
|
||||
const btn = document.getElementById('updates-update-all-btn');
|
||||
if (!confirm(`Update all ${updates.length} containers? Each will restart.`)) return;
|
||||
btn.textContent = '⏳ Updating...';
|
||||
btn.disabled = true;
|
||||
let success = 0, failed = 0;
|
||||
for (const u of updates) {
|
||||
try {
|
||||
const r = await secureFetch(`/api/v1/updates/update/${encodeURIComponent(u.containerId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoRollback: true })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) success++;
|
||||
else failed++;
|
||||
} catch (_) { failed++; }
|
||||
}
|
||||
btn.textContent = `✅ Done`;
|
||||
showNotification(`Update all: ${success} succeeded, ${failed} failed.`, success > 0 && failed === 0 ? 'success' : 'error');
|
||||
setTimeout(() => {
|
||||
btn.textContent = '⬆️ Update All';
|
||||
btn.disabled = false;
|
||||
loadAvailable();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
document.getElementById('updates-update-all-btn')?.addEventListener('click', updateAllContainers);
|
||||
|
||||
async function checkForUpdates() {
|
||||
checkBtn.textContent = '🔍 Checking...';
|
||||
checkBtn.disabled = true;
|
||||
@@ -499,6 +558,21 @@
|
||||
});
|
||||
wireModal(modal, cancelBtn);
|
||||
|
||||
// Open Update Management modal, optionally scrolled to a specific app
|
||||
window.openUpdateModal = function(appId) {
|
||||
modal?.classList.add('show');
|
||||
loadAvailable().then(() => {
|
||||
if (!appId) return;
|
||||
// Scroll to and highlight the matching row
|
||||
const row = availableContainer.querySelector(`[data-app-id="${appId}"]`);
|
||||
if (row) {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
row.style.background = 'rgba(249,115,22,0.15)';
|
||||
setTimeout(() => { row.style.background = ''; }, 3000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Lazy-load tabs
|
||||
document.querySelector('[data-panel="updates-history"]')?.addEventListener('click', loadHistory);
|
||||
document.querySelector('[data-panel="updates-auto"]')?.addEventListener('click', loadAutoConfig);
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-8ef9c82616';
|
||||
const CACHE = 'dashcaddy-shell-f6673e7190';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in New Issue
Block a user