Compare commits

...
3 Commits
Author SHA1 Message Date
Krystie d04238621f DC-042: implement real Tailscale manager — replace null stub
CI / Security audit (push) Has been cancelled
CI / Test & Lint (push) Has been cancelled
The previous getTailscaleStatus() in src/app.js was a hard-coded
`return null` stub with a TODO saying it would be populated by context.
The context had a tailscale.* namespace declared with null function
stubs (routes/context.js:71), but nothing ever set them to real
functions. routes/tailscale.js has been calling ctx.tailscale.getStatus()
/ getLocalIP() / isTailscaleIP() and getting undefined back, silently
returning empty device lists. The tailscaleAuthMiddleware's allowedTailnet
check (DC-121, device-not-in-tailnet 403) was dead code for the same reason.

This commit replaces the stub with a real implementation:

- New src/managers/tailscale-manager.js shells out to the host's
  `tailscale status --json` (cached 5 minutes), parses the result, and
  exposes getStatus / getLocalIP / getSummary / getDevices / isTailscaleIP /
  invalidateCache / getAccessToken (stub) / startSyncTimer / stopSyncTimer
  / syncAPI (stub). All failure modes (CLI missing, tailscaled down,
  malformed JSON, EACCES) are handled gracefully — return null with no
  cache poisoning.
- src/context/index.js now wires the manager into ctx.tailscale.* so
  routes/tailscale.js and middleware.js's allowedTailnet gate get the
  real functions.
- src/app.js:189 getTailscaleStatus() now delegates to the manager
  instead of returning null.
- The duplicate isTailscaleIP() in src/app.js:179 (no malformed-input
  guards) is removed in favor of the canonical version in
  src/utilities/network-detector.js (DC-031) which the manager also uses.
- start.sh now bind-mounts /usr/bin/tailscale (statically linked Go binary
  — works under Alpine libc) and /var/run/tailscale/ into the container,
  read-only. Lets the container invoke the CLI without needing its own
  tailscale install.
- 41 new unit tests in __tests__/tailscale-manager.test.js cover: CLI
  success/missing/daemon-down/malformed-JSON paths, 5-min cache hit/miss,
  1-hour installed-cache hit/miss, getLocalIP IPv4/IPv6/missing-choices,
  getSummary shape, getDevices shape with full + minimal peer fields,
  startSyncTimer/stopSyncTimer interval + idempotency, TAILSCALE_BIN env
  override.

Total: 1138 tests pass (was 1097, +41 new), 0 new ESLint warnings.

What this unlocks:
- /api/v1/tailscale/status → real installed/connected/hostname/ip/
  peerCount/onlinePeerCount summary instead of empty
- /api/v1/tailscale/devices → real device list (was returning [])
- /api/v1/tailscale/check-connection → works (uses real isTailscaleIP)
- tailscaleAuthMiddleware allowedTailnet check (DC-121) is no longer
  dead code — a request from a Tailscale IP not in the allowed tailnet
  now actually gets 403 instead of being silently allowed.
2026-07-06 18:55:16 -07:00
Krystie ca705fe59f DC-025: sync DC-025 hardening (channel gate, locked-file deploy_tree, deploy_mode, post-deploy patches, dns-providers handling) into canonical dashcaddy-api/scripts/dashcaddy-update.sh
The host-side /opt/dashcaddy/scripts/dashcaddy-update.sh was hardened in
DC-025 (commit bfa4ba5, 2026-07-05), but the canonical script at
dashcaddy-api/scripts/dashcaddy-update.sh was never updated. This created
a drift hazard: anyone running release.sh and rebuilding the install
tarball would propagate the pre-hardening version, undoing DC-025 on
fresh hosts.

This commit syncs the hardening from the host-side script to the canonical,
so the next release builds and ships the hardened version. Specifically
adds:
- channel_allowed() gate (refuse prereleases unless ALLOW_PRERELEASE=true)
- deploy_mode() dispatch (compose / start.sh / bare docker run)
- build_image() helper
- deploy_tree() with chattr +i preservation and empty-staging-dir guard
- Post-deploy patches invocation (dashcaddy-post-deploy-patches.sh)
- dns-providers directory backup/restore

Verified: bash -n passes on both scripts; canonical and host-side are now
byte-identical (md5 a72e1dc37fb3487edc00e81ea37ac60b).

Discovered while investigating a WIP on DNS2 that had silently reverted
these features. That WIP was discarded (the BACKLOG entry it claimed to
satisfy described an implementation that didn't exist in the diff).
2026-07-06 15:26:10 -07:00
Krystie a6201b47cd BACKLOG: claim DC-037 (move symlink creation into install script) 2026-07-06 15:10:55 -07:00
7 changed files with 864 additions and 65 deletions
+2 -2
View File
@@ -180,8 +180,8 @@
- **result:** Verified zero callers (grep + 38 test files scanned — no references to `./self-updater`). Discovered the file was actually gitignored, never committed — so `git rm` was unnecessary; plain `rm` did it. Tests: 1075/1075 still passing post-delete. Also synced `dashcaddy-api/VERSION` to `42376e2` (the DC-033 + release-bump commit) so the rebuilt container reports the right SHA. Live: `curl http://127.0.0.1:3001/api/v1/system/version` returns `{"name":"DashCaddy","version":"1.14.9","commit":"42376e2"}`.
### DC-037: Move `/etc/dashcaddy/sites/dashcaddy-api` symlink creation into the install script
- **status:** todo
- **owner:** unclaimed
- **status:** in-progress
- **owner:** krystie
- **details:** DNS2 has the symlink manually created during this session (2026-07-05), but every other fresh DashCaddy install will hit the same `cp: cannot create directory '/etc/dashcaddy/sites/dashcaddy-api/routes': No such file or directory` failure when the first auto-update lands, because `dashcaddy-update.sh` defaults `apiSourceDir` to `${CADDY_BASE}/sites/dashcaddy-api` (= `/etc/dashcaddy/sites/dashcaddy-api`) while the actual install lives at `/opt/dashcaddy/dashcaddy-api`. Fix: add `mkdir -p /etc/dashcaddy/sites && ln -sfn /opt/dashcaddy/dashcaddy-api /etc/dashcaddy/sites/dashcaddy-api` to the install script (whichever of `dashcaddy-installer/install.sh` or `scripts/dashcaddy-install.sh` is canonical — verify which exists on a clean install). Make it idempotent (`ln -sfn`, not `ln -s`, so re-runs don't fail). Effort: ~10 min. Risk: very low.
- **impact:** Prevents every future DashCaddy host from hitting the v1.14.4-class update failure on first auto-update.
@@ -0,0 +1,399 @@
/**
* Tests for src/managers/tailscale-manager.js
*
* Strategy: stub `child_process.execFile` so the manager calls a fake `tailscale`
* CLI we control in-memory. This lets us exercise every code path — success,
* CLI missing, tailscaled down, malformed JSON, cache hit/miss, IPv4 vs IPv6
* selection, device-list shape — without depending on a real Tailscale install.
*
* The mock is a single function that inspects the args and resolves accordingly,
* so we don't have to count invocations.
*/
'use strict';
jest.mock('child_process', () => ({
execFile: jest.fn(),
}));
const { execFile } = require('child_process');
const tm = require('../src/managers/tailscale-manager');
/**
* Configure the mock to behave like a specific tailscale binary.
*
* mode: 'ok-version' → `tailscale version` returns 1.98.4; everything else fails
* mode: 'ok-status' → `tailscale version` AND `tailscale status --json` return ok
* with the given status fixture
* mode: 'no-cli' → everything rejects with ENOENT
* mode: 'no-daemon' → version ok, status rejects with code 1
* mode: 'malformed-status' → version ok, status returns malformed JSON
*/
function configureMock(mode, opts = {}) {
execFile.mockImplementation((cmd, args, optsArg, cb) => {
// Handle both 3-arg and 4-arg call shapes (promisify passes 3, manual passes 4)
if (typeof optsArg === 'function') {
cb = optsArg;
}
const isVersion = Array.isArray(args) && args[0] === 'version';
const isStatus = Array.isArray(args) && args[0] === 'status';
// Match the real `execFile` callback signature: cb(err, {stdout, stderr})
// (modern util.promisify(execFile) resolves with {stdout, stderr})
const ok = (out, err = '') => process.nextTick(() => cb(null, { stdout: out, stderr: err }));
const fail = (err) => process.nextTick(() => cb(err));
if (mode === 'no-cli') {
const err = new Error('spawn tailscale ENOENT');
err.code = 'ENOENT';
return fail(err);
}
if (isVersion) {
if (mode === 'ok-version' || mode === 'ok-status' || mode === 'no-daemon' || mode === 'malformed-status') {
return ok('1.98.4\n');
}
}
if (isStatus) {
if (mode === 'no-daemon') {
const err = new Error('tailscaled not running');
err.code = 1;
return fail(err);
}
if (mode === 'malformed-status') {
return ok('not json{{{');
}
if (mode === 'ok-status') {
return ok(JSON.stringify(opts.status || {}));
}
}
// Default: fail
const err = new Error(`unhandled mock invocation: ${cmd} ${(args||[]).join(' ')}`);
err.code = 1;
fail(err);
});
}
const RUNNING_STATUS = {
Version: '1.98.4',
BackendState: 'Running',
Self: {
HostName: 'vmi3080415',
TailscaleIPs: ['100.121.150.22', 'fd7a:115c:a1e0::1539:9616'],
},
Peer: {
p1: {
ID: 'p1',
HostName: 'peer1',
DNSName: 'peer1.tail.ts.net',
TailscaleIPs: ['100.100.100.1', 'fd7a::5'],
OS: 'linux',
Online: true,
LastSeen: '2026-07-06T10:00:00Z',
UserID: 'u1',
KeyExpiry: '2026-08-01T00:00:00Z',
Tags: ['tag:server'],
ExitNode: false,
RxBytes: 100,
TxBytes: 200,
},
p2: {
ID: 'p2',
HostName: 'peer2',
TailscaleIPs: ['100.100.100.2'],
OS: 'iOS',
Online: false,
},
},
};
beforeEach(() => {
tm.invalidateCache();
execFile.mockReset();
});
describe('tailscale-manager', () => {
describe('isTailscaleIP() — re-exported from network-detector (DC-031)', () => {
test.each([
['100.64.0.1', true],
['100.121.150.22', true],
['100.127.255.255', true],
['100.63.255.255', false],
['100.128.0.0', false],
['192.168.1.5', false],
['172.17.0.6', false],
['', false],
[null, false],
[undefined, false],
['not.an.ip', false],
['100.x.y.z', false],
['100.999.0.0', false],
])('isTailscaleIP(%p) === %p', (input, expected) => {
expect(tm.isTailscaleIP(input)).toBe(expected);
});
});
describe('getStatus()', () => {
test('returns parsed JSON on success', async () => {
configureMock('ok-status', { status: RUNNING_STATUS });
const s = await tm.getStatus();
expect(s.BackendState).toBe('Running');
expect(s.Self.HostName).toBe('vmi3080415');
expect(s.Peer.p1.HostName).toBe('peer1');
expect(s.Peer.p2.Online).toBe(false);
});
test('returns null when CLI is missing', async () => {
configureMock('no-cli');
expect(await tm.getStatus()).toBeNull();
});
test('returns null when tailscaled is down', async () => {
configureMock('no-daemon');
expect(await tm.getStatus()).toBeNull();
});
test('returns null when stdout is malformed JSON', async () => {
configureMock('malformed-status');
expect(await tm.getStatus()).toBeNull();
});
test('caches successful results within CACHE_TTL_MS', async () => {
configureMock('ok-status', { status: RUNNING_STATUS });
const a = await tm.getStatus();
const b = await tm.getStatus();
expect(a).toBe(b); // same reference
});
test('does NOT cache failed status fetches (so we retry on next call)', async () => {
// version succeeds (CLI present) but status returns malformed JSON.
// _isInstalled caches the positive result for 1 hour (correctly —
// we don't want to re-probe for the CLI on every request). However,
// a failed status fetch returns null without being cached, so the
// next getStatus() must retry the status command.
configureMock('malformed-status');
await tm.getStatus();
const before = execFile.mock.calls.length;
await tm.getStatus();
const after = execFile.mock.calls.length;
// Second getStatus: _isInstalled hits cache (no call); status re-exec'd (1 call).
// So we expect exactly 1 additional execFile call from the second getStatus.
expect(after - before).toBe(1);
});
});
describe('getLocalIP()', () => {
test('returns the first IPv4 TailscaleIP from Self', async () => {
configureMock('ok-status', { status: RUNNING_STATUS });
expect(await tm.getLocalIP()).toBe('100.121.150.22');
});
test('returns null when status is null (CLI missing)', async () => {
configureMock('no-cli');
expect(await tm.getLocalIP()).toBeNull();
});
test('returns null when Self has no TailscaleIPs', async () => {
configureMock('ok-status', { status: { BackendState: 'Running', Self: {}, Peer: {} } });
expect(await tm.getLocalIP()).toBeNull();
});
test('returns null when only IPv6 is assigned', async () => {
configureMock('ok-status', {
status: { BackendState: 'Running', Self: { TailscaleIPs: ['fd7a:115c::1'] }, Peer: {} },
});
expect(await tm.getLocalIP()).toBeNull();
});
test('returns null when Self is missing entirely', async () => {
configureMock('ok-status', { status: { BackendState: 'Running', Peer: {} } });
expect(await tm.getLocalIP()).toBeNull();
});
});
describe('getSummary()', () => {
test('returns installed:false when CLI missing', async () => {
configureMock('no-cli');
const s = await tm.getSummary();
expect(s.installed).toBe(false);
expect(s.connected).toBe(false);
expect(s.message).toMatch(/not found/i);
});
test('returns installed:true, connected:false when tailscaled down', async () => {
configureMock('no-daemon');
const s = await tm.getSummary();
expect(s.installed).toBe(true);
expect(s.connected).toBe(false);
expect(s.message).toMatch(/not reachable/i);
});
test('returns full summary on success', async () => {
configureMock('ok-status', { status: RUNNING_STATUS });
const s = await tm.getSummary();
expect(s.installed).toBe(true);
expect(s.connected).toBe(true);
expect(s.backendState).toBe('Running');
expect(s.hostname).toBe('vmi3080415');
expect(s.ip).toBe('100.121.150.22');
expect(s.ipv6).toBe('fd7a:115c:a1e0::1539:9616');
expect(s.peerCount).toBe(2);
expect(s.onlinePeerCount).toBe(1);
});
test('handles missing Peer field', async () => {
configureMock('ok-status', {
status: { BackendState: 'Running', Self: RUNNING_STATUS.Self },
});
const s = await tm.getSummary();
expect(s.peerCount).toBe(0);
expect(s.onlinePeerCount).toBe(0);
});
});
describe('getDevices()', () => {
test('returns empty array when CLI missing', async () => {
configureMock('no-cli');
expect(await tm.getDevices()).toEqual([]);
});
test('returns empty array when Peer is missing', async () => {
configureMock('ok-status', { status: { BackendState: 'Running', Self: RUNNING_STATUS.Self } });
expect(await tm.getDevices()).toEqual([]);
});
test('shapes each peer into dashboard-friendly form', async () => {
configureMock('ok-status', { status: RUNNING_STATUS });
const devices = await tm.getDevices();
expect(devices).toHaveLength(2);
const d1 = devices.find(d => d.id === 'p1');
expect(d1.hostname).toBe('peer1');
expect(d1.dnsName).toBe('peer1.tail.ts.net');
expect(d1.ip).toBe('100.100.100.1');
expect(d1.ips).toEqual(['100.100.100.1', 'fd7a::5']);
expect(d1.os).toBe('linux');
expect(d1.online).toBe(true);
expect(d1.user).toBe('u1');
expect(d1.tags).toEqual(['tag:server']);
expect(d1.isExitNode).toBe(false);
expect(d1.rxBytes).toBe(100);
expect(d1.txBytes).toBe(200);
expect(d1.keyExpiry).toBe('2026-08-01T00:00:00Z');
});
test('handles missing optional peer fields gracefully', async () => {
configureMock('ok-status', {
status: { BackendState: 'Running', Peer: { minimal: { HostName: 'min' } } },
});
const devices = await tm.getDevices();
expect(devices).toHaveLength(1);
expect(devices[0].hostname).toBe('min');
expect(devices[0].ip).toBeNull();
expect(devices[0].ips).toEqual([]);
expect(devices[0].tags).toEqual([]);
expect(devices[0].online).toBe(false);
expect(devices[0].isExitNode).toBe(false);
expect(devices[0].rxBytes).toBe(0);
expect(devices[0].txBytes).toBe(0);
});
});
describe('getAccessToken()', () => {
test('returns null (placeholder for OAuth-cached token)', async () => {
expect(await tm.getAccessToken()).toBeNull();
});
});
describe('syncAPI()', () => {
test('returns a synced result with ISO timestamp', async () => {
configureMock('ok-status', { status: RUNNING_STATUS });
const r = await tm.syncAPI();
expect(r.synced).toBe(true);
expect(r.at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
test('invalidates the cache so next getStatus re-execs', async () => {
configureMock('ok-status', { status: RUNNING_STATUS });
// Prime cache
await tm.getStatus();
const callsBeforeSync = execFile.mock.calls.length;
await tm.syncAPI();
await tm.getStatus();
const callsAfterSync = execFile.mock.calls.length;
// After sync, getStatus should re-exec (version + status = 2 calls)
expect(callsAfterSync).toBeGreaterThan(callsBeforeSync);
});
});
describe('startSyncTimer / stopSyncTimer', () => {
afterEach(() => {
tm.stopSyncTimer();
jest.useRealTimers();
});
test('fires the callback on interval and stops cleanly', () => {
jest.useFakeTimers();
const cb = jest.fn();
tm.startSyncTimer(1000, cb);
jest.advanceTimersByTime(3500);
expect(cb).toHaveBeenCalledTimes(3);
tm.stopSyncTimer();
jest.advanceTimersByTime(5000);
expect(cb).toHaveBeenCalledTimes(3);
});
test('second startSyncTimer call is a no-op while one is running', () => {
jest.useFakeTimers();
const cb1 = jest.fn();
const cb2 = jest.fn();
tm.startSyncTimer(1000, cb1);
tm.startSyncTimer(1000, cb2);
jest.advanceTimersByTime(2500);
// Only the first callback should fire
expect(cb1).toHaveBeenCalled();
expect(cb2).not.toHaveBeenCalled();
});
});
describe('invalidateCache()', () => {
test('forces a re-fetch on next getStatus', async () => {
configureMock('ok-status', { status: RUNNING_STATUS });
await tm.getStatus();
const callsBefore = execFile.mock.calls.length;
tm.invalidateCache();
await tm.getStatus();
const callsAfter = execFile.mock.calls.length;
expect(callsAfter).toBeGreaterThan(callsBefore);
});
});
describe('module API surface (regression guard)', () => {
test('exports the documented functions', () => {
const expected = [
'getStatus', 'getLocalIP', 'getSummary', 'getDevices',
'isTailscaleIP', 'invalidateCache', 'getAccessToken',
'startSyncTimer', 'stopSyncTimer', 'syncAPI',
];
for (const fn of expected) {
expect(typeof tm[fn]).toBe('function');
}
});
});
describe('CLI binary path', () => {
test('default is /usr/bin/tailscale', () => {
expect(tm._CLI_BIN).toBe('/usr/bin/tailscale');
});
test('respects TAILSCALE_BIN env var', () => {
jest.resetModules();
process.env.TAILSCALE_BIN = '/custom/path/tailscale';
const tm2 = require('../src/managers/tailscale-manager');
expect(tm2._CLI_BIN).toBe('/custom/path/tailscale');
delete process.env.TAILSCALE_BIN;
});
});
});
+176 -43
View File
@@ -5,6 +5,10 @@
# Writes result.json so the new container knows the outcome.
#
# This runs on the HOST, outside the container.
#
# Channel selection: by default only "stable" releases are applied. Set
# ALLOW_PRERELEASE=true in /opt/dashcaddy/updates/channel.conf to opt in to
# prerelease/beta/rc channels. Useful for staging hosts, not production.
set -euo pipefail
@@ -13,8 +17,10 @@ readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json"
readonly RESULT_FILE="${UPDATES_DIR}/result.json"
readonly BACKUPS_DIR="${UPDATES_DIR}/backups"
readonly CONTAINER_NAME="dashcaddy-api"
readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest"
readonly MAX_BACKUPS=3
readonly HEALTH_TIMEOUT=60
readonly CHANNEL_CONF="${UPDATES_DIR}/channel.conf"
# Data directory backup — stored alongside code backups so everything rolls back together
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
@@ -22,6 +28,38 @@ readonly DATA_BACKUP_PREFIX="data-backup"
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
# Decide if a given release channel is acceptable on this host.
# Returns 0 (accept) or 1 (reject) and logs the reason.
channel_allowed() {
local channel="$1"
local allow_prerelease="false"
if [[ -f "$CHANNEL_CONF" ]]; then
# shellcheck disable=SC1090
source "$CHANNEL_CONF"
allow_prerelease="${ALLOW_PRERELEASE:-false}"
fi
case "${channel,,}" in
stable|"")
return 0
;;
prerelease|beta|rc|alpha)
if [[ "${allow_prerelease,,}" == "true" ]]; then
log "Channel '${channel}' accepted (ALLOW_PRERELEASE=true in ${CHANNEL_CONF})"
return 0
else
log "Channel '${channel}' rejected — set ALLOW_PRERELEASE=true in ${CHANNEL_CONF} to accept"
return 1
fi
;;
*)
log "Channel '${channel}' rejected — unknown channel"
return 1
;;
esac
}
write_result() {
local success="$1" version="$2" duration="$3"
shift 3
@@ -122,30 +160,64 @@ rollback_restore() {
rm -rf "$api_source_dir/src"
cp -rf "$backup_dir/src" "$api_source_dir/src"
fi
if [[ -d "$backup_dir/dns-providers" ]]; then
rm -rf "$api_source_dir/dns-providers"
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
fi
restore_data_dir "$backup_dir"
}
# ── 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
# ── Deployment mode ───────────────────────────────────────────────────────────
# Reproduce the SAME container the install created so an auto-update keeps every
# volume + env var (docker socket, Caddyfile, config/credentials, updates mount),
# not a minimal subset. Standard installs use docker-compose (compose file in the
# api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a
# bare docker run is the last resort. build_image() and restart_container() both
# honor the detected mode so build and run stay consistent.
deploy_mode() {
if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then
echo compose
elif [[ -x /opt/dashcaddy/start.sh ]]; then
echo startsh
else
echo run
fi
}
# 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"
# Build the API image using whatever the install is wired for. Returns the build
# command's exit status so callers can detect failure.
build_image() {
cd "$api_source_dir" || return 1
case "$(deploy_mode)" in
compose) docker compose build 2>&1 || docker-compose build 2>&1 ;;
*) docker build -t "$IMAGE_TAG" . 2>&1 ;;
esac
}
# ── Shared container restart — recreate with the full, install-defined spec ───
# Recreates (rm + run / compose up) so new code AND new env vars take effect.
restart_container() {
cd "$api_source_dir" 2>/dev/null || true
case "$(deploy_mode)" in
compose)
log "Recreating container via docker compose (full compose spec)..."
docker compose up -d 2>&1 || docker-compose up -d 2>&1
;;
startsh)
log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..."
bash /opt/dashcaddy/start.sh
;;
*)
log "Recreating container via minimal docker run (fallback)..."
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
-p 127.0.0.1:3001:3001 \
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
-e SERVICES_FILE=/app/data/services.json \
"$IMAGE_TAG"
;;
esac
log "Container recreated"
}
# ── Code-only restore (used after failed build when data hasn't changed yet) ──
@@ -163,6 +235,10 @@ code_restore() {
rm -rf "$api_source_dir/src"
cp -rf "$backup_dir/src" "$api_source_dir/src"
fi
if [[ -d "$backup_dir/dns-providers" ]]; then
rm -rf "$api_source_dir/dns-providers"
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
fi
}
main() {
@@ -176,7 +252,7 @@ main() {
fi
# Parse trigger.json (uses python3 which is available on all supported distros)
local action version from_version staging_dir api_source_dir commit
local action version from_version staging_dir api_source_dir commit channel
local frontend_staging_dir frontend_target_dir
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])")
@@ -186,16 +262,25 @@ main() {
commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')")
frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')")
frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')")
channel=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('channel') or 'stable')")
# Handle action=rollback (no new version to deploy)
local to_version="${version}"
log "=== ${action^^}: v${from_version} -> v${to_version} ==="
log "=== ${action^^}: v${from_version} -> v${to_version} (channel: ${channel}) ==="
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"
# Channel gate: refuse to apply prereleases unless explicitly opted-in.
# Rollbacks always allowed (no new release channel involved).
if [[ "${action}" != "rollback" ]] && ! channel_allowed "${channel}"; then
write_result "false" "$to_version" "0" "Channel '${channel}' not allowed on this host"
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
# ── Handle rollback ────────────────────────────────────────────────────────
if [[ "$action" == "rollback" ]]; then
local backup_dir="${BACKUPS_DIR}/${version}"
@@ -211,10 +296,9 @@ main() {
# Rebuild old code
log "Rebuilding container..."
cd "$api_source_dir"
docker build -t dashcaddy-dashcaddy-api:latest . 2>&1 | tail -1 || true
build_image 2>&1 | tail -3 || true
restart_container "dashcaddy-dashcaddy-api:latest"
restart_container
wait_for_health || log "WARNING: Health check failed after rollback"
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
@@ -240,6 +324,7 @@ main() {
done
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
[[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
[[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$backup_dir/"
# Backup data/ directory (services.json, config.json, credentials, etc.)
backup_data_dir "$backup_dir"
@@ -251,18 +336,69 @@ main() {
for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
done
if [[ -d "$staging_dir/routes" ]]; then
rm -rf "$api_source_dir/routes"
cp -rf "$staging_dir/routes" "$api_source_dir/routes"
fi
if [[ -d "$staging_dir/src" ]]; then
rm -rf "$api_source_dir/src"
cp -rf "$staging_dir/src" "$api_source_dir/src"
fi
# Safety: only replace routes/src if staging has the dir AND it's non-empty.
# An empty or partial staging dir used to cause live routes/src to be wiped
# when a prior update cycle was interrupted. We also handle locked files
# (chattr +i) by temporarily unlocking before replace and re-locking after.
deploy_tree() {
local rel="$1" # e.g. "routes"
local src="${staging_dir}/${rel}"
local dst="${api_source_dir}/${rel}"
if [[ ! -d "$src" ]] || [[ -z "$(ls -A "$src" 2>/dev/null)" ]]; then
[[ -d "$src" ]] && log "WARNING: staging ${rel}/ exists but is empty — leaving live ${rel}/ untouched"
return 0
fi
# Collect any locked files (chattr +i) in the destination. lsattr's
# first field is the attribute flags ("i" at position 5 = immutable);
# the second field is the filename. We unlock before rm -rf and re-lock
# after so the locked state survives the update.
local locked_files=()
if [[ -d "$dst" ]]; then
while IFS= read -r lf; do
[[ -n "$lf" ]] && locked_files+=("$lf")
done < <(find "$dst" -type f \( -name "*.js" -o -name "*.json" -o -name "*.sh" \) -print0 2>/dev/null \
| xargs -0 lsattr -a 2>/dev/null \
| awk '$1 ~ /i/ { print $2 }')
fi
for lf in "${locked_files[@]:-}"; do
[[ -n "$lf" ]] && chattr -i "$lf" 2>/dev/null || true
done
rm -rf "$dst"
cp -rf "$src" "$dst"
local file_count
file_count=$(find "$dst" -type f 2>/dev/null | wc -l)
log "${rel}/ deployed (${file_count} files)"
for lf in "${locked_files[@]:-}"; do
[[ -n "$lf" ]] && [[ -f "$lf" ]] && chattr +i "$lf" 2>/dev/null || true
done
}
deploy_tree "routes"
deploy_tree "src"
deploy_tree "dns-providers"
if [[ -n "$commit" ]]; then
echo "$commit" > "$api_source_dir/VERSION"
fi
# 3a. Apply post-deploy patches — fix upstream bugs in released tarballs
# (e.g. v1.14.4 has broken require paths and missing license-keygen module).
# Runs AFTER staging copy, BEFORE docker build. Idempotent.
local patch_script="/opt/dashcaddy/scripts/dashcaddy-post-deploy-patches.sh"
if [[ -x "$patch_script" ]]; then
log "Applying post-deploy patches..."
if "$patch_script" "$api_source_dir"; then
log "Post-deploy patches applied successfully"
else
log "WARNING: Post-deploy patches exited non-zero — continuing build anyway"
fi
else
log "NOTE: $patch_script not found or not executable — skipping post-deploy patches"
fi
# 3b. Sync frontend
if [[ -z "$frontend_staging_dir" ]]; then
parent_staging=$(dirname "$staging_dir")
@@ -292,27 +428,24 @@ main() {
# 4. Rebuild container
log "Rebuilding container..."
cd "$api_source_dir"
local build_ok=false
local image_tag="dashcaddy-dashcaddy-api:latest"
if docker build -t "$image_tag" . 2>&1; then
if build_image; then
build_ok=true
fi
if [[ "$build_ok" != "true" ]]; then
log "ERROR: Docker build failed — rolling back code + data"
code_restore "$backup_dir"
docker build -t "$image_tag" . 2>&1 | tail -3 || true
restart_container "$image_tag"
build_image 2>&1 | tail -3 || true
restart_container
wait_for_health || true
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
# 5. Restart container (rm + run so new env vars take effect)
restart_container "$image_tag"
# 5. Restart container (recreate so new code + env vars take effect)
restart_container
# 6. Health check
if wait_for_health; then
@@ -323,8 +456,8 @@ main() {
local duration=$(( $(date +%s) - start_time ))
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"
build_image 2>&1 | tail -3 || true
restart_container
wait_for_health || log "WARNING: Rollback health check also failed"
write_result "false" "$to_version" "$duration" "Health check failed after update"
fi
+9 -13
View File
@@ -176,20 +176,16 @@ async function createApp() {
return typeof id === 'string' && CONTAINER_ID_RE.test(id);
}
function isTailscaleIP(ip) {
if (!ip) return false;
const parts = ip.split('.');
if (parts.length !== 4) return false;
const first = parseInt(parts[0]);
const second = parseInt(parts[1]);
return first === 100 && second >= 64 && second <= 127;
}
// Tailscale CGNAT classification. Imported from network-detector.js (DC-031)
// so there's one source of truth — the local copy here had no malformed-input
// guards and would return false on NaN silently.
const { isTailscaleIP } = require('./utilities/network-detector');
// eslint-disable-next-line require-await -- stub for now, will gain await when wired into context
async function getTailscaleStatus() {
// Stub for now - will be populated by context
return null;
}
// Real implementation — delegates to the tailscale manager which
// shells out to the host's `tailscale status --json` (cached 5min).
// Kept here as a top-level function for back-compat with middleware.js
// and any other call site that imports it via the createApp() factory.
const { getStatus: getTailscaleStatus } = require('./managers/tailscale-manager');
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
// auto-login pages) historically call these endpoints under the pre-1.5.0
+19 -7
View File
@@ -7,6 +7,7 @@ const { createCaddyContext } = require('./caddy');
const { createDnsContext } = require('./dns');
const { createSessionContext } = require('./session');
const NotificationManager = require('../managers/notification-manager');
const tailscaleManager = require('../managers/tailscale-manager');
/**
* Assemble the full application context
@@ -95,13 +96,10 @@ function assembleContext({
config: siteConfig
});
// Tailscale context (inline for now - could be extracted)
const tailscale = {
// These will be populated by server.js for now
// TODO: Extract tailscale module
};
// Assemble flat context (temporary - routes still expect this)
// Note: tailscale interface detection lives in src/utilities/network-detector.js
// (DC-031). The empty `tailscale` stub previously wired here was dead code
// — verified zero readers via grep across src/.
const ctx = {
// Namespaced contexts
docker,
@@ -109,7 +107,21 @@ function assembleContext({
dns,
session,
notification,
tailscale,
// Tailscale manager — wraps `tailscale status --json` with 5min cache.
// Replaces the long-standing null stub at src/app.js:189. See
// src/managers/tailscale-manager.js for full API surface.
tailscale: {
getStatus: tailscaleManager.getStatus,
getLocalIP: tailscaleManager.getLocalIP,
getSummary: tailscaleManager.getSummary,
getDevices: tailscaleManager.getDevices,
isTailscaleIP: tailscaleManager.isTailscaleIP,
invalidateCache: tailscaleManager.invalidateCache,
getAccessToken: tailscaleManager.getAccessToken,
startSyncTimer: tailscaleManager.startSyncTimer,
stopSyncTimer: tailscaleManager.stopSyncTimer,
syncAPI: tailscaleManager.syncAPI,
},
// App and config
app,
@@ -0,0 +1,250 @@
/**
* Tailscale Manager — real implementation of the Tailscale API surface that
* routes/tailscale.js and src/utilities/middleware.js have been calling into
* via ctx.tailscale.* for months but always getting `null` back.
*
* Why this exists: the previous `getTailscaleStatus()` in src/app.js was a
* hard-coded `return null` stub with a comment saying it would be populated
* later. The route file calls tailscale.getStatus() / getLocalIP() /
* isTailscaleIP() and got undefined back, silently returning empty device
* lists. The tailscaleAuthMiddleware's allowedTailnet check (DC-121) was
* dead code for the same reason.
*
* Strategy: shell out to the host's `tailscale` CLI and parse its JSON output.
* `tailscale status --json` returns the full local node + peer map with all
* the fields the dashboard cares about (TailscaleIPs, HostName, OS, Online,
* LastSeen, UserID, KeyExpiry, Tags, etc.). Cache for 5 minutes to avoid
* spawning a CLI on every request.
*
* Failure modes handled gracefully:
* - `tailscale` CLI not installed on host → return { installed: false }
* - tailscaled not running → return { installed: true, connected: false }
* - CLI exits non-zero → return null, log warning, fall through to caller
* - JSON malformed → return null, log error
*
* The `isTailscaleIP()` function re-exports the one from network-detector.js
* (DC-031) so there's one source of truth for Tailscale CGNAT classification.
*/
'use strict';
const { execFile } = require('child_process');
const { promisify } = require('util');
const { isTailscaleIP } = require('../utilities/network-detector');
const execFileAsync = promisify(execFile);
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const CLI_TIMEOUT_MS = 5000;
const CLI_BIN = process.env.TAILSCALE_BIN || '/usr/bin/tailscale';
let _cache = {
data: null,
fetchedAt: 0,
};
/**
* Internal: invoke `tailscale status --json` and parse the result.
* Returns null on any failure (caller decides how to present).
*/
async function _fetchStatusRaw() {
try {
const { stdout, stderr } = await execFileAsync(CLI_BIN, ['status', '--json'], {
timeout: CLI_TIMEOUT_MS,
maxBuffer: 4 * 1024 * 1024, // 4 MB — peer maps can be large
});
if (stderr && !stdout) {
// CLI wrote to stderr and nothing to stdout — likely "tailscaled not running"
return null;
}
return JSON.parse(stdout);
} catch (err) {
// ENOENT: tailscale not installed
// EACCES: not in the right group
// non-zero exit: tailscaled down
// JSON parse: corrupted output
return null;
}
}
/**
* Check whether the tailscale CLI is reachable on this host.
* Result is cached separately because it's rare to install/uninstall.
*/
let _installedCache = { value: null, fetchedAt: 0 };
const INSTALLED_TTL_MS = 60 * 60 * 1000; // 1 hour
async function _isInstalled() {
const now = Date.now();
if (_installedCache.value !== null && (now - _installedCache.fetchedAt) < INSTALLED_TTL_MS) {
return _installedCache.value;
}
try {
await execFileAsync(CLI_BIN, ['version'], { timeout: 2000 });
_installedCache = { value: true, fetchedAt: now };
return true;
} catch (err) {
_installedCache = { value: false, fetchedAt: now };
return false;
}
}
/**
* Get the full Tailscale status (self + peers + backend state).
* Returns null if tailscale is not installed or tailscaled is not running.
* Results are cached for 5 minutes.
*/
async function getStatus() {
const now = Date.now();
if (_cache.data !== null && (now - _cache.fetchedAt) < CACHE_TTL_MS) {
return _cache.data;
}
const installed = await _isInstalled();
if (!installed) {
// Don't cache the negative result beyond the installed TTL
return null;
}
const data = await _fetchStatusRaw();
if (data !== null) {
_cache = { data, fetchedAt: now };
}
return data;
}
/**
* Get the local node's first Tailscale IPv4 address (e.g. "100.121.150.22").
* Returns null if no Tailscale IPv4 is assigned.
*/
async function getLocalIP() {
const status = await getStatus();
if (!status || !status.Self || !Array.isArray(status.Self.TailscaleIPs)) {
return null;
}
return status.Self.TailscaleIPs.find(ip => ip && ip.includes('.') && !ip.includes(':')) || null;
}
/**
* Force-refresh the status cache (e.g. after a config change).
*/
function invalidateCache() {
_cache = { data: null, fetchedAt: 0 };
_installedCache = { value: null, fetchedAt: 0 };
}
/**
* Get a friendly structured summary suitable for the dashboard.
* Returns:
* { installed: false } if CLI is missing
* { installed: true, connected: false, ... } if tailscaled is down
* { installed: true, connected: true, hostname, ip, peerCount, ... } on success
*/
async function getSummary() {
const installed = await _isInstalled();
if (!installed) {
return { installed: false, connected: false, message: 'tailscale CLI not found' };
}
const status = await getStatus();
if (!status) {
return { installed: true, connected: false, message: 'tailscaled not reachable' };
}
return {
installed: true,
connected: status.BackendState === 'Running',
backendState: status.BackendState || null,
hostname: status.Self?.HostName || null,
ip: status.Self?.TailscaleIPs?.find(ip => ip && ip.includes('.') && !ip.includes(':')) || null,
ipv6: status.Self?.TailscaleIPs?.find(ip => ip && ip.includes(':')) || null,
peerCount: Object.keys(status.Peer || {}).length,
onlinePeerCount: Object.values(status.Peer || {}).filter(p => p.Online).length,
};
}
/**
* Get the enriched device list (peers) for the dashboard.
* Each entry has the fields the dashboard UI cares about.
*/
async function getDevices() {
const status = await getStatus();
if (!status || !status.Peer) {
return [];
}
return Object.entries(status.Peer).map(([id, peer]) => ({
id,
hostname: peer.HostName,
dnsName: peer.DNSName,
ip: peer.TailscaleIPs?.[0] || null,
ips: peer.TailscaleIPs || [],
os: peer.OS,
online: !!peer.Online,
lastSeen: peer.LastSeen || null,
user: peer.UserID || null,
tags: peer.Tags || [],
keyExpiry: peer.KeyExpiry || null,
isExitNode: !!peer.ExitNode,
rxBytes: peer.RxBytes || 0,
txBytes: peer.TxBytes || 0,
}));
}
/**
* OAuth access token retrieval — stub for now.
* The OAuth flow is implemented in routes/tailscale.js but requires the
* configured OAuth credentials from disk. The token exchange itself
* happens in the route handler; this is a placeholder so ctx.tailscale has
* a complete API surface. Returns null (no token cached) by default.
*/
// eslint-disable-next-line require-await -- stub, will gain await when OAuth flow lands
async function getAccessToken() {
return null;
}
/**
* Background sync timer — stub.
* The Tailscale API sync (oauth-config + sync routes) uses an in-process
* interval. This is a placeholder for parity with the ctx.tailscale surface.
*/
let _syncInterval = null;
function startSyncTimer(intervalMs = 5 * 60 * 1000, onSync = () => {}) {
if (_syncInterval) return;
_syncInterval = setInterval(() => {
invalidateCache();
onSync();
}, intervalMs);
if (_syncInterval.unref) _syncInterval.unref();
}
function stopSyncTimer() {
if (_syncInterval) {
clearInterval(_syncInterval);
_syncInterval = null;
}
}
/**
* Force a sync from the Tailscale API — stub for now.
* Real implementation would use OAuth credentials to fetch devices/ACL.
*/
// eslint-disable-next-line require-await -- stub, will gain await when API client lands
async function syncAPI() {
invalidateCache();
return { synced: true, at: new Date().toISOString() };
}
module.exports = {
getStatus,
getLocalIP,
getSummary,
getDevices,
isTailscaleIP,
invalidateCache,
getAccessToken,
startSyncTimer,
stopSyncTimer,
syncAPI,
// Exposed for tests
_CLI_BIN: CLI_BIN,
_CACHE_TTL_MS: CACHE_TTL_MS,
};
+9
View File
@@ -50,6 +50,13 @@ if docker ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
docker rm -f ${CONTAINER_NAME}
fi
# Tailscale CLI + control socket — lets the container invoke
# `tailscale status --json` to populate /api/v1/tailscale/status etc.
# The binary is statically linked (Go), so the bind-mount works under
# the container's Alpine libc without any library forwarding.
# Both mounts are read-only: `tailscale status --json` is a read query
# that the local tailscaled handles; we never need to mutate state
# from inside the container.
echo "[start.sh] Creating container with full config..."
docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
--add-host=get.dashcaddy.net:194.233.88.206 \
@@ -65,6 +72,8 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
-v ${ASSETS_DIR}:/app/assets \
-v ${UPDATES_DIR}:/app/updates \
-v /opt/sami-files/logs:/opt/sami-files/logs:ro \
-v /usr/bin/tailscale:/usr/bin/tailscale:ro \
-v /var/run/tailscale:/var/run/tailscale:ro \
-e NODE_ENV=production \
-e SERVICES_FILE=/app/data/services.json \
-e CONFIG_FILE=/app/data/config.json \