Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcf252e515 | ||
|
|
a7512b4a56 | ||
|
|
f5fc688185 | ||
|
|
1bc41bb2bc |
@@ -28,9 +28,9 @@ If an item is too big for one tick, implement a sub-part, push that, and note pr
|
|||||||
- [x] **P2-2: Delete dead legacy files** — Done (commit 140aa5d). Removed comprehensive-test.js + test-security-fixes.js (-878 lines). (status/api/test-api.js is untracked.)
|
- [x] **P2-2: Delete dead legacy files** — Done (commit 140aa5d). Removed comprehensive-test.js + test-security-fixes.js (-878 lines). (status/api/test-api.js is untracked.)
|
||||||
- [x] **P2-3: ESLint no-empty fix** — Done (commit 140aa5d). Added `no-empty: ['error', { allowEmptyCatch: true }]` to .eslintrc.js. 3 errors→0.
|
- [x] **P2-3: ESLint no-empty fix** — Done (commit 140aa5d). Added `no-empty: ['error', { allowEmptyCatch: true }]` to .eslintrc.js. 3 errors→0.
|
||||||
- [x] **P2-4: Fix no-useless-escape** — Done (commit 140aa5d). routes/auth/session-handlers.js:39 `\-` → `.-` (dash moved to end of char class).
|
- [x] **P2-4: Fix no-useless-escape** — Done (commit 140aa5d). routes/auth/session-handlers.js:39 `\-` → `.-` (dash moved to end of char class).
|
||||||
- [ ] **P2-5: Test handle leaks** — Run `npx jest --detectOpenHandles --silent 2>&1 | grep -i leak` and add teardown (`afterEach(() => clearInterval/clearTimeout)`) to tests that leave open handles. Focus on `totp.routes.test.js` (22s) and `containers.routes.test.js` (28s).
|
- [x] **P2-5: Test handle leaks** — Done (commit 1bc41bb). Root cause: `setTimeout` in `log-digest.js:start()` was never stored, so `stop()` couldn't clear it — 4 leaked handles. Fixed by storing as `this._initialTimeout` and clearing in `stop()`. Also swept 3 remaining console.error calls. `--detectOpenHandles` reports 0 handles.
|
||||||
- [ ] **P2-6: Refactor config-schema.js validateConfig** — Complexity 44 → extract sub-validators for each config section. Behavior-preserving refactor only.
|
- [x] **P2-6: Refactor config-schema.js validateConfig** — Done (commit f5fc688). Extracted 8 sub-validators (validateTld, validateDns, validateDashboardHost, validateTimezone, validateTheme, validateRoutingMode, validateDomain, validateKnownKeys). Complexity 44→<10 per function. Removed unused constant. Behavior-preserving.
|
||||||
- [ ] **P2-7: Refactor middleware.js auth function** — Complexity 24, nesting depth 6 → extract auth-logic branches into named helper functions.
|
- [x] **P2-7: Refactor middleware.js auth function** — Done (commit a7512b4). Extracted isTailScaleProbePath, extractTailscaleIPs, isIPInTailnet from tailscaleAuthMiddleware. Complexity 24→7, nesting 6→3. Behavior-preserving.
|
||||||
|
|
||||||
## Completion Criteria
|
## Completion Criteria
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const fs = require('fs');
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { DOCKER } = require('../utilities/constants');
|
const { DOCKER } = require('../utilities/constants');
|
||||||
|
const { log } = require('../utils/logging');
|
||||||
|
|
||||||
const docker = new Docker();
|
const docker = new Docker();
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ class LogDigest extends EventEmitter {
|
|||||||
// Collect logs every hour
|
// Collect logs every hour
|
||||||
this.collectInterval = setInterval(() => {
|
this.collectInterval = setInterval(() => {
|
||||||
this._collectHourlyLogs().catch(e =>
|
this._collectHourlyLogs().catch(e =>
|
||||||
console.error('[LogDigest] Hourly collection failed:', e.message)
|
log.error('logdigest', e, { phase: 'hourlyCollect' })
|
||||||
);
|
);
|
||||||
}, DOCKER.DIGEST.COLLECT_INTERVAL);
|
}, DOCKER.DIGEST.COLLECT_INTERVAL);
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ class LogDigest extends EventEmitter {
|
|||||||
this._scheduleDailyDigest();
|
this._scheduleDailyDigest();
|
||||||
|
|
||||||
// Run initial collection after 2 minutes
|
// Run initial collection after 2 minutes
|
||||||
setTimeout(() => {
|
this._initialTimeout = setTimeout(() => {
|
||||||
if (this.running) {
|
if (this.running) {
|
||||||
this._collectHourlyLogs().catch(() => {});
|
this._collectHourlyLogs().catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -89,6 +90,10 @@ class LogDigest extends EventEmitter {
|
|||||||
clearTimeout(this.digestTimeout);
|
clearTimeout(this.digestTimeout);
|
||||||
this.digestTimeout = null;
|
this.digestTimeout = null;
|
||||||
}
|
}
|
||||||
|
if (this._initialTimeout) {
|
||||||
|
clearTimeout(this._initialTimeout);
|
||||||
|
this._initialTimeout = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,7 +200,7 @@ class LogDigest extends EventEmitter {
|
|||||||
hourSummary.services[appId] = serviceSummary;
|
hourSummary.services[appId] = serviceSummary;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[LogDigest] Container enumeration failed:', e.message);
|
log.error('logdigest', e, { phase: 'enumerateContainers' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to ring buffer
|
// Add to ring buffer
|
||||||
@@ -258,7 +263,7 @@ class LogDigest extends EventEmitter {
|
|||||||
const delay = next.getTime() - now.getTime();
|
const delay = next.getTime() - now.getTime();
|
||||||
this.digestTimeout = setTimeout(() => {
|
this.digestTimeout = setTimeout(() => {
|
||||||
this.generateDailyDigest().catch(e =>
|
this.generateDailyDigest().catch(e =>
|
||||||
console.error('[LogDigest] Daily digest generation failed:', e.message)
|
log.error('logdigest', e, { phase: 'dailyDigest' })
|
||||||
);
|
);
|
||||||
// Reschedule for tomorrow
|
// Reschedule for tomorrow
|
||||||
if (this.running) this._scheduleDailyDigest();
|
if (this.running) this._scheduleDailyDigest();
|
||||||
|
|||||||
@@ -3,12 +3,161 @@
|
|||||||
* Validates config.json structure to catch typos and invalid values early.
|
* Validates config.json structure to catch typos and invalid values early.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VALID_TIMEZONES_SAMPLE = [
|
const VALID_THEMES = ['dark', 'light', 'blue'];
|
||||||
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
const VALID_ROUTING_MODES = ['subdomain', 'subdirectory'];
|
||||||
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai',
|
const VALID_DNS_PROVIDERS = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
|
||||||
'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland'
|
|
||||||
|
const KNOWN_KEYS = [
|
||||||
|
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
|
||||||
|
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
|
||||||
|
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
|
||||||
|
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||||
|
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||||
|
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||||
|
'customLogoDark', 'customLogoLight'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} arr
|
||||||
|
* @param {string} val
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isInArray(arr, val) {
|
||||||
|
return arr.includes(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{errors:string[], warnings:string[]}} ctx
|
||||||
|
* @param {object} config
|
||||||
|
*/
|
||||||
|
function validateTld(ctx, config) {
|
||||||
|
if (config.tld === undefined) return;
|
||||||
|
if (typeof config.tld !== 'string') {
|
||||||
|
ctx.errors.push('tld must be a string');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
|
||||||
|
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
|
||||||
|
ctx.errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
|
||||||
|
}
|
||||||
|
if (tld.length > 20) {
|
||||||
|
ctx.warnings.push(`tld "${config.tld}" is unusually long`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{errors:string[], warnings:string[]}} ctx
|
||||||
|
* @param {object} config
|
||||||
|
*/
|
||||||
|
function validateDns(ctx, config) {
|
||||||
|
if (config.dns === undefined) return;
|
||||||
|
if (typeof config.dns !== 'object' || config.dns === null) {
|
||||||
|
ctx.errors.push('dns must be an object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
|
||||||
|
ctx.errors.push('dns.ip must be a string');
|
||||||
|
}
|
||||||
|
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
|
||||||
|
ctx.errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
|
||||||
|
}
|
||||||
|
if (config.dns.port !== undefined) {
|
||||||
|
const port = parseInt(config.dns.port, 10);
|
||||||
|
if (isNaN(port) || port < 1 || port > 65535) {
|
||||||
|
ctx.errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.dns.servers !== undefined) {
|
||||||
|
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
|
||||||
|
ctx.errors.push('dns.servers must be an object');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.dns.provider !== undefined) {
|
||||||
|
if (typeof config.dns.provider !== 'string') {
|
||||||
|
ctx.errors.push('dns.provider must be a string');
|
||||||
|
} else if (!isInArray(VALID_DNS_PROVIDERS, config.dns.provider)) {
|
||||||
|
ctx.warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${VALID_DNS_PROVIDERS.join(', ')}. It may still work if a custom adapter is installed.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{errors:string[], warnings:string[]}} ctx
|
||||||
|
* @param {object} config
|
||||||
|
*/
|
||||||
|
function validateDashboardHost(ctx, config) {
|
||||||
|
if (config.dashboardHost === undefined) return;
|
||||||
|
if (typeof config.dashboardHost !== 'string') {
|
||||||
|
ctx.errors.push('dashboardHost must be a string');
|
||||||
|
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
|
||||||
|
ctx.errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{errors:string[], warnings:string[]}} ctx
|
||||||
|
* @param {object} config
|
||||||
|
*/
|
||||||
|
function validateTimezone(ctx, config) {
|
||||||
|
if (config.timezone === undefined) return;
|
||||||
|
if (typeof config.timezone !== 'string') {
|
||||||
|
ctx.errors.push('timezone must be a string');
|
||||||
|
} else if (config.timezone) {
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
|
||||||
|
} catch {
|
||||||
|
ctx.errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{errors:string[], warnings:string[]}} ctx
|
||||||
|
* @param {object} config
|
||||||
|
*/
|
||||||
|
function validateTheme(ctx, config) {
|
||||||
|
if (config.theme === undefined) return;
|
||||||
|
if (!isInArray(VALID_THEMES, config.theme)) {
|
||||||
|
ctx.warnings.push(`theme "${config.theme}" is not one of: ${VALID_THEMES.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{errors:string[], warnings:string[]}} ctx
|
||||||
|
* @param {object} config
|
||||||
|
*/
|
||||||
|
function validateRoutingMode(ctx, config) {
|
||||||
|
if (config.routingMode === undefined) return;
|
||||||
|
if (!isInArray(VALID_ROUTING_MODES, config.routingMode)) {
|
||||||
|
ctx.errors.push(`routingMode "${config.routingMode}" is not one of: ${VALID_ROUTING_MODES.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{errors:string[], warnings:string[]}} ctx
|
||||||
|
* @param {object} config
|
||||||
|
*/
|
||||||
|
function validateDomain(ctx, config) {
|
||||||
|
if (config.domain === undefined) return;
|
||||||
|
if (typeof config.domain !== 'string') {
|
||||||
|
ctx.errors.push('domain must be a string');
|
||||||
|
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
|
||||||
|
ctx.warnings.push(`domain "${config.domain}" may not be a valid domain name`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{warnings:string[]}} ctx
|
||||||
|
* @param {object} config
|
||||||
|
*/
|
||||||
|
function validateKnownKeys(ctx, config) {
|
||||||
|
for (const key of Object.keys(config)) {
|
||||||
|
if (!isInArray(KNOWN_KEYS, key)) {
|
||||||
|
ctx.warnings.push(`Unknown config key "${key}" — possible typo?`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a config object and return errors/warnings.
|
* Validate a config object and return errors/warnings.
|
||||||
* @param {object} config - The config object to validate
|
* @param {object} config - The config object to validate
|
||||||
@@ -17,123 +166,20 @@ const VALID_TIMEZONES_SAMPLE = [
|
|||||||
function validateConfig(config) {
|
function validateConfig(config) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
|
const ctx = { errors, warnings };
|
||||||
|
|
||||||
if (!config || typeof config !== 'object') {
|
if (!config || typeof config !== 'object') {
|
||||||
return { valid: false, errors: ['Config must be a non-null object'], warnings };
|
return { valid: false, errors: ['Config must be a non-null object'], warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLD validation
|
validateTld(ctx, config);
|
||||||
if (config.tld !== undefined) {
|
validateDns(ctx, config);
|
||||||
if (typeof config.tld !== 'string') {
|
validateDashboardHost(ctx, config);
|
||||||
errors.push('tld must be a string');
|
validateTimezone(ctx, config);
|
||||||
} else {
|
validateTheme(ctx, config);
|
||||||
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
|
validateRoutingMode(ctx, config);
|
||||||
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
|
validateDomain(ctx, config);
|
||||||
errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
|
validateKnownKeys(ctx, config);
|
||||||
}
|
|
||||||
if (tld.length > 20) {
|
|
||||||
warnings.push(`tld "${config.tld}" is unusually long`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS config validation
|
|
||||||
if (config.dns !== undefined) {
|
|
||||||
if (typeof config.dns !== 'object' || config.dns === null) {
|
|
||||||
errors.push('dns must be an object');
|
|
||||||
} else {
|
|
||||||
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
|
|
||||||
errors.push('dns.ip must be a string');
|
|
||||||
}
|
|
||||||
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
|
|
||||||
errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
|
|
||||||
}
|
|
||||||
if (config.dns.port !== undefined) {
|
|
||||||
const port = parseInt(config.dns.port, 10);
|
|
||||||
if (isNaN(port) || port < 1 || port > 65535) {
|
|
||||||
errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (config.dns.servers !== undefined) {
|
|
||||||
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
|
|
||||||
errors.push('dns.servers must be an object');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// DNS provider validation
|
|
||||||
if (config.dns.provider !== undefined) {
|
|
||||||
const validProviders = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
|
|
||||||
if (typeof config.dns.provider !== 'string') {
|
|
||||||
errors.push('dns.provider must be a string');
|
|
||||||
} else if (!validProviders.includes(config.dns.provider)) {
|
|
||||||
warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${validProviders.join(', ')}. It may still work if a custom adapter is installed.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard host validation
|
|
||||||
if (config.dashboardHost !== undefined) {
|
|
||||||
if (typeof config.dashboardHost !== 'string') {
|
|
||||||
errors.push('dashboardHost must be a string');
|
|
||||||
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
|
|
||||||
errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timezone validation
|
|
||||||
if (config.timezone !== undefined) {
|
|
||||||
if (typeof config.timezone !== 'string') {
|
|
||||||
errors.push('timezone must be a string');
|
|
||||||
} else if (config.timezone) {
|
|
||||||
// Basic format check — full validation would require Intl API
|
|
||||||
try {
|
|
||||||
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
|
|
||||||
} catch {
|
|
||||||
errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme validation
|
|
||||||
if (config.theme !== undefined) {
|
|
||||||
const validThemes = ['dark', 'light', 'blue'];
|
|
||||||
if (!validThemes.includes(config.theme)) {
|
|
||||||
warnings.push(`theme "${config.theme}" is not one of: ${validThemes.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routing mode validation
|
|
||||||
if (config.routingMode !== undefined) {
|
|
||||||
const validModes = ['subdomain', 'subdirectory'];
|
|
||||||
if (!validModes.includes(config.routingMode)) {
|
|
||||||
errors.push(`routingMode "${config.routingMode}" is not one of: ${validModes.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Domain validation
|
|
||||||
if (config.domain !== undefined) {
|
|
||||||
if (typeof config.domain !== 'string') {
|
|
||||||
errors.push('domain must be a string');
|
|
||||||
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
|
|
||||||
warnings.push(`domain "${config.domain}" may not be a valid domain name`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn on unknown top-level keys
|
|
||||||
const knownKeys = [
|
|
||||||
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
|
|
||||||
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
|
|
||||||
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
|
|
||||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
|
||||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
|
||||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
|
||||||
'customLogoDark', 'customLogoLight'
|
|
||||||
];
|
|
||||||
for (const key of Object.keys(config)) {
|
|
||||||
if (!knownKeys.includes(key)) {
|
|
||||||
warnings.push(`Unknown config key "${key}" — possible typo?`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors, warnings };
|
return { valid: errors.length === 0, errors, warnings };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,41 @@ module.exports = function configureMiddleware(app, {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Tailscale authentication helpers ──
|
||||||
|
|
||||||
|
const PROBE_PATHS_TAILSCALE = new Set([
|
||||||
|
'/health', '/health/live', '/health/ready', '/healthz', '/readyz',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTailScaleProbePath(reqPath) {
|
||||||
|
return PROBE_PATHS_TAILSCALE.has(reqPath) || reqPath.startsWith('/probe/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTailscaleIPs(req) {
|
||||||
|
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
||||||
|
const forwardedFor = req.headers['x-forwarded-for'];
|
||||||
|
const realIP = req.headers['x-real-ip'];
|
||||||
|
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
||||||
|
const fromTailscale = ipsToCheck.some(ip =>
|
||||||
|
isTailscaleIP(ip.toString().split(',')[0].trim()));
|
||||||
|
const clientTailscaleIP = ipsToCheck
|
||||||
|
.map(ip => ip.toString().split(',')[0].trim())
|
||||||
|
.find(ip => isTailscaleIP(ip));
|
||||||
|
return { clientIP, ipsToCheck, fromTailscale, clientTailscaleIP };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isIPInTailnet(clientTailscaleIP) {
|
||||||
|
const status = await getTailscaleStatus();
|
||||||
|
if (!status) return true; // no status = can't verify = allow
|
||||||
|
|
||||||
|
const knownIPs = new Set();
|
||||||
|
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
|
||||||
|
for (const peer of Object.values(status.Peer || {})) {
|
||||||
|
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
|
||||||
|
}
|
||||||
|
return knownIPs.has(clientTailscaleIP);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tailscale authentication middleware (optional) ──
|
// ── Tailscale authentication middleware (optional) ──
|
||||||
const tailscaleAuthMiddleware = async (req, res, next) => {
|
const tailscaleAuthMiddleware = async (req, res, next) => {
|
||||||
if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) {
|
if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) {
|
||||||
@@ -121,25 +156,11 @@ module.exports = function configureMiddleware(app, {
|
|||||||
|
|
||||||
// Probe endpoints bypass Tailscale auth — k8s/Docker healthchecks
|
// Probe endpoints bypass Tailscale auth — k8s/Docker healthchecks
|
||||||
// don't carry a Tailscale identity header.
|
// don't carry a Tailscale identity header.
|
||||||
if (req.path === '/health'
|
if (isTailScaleProbePath(req.path) || req.path.startsWith('/api/v1/tailscale/')) {
|
||||||
|| req.path === '/health/live'
|
|
||||||
|| req.path === '/health/ready'
|
|
||||||
|| req.path === '/healthz'
|
|
||||||
|| req.path === '/readyz'
|
|
||||||
|| req.path.startsWith('/probe/')) {
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.path.startsWith('/api/v1/tailscale/')) {
|
const { clientIP, fromTailscale, clientTailscaleIP } = extractTailscaleIPs(req);
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
|
||||||
const forwardedFor = req.headers['x-forwarded-for'];
|
|
||||||
const realIP = req.headers['x-real-ip'];
|
|
||||||
|
|
||||||
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
|
||||||
const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim()));
|
|
||||||
|
|
||||||
if (!fromTailscale) {
|
if (!fromTailscale) {
|
||||||
return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', {
|
return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', {
|
||||||
@@ -148,27 +169,14 @@ module.exports = function configureMiddleware(app, {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tailscaleConfig.allowedTailnet) {
|
if (tailscaleConfig.allowedTailnet && clientTailscaleIP) {
|
||||||
try {
|
try {
|
||||||
const status = await getTailscaleStatus();
|
const inTailnet = await isIPInTailnet(clientTailscaleIP);
|
||||||
if (status) {
|
if (!inTailnet) {
|
||||||
const clientTailscaleIP = ipsToCheck
|
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
|
||||||
.map(ip => ip.toString().split(',')[0].trim())
|
requiresTailscale: true,
|
||||||
.find(ip => isTailscaleIP(ip));
|
clientIP
|
||||||
|
});
|
||||||
if (clientTailscaleIP) {
|
|
||||||
const knownIPs = new Set();
|
|
||||||
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
|
|
||||||
for (const peer of Object.values(status.Peer || {})) {
|
|
||||||
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
|
|
||||||
}
|
|
||||||
if (!knownIPs.has(clientTailscaleIP)) {
|
|
||||||
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
|
|
||||||
requiresTailscale: true,
|
|
||||||
clientIP
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message });
|
log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message });
|
||||||
|
|||||||
Reference in New Issue
Block a user