Fix Tailscale route prefix mismatch and increase health check timeout
Mount Tailscale router at /tailscale prefix so all 10 routes resolve to /api/tailscale/* as expected by middleware, audit logger, and frontend. Previously 5 routes (status, config, check-connection, devices, protect-service) resolved to /api/* instead, with config colliding with the settings route. Strip redundant /tailscale/ prefix from OAuth routes that were compensating for the missing mount prefix. Increase default health check timeout from 10s to 20s to reduce false positives on slower services. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,16 @@
|
||||
* Tailscale Route Tests
|
||||
*
|
||||
* Tests Tailscale status, configuration, and connection-checking endpoints.
|
||||
* The Tailscale routes are mounted without a prefix on the API router, so:
|
||||
* - GET /api/status — Tailscale status (returns null status if not installed)
|
||||
* - POST /api/config — NOTE: shadowed by config/settings.js which also defines POST /config;
|
||||
* we test it here but it may hit the DashCaddy config route instead.
|
||||
* - GET /api/check-connection — Check if request comes from Tailscale IP
|
||||
* - POST /api/tailscale/oauth-config — OAuth credential setup (requires live API)
|
||||
* The Tailscale routes are mounted at /api/tailscale/ on the API router:
|
||||
* - GET /api/tailscale/status — Tailscale status
|
||||
* - POST /api/tailscale/config — Update Tailscale configuration
|
||||
* - GET /api/tailscale/check-connection — Check if request comes from Tailscale IP
|
||||
* - GET /api/tailscale/devices — List Tailscale devices
|
||||
* - POST /api/tailscale/protect-service — Toggle Tailscale-only for a service
|
||||
* - POST /api/tailscale/oauth-config — OAuth credential setup (requires live API)
|
||||
* - GET /api/tailscale/api-devices — Enriched device list from API
|
||||
* - POST /api/tailscale/sync — Trigger API sync
|
||||
* - GET /api/tailscale/acl — Fetch ACL policy
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
@@ -34,9 +38,9 @@ describe('Tailscale Routes', () => {
|
||||
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('GET /api/status (Tailscale status)', () => {
|
||||
describe('GET /api/tailscale/status', () => {
|
||||
test('should return 200 with status data', async () => {
|
||||
const res = await request(app).get('/api/status');
|
||||
const res = await request(app).get('/api/tailscale/status');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -57,9 +61,9 @@ describe('Tailscale Routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/check-connection', () => {
|
||||
describe('GET /api/tailscale/check-connection', () => {
|
||||
test('should return 200 with connection info', async () => {
|
||||
const res = await request(app).get('/api/check-connection');
|
||||
const res = await request(app).get('/api/tailscale/check-connection');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -69,7 +73,7 @@ describe('Tailscale Routes', () => {
|
||||
});
|
||||
|
||||
test('should detect non-Tailscale IP for localhost requests', async () => {
|
||||
const res = await request(app).get('/api/check-connection');
|
||||
const res = await request(app).get('/api/tailscale/check-connection');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// Supertest connects via loopback, not a 100.x.x.x address
|
||||
@@ -77,9 +81,9 @@ describe('Tailscale Routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/devices (Tailscale devices)', () => {
|
||||
describe('GET /api/tailscale/devices', () => {
|
||||
test('should return 200 with devices array', async () => {
|
||||
const res = await request(app).get('/api/devices');
|
||||
const res = await request(app).get('/api/tailscale/devices');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -122,10 +126,10 @@ describe('Tailscale Routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/protect-service', () => {
|
||||
describe('POST /api/tailscale/protect-service', () => {
|
||||
test('should reject missing subdomain', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/protect-service')
|
||||
.post('/api/tailscale/protect-service')
|
||||
.send({});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
|
||||
@@ -168,7 +168,7 @@ class HealthChecker extends EventEmitter {
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
timeout: config.timeout || 10000,
|
||||
timeout: config.timeout || 20000,
|
||||
headers: config.headers || {},
|
||||
rejectUnauthorized: false // Trust internal CA certs (.sami TLD)
|
||||
};
|
||||
@@ -497,7 +497,7 @@ class HealthChecker extends EventEmitter {
|
||||
name: config.name || serviceId,
|
||||
url: config.url,
|
||||
method: config.method || 'GET',
|
||||
timeout: config.timeout || 10000,
|
||||
timeout: config.timeout || 20000,
|
||||
expectedStatusCodes: config.expectedStatusCodes || [200],
|
||||
expectedBodyPattern: config.expectedBodyPattern,
|
||||
expectedBodyContains: config.expectedBodyContains,
|
||||
|
||||
@@ -177,7 +177,7 @@ module.exports = function(ctx) {
|
||||
// ── Tailscale API Integration (OAuth 2.0) ──
|
||||
|
||||
// Save OAuth client credentials + validate by exchanging for a token
|
||||
router.post('/tailscale/oauth-config', ctx.asyncHandler(async (req, res) => {
|
||||
router.post('/oauth-config', ctx.asyncHandler(async (req, res) => {
|
||||
const { clientId, clientSecret, tailnet } = req.body;
|
||||
|
||||
if (!clientId || !clientSecret || !tailnet) {
|
||||
@@ -235,7 +235,7 @@ module.exports = function(ctx) {
|
||||
}, 'tailscale-oauth-config'));
|
||||
|
||||
// Remove OAuth credentials and disable API sync
|
||||
router.delete('/tailscale/oauth-config', ctx.asyncHandler(async (req, res) => {
|
||||
router.delete('/oauth-config', ctx.asyncHandler(async (req, res) => {
|
||||
await ctx.credentialManager.delete('tailscale.oauth.client_id');
|
||||
await ctx.credentialManager.delete('tailscale.oauth.client_secret');
|
||||
|
||||
@@ -250,7 +250,7 @@ module.exports = function(ctx) {
|
||||
}, 'tailscale-oauth-delete'));
|
||||
|
||||
// Get enriched device list from Tailscale API
|
||||
router.get('/tailscale/api-devices', ctx.asyncHandler(async (req, res) => {
|
||||
router.get('/api-devices', ctx.asyncHandler(async (req, res) => {
|
||||
if (!ctx.tailscale.config.oauthConfigured) {
|
||||
return ctx.errorResponse(res, 400, 'Tailscale API not configured. Set up OAuth first.');
|
||||
}
|
||||
@@ -264,7 +264,7 @@ module.exports = function(ctx) {
|
||||
}, 'tailscale-api-devices'));
|
||||
|
||||
// Manually trigger an API sync
|
||||
router.post('/tailscale/sync', ctx.asyncHandler(async (req, res) => {
|
||||
router.post('/sync', ctx.asyncHandler(async (req, res) => {
|
||||
if (!ctx.tailscale.config.oauthConfigured) {
|
||||
return ctx.errorResponse(res, 400, 'Tailscale API not configured. Set up OAuth first.');
|
||||
}
|
||||
@@ -279,7 +279,7 @@ module.exports = function(ctx) {
|
||||
}, 'tailscale-sync'));
|
||||
|
||||
// Fetch ACL policy (read-only)
|
||||
router.get('/tailscale/acl', ctx.asyncHandler(async (req, res) => {
|
||||
router.get('/acl', ctx.asyncHandler(async (req, res) => {
|
||||
const token = await ctx.tailscale.getAccessToken();
|
||||
const tailnet = ctx.tailscale.config.tailnet;
|
||||
if (!token || !tailnet) {
|
||||
|
||||
@@ -1186,7 +1186,7 @@ apiRouter.use(serviceRoutes(ctx));
|
||||
apiRouter.use(healthRoutes(ctx));
|
||||
apiRouter.use(monitoringRoutes(ctx));
|
||||
apiRouter.use(updatesRoutes(ctx));
|
||||
apiRouter.use(tailscaleRoutes(ctx));
|
||||
apiRouter.use('/tailscale', tailscaleRoutes(ctx));
|
||||
apiRouter.use(sitesRoutes(ctx));
|
||||
apiRouter.use(credentialsRoutes(ctx));
|
||||
apiRouter.use(arrRoutes(ctx));
|
||||
|
||||
Reference in New Issue
Block a user