Phase 1: Add ESLint/Prettier config + baseline auto-fixes

This commit is contained in:
Krystie
2026-03-22 11:00:25 +01:00
parent 41a0cdee7e
commit e2c67a8fe8
90 changed files with 4008 additions and 3066 deletions

View File

@@ -0,0 +1,4 @@
node_modules/
coverage/
dist/
*.min.js

View File

@@ -0,0 +1,56 @@
module.exports = {
env: {
node: true,
es2021: true,
jest: true,
},
extends: 'eslint:recommended',
parserOptions: {
ecmaVersion: 2021,
},
rules: {
// Possible errors
'no-await-in-loop': 'warn',
'no-console': 'off', // We use console in server code
'no-template-curly-in-string': 'error',
// Best practices
'curly': ['error', 'multi-line'],
'eqeqeq': ['error', 'always', { null: 'ignore' }],
'no-eval': 'error',
'no-implied-eval': 'error',
'no-return-await': 'error',
'no-throw-literal': 'error',
'prefer-promise-reject-errors': 'error',
'require-await': 'warn',
// Variables
'no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],
'no-use-before-define': ['error', {
functions: false,
classes: true,
}],
// Stylistic
'comma-dangle': ['error', 'always-multiline'],
'quotes': ['error', 'single', { avoidEscape: true }],
'semi': ['error', 'always'],
'indent': ['error', 2, { SwitchCase: 1 }],
'max-len': ['warn', {
code: 120,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
}],
// ES6
'arrow-spacing': 'error',
'no-var': 'error',
'prefer-const': 'error',
'prefer-arrow-callback': 'warn',
'prefer-template': 'warn',
},
};

View File

@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 120,
"arrowParens": "always"
}

View File

@@ -77,7 +77,7 @@ describe('API Endpoints', () => {
name: 'Test Service', name: 'Test Service',
logo: '/assets/test.png', logo: '/assets/test.png',
ip: 'localhost', ip: 'localhost',
tailscaleOnly: false tailscaleOnly: false,
}); });
// Now get services // Now get services
@@ -87,7 +87,7 @@ describe('API Endpoints', () => {
expect(res.body.length).toBe(1); expect(res.body.length).toBe(1);
expect(res.body[0]).toMatchObject({ expect(res.body[0]).toMatchObject({
id: 'test-service', id: 'test-service',
name: 'Test Service' name: 'Test Service',
}); });
}); });
@@ -113,7 +113,7 @@ describe('API Endpoints', () => {
name: 'Plex', name: 'Plex',
logo: '/assets/plex.png', logo: '/assets/plex.png',
ip: 'localhost', ip: 'localhost',
tailscaleOnly: false tailscaleOnly: false,
}; };
const res = await request(app) const res = await request(app)
@@ -134,7 +134,7 @@ describe('API Endpoints', () => {
test('should reject duplicate service IDs', async () => { test('should reject duplicate service IDs', async () => {
const service = { const service = {
id: 'duplicate', id: 'duplicate',
name: 'Duplicate Service' name: 'Duplicate Service',
}; };
// Add first time // Add first time
@@ -153,7 +153,7 @@ describe('API Endpoints', () => {
.post('/api/services') .post('/api/services')
.send({ .send({
// Missing 'id' and 'name' // Missing 'id' and 'name'
logo: '/assets/test.png' logo: '/assets/test.png',
}); });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
@@ -164,7 +164,7 @@ describe('API Endpoints', () => {
const maliciousService = { const maliciousService = {
id: 'test<script>alert(1)</script>', id: 'test<script>alert(1)</script>',
name: '<img src=x onerror=alert(1)>', name: '<img src=x onerror=alert(1)>',
logo: '/assets/test.png' logo: '/assets/test.png',
}; };
const res = await request(app) const res = await request(app)
@@ -192,8 +192,8 @@ describe('API Endpoints', () => {
promises.push( promises.push(
request(app).post('/api/services').send({ request(app).post('/api/services').send({
id: `service-${i}`, id: `service-${i}`,
name: `Service ${i}` name: `Service ${i}`,
}) }),
); );
} }
@@ -215,11 +215,11 @@ describe('API Endpoints', () => {
// Add test services // Add test services
await request(app).post('/api/services').send({ await request(app).post('/api/services').send({
id: 'service1', id: 'service1',
name: 'Service 1' name: 'Service 1',
}); });
await request(app).post('/api/services').send({ await request(app).post('/api/services').send({
id: 'service2', id: 'service2',
name: 'Service 2' name: 'Service 2',
}); });
}); });
@@ -246,7 +246,7 @@ describe('API Endpoints', () => {
// Try to delete the same service twice simultaneously // Try to delete the same service twice simultaneously
const promises = [ const promises = [
request(app).delete('/api/services/service1'), request(app).delete('/api/services/service1'),
request(app).delete('/api/services/service1') request(app).delete('/api/services/service1'),
]; ];
const results = await Promise.all(promises); const results = await Promise.all(promises);
@@ -263,7 +263,7 @@ describe('API Endpoints', () => {
const services = [ const services = [
{ id: 'plex', name: 'Plex' }, { id: 'plex', name: 'Plex' },
{ id: 'jellyfin', name: 'Jellyfin' }, { id: 'jellyfin', name: 'Jellyfin' },
{ id: 'emby', name: 'Emby' } { id: 'emby', name: 'Emby' },
]; ];
const res = await request(app) const res = await request(app)
@@ -282,13 +282,13 @@ describe('API Endpoints', () => {
// Add initial service // Add initial service
await request(app).post('/api/services').send({ await request(app).post('/api/services').send({
id: 'old', id: 'old',
name: 'Old Service' name: 'Old Service',
}); });
// Import new services (should replace) // Import new services (should replace)
const newServices = [ const newServices = [
{ id: 'new1', name: 'New Service 1' }, { id: 'new1', name: 'New Service 1' },
{ id: 'new2', name: 'New Service 2' } { id: 'new2', name: 'New Service 2' },
]; ];
await request(app).put('/api/services').send(newServices); await request(app).put('/api/services').send(newServices);
@@ -360,7 +360,7 @@ describe('API Endpoints', () => {
test('should save config', async () => { test('should save config', async () => {
const config = { const config = {
theme: 'dark', theme: 'dark',
domain: 'test.local' domain: 'test.local',
}; };
const res = await request(app) const res = await request(app)

View File

@@ -12,7 +12,7 @@ const credentialManager = require('../credential-manager');
// Mock credential manager // Mock credential manager
jest.mock('../credential-manager'); jest.mock('../credential-manager');
jest.mock('../logger-utils', () => ({ jest.mock('../logger-utils', () => ({
safeLog: jest.fn() safeLog: jest.fn(),
})); }));
describe('AuthManager', () => { describe('AuthManager', () => {
@@ -166,8 +166,8 @@ describe('AuthManager', () => {
expect(credentialManager.save).toHaveBeenCalledWith( expect(credentialManager.save).toHaveBeenCalledWith(
expect.stringMatching(/^auth\.apikey\./), expect.stringMatching(/^auth\.apikey\./),
expect.objectContaining({ expect.objectContaining({
keySecret: expect.any(String) keySecret: expect.any(String),
}) }),
); );
}); });
@@ -179,8 +179,8 @@ describe('AuthManager', () => {
expect.objectContaining({ expect.objectContaining({
name: 'test-key', name: 'test-key',
scopes: ['read'], scopes: ['read'],
createdAt: expect.any(String) createdAt: expect.any(String),
}) }),
); );
}); });
@@ -210,12 +210,12 @@ describe('AuthManager', () => {
// Mock credential manager to return the stored key // Mock credential manager to return the stored key
credentialManager.get.mockResolvedValueOnce({ credentialManager.get.mockResolvedValueOnce({
keySecret: key.split('_')[2] keySecret: key.split('_')[2],
}); });
credentialManager.get.mockResolvedValueOnce({ credentialManager.get.mockResolvedValueOnce({
name: 'test-key', name: 'test-key',
scopes: ['read', 'write'], scopes: ['read', 'write'],
createdAt: new Date().toISOString() createdAt: new Date().toISOString(),
}); });
const validated = await authManager.validateAPIKey(key); const validated = await authManager.validateAPIKey(key);
@@ -239,7 +239,7 @@ describe('AuthManager', () => {
}); });
test('should reject non-existent API key', async () => { test('should reject non-existent API key', async () => {
const fakeKey = 'dk_' + crypto.randomBytes(16).toString('hex') + '_' + crypto.randomBytes(32).toString('hex'); const fakeKey = `dk_${ crypto.randomBytes(16).toString('hex') }_${ crypto.randomBytes(32).toString('hex')}`;
credentialManager.get.mockResolvedValue(null); // Key doesn't exist credentialManager.get.mockResolvedValue(null); // Key doesn't exist
const validated = await authManager.validateAPIKey(fakeKey); const validated = await authManager.validateAPIKey(fakeKey);
@@ -252,7 +252,7 @@ describe('AuthManager', () => {
credentialManager.get.mockResolvedValueOnce({ credentialManager.get.mockResolvedValueOnce({
keySecret: key.split('_')[2], keySecret: key.split('_')[2],
revoked: true // Key is revoked revoked: true, // Key is revoked
}); });
const validated = await authManager.validateAPIKey(key); const validated = await authManager.validateAPIKey(key);
@@ -278,7 +278,7 @@ describe('AuthManager', () => {
const { id } = await authManager.generateAPIKey('test-key'); const { id } = await authManager.generateAPIKey('test-key');
credentialManager.get.mockResolvedValue({ credentialManager.get.mockResolvedValue({
keySecret: 'test-secret' keySecret: 'test-secret',
}); });
const revoked = await authManager.revokeAPIKey(id); const revoked = await authManager.revokeAPIKey(id);
@@ -288,8 +288,8 @@ describe('AuthManager', () => {
`auth.apikey.${id}`, `auth.apikey.${id}`,
expect.objectContaining({ expect.objectContaining({
revoked: true, revoked: true,
revokedAt: expect.any(String) revokedAt: expect.any(String),
}) }),
); );
}); });
@@ -305,19 +305,19 @@ describe('AuthManager', () => {
test('should list all API keys with metadata', async () => { test('should list all API keys with metadata', async () => {
credentialManager.list.mockResolvedValue([ credentialManager.list.mockResolvedValue([
'auth.metadata.key1', 'auth.metadata.key1',
'auth.metadata.key2' 'auth.metadata.key2',
]); ]);
credentialManager.get.mockResolvedValueOnce({ credentialManager.get.mockResolvedValueOnce({
name: 'Key 1', name: 'Key 1',
scopes: ['read'], scopes: ['read'],
createdAt: '2026-01-01T00:00:00Z' createdAt: '2026-01-01T00:00:00Z',
}); });
credentialManager.get.mockResolvedValueOnce({ credentialManager.get.mockResolvedValueOnce({
name: 'Key 2', name: 'Key 2',
scopes: ['read', 'write'], scopes: ['read', 'write'],
createdAt: '2026-01-02T00:00:00Z' createdAt: '2026-01-02T00:00:00Z',
}); });
const keys = await authManager.listAPIKeys(); const keys = await authManager.listAPIKeys();

View File

@@ -198,7 +198,7 @@ describe('cleanupOldBackups', () => {
name: 'daily', name: 'daily',
status: 'success', status: 'success',
timestamp: new Date(Date.now() - i * 86400000).toISOString(), timestamp: new Date(Date.now() - i * 86400000).toISOString(),
locations: [{ type: 'local', path: `/tmp/fake-${i}.backup` }] locations: [{ type: 'local', path: `/tmp/fake-${i}.backup` }],
}); });
} }

View File

@@ -47,7 +47,7 @@ describe('Config Routes', () => {
const validConfig = { const validConfig = {
tld: 'sami', tld: 'sami',
theme: 'dark', theme: 'dark',
timezone: 'America/New_York' timezone: 'America/New_York',
}; };
const res = await request(app) const res = await request(app)
@@ -76,7 +76,7 @@ describe('Config Routes', () => {
test('should return 400 for config with invalid field values', async () => { test('should return 400 for config with invalid field values', async () => {
const invalidConfig = { const invalidConfig = {
tld: 123, // tld must be a string tld: 123, // tld must be a string
dns: 'not-an-object' // dns must be an object dns: 'not-an-object', // dns must be an object
}; };
const res = await request(app) const res = await request(app)

View File

@@ -68,7 +68,7 @@ describe('store', () => {
'key-with-dashes', 'key-with-dashes',
'key_with_underscores', 'key_with_underscores',
'key:with:colons', 'key:with:colons',
'key/with/slashes' 'key/with/slashes',
]; ];
for (const key of specialKeys) { for (const key of specialKeys) {
@@ -83,8 +83,8 @@ describe('store', () => {
'password!@#$%^&*()', 'password!@#$%^&*()',
'token\nwith\nnewlines', 'token\nwith\nnewlines',
'json{"key":"value"}', 'json{"key":"value"}',
'unicode=ƒöÉ=ƒöæG£à', 'unicode=<EFBFBD><EFBFBD><EFBFBD>=<3D><><EFBFBD>G<EFBFBD><47>',
'quotes"and\'apostrophes' 'quotes"and\'apostrophes',
]; ];
for (let i = 0; i < specialValues.length; i++) { for (let i = 0; i < specialValues.length; i++) {
@@ -210,7 +210,7 @@ describe('getMetadata', () => {
description: 'API Key', description: 'API Key',
service: 'GitHub', service: 'GitHub',
expiresAt: '2026-12-31', expiresAt: '2026-12-31',
createdBy: 'admin' createdBy: 'admin',
}; };
await credentialManager.store('meta.complex', 'value', metadata); await credentialManager.store('meta.complex', 'value', metadata);
@@ -328,7 +328,7 @@ describe('Concurrent Access', () => {
const promises = [ const promises = [
credentialManager.store('concurrent.key', 'value1'), credentialManager.store('concurrent.key', 'value1'),
credentialManager.store('concurrent.key', 'value2'), credentialManager.store('concurrent.key', 'value2'),
credentialManager.store('concurrent.key', 'value3') credentialManager.store('concurrent.key', 'value3'),
]; ];
await Promise.all(promises); await Promise.all(promises);
@@ -359,7 +359,7 @@ describe('Concurrent Access', () => {
const promises = [ const promises = [
credentialManager.retrieve('readwrite.key'), credentialManager.retrieve('readwrite.key'),
credentialManager.store('readwrite.key', 'updated'), credentialManager.store('readwrite.key', 'updated'),
credentialManager.retrieve('readwrite.key') credentialManager.retrieve('readwrite.key'),
]; ];
const results = await Promise.all(promises); const results = await Promise.all(promises);
@@ -496,7 +496,7 @@ describe('Credential Manager - Extended Coverage', () => {
const promises = [ const promises = [
credentialManager.delete('delete.concurrent'), credentialManager.delete('delete.concurrent'),
credentialManager.delete('delete.concurrent'), credentialManager.delete('delete.concurrent'),
credentialManager.delete('delete.concurrent') credentialManager.delete('delete.concurrent'),
]; ];
// Should not throw // Should not throw
@@ -532,7 +532,7 @@ describe('Credential Manager - Extended Coverage', () => {
}); });
test('should handle unicode characters', async () => { test('should handle unicode characters', async () => {
const unicode = 'S+ásÑ+S+ûtòî =ƒÜÇ +à+¦+¡+¿+º +º+ä+¦+º+ä+à'; const unicode = 'S+<EFBFBD>s<EFBFBD>+S+<2B>t<EFBFBD><74> =<3D><><EFBFBD> +<2B>+<2B>+<2B>+<2B>+<2B> +<2B>+<2B>+<2B>+<2B>+<2B>+<2B>';
const stored = await credentialManager.store('unicode.key', unicode); const stored = await credentialManager.store('unicode.key', unicode);
expect(stored).toBe(true); expect(stored).toBe(true);
@@ -621,7 +621,7 @@ describe('Credential Manager - Extended Coverage', () => {
description: 'Production database password', description: 'Production database password',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
owner: 'admin', owner: 'admin',
tags: ['production', 'database'] tags: ['production', 'database'],
}; };
await credentialManager.store('meta.full', 'value', metadata); await credentialManager.store('meta.full', 'value', metadata);
@@ -648,7 +648,7 @@ describe('Credential Manager - Extended Coverage', () => {
test('should handle metadata with special characters', async () => { test('should handle metadata with special characters', async () => {
const metadata = { const metadata = {
description: 'Test with "quotes" and \'apostrophes\'', description: 'Test with "quotes" and \'apostrophes\'',
notes: 'Line 1\nLine 2\tTabbed' notes: 'Line 1\nLine 2\tTabbed',
}; };
await credentialManager.store('meta.special', 'value', metadata); await credentialManager.store('meta.special', 'value', metadata);

View File

@@ -43,14 +43,14 @@ describe('encrypt / decrypt', () => {
test('throws on tampered ciphertext', () => { test('throws on tampered ciphertext', () => {
const encrypted = cryptoUtils.encrypt('test'); const encrypted = cryptoUtils.encrypt('test');
const parts = encrypted.split(':'); const parts = encrypted.split(':');
parts[2] = 'AAAA' + parts[2].slice(4); // tamper with ciphertext parts[2] = `AAAA${ parts[2].slice(4)}`; // tamper with ciphertext
expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow(); expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow();
}); });
test('throws on tampered authTag', () => { test('throws on tampered authTag', () => {
const encrypted = cryptoUtils.encrypt('test'); const encrypted = cryptoUtils.encrypt('test');
const parts = encrypted.split(':'); const parts = encrypted.split(':');
parts[1] = 'AAAA' + parts[1].slice(4); // tamper with auth tag parts[1] = `AAAA${ parts[1].slice(4)}`; // tamper with auth tag
expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow(); expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow();
}); });

View File

@@ -151,7 +151,7 @@ describe('DockerSecurity Module', () => {
}); });
test('should handle very long image names', () => { test('should handle very long image names', () => {
const longName = 'registry.example.com/team/project/' + 'a'.repeat(100) + ':v1.2.3'; const longName = `registry.example.com/team/project/${ 'a'.repeat(100) }:v1.2.3`;
dockerSecurity.setTrustedDigest(longName, 'sha256:long'); dockerSecurity.setTrustedDigest(longName, 'sha256:long');
expect(dockerSecurity.config.trustedDigests[longName]).toBe('sha256:long'); expect(dockerSecurity.config.trustedDigests[longName]).toBe('sha256:long');

View File

@@ -202,7 +202,7 @@ describe('Edge Case Tests', () => {
.send({ .send({
id: 'path-traversal', id: 'path-traversal',
name: 'Path Traversal', name: 'Path Traversal',
logo: '../../../../../../etc/passwd' logo: '../../../../../../etc/passwd',
}); });
// Should handle safely // Should handle safely
@@ -255,7 +255,7 @@ describe('Edge Case Tests', () => {
test('should handle bulk import of 200 services', async () => { test('should handle bulk import of 200 services', async () => {
const bulkServices = Array.from({ length: 200 }, (_, i) => ({ const bulkServices = Array.from({ length: 200 }, (_, i) => ({
id: `bulk-${i}`, id: `bulk-${i}`,
name: `Bulk Service ${i}` name: `Bulk Service ${i}`,
})); }));
const res = await request(app) const res = await request(app)
@@ -277,7 +277,7 @@ describe('Edge Case Tests', () => {
.send({ .send({
id: 'large-data', id: 'large-data',
name: 'Large Data', name: 'Large Data',
description: largeData description: largeData,
}); });
// Might reject due to size // Might reject due to size
@@ -290,7 +290,7 @@ describe('Edge Case Tests', () => {
const promises = Array.from({ length: 20 }, (_, i) => const promises = Array.from({ length: 20 }, (_, i) =>
request(app) request(app)
.post('/api/services') .post('/api/services')
.send({ id: `concurrent-${i}`, name: `Concurrent ${i}` }) .send({ id: `concurrent-${i}`, name: `Concurrent ${i}` }),
); );
const results = await Promise.all(promises); const results = await Promise.all(promises);
@@ -317,7 +317,7 @@ describe('Edge Case Tests', () => {
// Simultaneously add again and delete // Simultaneously add again and delete
const [addRes, deleteRes] = await Promise.all([ const [addRes, deleteRes] = await Promise.all([
request(app).post('/api/services').send({ id: 'race', name: 'Race 2' }), request(app).post('/api/services').send({ id: 'race', name: 'Race 2' }),
request(app).delete('/api/services/race') request(app).delete('/api/services/race'),
]); ]);
// One should succeed, states should be consistent // One should succeed, states should be consistent
@@ -331,7 +331,7 @@ describe('Edge Case Tests', () => {
const [res1, res2] = await Promise.all([ const [res1, res2] = await Promise.all([
request(app).put('/api/services').send(set1), request(app).put('/api/services').send(set1),
request(app).put('/api/services').send(set2) request(app).put('/api/services').send(set2),
]); ]);
// Both operations should complete // Both operations should complete
@@ -463,7 +463,7 @@ describe('Edge Case Tests', () => {
test('should handle double-encoded JSON', async () => { test('should handle double-encoded JSON', async () => {
const doubleEncoded = JSON.stringify( const doubleEncoded = JSON.stringify(
JSON.stringify({ id: 'double', name: 'Double Encoded' }) JSON.stringify({ id: 'double', name: 'Double Encoded' }),
); );
const res = await request(app) const res = await request(app)
@@ -525,7 +525,7 @@ describe('Edge Case Tests', () => {
test('should handle configuration with nested arrays', async () => { test('should handle configuration with nested arrays', async () => {
const config = { const config = {
nested: [[['deep', 'array'], ['values']], [['more']]] nested: [[['deep', 'array'], ['values']], [['more']]],
}; };
const res = await request(app) const res = await request(app)
@@ -558,7 +558,7 @@ describe('Edge Case Tests', () => {
// Delete twice at once // Delete twice at once
const [res1, res2] = await Promise.all([ const [res1, res2] = await Promise.all([
request(app).delete('/api/services/delete-me'), request(app).delete('/api/services/delete-me'),
request(app).delete('/api/services/delete-me') request(app).delete('/api/services/delete-me'),
]); ]);
// One should succeed (200), one should fail (404) // One should succeed (200), one should fail (404)

View File

@@ -37,25 +37,25 @@ describe('evaluateHealth', () => {
test('returns false when expectedBodyPattern regex does not match', () => { test('returns false when expectedBodyPattern regex does not match', () => {
expect(healthChecker.evaluateHealth(200, 'error occurred', { expect(healthChecker.evaluateHealth(200, 'error occurred', {
expectedBodyPattern: 'ok|healthy' expectedBodyPattern: 'ok|healthy',
})).toBe(false); })).toBe(false);
}); });
test('returns true when expectedBodyPattern regex matches', () => { test('returns true when expectedBodyPattern regex matches', () => {
expect(healthChecker.evaluateHealth(200, 'status: healthy', { expect(healthChecker.evaluateHealth(200, 'status: healthy', {
expectedBodyPattern: 'healthy' expectedBodyPattern: 'healthy',
})).toBe(true); })).toBe(true);
}); });
test('returns false when expectedBodyContains text is missing', () => { test('returns false when expectedBodyContains text is missing', () => {
expect(healthChecker.evaluateHealth(200, 'some response', { expect(healthChecker.evaluateHealth(200, 'some response', {
expectedBodyContains: 'healthy' expectedBodyContains: 'healthy',
})).toBe(false); })).toBe(false);
}); });
test('returns true when expectedBodyContains text is present', () => { test('returns true when expectedBodyContains text is present', () => {
expect(healthChecker.evaluateHealth(200, 'service is healthy', { expect(healthChecker.evaluateHealth(200, 'service is healthy', {
expectedBodyContains: 'healthy' expectedBodyContains: 'healthy',
})).toBe(true); })).toBe(true);
}); });
@@ -64,21 +64,21 @@ describe('evaluateHealth', () => {
expect(healthChecker.evaluateHealth(200, 'healthy ok', { expect(healthChecker.evaluateHealth(200, 'healthy ok', {
expectedStatusCodes: [200], expectedStatusCodes: [200],
expectedBodyPattern: 'healthy', expectedBodyPattern: 'healthy',
expectedBodyContains: 'ok' expectedBodyContains: 'ok',
})).toBe(true); })).toBe(true);
// Status fails // Status fails
expect(healthChecker.evaluateHealth(500, 'healthy ok', { expect(healthChecker.evaluateHealth(500, 'healthy ok', {
expectedStatusCodes: [200], expectedStatusCodes: [200],
expectedBodyPattern: 'healthy', expectedBodyPattern: 'healthy',
expectedBodyContains: 'ok' expectedBodyContains: 'ok',
})).toBe(false); })).toBe(false);
// Body pattern fails // Body pattern fails
expect(healthChecker.evaluateHealth(200, 'error', { expect(healthChecker.evaluateHealth(200, 'error', {
expectedStatusCodes: [200], expectedStatusCodes: [200],
expectedBodyPattern: 'healthy', expectedBodyPattern: 'healthy',
expectedBodyContains: 'error' expectedBodyContains: 'error',
})).toBe(false); })).toBe(false);
}); });
}); });

View File

@@ -9,7 +9,7 @@ const {
validateServiceConfig, validateServiceConfig,
sanitizeString, sanitizeString,
isValidPort, isValidPort,
isPrivateIP isPrivateIP,
} = require('../input-validator'); } = require('../input-validator');
// Helper: extract .errors from ValidationError // Helper: extract .errors from ValidationError

View File

@@ -61,7 +61,7 @@ describe('Integration Tests', () => {
id: 'test-app', id: 'test-app',
name: 'Test Application', name: 'Test Application',
logo: '/assets/test.png', logo: '/assets/test.png',
url: 'https://test.test.local' url: 'https://test.test.local',
}; };
const addRes = await request(app) const addRes = await request(app)
@@ -81,7 +81,7 @@ describe('Integration Tests', () => {
const updatedServices = [{ const updatedServices = [{
...newService, ...newService,
status: 'online', status: 'online',
responseTime: 150 responseTime: 150,
}]; }];
const updateRes = await request(app) const updateRes = await request(app)
@@ -116,7 +116,7 @@ describe('Integration Tests', () => {
name: template.name, name: template.name,
logo: template.logo, logo: template.logo,
port: 8096, port: 8096,
subdomain: 'jellyfin' subdomain: 'jellyfin',
}; };
// Step 3: Add configured service // Step 3: Add configured service
@@ -129,7 +129,7 @@ describe('Integration Tests', () => {
// Step 4: Verify service is listed // Step 4: Verify service is listed
const servicesRes = await request(app).get('/api/services'); const servicesRes = await request(app).get('/api/services');
expect(servicesRes.body).toContainEqual( expect(servicesRes.body).toContainEqual(
expect.objectContaining({ id: 'jellyfin' }) expect.objectContaining({ id: 'jellyfin' }),
); );
}); });
}); });
@@ -140,11 +140,11 @@ describe('Integration Tests', () => {
const services = Array.from({ length: 5 }, (_, i) => ({ const services = Array.from({ length: 5 }, (_, i) => ({
id: `concurrent-${i}`, id: `concurrent-${i}`,
name: `Concurrent Service ${i}`, name: `Concurrent Service ${i}`,
logo: `/assets/service-${i}.png` logo: `/assets/service-${i}.png`,
})); }));
const deployPromises = services.map(service => const deployPromises = services.map(service =>
request(app).post('/api/services').send(service) request(app).post('/api/services').send(service),
); );
const results = await Promise.all(deployPromises); const results = await Promise.all(deployPromises);
@@ -167,7 +167,7 @@ describe('Integration Tests', () => {
const bulkServices = [ const bulkServices = [
{ id: 'plex', name: 'Plex' }, { id: 'plex', name: 'Plex' },
{ id: 'jellyfin', name: 'Jellyfin' }, { id: 'jellyfin', name: 'Jellyfin' },
{ id: 'emby', name: 'Emby' } { id: 'emby', name: 'Emby' },
]; ];
const importRes = await request(app) const importRes = await request(app)
@@ -180,7 +180,7 @@ describe('Integration Tests', () => {
const updatedServices = [ const updatedServices = [
{ id: 'plex', name: 'Plex', status: 'online' }, { id: 'plex', name: 'Plex', status: 'online' },
{ id: 'jellyfin', name: 'Jellyfin' }, { id: 'jellyfin', name: 'Jellyfin' },
{ id: 'emby', name: 'Emby' } { id: 'emby', name: 'Emby' },
]; ];
await request(app).put('/api/services').send(updatedServices); await request(app).put('/api/services').send(updatedServices);
@@ -219,7 +219,7 @@ describe('Integration Tests', () => {
const config = { const config = {
domain: 'example.local', domain: 'example.local',
theme: 'dark', theme: 'dark',
enableHealthCheck: false enableHealthCheck: false,
}; };
const configRes = await request(app) const configRes = await request(app)
@@ -232,7 +232,7 @@ describe('Integration Tests', () => {
const service = { const service = {
id: 'test', id: 'test',
name: 'Test Service', name: 'Test Service',
subdomain: 'test' subdomain: 'test',
}; };
await request(app).post('/api/services').send(service); await request(app).post('/api/services').send(service);
@@ -282,7 +282,7 @@ describe('Integration Tests', () => {
const service = { const service = {
id: firstTemplateId, id: firstTemplateId,
name: singleTemplateRes.body.template.name, name: singleTemplateRes.body.template.name,
logo: singleTemplateRes.body.template.logo logo: singleTemplateRes.body.template.logo,
}; };
const deployRes = await request(app) const deployRes = await request(app)
@@ -310,7 +310,7 @@ describe('Integration Tests', () => {
name: 'Plex Production', name: 'Plex Production',
logo: template.logo, logo: template.logo,
port: 32400, port: 32400,
subdomain: 'plex' subdomain: 'plex',
}; };
const deployRes = await request(app) const deployRes = await request(app)
@@ -322,7 +322,7 @@ describe('Integration Tests', () => {
// Verify service exists // Verify service exists
const servicesRes = await request(app).get('/api/services'); const servicesRes = await request(app).get('/api/services');
expect(servicesRes.body).toContainEqual( expect(servicesRes.body).toContainEqual(
expect.objectContaining({ id: 'plex-prod' }) expect.objectContaining({ id: 'plex-prod' }),
); );
}); });
}); });
@@ -367,7 +367,7 @@ describe('Integration Tests', () => {
// Start with empty state // Start with empty state
const initialServices = [ const initialServices = [
{ id: 'base1', name: 'Base 1' }, { id: 'base1', name: 'Base 1' },
{ id: 'base2', name: 'Base 2' } { id: 'base2', name: 'Base 2' },
]; ];
await request(app).put('/api/services').send(initialServices); await request(app).put('/api/services').send(initialServices);
@@ -377,7 +377,7 @@ describe('Integration Tests', () => {
request(app).post('/api/services').send({ id: 'new1', name: 'New 1' }), request(app).post('/api/services').send({ id: 'new1', name: 'New 1' }),
request(app).post('/api/services').send({ id: 'new2', name: 'New 2' }), request(app).post('/api/services').send({ id: 'new2', name: 'New 2' }),
request(app).delete('/api/services/base1'), request(app).delete('/api/services/base1'),
request(app).post('/api/services').send({ id: 'new3', name: 'New 3' }) request(app).post('/api/services').send({ id: 'new3', name: 'New 3' }),
]; ];
await Promise.all(operations); await Promise.all(operations);
@@ -426,7 +426,7 @@ describe('Integration Tests', () => {
const selectedApps = mediaApps.map(id => ({ const selectedApps = mediaApps.map(id => ({
id, id,
name: templates[id].name, name: templates[id].name,
logo: templates[id].logo logo: templates[id].logo,
})); }));
// Step 3: Deploy all media apps // Step 3: Deploy all media apps
@@ -451,7 +451,7 @@ describe('Integration Tests', () => {
const config = { const config = {
domain: 'homelab.local', domain: 'homelab.local',
theme: 'dark', theme: 'dark',
enableHealthCheck: true enableHealthCheck: true,
}; };
await request(app).post('/api/config').send(config); await request(app).post('/api/config').send(config);
@@ -460,7 +460,7 @@ describe('Integration Tests', () => {
const existingServices = [ const existingServices = [
{ id: 'router', name: 'Router', logo: '/assets/router.png' }, { id: 'router', name: 'Router', logo: '/assets/router.png' },
{ id: 'nas', name: 'NAS', logo: '/assets/nas.png' }, { id: 'nas', name: 'NAS', logo: '/assets/nas.png' },
{ id: 'pihole', name: 'Pi-hole', logo: '/assets/pihole.png' } { id: 'pihole', name: 'Pi-hole', logo: '/assets/pihole.png' },
]; ];
await request(app).put('/api/services').send(existingServices); await request(app).put('/api/services').send(existingServices);
@@ -484,7 +484,7 @@ describe('Integration Tests', () => {
const oldServices = [ const oldServices = [
{ id: 'old1', name: 'Old Service 1' }, { id: 'old1', name: 'Old Service 1' },
{ id: 'old2', name: 'Old Service 2' }, { id: 'old2', name: 'Old Service 2' },
{ id: 'keep', name: 'Keep This' } { id: 'keep', name: 'Keep This' },
]; ];
await request(app).put('/api/services').send(oldServices); await request(app).put('/api/services').send(oldServices);

View File

@@ -12,7 +12,7 @@ describe('logger-utils', () => {
username: 'admin', username: 'admin',
password: 'secret123', password: 'secret123',
apiKey: 'abc-def-ghi', apiKey: 'abc-def-ghi',
token: 'xyz123' token: 'xyz123',
}; };
const result = sanitizeForLog(input); const result = sanitizeForLog(input);
@@ -29,9 +29,9 @@ describe('logger-utils', () => {
name: 'Alice', name: 'Alice',
credentials: { credentials: {
password: 'secret', password: 'secret',
token: 'abc123' token: 'abc123',
} },
} },
}; };
const result = sanitizeForLog(input); const result = sanitizeForLog(input);
@@ -44,7 +44,7 @@ describe('logger-utils', () => {
test('should handle arrays', () => { test('should handle arrays', () => {
const input = [ const input = [
{ name: 'user1', password: 'pass1' }, { name: 'user1', password: 'pass1' },
{ name: 'user2', secret: 'pass2' } { name: 'user2', secret: 'pass2' },
]; ];
const result = sanitizeForLog(input); const result = sanitizeForLog(input);
@@ -63,7 +63,7 @@ describe('logger-utils', () => {
test('should support additional sensitive keys', () => { test('should support additional sensitive keys', () => {
const input = { const input = {
email: 'user@example.com', email: 'user@example.com',
ssn: '123-45-6789' ssn: '123-45-6789',
}; };
const result = sanitizeForLog(input, ['ssn']); const result = sanitizeForLog(input, ['ssn']);
@@ -76,7 +76,7 @@ describe('logger-utils', () => {
const input = { const input = {
PASSWORD: 'secret', PASSWORD: 'secret',
ApiKey: 'key123', ApiKey: 'key123',
Bearer_Token: 'token456' Bearer_Token: 'token456',
}; };
const result = sanitizeForLog(input); const result = sanitizeForLog(input);
@@ -125,7 +125,7 @@ describe('logger-utils', () => {
test('should create safe log object with message and sanitized data', () => { test('should create safe log object with message and sanitized data', () => {
const result = safeLog('User login', { const result = safeLog('User login', {
username: 'alice', username: 'alice',
password: 'secret123' password: 'secret123',
}); });
expect(result).toHaveProperty('message', 'User login'); expect(result).toHaveProperty('message', 'User login');

View File

@@ -72,8 +72,8 @@ describe('Notification Routes', () => {
.send({ .send({
events: { events: {
containerDown: true, containerDown: true,
containerUp: false containerUp: false,
} },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
@@ -87,9 +87,9 @@ describe('Notification Routes', () => {
providers: { providers: {
discord: { discord: {
enabled: true, enabled: true,
webhookUrl: 'not-a-valid-url' webhookUrl: 'not-a-valid-url',
} },
} },
}); });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
@@ -102,9 +102,9 @@ describe('Notification Routes', () => {
providers: { providers: {
ntfy: { ntfy: {
enabled: true, enabled: true,
topic: 'invalid topic with spaces!!!' topic: 'invalid topic with spaces!!!',
} },
} },
}); });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);

View File

@@ -27,7 +27,7 @@ function makeStat(cpu = 10, memory = 50, timestamp = new Date().toISOString()) {
memory: { usage: memory * 1024 * 1024, limit: 1024 * 1024 * 1024, percent: memory, usageMB: memory, limitMB: 1024 }, memory: { usage: memory * 1024 * 1024, limit: 1024 * 1024 * 1024, percent: memory, usageMB: memory, limitMB: 1024 },
network: { rxBytes: 0, txBytes: 0, rxMB: 0, txMB: 0 }, network: { rxBytes: 0, txBytes: 0, rxMB: 0, txMB: 0 },
disk: { readBytes: 0, writeBytes: 0, readMB: 0, writeMB: 0 }, disk: { readBytes: 0, writeBytes: 0, readMB: 0, writeMB: 0 },
pids: 5 pids: 5,
}; };
} }
@@ -95,7 +95,7 @@ describe('getAggregatedStats', () => {
const now = new Date().toISOString(); const now = new Date().toISOString();
resourceMonitor.stats.set('c1', { resourceMonitor.stats.set('c1', {
name: '/app', name: '/app',
history: [makeStat(10, 50, now), makeStat(30, 50, now), makeStat(20, 50, now)] history: [makeStat(10, 50, now), makeStat(30, 50, now), makeStat(20, 50, now)],
}); });
const agg = resourceMonitor.getAggregatedStats('c1', 24); const agg = resourceMonitor.getAggregatedStats('c1', 24);
expect(agg.cpu.avg).toBe(20); expect(agg.cpu.avg).toBe(20);
@@ -107,7 +107,7 @@ describe('getAggregatedStats', () => {
const now = new Date().toISOString(); const now = new Date().toISOString();
resourceMonitor.stats.set('c1', { resourceMonitor.stats.set('c1', {
name: '/app', name: '/app',
history: [makeStat(10, 40, now), makeStat(10, 60, now), makeStat(10, 80, now)] history: [makeStat(10, 40, now), makeStat(10, 60, now), makeStat(10, 80, now)],
}); });
const agg = resourceMonitor.getAggregatedStats('c1', 24); const agg = resourceMonitor.getAggregatedStats('c1', 24);
expect(agg.memory.avg).toBe(60); expect(agg.memory.avg).toBe(60);
@@ -239,7 +239,7 @@ describe('exportStats / importStats', () => {
test('import restores stats from backup', () => { test('import restores stats from backup', () => {
const backup = { const backup = {
stats: { 'c1': { name: '/app', history: [makeStat()] } }, stats: { 'c1': { name: '/app', history: [makeStat()] } },
alerts: { 'c1': { enabled: true, cpuThreshold: 80 } } alerts: { 'c1': { enabled: true, cpuThreshold: 80 } },
}; };
resourceMonitor.importStats(backup); resourceMonitor.importStats(backup);
expect(resourceMonitor.stats.has('c1')).toBe(true); expect(resourceMonitor.stats.has('c1')).toBe(true);

View File

@@ -150,7 +150,7 @@ describe('Sites Route Security', () => {
.post('/api/site/external') .post('/api/site/external')
.send({ .send({
subdomain: 'test', subdomain: 'test',
externalUrl: 'https://evil.com/path{inject}' externalUrl: 'https://evil.com/path{inject}',
}); });
// Should be rejected — either 400 (our validation) or 500 (URL constructor throws on {}) // Should be rejected — either 400 (our validation) or 500 (URL constructor throws on {})
@@ -164,7 +164,7 @@ describe('Sites Route Security', () => {
.post('/api/site/external') .post('/api/site/external')
.send({ .send({
subdomain: 'test', subdomain: 'test',
externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234' externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234',
}); });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
@@ -183,7 +183,7 @@ describe('Sites Route Security', () => {
.post('/api/site/external') .post('/api/site/external')
.send({ .send({
subdomain: '../etc/passwd', subdomain: '../etc/passwd',
externalUrl: 'https://example.com' externalUrl: 'https://example.com',
}); });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
@@ -205,7 +205,7 @@ describe('Error Logs — No Stack Trace Leak', () => {
'[2026-03-07 12:01:00] dns: DNS timeout', '[2026-03-07 12:01:00] dns: DNS timeout',
'Error: connect ECONNREFUSED 192.168.1.1:5380', 'Error: connect ECONNREFUSED 192.168.1.1:5380',
' at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1234:16)', ' at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1234:16)',
'================================================================================' '================================================================================',
].join('\n'); ].join('\n');
// Write to the server's error log file location // Write to the server's error log file location
// The server uses ctx.ERROR_LOG_FILE — we need to check what that resolves to // The server uses ctx.ERROR_LOG_FILE — we need to check what that resolves to
@@ -334,10 +334,10 @@ describe('Backup Security', () => {
files: { files: {
encryptionKey: { encryptionKey: {
type: 'text', type: 'text',
content: 'malicious-key-data' content: 'malicious-key-data',
} },
} },
} },
}); });
// The encryptionKey should be skipped (not in fileMapping) // The encryptionKey should be skipped (not in fileMapping)
@@ -392,8 +392,8 @@ describe('Custom Volume Path Validation', () => {
port: '32400', port: '32400',
customVolumes: [{ customVolumes: [{
containerPath: '/config', containerPath: '/config',
hostPath: '/etc/shadow' hostPath: '/etc/shadow',
}] }],
}); });
// The deploy will likely fail for other reasons (no Docker, etc.) // The deploy will likely fail for other reasons (no Docker, etc.)
@@ -414,7 +414,7 @@ describe('Logo Delete Path Traversal', () => {
// Write config with a malicious logo path // Write config with a malicious logo path
const configWithMaliciousLogo = { const configWithMaliciousLogo = {
customLogo: '/assets/../../etc/passwd', customLogo: '/assets/../../etc/passwd',
customLogoDark: '/assets/../../../root/.ssh/id_rsa' customLogoDark: '/assets/../../../root/.ssh/id_rsa',
}; };
await fsp.writeFile(testConfigFile, JSON.stringify(configWithMaliciousLogo), 'utf8'); await fsp.writeFile(testConfigFile, JSON.stringify(configWithMaliciousLogo), 'utf8');
@@ -439,7 +439,7 @@ describe('DNS Server SSRF Prevention', () => {
.query({ .query({
domain: 'test.sami', domain: 'test.sami',
type: 'A', type: 'A',
server: '169.254.169.254' // AWS metadata endpoint server: '169.254.169.254', // AWS metadata endpoint
}); });
// Must never succeed — 400 (server rejected), 401 (no token), or 500 (dns not configured in test) // Must never succeed — 400 (server rejected), 401 (no token), or 500 (dns not configured in test)
@@ -452,7 +452,7 @@ describe('DNS Server SSRF Prevention', () => {
.send({ .send({
domain: 'test.sami', domain: 'test.sami',
ipAddress: '192.168.1.1', ipAddress: '192.168.1.1',
server: '10.0.0.1' // Not a configured DNS server server: '10.0.0.1', // Not a configured DNS server
}); });
expect(res.statusCode).not.toBe(200); expect(res.statusCode).not.toBe(200);
@@ -463,7 +463,7 @@ describe('DNS Server SSRF Prevention', () => {
.get('/api/dns/resolve') .get('/api/dns/resolve')
.query({ .query({
domain: 'test.sami', domain: 'test.sami',
server: '127.0.0.1' server: '127.0.0.1',
}); });
expect(res.statusCode).not.toBe(200); expect(res.statusCode).not.toBe(200);
@@ -503,7 +503,7 @@ describe('HTTP Fetch Response Size Limit', () => {
test('server should define MAX_RESPONSE_SIZE constant', () => { test('server should define MAX_RESPONSE_SIZE constant', () => {
// Read server.js and verify the limit is defined // Read server.js and verify the limit is defined
const serverSource = fs.readFileSync( const serverSource = fs.readFileSync(
path.join(__dirname, '..', 'server.js'), 'utf8' path.join(__dirname, '..', 'server.js'), 'utf8',
); );
expect(serverSource).toContain('MAX_RESPONSE_SIZE'); expect(serverSource).toContain('MAX_RESPONSE_SIZE');
expect(serverSource).toContain('10 * 1024 * 1024'); expect(serverSource).toContain('10 * 1024 * 1024');
@@ -516,7 +516,7 @@ describe('HTTP Fetch Response Size Limit', () => {
describe('Middleware Security', () => { describe('Middleware Security', () => {
test('middleware should set Secure flag on cookies', () => { test('middleware should set Secure flag on cookies', () => {
const middlewareSource = fs.readFileSync( const middlewareSource = fs.readFileSync(
path.join(__dirname, '..', 'middleware.js'), 'utf8' path.join(__dirname, '..', 'middleware.js'), 'utf8',
); );
// Verify the Set-Cookie string includes Secure // Verify the Set-Cookie string includes Secure
expect(middlewareSource).toContain('; Secure;'); expect(middlewareSource).toContain('; Secure;');
@@ -529,7 +529,7 @@ describe('Middleware Security', () => {
describe('Config Save Atomicity', () => { describe('Config Save Atomicity', () => {
test('saveConfig should use state manager for locking', () => { test('saveConfig should use state manager for locking', () => {
const serverSource = fs.readFileSync( const serverSource = fs.readFileSync(
path.join(__dirname, '..', 'server.js'), 'utf8' path.join(__dirname, '..', 'server.js'), 'utf8',
); );
// Verify saveConfig uses configStateManager.update (not raw fs.writeFile) // Verify saveConfig uses configStateManager.update (not raw fs.writeFile)
expect(serverSource).toContain('configStateManager.update'); expect(serverSource).toContain('configStateManager.update');
@@ -542,7 +542,7 @@ describe('Config Save Atomicity', () => {
describe('External URL Security', () => { describe('External URL Security', () => {
test('sites.js should validate URL components for unsafe chars', () => { test('sites.js should validate URL components for unsafe chars', () => {
const sitesSource = fs.readFileSync( const sitesSource = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'sites.js'), 'utf8' path.join(__dirname, '..', 'routes', 'sites.js'), 'utf8',
); );
// Verify the unsafe character regex exists // Verify the unsafe character regex exists
expect(sitesSource).toContain('unsafeCaddyChars'); expect(sitesSource).toContain('unsafeCaddyChars');
@@ -556,7 +556,7 @@ describe('External URL Security', () => {
describe('Credential Manager File Locking', () => { describe('Credential Manager File Locking', () => {
test('credential-manager should use proper-lockfile', () => { test('credential-manager should use proper-lockfile', () => {
const cmSource = fs.readFileSync( const cmSource = fs.readFileSync(
path.join(__dirname, '..', 'credential-manager.js'), 'utf8' path.join(__dirname, '..', 'credential-manager.js'), 'utf8',
); );
expect(cmSource).toContain('proper-lockfile'); expect(cmSource).toContain('proper-lockfile');
expect(cmSource).toContain('_lockedUpdate'); expect(cmSource).toContain('_lockedUpdate');
@@ -569,7 +569,7 @@ describe('Credential Manager File Locking', () => {
describe('TOTP Config File Security', () => { describe('TOTP Config File Security', () => {
test('loadTotpConfig should delete secret from file data', () => { test('loadTotpConfig should delete secret from file data', () => {
const serverSource = fs.readFileSync( const serverSource = fs.readFileSync(
path.join(__dirname, '..', 'server.js'), 'utf8' path.join(__dirname, '..', 'server.js'), 'utf8',
); );
// Verify the secret deletion exists in loadTotpConfig // Verify the secret deletion exists in loadTotpConfig
expect(serverSource).toContain('delete loaded.secret'); expect(serverSource).toContain('delete loaded.secret');
@@ -577,7 +577,7 @@ describe('TOTP Config File Security', () => {
test('totp verify-setup should not write secret to config file', () => { test('totp verify-setup should not write secret to config file', () => {
const totpSource = fs.readFileSync( const totpSource = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8' path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8',
); );
// Verify totpConfig.secret assignment is NOT present // Verify totpConfig.secret assignment is NOT present
expect(totpSource).not.toContain('totpConfig.secret = pendingSecret'); expect(totpSource).not.toContain('totpConfig.secret = pendingSecret');
@@ -591,7 +591,7 @@ describe('TOTP Config File Security', () => {
describe('Helpers — Volume Security', () => { describe('Helpers — Volume Security', () => {
test('helpers.js should validate hostPath against allowed roots', () => { test('helpers.js should validate hostPath against allowed roots', () => {
const helpersSource = fs.readFileSync( const helpersSource = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'apps', 'helpers.js'), 'utf8' path.join(__dirname, '..', 'routes', 'apps', 'helpers.js'), 'utf8',
); );
expect(helpersSource).toContain('allowedRoots'); expect(helpersSource).toContain('allowedRoots');
expect(helpersSource).toContain('platformPaths.dockerData'); expect(helpersSource).toContain('platformPaths.dockerData');
@@ -605,7 +605,7 @@ describe('Helpers — Volume Security', () => {
describe('Error Logs — Response Format', () => { describe('Error Logs — Response Format', () => {
test('errorlogs.js should not include details field', () => { test('errorlogs.js should not include details field', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8' path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8',
); );
// The parsed log object should only have timestamp, context, error // The parsed log object should only have timestamp, context, error
// NOT details (which contains stack traces) // NOT details (which contains stack traces)
@@ -622,7 +622,7 @@ describe('Error Logs — Response Format', () => {
describe('Assets — Logo Path Safety', () => { describe('Assets — Logo Path Safety', () => {
test('assets.js should use path.basename for logo filename extraction', () => { test('assets.js should use path.basename for logo filename extraction', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'config', 'assets.js'), 'utf8' path.join(__dirname, '..', 'routes', 'config', 'assets.js'), 'utf8',
); );
expect(source).toContain('path.basename(logoPath)'); expect(source).toContain('path.basename(logoPath)');
// Should NOT use string replace for path extraction // Should NOT use string replace for path extraction
@@ -636,7 +636,7 @@ describe('Assets — Logo Path Safety', () => {
describe('Backup — Encryption Key Exclusion', () => { describe('Backup — Encryption Key Exclusion', () => {
test('backup.js should not include encryptionKey in filesToBackup', () => { test('backup.js should not include encryptionKey in filesToBackup', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8',
); );
// Should have a comment about deliberate exclusion // Should have a comment about deliberate exclusion
expect(source).toContain('encryptionKey deliberately excluded'); expect(source).toContain('encryptionKey deliberately excluded');
@@ -646,7 +646,7 @@ describe('Backup — Encryption Key Exclusion', () => {
test('backup.js restore fileMapping should not include encryptionKey', () => { test('backup.js restore fileMapping should not include encryptionKey', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8',
); );
// The RESTORE route's fileMapping (after "encryptionKey excluded" comment) must not have it // The RESTORE route's fileMapping (after "encryptionKey excluded" comment) must not have it
// The preview route's fileMapping is allowed to have it (informational only) // The preview route's fileMapping is allowed to have it (informational only)
@@ -659,7 +659,7 @@ describe('Backup — Encryption Key Exclusion', () => {
test('backup.js should require TOTP for sensitive restores', () => { test('backup.js should require TOTP for sensitive restores', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8',
); );
expect(source).toContain('sensitiveKeys'); expect(source).toContain('sensitiveKeys');
expect(source).toContain('totpCode'); expect(source).toContain('totpCode');
@@ -673,7 +673,7 @@ describe('Backup — Encryption Key Exclusion', () => {
describe('DNS — Server Validation Function', () => { describe('DNS — Server Validation Function', () => {
test('dns.js should define validateDnsServer', () => { test('dns.js should define validateDnsServer', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8' path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8',
); );
expect(source).toContain('function validateDnsServer'); expect(source).toContain('function validateDnsServer');
expect(source).toContain('configuredIps'); expect(source).toContain('configuredIps');
@@ -687,7 +687,7 @@ describe('DNS — Server Validation Function', () => {
describe('Containers — Verified Container Access', () => { describe('Containers — Verified Container Access', () => {
test('containers.js update route should use getVerifiedContainer', () => { test('containers.js update route should use getVerifiedContainer', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8' path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8',
); );
// update and check-update should both use getVerifiedContainer // update and check-update should both use getVerifiedContainer
const updateSection = source.substring(source.indexOf("'/:id/update'")); const updateSection = source.substring(source.indexOf("'/:id/update'"));
@@ -704,7 +704,7 @@ describe('Containers — Verified Container Access', () => {
describe('Logs — Symlink Resolution', () => { describe('Logs — Symlink Resolution', () => {
test('logs.js should use realpath for symlink resolution', () => { test('logs.js should use realpath for symlink resolution', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8' path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8',
); );
expect(source).toContain('fsp.realpath'); expect(source).toContain('fsp.realpath');
expect(source).toContain('path.sep'); expect(source).toContain('path.sep');
@@ -712,7 +712,7 @@ describe('Logs — Symlink Resolution', () => {
test('logs.js container routes should verify container exists', () => { test('logs.js container routes should verify container exists', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8' path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8',
); );
// Both container/:id and stream/:id should have inspect + NotFoundError // Both container/:id and stream/:id should have inspect + NotFoundError
expect(source).toContain('container.inspect()'); expect(source).toContain('container.inspect()');

View File

@@ -85,7 +85,7 @@ describe('Sites Routes', () => {
.send({ .send({
subdomain: 'INVALID SUBDOMAIN!', subdomain: 'INVALID SUBDOMAIN!',
targetUrl: 'https://example.com', targetUrl: 'https://example.com',
name: 'Test' name: 'Test',
}); });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);

View File

@@ -29,7 +29,7 @@ describe('StateManager', () => {
stateManager = new StateManager(testFile, { stateManager = new StateManager(testFile, {
lockRetries: 20, lockRetries: 20,
lockRetryInterval: 50, lockRetryInterval: 50,
lockTimeout: 15000 lockTimeout: 15000,
}); });
}); });
@@ -53,7 +53,7 @@ describe('StateManager', () => {
test('write and read roundtrip', async () => { test('write and read roundtrip', async () => {
const testData = [ const testData = [
{ id: '1', name: 'Test Service 1' }, { id: '1', name: 'Test Service 1' },
{ id: '2', name: 'Test Service 2' } { id: '2', name: 'Test Service 2' },
]; ];
await stateManager.write(testData); await stateManager.write(testData);
@@ -88,7 +88,7 @@ describe('StateManager', () => {
await stateManager.write([ await stateManager.write([
{ id: '1', name: 'Service 1' }, { id: '1', name: 'Service 1' },
{ id: '2', name: 'Service 2' }, { id: '2', name: 'Service 2' },
{ id: '3', name: 'Service 3' } { id: '3', name: 'Service 3' },
]); ]);
await stateManager.removeItem('2'); await stateManager.removeItem('2');
@@ -100,7 +100,7 @@ describe('StateManager', () => {
test('updateItem updates by ID', async () => { test('updateItem updates by ID', async () => {
await stateManager.write([ await stateManager.write([
{ id: '1', name: 'Service 1', status: 'offline' } { id: '1', name: 'Service 1', status: 'offline' },
]); ]);
await stateManager.updateItem('1', { status: 'online' }); await stateManager.updateItem('1', { status: 'online' });
@@ -130,7 +130,7 @@ describe('StateManager', () => {
stateManager.update(items => { stateManager.update(items => {
items.push({ id: `service-${i}`, name: `Service ${i}` }); items.push({ id: `service-${i}`, name: `Service ${i}` });
return items; return items;
}) }),
); );
} }
@@ -187,7 +187,7 @@ describe('StateManager', () => {
await expect( await expect(
stateManager.update(() => { stateManager.update(() => {
throw new Error('Test error'); throw new Error('Test error');
}) }),
).rejects.toThrow('Test error'); ).rejects.toThrow('Test error');
}); });
}); });
@@ -229,7 +229,7 @@ describe('StateManager', () => {
id: `service-${i}`, id: `service-${i}`,
name: `Service ${i}`, name: `Service ${i}`,
url: `https://service-${i}.example.com`, url: `https://service-${i}.example.com`,
status: 'online' status: 'online',
}); });
} }

View File

@@ -123,7 +123,7 @@ describe('configureAutoUpdate', () => {
updateManager.configureAutoUpdate('c1', { updateManager.configureAutoUpdate('c1', {
enabled: true, enabled: true,
schedule: 'daily', schedule: 'daily',
securityOnly: true securityOnly: true,
}); });
expect(updateManager.config.autoUpdate['c1'].schedule).toBe('daily'); expect(updateManager.config.autoUpdate['c1'].schedule).toBe('daily');
expect(updateManager.config.autoUpdate['c1'].securityOnly).toBe(true); expect(updateManager.config.autoUpdate['c1'].securityOnly).toBe(true);

File diff suppressed because it is too large Load Diff

View File

@@ -111,7 +111,7 @@ class AuditLogger {
action: action || '', action: action || '',
resource: resource || '', resource: resource || '',
details: details || {}, details: details || {},
outcome: outcome || 'unknown' outcome: outcome || 'unknown',
}; };
await this.stateManager.update(entries => { await this.stateManager.update(entries => {

View File

@@ -40,10 +40,10 @@ class AuthManager {
{ {
...payload, ...payload,
iat: Math.floor(Date.now() / 1000), iat: Math.floor(Date.now() / 1000),
scope: payload.scope || ['read', 'write'] scope: payload.scope || ['read', 'write'],
}, },
JWT_SECRET, JWT_SECRET,
{ expiresIn } { expiresIn },
); );
// SECURITY: Log event only, never log the actual token // SECURITY: Log event only, never log the actual token
@@ -67,7 +67,7 @@ class AuthManager {
userId: decoded.sub, userId: decoded.sub,
scope: decoded.scope || [], scope: decoded.scope || [],
iat: decoded.iat, iat: decoded.iat,
exp: decoded.exp exp: decoded.exp,
}; };
} catch (error) { } catch (error) {
if (error.name === 'TokenExpiredError') { if (error.name === 'TokenExpiredError') {
@@ -111,7 +111,7 @@ class AuthManager {
name, name,
scopes, scopes,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsed: null lastUsed: null,
}; };
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
@@ -128,7 +128,7 @@ class AuthManager {
id: keyId, id: keyId,
name, name,
scopes, scopes,
createdAt: metadata.createdAt createdAt: metadata.createdAt,
}; };
} catch (error) { } catch (error) {
console.error('[AuthManager] API key generation failed:', error.message); console.error('[AuthManager] API key generation failed:', error.message);
@@ -179,7 +179,7 @@ class AuthManager {
// Update last used timestamp (non-blocking) // Update last used timestamp (non-blocking)
this.updateLastUsed(keyId, metadata).catch(err => this.updateLastUsed(keyId, metadata).catch(err =>
console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message) console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message),
); );
console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`); console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`);
@@ -187,7 +187,7 @@ class AuthManager {
return { return {
keyId, keyId,
scopes: metadata.scopes || [], scopes: metadata.scopes || [],
name: metadata.name name: metadata.name,
}; };
} catch (error) { } catch (error) {
console.error('[AuthManager] API key verification failed:', error.message); console.error('[AuthManager] API key verification failed:', error.message);
@@ -282,7 +282,7 @@ class AuthManager {
try { try {
const updatedMetadata = { const updatedMetadata = {
...metadata, ...metadata,
lastUsed: new Date().toISOString() lastUsed: new Date().toISOString(),
}; };
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;

View File

@@ -165,7 +165,7 @@ class BackupManager extends EventEmitter {
locations: savedLocations, locations: savedLocations,
encrypted: !!backup.encrypt, encrypted: !!backup.encrypt,
compressed: true, compressed: true,
status: 'success' status: 'success',
}; };
this.addToHistory(historyEntry); this.addToHistory(historyEntry);
@@ -187,7 +187,7 @@ class BackupManager extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
duration, duration,
status: 'failed', status: 'failed',
error: error.message error: error.message,
}; };
this.addToHistory(historyEntry); this.addToHistory(historyEntry);
@@ -205,7 +205,7 @@ class BackupManager extends EventEmitter {
version: '1.0', version: '1.0',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
hostname: require('os').hostname(), hostname: require('os').hostname(),
data: {} data: {},
}; };
for (const source of include) { for (const source of include) {
@@ -332,10 +332,10 @@ class BackupManager extends EventEmitter {
HostConfig: { HostConfig: {
Binds: [ Binds: [
`${volumeName}:/volume:ro`, `${volumeName}:/volume:ro`,
`${backupDir}:/backup` `${backupDir}:/backup`,
], ],
AutoRemove: true AutoRemove: true,
} },
}); });
// Start and wait for completion // Start and wait for completion
@@ -354,7 +354,7 @@ class BackupManager extends EventEmitter {
path: backupFile, path: backupFile,
size: stats.size, size: stats.size,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: 'success' status: 'success',
}); });
} }
} catch (volumeError) { } catch (volumeError) {
@@ -362,7 +362,7 @@ class BackupManager extends EventEmitter {
backupResults.push({ backupResults.push({
name: volume.Name, name: volume.Name,
status: 'failed', status: 'failed',
error: volumeError.message error: volumeError.message,
}); });
} }
} }
@@ -371,7 +371,7 @@ class BackupManager extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
totalVolumes: volumes.length, totalVolumes: volumes.length,
successCount: backupResults.filter(r => r.status === 'success').length, successCount: backupResults.filter(r => r.status === 'success').length,
volumes: backupResults volumes: backupResults,
}; };
} catch (error) { } catch (error) {
console.error('[BackupManager] Error backing up volumes:', error.message); console.error('[BackupManager] Error backing up volumes:', error.message);
@@ -425,10 +425,10 @@ class BackupManager extends EventEmitter {
HostConfig: { HostConfig: {
Binds: [ Binds: [
`${volumeName}:/volume`, `${volumeName}:/volume`,
`${backupDir}:/backup:ro` `${backupDir}:/backup:ro`,
], ],
AutoRemove: true AutoRemove: true,
} },
}); });
await container.start(); await container.start();
@@ -442,7 +442,7 @@ class BackupManager extends EventEmitter {
restoreResults.push({ restoreResults.push({
name: volumeName, name: volumeName,
status: 'success', status: 'success',
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
console.log(`[BackupManager] Volume ${volumeName} restored successfully`); console.log(`[BackupManager] Volume ${volumeName} restored successfully`);
@@ -451,7 +451,7 @@ class BackupManager extends EventEmitter {
restoreResults.push({ restoreResults.push({
name: volBackup.name, name: volBackup.name,
status: 'failed', status: 'failed',
error: restoreError.message error: restoreError.message,
}); });
} }
} }
@@ -460,7 +460,7 @@ class BackupManager extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
results: restoreResults, results: restoreResults,
successCount: restoreResults.filter(r => r.status === 'success').length, successCount: restoreResults.filter(r => r.status === 'success').length,
failedCount: restoreResults.filter(r => r.status === 'failed').length failedCount: restoreResults.filter(r => r.status === 'failed').length,
}; };
} }
@@ -498,7 +498,7 @@ class BackupManager extends EventEmitter {
// Return: iv:authTag:encrypted (all base64) // Return: iv:authTag:encrypted (all base64)
return Buffer.from( return Buffer.from(
iv.toString('base64') + ':' + authTag.toString('base64') + ':' + encrypted.toString('base64') `${iv.toString('base64') }:${ authTag.toString('base64') }:${ encrypted.toString('base64')}`,
); );
} }
@@ -566,7 +566,7 @@ class BackupManager extends EventEmitter {
return { return {
type: 'local', type: 'local',
path: filepath, path: filepath,
size: data.length size: data.length,
}; };
} }
@@ -652,7 +652,7 @@ class BackupManager extends EventEmitter {
this.emit('restore-complete', { this.emit('restore-complete', {
backupId, backupId,
restored, restored,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
console.log('[BackupManager] Restore completed successfully'); console.log('[BackupManager] Restore completed successfully');
@@ -661,7 +661,7 @@ class BackupManager extends EventEmitter {
this.emit('restore-failed', { this.emit('restore-failed', {
backupId, backupId,
error: error.message, error: error.message,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
throw error; throw error;
} }
@@ -790,7 +790,7 @@ class BackupManager extends EventEmitter {
return { return {
backups: {}, backups: {},
defaultRetention: { keep: 7 } defaultRetention: { keep: 7 },
}; };
} }

View File

@@ -13,7 +13,7 @@ const CACHE_CONFIGS = {
max: 500, // Max 500 different services max: 500, // Max 500 different services
ttl: 60 * 60 * 1000, // 1 hour TTL ttl: 60 * 60 * 1000, // 1 hour TTL
updateAgeOnGet: true, // Refresh TTL on access updateAgeOnGet: true, // Refresh TTL on access
ttlAutopurge: true // Auto-cleanup expired entries ttlAutopurge: true, // Auto-cleanup expired entries
}, },
// IP-based router sessions (Frontier NVG468MQ) // IP-based router sessions (Frontier NVG468MQ)
@@ -21,7 +21,7 @@ const CACHE_CONFIGS = {
max: 1000, // Support up to 1000 IP addresses max: 1000, // Support up to 1000 IP addresses
ttl: 24 * 60 * 60 * 1000, // 24 hour TTL ttl: 24 * 60 * 60 * 1000, // 24 hour TTL
updateAgeOnGet: true, updateAgeOnGet: true,
ttlAutopurge: true ttlAutopurge: true,
}, },
// DNS server authentication tokens (Technitium) // DNS server authentication tokens (Technitium)
@@ -29,7 +29,7 @@ const CACHE_CONFIGS = {
max: 50, // Max 50 DNS servers max: 50, // Max 50 DNS servers
ttl: 6 * 60 * 60 * 1000, // 6 hour TTL (matches SESSION_TTL.DNS_TOKEN) ttl: 6 * 60 * 60 * 1000, // 6 hour TTL (matches SESSION_TTL.DNS_TOKEN)
updateAgeOnGet: false, // Don't refresh - tokens have fixed expiry updateAgeOnGet: false, // Don't refresh - tokens have fixed expiry
ttlAutopurge: true ttlAutopurge: true,
}, },
// Tailscale network status // Tailscale network status
@@ -37,7 +37,7 @@ const CACHE_CONFIGS = {
max: 1, // Only one status object max: 1, // Only one status object
ttl: 60 * 1000, // 1 minute TTL ttl: 60 * 1000, // 1 minute TTL
updateAgeOnGet: false, updateAgeOnGet: false,
ttlAutopurge: true ttlAutopurge: true,
}, },
// Tailscale API responses (devices, ACLs) // Tailscale API responses (devices, ACLs)
@@ -45,8 +45,8 @@ const CACHE_CONFIGS = {
max: 5, // devices + ACL + misc max: 5, // devices + ACL + misc
ttl: 5 * 60 * 1000, // 5 min (matches sync interval) ttl: 5 * 60 * 1000, // 5 min (matches sync interval)
updateAgeOnGet: false, updateAgeOnGet: false,
ttlAutopurge: true ttlAutopurge: true,
} },
}; };
/** /**

View File

@@ -17,15 +17,15 @@ const colors = {
yellow: '\x1b[33m', yellow: '\x1b[33m',
blue: '\x1b[34m', blue: '\x1b[34m',
cyan: '\x1b[36m', cyan: '\x1b[36m',
magenta: '\x1b[35m' magenta: '\x1b[35m',
}; };
let testResults = { const testResults = {
passed: 0, passed: 0,
failed: 0, failed: 0,
warnings: 0, warnings: 0,
total: 0, total: 0,
details: [] details: [],
}; };
function log(message, color = 'reset') { function log(message, color = 'reset') {
@@ -62,7 +62,7 @@ async function makeRequest(path, options = {}) {
path: url.pathname + url.search, path: url.pathname + url.search,
method: options.method || 'GET', method: options.method || 'GET',
headers: options.headers || {}, headers: options.headers || {},
timeout: options.timeout || 10000 timeout: options.timeout || 10000,
}; };
const req = http.request(requestOptions, (res) => { const req = http.request(requestOptions, (res) => {
@@ -74,7 +74,7 @@ async function makeRequest(path, options = {}) {
headers: res.headers, headers: res.headers,
body: data, body: data,
data: data && (data.startsWith('{') || data.startsWith('[')) ? data: data && (data.startsWith('{') || data.startsWith('[')) ?
(() => { try { return JSON.parse(data); } catch(e) { return null; } })() : data (() => { try { return JSON.parse(data); } catch(e) { return null; } })() : data,
}); });
}); });
}); });
@@ -143,7 +143,7 @@ async function testCSRFProtection() {
const response = await makeRequest('/api/test-endpoint', { const response = await makeRequest('/api/test-endpoint', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: { test: 'data' } body: { test: 'data' },
}); });
if (response.data?.error?.includes('CSRF') || response.data?.message?.includes('CSRF')) { if (response.data?.error?.includes('CSRF') || response.data?.message?.includes('CSRF')) {
@@ -183,7 +183,7 @@ async function testRequestSizeLimits() {
const response = await makeRequest('/api/services', { const response = await makeRequest('/api/services', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(smallPayload) body: JSON.stringify(smallPayload),
}); });
if (response.statusCode !== 413) { if (response.statusCode !== 413) {
@@ -465,7 +465,7 @@ async function runAllTests() {
.forEach(t => log(`${t.name}: ${t.message}`, 'yellow')); .forEach(t => log(`${t.name}: ${t.message}`, 'yellow'));
} }
log('\n' + '═'.repeat(60), 'cyan'); log(`\n${ '═'.repeat(60)}`, 'cyan');
if (testResults.failed === 0) { if (testResults.failed === 0) {
log('\n✅ ALL AUTOMATED TESTS PASSED!', 'green'); log('\n✅ ALL AUTOMATED TESTS PASSED!', 'green');

View File

@@ -6,7 +6,7 @@
const VALID_TIMEZONES_SAMPLE = [ const VALID_TIMEZONES_SAMPLE = [
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai',
'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland' 'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland',
]; ];
/** /**
@@ -27,7 +27,7 @@ function validateConfig(config) {
if (typeof config.tld !== 'string') { if (typeof config.tld !== 'string') {
errors.push('tld must be a string'); errors.push('tld must be a string');
} else { } else {
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld; const tld = config.tld.startsWith('.') ? config.tld : `.${ config.tld}`;
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) { if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`); errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
} }
@@ -117,7 +117,7 @@ function validateConfig(config) {
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted', 'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
'configurationType', 'defaults', 'customLogo', 'customFavicon', 'configurationType', 'defaults', 'customLogo', 'customFavicon',
'dashboardTitle', 'tailscale', 'license', 'skipped', 'dashboardTitle', 'tailscale', 'license', 'skipped',
'routingMode', 'domain', 'email', 'defaultIP' 'routingMode', 'domain', 'email', 'defaultIP',
]; ];
for (const key of Object.keys(config)) { for (const key of Object.keys(config)) {
if (!knownKeys.includes(key)) { if (!knownKeys.includes(key)) {

View File

@@ -105,7 +105,7 @@ const DOCKER = {
TIMEOUT: 30000, // 30s — timeout for docker pull/create operations TIMEOUT: 30000, // 30s — timeout for docker pull/create operations
LOG_CONFIG: { LOG_CONFIG: {
Type: 'json-file', Type: 'json-file',
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container Config: { 'max-size': '10m', 'max-file': '3' }, // 30MB max per container
}, },
MAINTENANCE: { MAINTENANCE: {
INTERVAL: 24 * 60 * 60 * 1000, // 24 hours INTERVAL: 24 * 60 * 60 * 1000, // 24 hours

View File

@@ -19,7 +19,7 @@ class CredentialManager {
this.CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes this.CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
this.lockOptions = { this.lockOptions = {
retries: { retries: 10, minTimeout: 100, maxTimeout: 300 }, retries: { retries: 10, minTimeout: 100, maxTimeout: 300 },
stale: 30000 stale: 30000,
}; };
console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`); console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`);
@@ -185,7 +185,7 @@ class CredentialManager {
const value = credentials[key].value; const value = credentials[key].value;
decryptedEntries[key] = { decryptedEntries[key] = {
plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value, plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value,
metadata: credentials[key].metadata metadata: credentials[key].metadata,
}; };
} }
@@ -198,7 +198,7 @@ class CredentialManager {
rotated[key] = { rotated[key] = {
value: cryptoUtils.encrypt(decryptedEntries[key].plaintext), value: cryptoUtils.encrypt(decryptedEntries[key].plaintext),
metadata: decryptedEntries[key].metadata, metadata: decryptedEntries[key].metadata,
rotatedAt: new Date().toISOString() rotatedAt: new Date().toISOString(),
}; };
} }
@@ -303,7 +303,7 @@ class CredentialManager {
credentials[key] = { credentials[key] = {
value: cryptoUtils.encrypt(value), value: cryptoUtils.encrypt(value),
metadata, metadata,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString(),
}; };
return credentials; return credentials;
}); });
@@ -360,7 +360,7 @@ class CredentialManager {
const backup = { const backup = {
version: '1.0', version: '1.0',
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
credentials credentials,
}; };
return cryptoUtils.encrypt(JSON.stringify(backup)); return cryptoUtils.encrypt(JSON.stringify(backup));
} }

View File

@@ -336,5 +336,5 @@ module.exports = {
deriveKey, deriveKey,
rotateKey, rotateKey,
decryptWithKey, decryptWithKey,
clearCachedKey clearCachedKey,
}; };

View File

@@ -68,7 +68,7 @@ function csrfCookieMiddleware(req, res, next) {
secure: req.secure || req.protocol === 'https', // Only secure in HTTPS secure: req.secure || req.protocol === 'https', // Only secure in HTTPS
sameSite: 'strict', sameSite: 'strict',
path: '/', path: '/',
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
}); });
next(); next();
@@ -96,7 +96,7 @@ function csrfValidationMiddleware(req, res, next) {
'/api/totp/verify', '/api/totp/verify',
'/api/totp/verify-setup', '/api/totp/verify-setup',
'/health', '/health',
'/api/health' '/api/health',
]; ];
// Check if path starts with excluded prefix // Check if path starts with excluded prefix
@@ -126,7 +126,7 @@ function csrfValidationMiddleware(req, res, next) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: '[DC-100] CSRF token missing', error: '[DC-100] CSRF token missing',
message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.' message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.',
}); });
} }
@@ -135,7 +135,7 @@ function csrfValidationMiddleware(req, res, next) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: '[DC-100] CSRF token missing', error: '[DC-100] CSRF token missing',
message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.' message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.',
}); });
} }
@@ -161,7 +161,7 @@ function csrfValidationMiddleware(req, res, next) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: '[DC-101] CSRF token invalid', error: '[DC-101] CSRF token invalid',
message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.' message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.',
}); });
} }
} }
@@ -174,5 +174,5 @@ module.exports = {
signToken, signToken,
parseCookie, parseCookie,
csrfCookieMiddleware, csrfCookieMiddleware,
csrfValidationMiddleware csrfValidationMiddleware,
}; };

View File

@@ -55,7 +55,7 @@ class DockerMaintenance extends EventEmitter {
spaceReclaimed: { images: 0, containers: 0, buildCache: 0, total: 0 }, spaceReclaimed: { images: 0, containers: 0, buildCache: 0, total: 0 },
diskUsage: null, diskUsage: null,
warnings: [], warnings: [],
containersWithoutLogLimits: [] containersWithoutLogLimits: [],
}; };
try { try {
@@ -72,7 +72,7 @@ class DockerMaintenance extends EventEmitter {
try { try {
const stopped = await docker.listContainers({ const stopped = await docker.listContainers({
all: true, all: true,
filters: { status: ['exited', 'dead'] } filters: { status: ['exited', 'dead'] },
}); });
for (const c of stopped) { for (const c of stopped) {
// Skip DashCaddy-managed containers — user may want to restart them // Skip DashCaddy-managed containers — user may want to restart them
@@ -108,20 +108,20 @@ class DockerMaintenance extends EventEmitter {
result.diskUsage = { result.diskUsage = {
images: { images: {
count: (df.Images || []).length, count: (df.Images || []).length,
sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0) sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0),
}, },
containers: { containers: {
count: (df.Containers || []).length, count: (df.Containers || []).length,
sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0) sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0),
}, },
volumes: { volumes: {
count: (df.Volumes?.Volumes || []).length, count: (df.Volumes?.Volumes || []).length,
sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0) sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0),
}, },
buildCache: { buildCache: {
count: (df.BuildCache || []).length, count: (df.BuildCache || []).length,
sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0) sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0),
} },
}; };
result.diskUsage.totalBytes = result.diskUsage.totalBytes =
result.diskUsage.images.sizeBytes + result.diskUsage.images.sizeBytes +
@@ -149,7 +149,7 @@ class DockerMaintenance extends EventEmitter {
if (!logConfig?.Config?.['max-size']) { if (!logConfig?.Config?.['max-size']) {
result.containersWithoutLogLimits.push({ result.containersWithoutLogLimits.push({
name: c.Names[0]?.replace(/^\//, '') || c.Id.slice(0, 12), name: c.Names[0]?.replace(/^\//, '') || c.Id.slice(0, 12),
id: c.Id.slice(0, 12) id: c.Id.slice(0, 12),
}); });
} }
} catch (e) { } catch (e) {
@@ -158,7 +158,7 @@ class DockerMaintenance extends EventEmitter {
} }
if (result.containersWithoutLogLimits.length > 0) { if (result.containersWithoutLogLimits.length > 0) {
result.warnings.push( result.warnings.push(
`${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}` `${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}`,
); );
} }
} catch (e) { } catch (e) {
@@ -204,7 +204,7 @@ class DockerMaintenance extends EventEmitter {
return { return {
running: this.running, running: this.running,
lastRun: this.lastRun, lastRun: this.lastRun,
lastResult: this.lastResult lastResult: this.lastResult,
}; };
} }
} }

View File

@@ -39,7 +39,7 @@ class DockerSecurity {
trustedDigests: {}, trustedDigests: {},
verificationMode: VERIFICATION_MODE, verificationMode: VERIFICATION_MODE,
allowUnverified: true, allowUnverified: true,
updateTrustedOnPull: true updateTrustedOnPull: true,
}; };
} }
@@ -124,7 +124,7 @@ class DockerSecurity {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json', 'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
} },
}; };
if (token) { if (token) {
@@ -198,7 +198,7 @@ class DockerSecurity {
imageName, imageName,
actualDigest, actualDigest,
trustedDigest: trustedDigest || null, trustedDigest: trustedDigest || null,
action: 'unknown' action: 'unknown',
}; };
if (!trustedDigest) { if (!trustedDigest) {
@@ -280,7 +280,7 @@ class DockerSecurity {
imageName, imageName,
action: this.mode === 'permissive' ? 'accept' : 'warn', action: this.mode === 'permissive' ? 'accept' : 'warn',
error: error.message, error: error.message,
reason: `Verification error (${this.mode} mode)` reason: `Verification error (${this.mode} mode)`,
}; };
} }
} }
@@ -335,7 +335,7 @@ class DockerSecurity {
mode: this.mode, mode: this.mode,
trustedImagesCount: Object.keys(this.config.trustedDigests).length, trustedImagesCount: Object.keys(this.config.trustedDigests).length,
configFile: SECURITY_CONFIG_FILE, configFile: SECURITY_CONFIG_FILE,
updateTrustedOnPull: this.config.updateTrustedOnPull updateTrustedOnPull: this.config.updateTrustedOnPull,
}; };
} }
} }

View File

@@ -111,7 +111,7 @@ class HealthChecker extends EventEmitter {
responseTime, responseTime,
statusCode: result.statusCode, statusCode: result.statusCode,
message: result.message, message: result.message,
details: result.details details: result.details,
}; };
// Track consecutive failures for exponential backoff // Track consecutive failures for exponential backoff
@@ -136,7 +136,7 @@ class HealthChecker extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: 'down', status: 'down',
responseTime, responseTime,
error: error.message error: error.message,
}; };
this.recordStatus(serviceId, status); this.recordStatus(serviceId, status);
@@ -170,7 +170,7 @@ class HealthChecker extends EventEmitter {
method, method,
timeout: config.timeout || 20000, timeout: config.timeout || 20000,
headers: config.headers || {}, headers: config.headers || {},
rejectUnauthorized: false // Trust internal CA certs (.sami TLD) rejectUnauthorized: false, // Trust internal CA certs (.sami TLD)
}; };
const req = protocol.request(options, (res) => { const req = protocol.request(options, (res) => {
@@ -189,8 +189,8 @@ class HealthChecker extends EventEmitter {
message: healthy ? 'Service is healthy' : 'Service check failed', message: healthy ? 'Service is healthy' : 'Service check failed',
details: { details: {
headers: res.headers, headers: res.headers,
bodyLength: data.length bodyLength: data.length,
} },
}); });
}); });
}); });
@@ -306,7 +306,7 @@ class HealthChecker extends EventEmitter {
const existing = this.incidents.find(i => const existing = this.incidents.find(i =>
i.serviceId === serviceId && i.serviceId === serviceId &&
i.type === type && i.type === type &&
i.status === 'open' i.status === 'open',
); );
if (existing) { if (existing) {
@@ -327,7 +327,7 @@ class HealthChecker extends EventEmitter {
createdAt: status.timestamp, createdAt: status.timestamp,
lastOccurrence: status.timestamp, lastOccurrence: status.timestamp,
occurrences: 1, occurrences: 1,
details: status details: status,
}; };
this.incidents.push(incident); this.incidents.push(incident);
@@ -343,7 +343,7 @@ class HealthChecker extends EventEmitter {
const incident = this.incidents.find(i => const incident = this.incidents.find(i =>
i.serviceId === serviceId && i.serviceId === serviceId &&
i.type === type && i.type === type &&
i.status === 'open' i.status === 'open',
); );
if (incident) { if (incident) {
@@ -402,7 +402,7 @@ class HealthChecker extends EventEmitter {
const history = this.history[serviceId] || []; const history = this.history[serviceId] || [];
return history.filter(h => return history.filter(h =>
new Date(h.timestamp).getTime() > cutoffTime new Date(h.timestamp).getTime() > cutoffTime,
); );
} }
@@ -423,10 +423,10 @@ class HealthChecker extends EventEmitter {
name: config?.name || serviceId, name: config?.name || serviceId,
uptime: { uptime: {
'24h': uptime24h, '24h': uptime24h,
'7d': uptime7d '7d': uptime7d,
}, },
avgResponseTime, avgResponseTime,
sla: config?.sla sla: config?.sla,
}; };
} }
@@ -456,8 +456,8 @@ class HealthChecker extends EventEmitter {
min: Math.min(...responseTimes), min: Math.min(...responseTimes),
max: Math.max(...responseTimes), max: Math.max(...responseTimes),
p95: this.calculatePercentile(responseTimes, 95), p95: this.calculatePercentile(responseTimes, 95),
p99: this.calculatePercentile(responseTimes, 99) p99: this.calculatePercentile(responseTimes, 99),
} },
}; };
} }
@@ -504,7 +504,7 @@ class HealthChecker extends EventEmitter {
slowResponseThreshold: config.slowResponseThreshold || 5000, slowResponseThreshold: config.slowResponseThreshold || 5000,
sla: config.sla, sla: config.sla,
headers: config.headers || {}, headers: config.headers || {},
body: config.body body: config.body,
}; };
this.saveConfig(); this.saveConfig();
@@ -531,7 +531,7 @@ class HealthChecker extends EventEmitter {
for (const serviceId in this.history) { for (const serviceId in this.history) {
this.history[serviceId] = this.history[serviceId].filter(h => this.history[serviceId] = this.history[serviceId].filter(h =>
new Date(h.timestamp).getTime() > cutoffTime new Date(h.timestamp).getTime() > cutoffTime,
); );
} }
} }

View File

@@ -30,7 +30,7 @@ function validateDNSRecord(data) {
if (!subdomainRegex.test(data.subdomain)) { if (!subdomainRegex.test(data.subdomain)) {
errors.push({ errors.push({
field: 'subdomain', field: 'subdomain',
message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)' message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)',
}); });
} }
@@ -80,7 +80,7 @@ function validateDNSRecord(data) {
subdomain: data.subdomain.toLowerCase().trim(), subdomain: data.subdomain.toLowerCase().trim(),
domain: data.domain ? data.domain.toLowerCase().trim() : null, domain: data.domain ? data.domain.toLowerCase().trim() : null,
ip: data.ip.trim(), ip: data.ip.trim(),
ttl: data.ttl ? parseInt(data.ttl, 10) : 3600 ttl: data.ttl ? parseInt(data.ttl, 10) : 3600,
}; };
} }
@@ -99,7 +99,7 @@ function validateDockerDeployment(data) {
if (!nameRegex.test(data.name)) { if (!nameRegex.test(data.name)) {
errors.push({ errors.push({
field: 'name', field: 'name',
message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens' message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens',
}); });
} }
@@ -119,7 +119,7 @@ function validateDockerDeployment(data) {
if (!imageRegex.test(data.image)) { if (!imageRegex.test(data.image)) {
errors.push({ errors.push({
field: 'image', field: 'image',
message: 'Invalid Docker image format' message: 'Invalid Docker image format',
}); });
} }
@@ -146,7 +146,7 @@ function validateDockerDeployment(data) {
if (!portRegex.test(port)) { if (!portRegex.test(port)) {
errors.push({ errors.push({
field: `ports[${index}]`, field: `ports[${index}]`,
message: 'Invalid port format. Use "host:container" or "host:container/protocol"' message: 'Invalid port format. Use "host:container" or "host:container/protocol"',
}); });
} else { } else {
const [, hostPort, containerPort] = port.match(portRegex); const [, hostPort, containerPort] = port.match(portRegex);
@@ -193,7 +193,7 @@ function validateDockerDeployment(data) {
if (!envKeyRegex.test(key)) { if (!envKeyRegex.test(key)) {
errors.push({ errors.push({
field: `environment.${key}`, field: `environment.${key}`,
message: 'Invalid environment variable name' message: 'Invalid environment variable name',
}); });
} }
@@ -201,7 +201,7 @@ function validateDockerDeployment(data) {
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
errors.push({ errors.push({
field: `environment.${key}`, field: `environment.${key}`,
message: 'Environment variable value must be string, number, or boolean' message: 'Environment variable value must be string, number, or boolean',
}); });
} }
}); });
@@ -219,7 +219,7 @@ function validateDockerDeployment(data) {
image: data.image.trim(), image: data.image.trim(),
ports: data.ports || [], ports: data.ports || [],
volumes: data.volumes || [], volumes: data.volumes || [],
environment: data.environment || {} environment: data.environment || {},
}; };
} }
@@ -248,7 +248,7 @@ function validateFilePath(filePath, allowedBasePaths = []) {
'C:\\Windows', 'C:\\Windows',
'C:\\Program Files', 'C:\\Program Files',
'/var/run', '/var/run',
'/var/lib/docker' '/var/lib/docker',
]; ];
const lowerPath = normalized.toLowerCase(); const lowerPath = normalized.toLowerCase();
@@ -284,7 +284,7 @@ function validateVolumePath(volume, index) {
if (!match) { if (!match) {
errors.push({ errors.push({
field: `volumes[${index}]`, field: `volumes[${index}]`,
message: 'Invalid volume format. Use "host:container" or "host:container:mode"' message: 'Invalid volume format. Use "host:container" or "host:container:mode"',
}); });
return errors; return errors;
} }
@@ -297,7 +297,7 @@ function validateVolumePath(volume, index) {
} catch (error) { } catch (error) {
errors.push({ errors.push({
field: `volumes[${index}].hostPath`, field: `volumes[${index}].hostPath`,
message: `Invalid host path: ${error.message}` message: `Invalid host path: ${error.message}`,
}); });
} }
@@ -305,7 +305,7 @@ function validateVolumePath(volume, index) {
if (containerPath.includes('..') || !path.isAbsolute(containerPath)) { if (containerPath.includes('..') || !path.isAbsolute(containerPath)) {
errors.push({ errors.push({
field: `volumes[${index}].containerPath`, field: `volumes[${index}].containerPath`,
message: 'Container path must be absolute and not contain ..' message: 'Container path must be absolute and not contain ..',
}); });
} }
@@ -313,7 +313,7 @@ function validateVolumePath(volume, index) {
if (mode && !['ro', 'rw', 'z', 'Z'].includes(mode)) { if (mode && !['ro', 'rw', 'z', 'Z'].includes(mode)) {
errors.push({ errors.push({
field: `volumes[${index}].mode`, field: `volumes[${index}].mode`,
message: 'Invalid volume mode. Use ro, rw, z, or Z' message: 'Invalid volume mode. Use ro, rw, z, or Z',
}); });
} }
@@ -333,7 +333,7 @@ function validateURL(url, options = {}) {
require_protocol: options.requireProtocol !== false, require_protocol: options.requireProtocol !== false,
require_valid_protocol: true, require_valid_protocol: true,
allow_underscores: false, allow_underscores: false,
...options ...options,
}; };
if (!validator.isURL(url, validatorOptions)) { if (!validator.isURL(url, validatorOptions)) {
@@ -451,7 +451,7 @@ function isPrivateIP(ip) {
/^169\.254\./, /^169\.254\./,
/^::1$/, /^::1$/,
/^fc00:/, /^fc00:/,
/^fe80:/ /^fe80:/,
]; ];
return privateRanges.some(range => range.test(ip)); return privateRanges.some(range => range.test(ip));
@@ -496,7 +496,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul
auditLogger.logSecurityEvent('path_traversal_blocked', { auditLogger.logSecurityEvent('path_traversal_blocked', {
requestedPath, requestedPath,
reason: 'null_byte_detected', reason: 'null_byte_detected',
severity: 'high' severity: 'high',
}); });
} }
throw new ValidationError('Invalid path - null byte detected', 'path'); throw new ValidationError('Invalid path - null byte detected', 'path');
@@ -510,7 +510,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul
/\.\%2f/i, // .%2F (encoded ./) /\.\%2f/i, // .%2F (encoded ./)
/%2e\./i, // %2E. /%2e\./i, // %2E.
/\.\\/, // .\ (Windows) /\.\\/, // .\ (Windows)
/%5c/i // URL encoded backslash /%5c/i, // URL encoded backslash
]; ];
if (suspiciousPatterns.some(pattern => pattern.test(requestedPath)) || if (suspiciousPatterns.some(pattern => pattern.test(requestedPath)) ||
@@ -520,7 +520,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul
requestedPath, requestedPath,
decodedPath, decodedPath,
reason: 'traversal_sequence_detected', reason: 'traversal_sequence_detected',
severity: 'high' severity: 'high',
}); });
} }
throw new ValidationError('Path traversal detected', 'path'); throw new ValidationError('Path traversal detected', 'path');
@@ -581,7 +581,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul
realPath, realPath,
allowedRoots, allowedRoots,
reason: 'outside_allowed_roots', reason: 'outside_allowed_roots',
severity: 'critical' severity: 'critical',
}); });
} }
throw new ValidationError('Access denied - path is outside allowed directories', 'path'); throw new ValidationError('Access denied - path is outside allowed directories', 'path');
@@ -602,5 +602,5 @@ module.exports = {
sanitizeString, sanitizeString,
isValidPort, isValidPort,
isPrivateIP, isPrivateIP,
validateSecurePath validateSecurePath,
}; };

View File

@@ -11,17 +11,17 @@ module.exports = {
'update-manager.js', 'update-manager.js',
'resource-monitor.js', 'resource-monitor.js',
'credential-manager.js', 'credential-manager.js',
'app-templates.js' 'app-templates.js',
], ],
coverageThreshold: { coverageThreshold: {
global: { global: {
branches: 80, branches: 80,
functions: 80, functions: 80,
lines: 80, lines: 80,
statements: 80 statements: 80,
} },
}, },
setupFilesAfterEnv: ['<rootDir>/__tests__/jest.setup.js'], setupFilesAfterEnv: ['<rootDir>/__tests__/jest.setup.js'],
restoreMocks: true, restoreMocks: true,
clearMocks: true clearMocks: true,
}; };

View File

@@ -182,7 +182,7 @@ class KeychainManager {
try { try {
execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], { execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], {
input: value, input: value,
stdio: ['pipe', 'ignore', 'ignore'] stdio: ['pipe', 'ignore', 'ignore'],
}); });
return true; return true;
} catch { } catch {

View File

@@ -177,7 +177,7 @@ function verifyCode(secret, code) {
codeId, codeId,
createdAt: createdDate.toISOString(), createdAt: createdDate.toISOString(),
expiresAt: isLifetime ? null : expiresDate.toISOString(), expiresAt: isLifetime ? null : expiresDate.toISOString(),
expired: isLifetime ? false : Date.now() > expiresDate.getTime() expired: isLifetime ? false : Date.now() > expiresDate.getTime(),
}; };
} catch (error) { } catch (error) {
return { valid: false, reason: error.message }; return { valid: false, reason: error.message };
@@ -230,7 +230,7 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
const isLifetime = result.durationDays === 0; const isLifetime = result.durationDays === 0;
console.log('Code is VALID'); console.log('Code is VALID');
console.log(` Version: ${result.version}`); console.log(` Version: ${result.version}`);
console.log(` Duration: ${isLifetime ? 'LIFETIME' : result.durationDays + ' days'}`); console.log(` Duration: ${isLifetime ? 'LIFETIME' : `${result.durationDays } days`}`);
console.log(` Code ID: ${result.codeId}`); console.log(` Code ID: ${result.codeId}`);
console.log(` Created: ${result.createdAt}`); console.log(` Created: ${result.createdAt}`);
console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`); console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`);
@@ -293,16 +293,16 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
console.log(output); console.log(output);
} }
} else { } else {
const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : c.durationDays + ' days'}, ID: ${c.codeId})`); const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : `${c.durationDays } days`}, ID: ${c.codeId})`);
if (outputIndex !== -1) { if (outputIndex !== -1) {
fs.writeFileSync(args[outputIndex + 1], codes.map(c => c.code).join('\n') + '\n'); fs.writeFileSync(args[outputIndex + 1], `${codes.map(c => c.code).join('\n') }\n`);
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`); console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
} else { } else {
lines.forEach(l => console.log(l)); lines.forEach(l => console.log(l));
} }
} }
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`); console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : `${duration } days`}. Next ID: ${startId + count}`);
} }
// Also export for use by license-manager.js // Also export for use by license-manager.js

View File

@@ -23,7 +23,7 @@ const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when l
const PREMIUM_FEATURES = { const PREMIUM_FEATURES = {
sso: { name: 'Auto-Login SSO', description: 'Automatic single sign-on for deployed apps' }, sso: { name: 'Auto-Login SSO', description: 'Automatic single sign-on for deployed apps' },
recipes: { name: 'Recipes', description: 'Multi-container stack deployment' }, recipes: { name: 'Recipes', description: 'Multi-container stack deployment' },
swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' } swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' },
}; };
class LicenseManager { class LicenseManager {
@@ -48,13 +48,13 @@ class LicenseManager {
if (this.isExpired()) { if (this.isExpired()) {
this.log.info?.('license', 'License has expired', { this.log.info?.('license', 'License has expired', {
code: this._maskCode(this.activation.code), code: this._maskCode(this.activation.code),
expiredAt: this.activation.expiresAt expiredAt: this.activation.expiresAt,
}); });
} else { } else {
this.log.info?.('license', 'License loaded', { this.log.info?.('license', 'License loaded', {
code: this._maskCode(this.activation.code), code: this._maskCode(this.activation.code),
expiresAt: this.activation.expiresAt, expiresAt: this.activation.expiresAt,
daysRemaining: this.daysRemaining() daysRemaining: this.daysRemaining(),
}); });
} }
} else { } else {
@@ -96,7 +96,7 @@ class LicenseManager {
os.hostname(), os.hostname(),
os.platform(), os.platform(),
os.arch(), os.arch(),
os.cpus()[0]?.model || 'unknown' os.cpus()[0]?.model || 'unknown',
]; ];
// Get primary MAC address // Get primary MAC address
const interfaces = os.networkInterfaces(); const interfaces = os.networkInterfaces();
@@ -132,7 +132,7 @@ class LicenseManager {
return { return {
success: true, success: true,
message: 'This code is already activated', message: 'This code is already activated',
activation: this.getStatus() activation: this.getStatus(),
}; };
} }
@@ -170,7 +170,7 @@ class LicenseManager {
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
machineId, machineId,
validationMethod: 'offline', validationMethod: 'offline',
features: Object.keys(PREMIUM_FEATURES) features: Object.keys(PREMIUM_FEATURES),
}; };
} else { } else {
// Online validation succeeded — use server response // Online validation succeeded — use server response
@@ -182,7 +182,7 @@ class LicenseManager {
try { try {
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), { await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), {
activatedAt: this.activation.activatedAt, activatedAt: this.activation.activatedAt,
expiresAt: this.activation.expiresAt expiresAt: this.activation.expiresAt,
}); });
} catch (error) { } catch (error) {
this.log.error?.('license', 'Failed to store activation', { error: error.message }); this.log.error?.('license', 'Failed to store activation', { error: error.message });
@@ -196,14 +196,14 @@ class LicenseManager {
code: this._maskCode(code), code: this._maskCode(code),
durationDays: this.activation.durationDays, durationDays: this.activation.durationDays,
expiresAt: this.activation.expiresAt, expiresAt: this.activation.expiresAt,
method: this.activation.validationMethod method: this.activation.validationMethod,
}); });
const durationLabel = this.activation.lifetime ? 'lifetime' : `${this.activation.durationDays} days`; const durationLabel = this.activation.lifetime ? 'lifetime' : `${this.activation.durationDays} days`;
return { return {
success: true, success: true,
message: `License activated for ${durationLabel}`, message: `License activated for ${durationLabel}`,
activation: this.getStatus() activation: this.getStatus(),
}; };
} }
@@ -247,7 +247,7 @@ class LicenseManager {
active: false, active: false,
tier: 'free', tier: 'free',
features: [], features: [],
premiumFeatures: PREMIUM_FEATURES premiumFeatures: PREMIUM_FEATURES,
}; };
} }
@@ -267,7 +267,7 @@ class LicenseManager {
expired, expired,
features: expired ? [] : (this.activation.features || Object.keys(PREMIUM_FEATURES)), features: expired ? [] : (this.activation.features || Object.keys(PREMIUM_FEATURES)),
premiumFeatures: PREMIUM_FEATURES, premiumFeatures: PREMIUM_FEATURES,
validationMethod: this.activation.validationMethod validationMethod: this.activation.validationMethod,
}; };
} }
@@ -320,7 +320,7 @@ class LicenseManager {
featureName: featureInfo.name, featureName: featureInfo.name,
featureDescription: featureInfo.description, featureDescription: featureInfo.description,
currentTier: this.isExpired() ? 'free' : 'expired', currentTier: this.isExpired() ? 'free' : 'expired',
upgradeUrl: '/settings#license' upgradeUrl: '/settings#license',
}); });
}; };
} }
@@ -359,7 +359,7 @@ class LicenseManager {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, machineId }), body: JSON.stringify({ code, machineId }),
signal: AbortSignal.timeout(10000) // 10s timeout signal: AbortSignal.timeout(10000), // 10s timeout
}); });
if (!response.ok) { if (!response.ok) {
@@ -379,8 +379,8 @@ class LicenseManager {
expiresAt: data.expiresAt, expiresAt: data.expiresAt,
machineId, machineId,
features: data.features || Object.keys(PREMIUM_FEATURES), features: data.features || Object.keys(PREMIUM_FEATURES),
serverToken: data.token serverToken: data.token,
} },
}; };
} }
@@ -388,7 +388,7 @@ class LicenseManager {
} catch (error) { } catch (error) {
// Server unreachable — return null to fallback to offline // Server unreachable — return null to fallback to offline
this.log.warn?.('license', 'License server unreachable, falling back to offline validation', { this.log.warn?.('license', 'License server unreachable, falling back to offline validation', {
error: error.message error: error.message,
}); });
return null; return null;
} }
@@ -405,9 +405,9 @@ class LicenseManager {
body: JSON.stringify({ body: JSON.stringify({
code: this.activation.code, code: this.activation.code,
machineId: this.activation.machineId, machineId: this.activation.machineId,
serverToken: this.activation.serverToken serverToken: this.activation.serverToken,
}), }),
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
} }
@@ -431,7 +431,7 @@ class LicenseManager {
tier: 'premium', tier: 'premium',
expiresAt: this.activation.expiresAt, expiresAt: this.activation.expiresAt,
daysRemaining: this.daysRemaining(), daysRemaining: this.daysRemaining(),
features: this.activation.features || Object.keys(PREMIUM_FEATURES) features: this.activation.features || Object.keys(PREMIUM_FEATURES),
}; };
} else { } else {
config.license = { active: false, tier: 'free' }; config.license = { active: false, tier: 'free' };

View File

@@ -18,12 +18,12 @@ const ERROR_PATTERNS = [
/\berror\b/i, /\bfailed\b/i, /\bfatal\b/i, /\bpanic\b/i, /\berror\b/i, /\bfailed\b/i, /\bfatal\b/i, /\bpanic\b/i,
/\bcrash(ed)?\b/i, /\bexception\b/i, /\btimeout\b/i, /\bcrash(ed)?\b/i, /\bexception\b/i, /\btimeout\b/i,
/\bOOM\b/, /\bout of memory\b/i, /\bkilled\b/i, /\bOOM\b/, /\bout of memory\b/i, /\bkilled\b/i,
/\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i /\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i,
]; ];
const WARNING_PATTERNS = [ const WARNING_PATTERNS = [
/\bwarn(ing)?\b/i, /\bdeprecated\b/i, /\bretry(ing)?\b/i, /\bwarn(ing)?\b/i, /\bdeprecated\b/i, /\bretry(ing)?\b/i,
/\bslow\b/i, /\blatency\b/i /\bslow\b/i, /\blatency\b/i,
]; ];
const EVENT_PATTERNS = [ const EVENT_PATTERNS = [
@@ -31,7 +31,7 @@ const EVENT_PATTERNS = [
{ pattern: /\b(stop(ped|ping)?|shutdown|exit(ed|ing)?|terminat(ed|ing)?)\b/i, type: 'shutdown' }, { pattern: /\b(stop(ped|ping)?|shutdown|exit(ed|ing)?|terminat(ed|ing)?)\b/i, type: 'shutdown' },
{ pattern: /\b(restart(ed|ing)?|reload(ed|ing)?)\b/i, type: 'restart' }, { pattern: /\b(restart(ed|ing)?|reload(ed|ing)?)\b/i, type: 'restart' },
{ pattern: /\bhealth.?check.*(fail|unhealthy)\b/i, type: 'health_failure' }, { pattern: /\bhealth.?check.*(fail|unhealthy)\b/i, type: 'health_failure' },
{ pattern: /\b(update|upgrade|migration)\b/i, type: 'update' } { pattern: /\b(update|upgrade|migration)\b/i, type: 'update' },
]; ];
class LogDigest extends EventEmitter { class LogDigest extends EventEmitter {
@@ -63,7 +63,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) console.error('[LogDigest] Hourly collection failed:', e.message),
); );
}, DOCKER.DIGEST.COLLECT_INTERVAL); }, DOCKER.DIGEST.COLLECT_INTERVAL);
@@ -102,7 +102,7 @@ class LogDigest extends EventEmitter {
const hourSummary = { const hourSummary = {
hour: hourKey, hour: hourKey,
timestamp: now.toISOString(), timestamp: now.toISOString(),
services: {} services: {},
}; };
try { try {
@@ -123,7 +123,7 @@ class LogDigest extends EventEmitter {
events: [], events: [],
errorCount: 0, errorCount: 0,
warningCount: 0, warningCount: 0,
totalLines: 0 totalLines: 0,
}; };
if (isRunning) { if (isRunning) {
@@ -134,7 +134,7 @@ class LogDigest extends EventEmitter {
stderr: true, stderr: true,
since: sinceTimestamp, since: sinceTimestamp,
tail: DOCKER.DIGEST.LOG_TAIL, tail: DOCKER.DIGEST.LOG_TAIL,
timestamps: true timestamps: true,
}); });
const lines = this._parseDockerLogs(logBuffer); const lines = this._parseDockerLogs(logBuffer);
@@ -147,7 +147,7 @@ class LogDigest extends EventEmitter {
if (serviceSummary.errors.length < 10) { if (serviceSummary.errors.length < 10) {
serviceSummary.errors.push({ serviceSummary.errors.push({
time: line.timestamp || hourKey, time: line.timestamp || hourKey,
text: line.text.slice(0, 500) text: line.text.slice(0, 500),
}); });
} }
continue; continue;
@@ -159,7 +159,7 @@ class LogDigest extends EventEmitter {
if (serviceSummary.warnings.length < 5) { if (serviceSummary.warnings.length < 5) {
serviceSummary.warnings.push({ serviceSummary.warnings.push({
time: line.timestamp || hourKey, time: line.timestamp || hourKey,
text: line.text.slice(0, 300) text: line.text.slice(0, 300),
}); });
} }
continue; continue;
@@ -171,7 +171,7 @@ class LogDigest extends EventEmitter {
serviceSummary.events.push({ serviceSummary.events.push({
type, type,
time: line.timestamp || hourKey, time: line.timestamp || hourKey,
text: line.text.slice(0, 300) text: line.text.slice(0, 300),
}); });
break; break;
} }
@@ -180,7 +180,7 @@ class LogDigest extends EventEmitter {
} catch (logErr) { } catch (logErr) {
serviceSummary.errors.push({ serviceSummary.errors.push({
time: now.toISOString(), time: now.toISOString(),
text: `Failed to fetch logs: ${logErr.message}` text: `Failed to fetch logs: ${logErr.message}`,
}); });
serviceSummary.errorCount++; serviceSummary.errorCount++;
} }
@@ -188,7 +188,7 @@ class LogDigest extends EventEmitter {
serviceSummary.events.push({ serviceSummary.events.push({
type: 'not_running', type: 'not_running',
time: now.toISOString(), time: now.toISOString(),
text: `Container is ${containerInfo.State}` text: `Container is ${containerInfo.State}`,
}); });
} }
@@ -237,7 +237,7 @@ class LogDigest extends EventEmitter {
lines.push({ lines.push({
stream: streamType === 2 ? 'stderr' : 'stdout', stream: streamType === 2 ? 'stderr' : 'stdout',
text: message, text: message,
timestamp timestamp,
}); });
} }
offset += 8 + size; offset += 8 + size;
@@ -258,7 +258,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) console.error('[LogDigest] Daily digest generation failed:', e.message),
); );
// Reschedule for tomorrow // Reschedule for tomorrow
if (this.running) this._scheduleDailyDigest(); if (this.running) this._scheduleDailyDigest();
@@ -288,7 +288,7 @@ class LogDigest extends EventEmitter {
totalLines: 0, totalLines: 0,
lastState: svc.state, lastState: svc.state,
topErrors: [], topErrors: [],
events: [] events: [],
}; };
} }
const agg = serviceAgg[appId]; const agg = serviceAgg[appId];
@@ -332,8 +332,8 @@ class LogDigest extends EventEmitter {
totalServices: Object.keys(serviceAgg).length, totalServices: Object.keys(serviceAgg).length,
servicesWithErrors: Object.values(serviceAgg).filter(s => s.totalErrors > 0).length, servicesWithErrors: Object.values(serviceAgg).filter(s => s.totalErrors > 0).length,
totalErrors: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalErrors, 0), totalErrors: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalErrors, 0),
totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0) totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0),
} },
}; };
// Write formatted digest file // Write formatted digest file
@@ -369,7 +369,7 @@ class LogDigest extends EventEmitter {
lines.push(''); lines.push('');
// Service summary table // Service summary table
lines.push('-- Service Summary ' + '-'.repeat(36)); lines.push(`-- Service Summary ${ '-'.repeat(36)}`);
const services = Object.values(digest.services); const services = Object.values(digest.services);
if (services.length === 0) { if (services.length === 0) {
lines.push(' No managed services found.'); lines.push(' No managed services found.');
@@ -387,14 +387,14 @@ class LogDigest extends EventEmitter {
// Notable events // Notable events
const events = digest.notableEvents; const events = digest.notableEvents;
if (events.length > 0) { if (events.length > 0) {
lines.push('-- Notable Events ' + '-'.repeat(37)); lines.push(`-- Notable Events ${ '-'.repeat(37)}`);
for (const evt of events) { for (const evt of events) {
const time = (evt.time || '').slice(11, 16) || '??:??'; const time = (evt.time || '').slice(11, 16) || '??:??';
lines.push(` [${time}] ${evt.service}: ${evt.text.slice(0, 80)}`); lines.push(` [${time}] ${evt.service}: ${evt.text.slice(0, 80)}`);
// Add guidance for where to look further // Add guidance for where to look further
const containerName = `${DOCKER.CONTAINER_PREFIX}${evt.appId}`; const containerName = `${DOCKER.CONTAINER_PREFIX}${evt.appId}`;
if (evt.type === 'health_failure' || evt.type === 'restart') { if (evt.type === 'health_failure' || evt.type === 'restart') {
const sinceDate = digest.date + 'T' + (evt.time || '').slice(11, 13) + ':00:00'; const sinceDate = `${digest.date }T${ (evt.time || '').slice(11, 13) }:00:00`;
lines.push(` See: docker logs ${containerName} --since ${sinceDate}`); lines.push(` See: docker logs ${containerName} --since ${sinceDate}`);
} }
} }
@@ -404,7 +404,7 @@ class LogDigest extends EventEmitter {
// Top errors per service // Top errors per service
const errServices = services.filter(s => s.totalErrors > 0); const errServices = services.filter(s => s.totalErrors > 0);
if (errServices.length > 0) { if (errServices.length > 0) {
lines.push('-- Error Details ' + '-'.repeat(38)); lines.push(`-- Error Details ${ '-'.repeat(38)}`);
for (const svc of errServices) { for (const svc of errServices) {
lines.push(` ${svc.name} (${svc.totalErrors} errors):`); lines.push(` ${svc.name} (${svc.totalErrors} errors):`);
for (const err of svc.topErrors) { for (const err of svc.topErrors) {
@@ -419,7 +419,7 @@ class LogDigest extends EventEmitter {
// Docker disk usage // Docker disk usage
if (digest.diskUsage) { if (digest.diskUsage) {
lines.push('-- Docker Disk Usage ' + '-'.repeat(34)); lines.push(`-- Docker Disk Usage ${ '-'.repeat(34)}`);
const du = digest.diskUsage; const du = digest.diskUsage;
lines.push(` Images: ${formatBytes(du.images.sizeBytes)} (${du.images.count} images)`); lines.push(` Images: ${formatBytes(du.images.sizeBytes)} (${du.images.count} images)`);
lines.push(` Containers: ${formatBytes(du.containers.sizeBytes)}`); lines.push(` Containers: ${formatBytes(du.containers.sizeBytes)}`);
@@ -439,7 +439,7 @@ class LogDigest extends EventEmitter {
lines.push(` Hours collected: ${digest.hoursCollected}/24`); lines.push(` Hours collected: ${digest.hoursCollected}/24`);
lines.push(hr); lines.push(hr);
return lines.join('\n') + '\n'; return `${lines.join('\n') }\n`;
} }
/** /**
@@ -551,7 +551,7 @@ class LogDigest extends EventEmitter {
date: today, date: today,
hoursCollected: todayHours.length, hoursCollected: todayHours.length,
lastCollect: this.lastCollect, lastCollect: this.lastCollect,
services: serviceAgg services: serviceAgg,
}; };
} }
@@ -560,7 +560,7 @@ class LogDigest extends EventEmitter {
running: this.running, running: this.running,
lastCollect: this.lastCollect, lastCollect: this.lastCollect,
hourlySummaries: this.hourlySummaries.length, hourlySummaries: this.hourlySummaries.length,
digestDir: this.digestDir digestDir: this.digestDir,
}; };
} }
} }
@@ -569,7 +569,7 @@ function formatBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]; return `${(bytes / Math.pow(1024, i)).toFixed(1) } ${ units[i]}`;
} }
module.exports = new LogDigest(); module.exports = new LogDigest();

View File

@@ -37,7 +37,7 @@ const SENSITIVE_FIELDS = [
'masterKey', 'masterKey',
'master_key', 'master_key',
'encryptionKey', 'encryptionKey',
'encryption_key' 'encryption_key',
]; ];
/** /**
@@ -116,7 +116,7 @@ function safeLog(message, data = {}, additionalSensitiveKeys = []) {
return { return {
message, message,
data: sanitizeForLog(data, additionalSensitiveKeys), data: sanitizeForLog(data, additionalSensitiveKeys),
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}; };
} }
@@ -124,5 +124,5 @@ module.exports = {
sanitizeForLog, sanitizeForLog,
redactCredential, redactCredential,
safeLog, safeLog,
SENSITIVE_FIELDS SENSITIVE_FIELDS,
}; };

View File

@@ -11,11 +11,11 @@ class Metrics {
total: 0, total: 0,
byStatus: {}, byStatus: {},
byMethod: {}, byMethod: {},
byPath: {} byPath: {},
}; };
this.errors = { this.errors = {
total: 0, total: 0,
byType: {} byType: {},
}; };
this.business = { this.business = {
containersDeployed: 0, containersDeployed: 0,
@@ -26,7 +26,7 @@ class Metrics {
totpLogins: 0, totpLogins: 0,
siteAdded: 0, siteAdded: 0,
siteRemoved: 0, siteRemoved: 0,
credentialRotations: 0 credentialRotations: 0,
}; };
} }
@@ -78,19 +78,19 @@ class Metrics {
perSecond: uptimeSec > 0 ? +(this.requests.total / uptimeSec).toFixed(2) : 0, perSecond: uptimeSec > 0 ? +(this.requests.total / uptimeSec).toFixed(2) : 0,
byStatus: this.requests.byStatus, byStatus: this.requests.byStatus,
byMethod: this.requests.byMethod, byMethod: this.requests.byMethod,
topEndpoints topEndpoints,
}, },
errors: { errors: {
total: this.errors.total, total: this.errors.total,
rate: this.requests.total > 0 ? +((this.errors.total / this.requests.total) * 100).toFixed(2) : 0, rate: this.requests.total > 0 ? +((this.errors.total / this.requests.total) * 100).toFixed(2) : 0,
byType: this.errors.byType byType: this.errors.byType,
}, },
business: this.business, business: this.business,
process: { process: {
memory: process.memoryUsage(), memory: process.memoryUsage(),
pid: process.pid, pid: process.pid,
nodeVersion: process.version nodeVersion: process.version,
} },
}; };
} }

View File

@@ -27,7 +27,7 @@ const { CACHE_CONFIGS, createCache } = require('./cache-config');
module.exports = function configureMiddleware(app, { module.exports = function configureMiddleware(app, {
siteConfig, totpConfig, tailscaleConfig, siteConfig, totpConfig, tailscaleConfig,
metrics, auditLogger, authManager, log, cryptoUtils, metrics, auditLogger, authManager, log, cryptoUtils,
isValidContainerId, isTailscaleIP, getTailscaleStatus isValidContainerId, isTailscaleIP, getTailscaleStatus,
}) { }) {
// ── Container ID param validation ── // ── Container ID param validation ──
@@ -44,7 +44,7 @@ module.exports = function configureMiddleware(app, {
app.use(cors({ app.use(cors({
origin: corsOrigins, origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true credentials: true,
})); }));
// ── Security headers with Helmet ── // ── Security headers with Helmet ──
@@ -54,16 +54,16 @@ module.exports = function configureMiddleware(app, {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
styleSrc: ["'self'"], styleSrc: ["'self'"],
scriptSrc: ["'self'"], scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"], imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"], connectSrc: ["'self'"],
fontSrc: ["'self'", "data:"], fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"], objectSrc: ["'none'"],
mediaSrc: ["'self'"], mediaSrc: ["'self'"],
frameSrc: ["'none'"] frameSrc: ["'none'"],
} },
}, },
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" } crossOriginResourcePolicy: { policy: 'cross-origin' },
})); }));
// ── Trust proxy (one hop — Caddy) ── // ── Trust proxy (one hop — Caddy) ──
@@ -95,7 +95,7 @@ module.exports = function configureMiddleware(app, {
if (req.path !== '/health' && req.path !== '/api/health') { if (req.path !== '/health' && req.path !== '/api/health') {
const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'debug'; const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'debug';
log[level]('http', `${req.method} ${req.path} ${res.statusCode}`, { log[level]('http', `${req.method} ${req.path} ${res.statusCode}`, {
ms: duration, ip: req.ip, id: req.id ms: duration, ip: req.ip, id: req.id,
}); });
} }
}); });
@@ -128,7 +128,7 @@ module.exports = function configureMiddleware(app, {
success: false, success: false,
error: '[DC-120] Access denied. This dashboard requires Tailscale connection.', error: '[DC-120] Access denied. This dashboard requires Tailscale connection.',
requiresTailscale: true, requiresTailscale: true,
clientIP: clientIP clientIP: clientIP,
}); });
} }
@@ -151,7 +151,7 @@ module.exports = function configureMiddleware(app, {
success: false, success: false,
error: '[DC-121] Access denied. Device not in allowed tailnet.', error: '[DC-121] Access denied. Device not in allowed tailnet.',
requiresTailscale: true, requiresTailscale: true,
clientIP clientIP,
}); });
} }
} }
@@ -178,7 +178,7 @@ module.exports = function configureMiddleware(app, {
'8h': 8 * 60 * 60 * 1000, '8h': 8 * 60 * 60 * 1000,
'12h': 12 * 60 * 60 * 1000, '12h': 12 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000,
'never': null 'never': null,
}; };
// IP-based session store (solves cross-domain cookie issues with .sami TLD) // IP-based session store (solves cross-domain cookie issues with .sami TLD)
@@ -222,7 +222,7 @@ module.exports = function configureMiddleware(app, {
const key = cryptoUtils.loadOrCreateKey(); const key = cryptoUtils.loadOrCreateKey();
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url'); const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
res.setHeader('Set-Cookie', res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`,
); );
} }
@@ -254,7 +254,7 @@ module.exports = function configureMiddleware(app, {
function clearSessionCookie(res) { function clearSessionCookie(res) {
res.setHeader('Set-Cookie', res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax` `${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`,
); );
} }
@@ -324,7 +324,7 @@ module.exports = function configureMiddleware(app, {
if (req.totpSessionValid || isSessionValid(req)) { if (req.totpSessionValid || isSessionValid(req)) {
req.auth = { req.auth = {
type: 'session', type: 'session',
scope: ['admin'] scope: ['admin'],
}; };
return next(); return next();
} }
@@ -340,7 +340,7 @@ module.exports = function configureMiddleware(app, {
req.auth = { req.auth = {
type: 'jwt', type: 'jwt',
userId: jwtPayload.userId, userId: jwtPayload.userId,
scope: jwtPayload.scope || [] scope: jwtPayload.scope || [],
}; };
return next(); return next();
} }
@@ -355,7 +355,7 @@ module.exports = function configureMiddleware(app, {
type: 'apikey', type: 'apikey',
keyId: keyData.keyId, keyId: keyData.keyId,
name: keyData.name, name: keyData.name,
scope: keyData.scopes || [] scope: keyData.scopes || [],
}; };
return next(); return next();
} }
@@ -364,7 +364,7 @@ module.exports = function configureMiddleware(app, {
if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') { if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') {
req.auth = { req.auth = {
type: 'none', type: 'none',
scope: ['admin'] scope: ['admin'],
}; };
return next(); return next();
} }
@@ -372,7 +372,7 @@ module.exports = function configureMiddleware(app, {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key',
requiresTotp: totpConfig.enabled requiresTotp: totpConfig.enabled,
}); });
}; };
@@ -385,7 +385,7 @@ module.exports = function configureMiddleware(app, {
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: (req) => isTest || req.path === '/health' || req.path === '/api/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/auth/gate/') || req.path === '/api/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs', skip: (req) => isTest || req.path === '/health' || req.path === '/api/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/auth/gate/') || req.path === '/api/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs',
message: { success: false, error: 'Too many requests, please try again later' } message: { success: false, error: 'Too many requests, please try again later' },
}); });
const strictLimiter = rateLimit({ const strictLimiter = rateLimit({
@@ -393,7 +393,7 @@ module.exports = function configureMiddleware(app, {
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: () => isTest, skip: () => isTest,
message: { success: false, error: 'Too many requests to this endpoint, please try again later' } message: { success: false, error: 'Too many requests to this endpoint, please try again later' },
}); });
app.use(generalLimiter); app.use(generalLimiter);
@@ -407,7 +407,7 @@ module.exports = function configureMiddleware(app, {
...RATE_LIMITS.TOTP, ...RATE_LIMITS.TOTP,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { success: false, error: 'Too many TOTP attempts, please try again later' } message: { success: false, error: 'Too many TOTP attempts, please try again later' },
}); });
app.use('/api/totp/verify', totpLimiter); app.use('/api/totp/verify', totpLimiter);
app.use('/api/totp/verify-setup', totpLimiter); app.use('/api/totp/verify-setup', totpLimiter);
@@ -425,6 +425,6 @@ module.exports = function configureMiddleware(app, {
clearIPSession, clearIPSession,
clearSessionCookie, clearSessionCookie,
isSessionValid, isSessionValid,
ipSessions ipSessions,
}; };
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@
"start": "node server.js", "start": "node server.js",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage" "test:coverage": "jest --coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write '**/*.{js,json,md}'"
}, },
"dependencies": { "dependencies": {
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -26,7 +29,9 @@
"validator": "^13.11.0" "validator": "^13.11.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.57.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.8.1",
"supertest": "^6.3.4" "supertest": "^6.3.4"
} }
} }

View File

@@ -51,12 +51,12 @@ const paths = {
process.env.APPDATA || 'C:\\Users', process.env.APPDATA || 'C:\\Users',
'C:\\ProgramData', 'C:\\ProgramData',
'/var/log', '/var/log',
'/opt' '/opt',
] ]
: [ : [
'/var/log', '/var/log',
'/opt', '/opt',
'/home' '/home',
], ],
// Platform detection helpers // Platform detection helpers

View File

@@ -16,10 +16,10 @@ const LOCK_RETRY_OPTIONS = {
retries: 10, retries: 10,
minTimeout: 100, minTimeout: 100,
maxTimeout: 1000, maxTimeout: 1000,
randomize: true randomize: true,
}, },
stale: LOCK_STALE_THRESHOLD, stale: LOCK_STALE_THRESHOLD,
realpath: false realpath: false,
}; };
class PortLockManager { class PortLockManager {
@@ -72,7 +72,7 @@ class PortLockManager {
if (!fs.existsSync(lockFilePath)) { if (!fs.existsSync(lockFilePath)) {
fs.writeFileSync(lockFilePath, JSON.stringify({ fs.writeFileSync(lockFilePath, JSON.stringify({
created: new Date().toISOString(), created: new Date().toISOString(),
port port,
})); }));
} }
@@ -89,7 +89,7 @@ class PortLockManager {
this.activeLocks.set(lockId, { this.activeLocks.set(lockId, {
ports: sortedPorts, ports: sortedPorts,
releases: releaseFunctions, releases: releaseFunctions,
timestamp: Date.now() timestamp: Date.now(),
}); });
console.log(`[PortLockManager] Successfully acquired all locks (ID: ${lockId})`); console.log(`[PortLockManager] Successfully acquired all locks (ID: ${lockId})`);
@@ -97,13 +97,13 @@ class PortLockManager {
} catch (error) { } catch (error) {
// Release any locks we managed to acquire // Release any locks we managed to acquire
console.error(`[PortLockManager] Failed to acquire all locks:`, error.message); console.error('[PortLockManager] Failed to acquire all locks:', error.message);
for (const release of releaseFunctions) { for (const release of releaseFunctions) {
try { try {
await release(); await release();
} catch (releaseError) { } catch (releaseError) {
console.error(`[PortLockManager] Error releasing lock during cleanup:`, releaseError.message); console.error('[PortLockManager] Error releasing lock during cleanup:', releaseError.message);
} }
} }
@@ -132,7 +132,7 @@ class PortLockManager {
await release(); await release();
} catch (error) { } catch (error) {
errors.push(error.message); errors.push(error.message);
console.error(`[PortLockManager] Error releasing lock:`, error.message); console.error('[PortLockManager] Error releasing lock:', error.message);
} }
} }
@@ -198,13 +198,13 @@ class PortLockManager {
lockId, lockId,
ports: info.ports, ports: info.ports,
age: Date.now() - info.timestamp, age: Date.now() - info.timestamp,
timestamp: new Date(info.timestamp).toISOString() timestamp: new Date(info.timestamp).toISOString(),
})); }));
return { return {
activeLocks: activeLocks.length, activeLocks: activeLocks.length,
locks: activeLocks, locks: activeLocks,
lockDirectory: LOCK_DIR lockDirectory: LOCK_DIR,
}; };
} }

View File

@@ -4,336 +4,336 @@
const RECIPE_TEMPLATES = { const RECIPE_TEMPLATES = {
// === MEDIA & ENTERTAINMENT === // === MEDIA & ENTERTAINMENT ===
"htpc-suite": { 'htpc-suite': {
name: "HTPC Suite", name: 'HTPC Suite',
description: "Complete media automation: find, download, organize, and stream", description: 'Complete media automation: find, download, organize, and stream',
icon: "\uD83C\uDFAC", icon: '\uD83C\uDFAC',
category: "Media", category: 'Media',
type: "recipe", type: 'recipe',
difficulty: "Intermediate", difficulty: 'Intermediate',
popularity: 98, popularity: 98,
components: [ components: [
{ {
id: "prowlarr", id: 'prowlarr',
role: "Indexer Manager", role: 'Indexer Manager',
templateRef: "prowlarr", templateRef: 'prowlarr',
required: true, required: true,
order: 1 order: 1,
}, },
{ {
id: "qbittorrent", id: 'qbittorrent',
role: "Download Client", role: 'Download Client',
templateRef: "qbittorrent", templateRef: 'qbittorrent',
required: true, required: true,
order: 2 order: 2,
}, },
{ {
id: "sonarr", id: 'sonarr',
role: "TV Show Manager", role: 'TV Show Manager',
templateRef: "sonarr", templateRef: 'sonarr',
required: true, required: true,
order: 3 order: 3,
}, },
{ {
id: "radarr", id: 'radarr',
role: "Movie Manager", role: 'Movie Manager',
templateRef: "radarr", templateRef: 'radarr',
required: true, required: true,
order: 4 order: 4,
}, },
{ {
id: "lidarr", id: 'lidarr',
role: "Music Manager", role: 'Music Manager',
templateRef: "lidarr", templateRef: 'lidarr',
required: false, required: false,
order: 5 order: 5,
}, },
{ {
id: "overseerr", id: 'overseerr',
role: "Request Manager", role: 'Request Manager',
templateRef: "seerr", templateRef: 'seerr',
required: false, required: false,
order: 6 order: 6,
} },
], ],
sharedVolumes: { sharedVolumes: {
media: { media: {
label: "Media Library", label: 'Media Library',
description: "Root folder for all media (movies, TV, music)", description: 'Root folder for all media (movies, TV, music)',
defaultPath: "/media", defaultPath: '/media',
usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"] usedBy: ['sonarr', 'radarr', 'lidarr', 'qbittorrent'],
}, },
downloads: { downloads: {
label: "Downloads", label: 'Downloads',
description: "Shared downloads folder for all download clients", description: 'Shared downloads folder for all download clients',
defaultPath: "/downloads", defaultPath: '/downloads',
usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"] usedBy: ['sonarr', 'radarr', 'lidarr', 'qbittorrent'],
} },
}, },
autoConnect: { autoConnect: {
enabled: true, enabled: true,
description: "Automatically connects Sonarr/Radarr to Prowlarr and qBittorrent", description: 'Automatically connects Sonarr/Radarr to Prowlarr and qBittorrent',
steps: [ steps: [
{ action: "configureProwlarrApps", targets: ["sonarr", "radarr", "lidarr"] }, { action: 'configureProwlarrApps', targets: ['sonarr', 'radarr', 'lidarr'] },
{ action: "configureDownloadClient", client: "qbittorrent", targets: ["sonarr", "radarr", "lidarr"] } { action: 'configureDownloadClient', client: 'qbittorrent', targets: ['sonarr', 'radarr', 'lidarr'] },
] ],
}, },
setupInstructions: [ setupInstructions: [
"All services share the same media and downloads folders", 'All services share the same media and downloads folders',
"Prowlarr is pre-connected to Sonarr, Radarr, and Lidarr", 'Prowlarr is pre-connected to Sonarr, Radarr, and Lidarr',
"Add indexers in Prowlarr \u2014 they sync automatically to all *arr apps", 'Add indexers in Prowlarr \u2014 they sync automatically to all *arr apps',
"Add your media library root folders in Sonarr and Radarr", 'Add your media library root folders in Sonarr and Radarr',
"qBittorrent is pre-configured as the download client" 'qBittorrent is pre-configured as the download client',
] ],
}, },
// === PRODUCTIVITY === // === PRODUCTIVITY ===
"nextcloud-complete": { 'nextcloud-complete': {
name: "Nextcloud Complete", name: 'Nextcloud Complete',
description: "Full productivity suite: cloud storage, office editing, and collaboration", description: 'Full productivity suite: cloud storage, office editing, and collaboration',
icon: "\u2601\uFE0F", icon: '\u2601\uFE0F',
category: "Productivity", category: 'Productivity',
type: "recipe", type: 'recipe',
difficulty: "Intermediate", difficulty: 'Intermediate',
popularity: 90, popularity: 90,
components: [ components: [
{ {
id: "nextcloud-db", id: 'nextcloud-db',
role: "Database", role: 'Database',
required: true, required: true,
order: 0, order: 0,
docker: { docker: {
image: "mariadb:11", image: 'mariadb:11',
ports: [], ports: [],
volumes: ["/opt/nextcloud-db/data:/var/lib/mysql"], volumes: ['/opt/nextcloud-db/data:/var/lib/mysql'],
environment: { environment: {
"MYSQL_ROOT_PASSWORD": "{{GENERATED_PASSWORD}}", 'MYSQL_ROOT_PASSWORD': '{{GENERATED_PASSWORD}}',
"MYSQL_DATABASE": "nextcloud", 'MYSQL_DATABASE': 'nextcloud',
"MYSQL_USER": "nextcloud", 'MYSQL_USER': 'nextcloud',
"MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}" 'MYSQL_PASSWORD': '{{GENERATED_PASSWORD}}',
}
}, },
internal: true },
internal: true,
}, },
{ {
id: "nextcloud-redis", id: 'nextcloud-redis',
role: "Cache", role: 'Cache',
required: true, required: true,
order: 0, order: 0,
docker: { docker: {
image: "redis:7-alpine", image: 'redis:7-alpine',
ports: [], ports: [],
volumes: ["/opt/nextcloud-redis/data:/data"], volumes: ['/opt/nextcloud-redis/data:/data'],
environment: {} environment: {},
}, },
internal: true internal: true,
}, },
{ {
id: "nextcloud", id: 'nextcloud',
role: "Cloud Platform", role: 'Cloud Platform',
templateRef: "nextcloud", templateRef: 'nextcloud',
required: true, required: true,
order: 1, order: 1,
envOverrides: { envOverrides: {
"MYSQL_HOST": "dashcaddy-nextcloud-db", 'MYSQL_HOST': 'dashcaddy-nextcloud-db',
"MYSQL_DATABASE": "nextcloud", 'MYSQL_DATABASE': 'nextcloud',
"MYSQL_USER": "nextcloud", 'MYSQL_USER': 'nextcloud',
"MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}", 'MYSQL_PASSWORD': '{{GENERATED_PASSWORD}}',
"REDIS_HOST": "dashcaddy-nextcloud-redis" 'REDIS_HOST': 'dashcaddy-nextcloud-redis',
} },
}, },
{ {
id: "collabora", id: 'collabora',
role: "Office Suite", role: 'Office Suite',
required: false, required: false,
order: 2, order: 2,
docker: { docker: {
image: "collabora/code:latest", image: 'collabora/code:latest',
ports: ["{{PORT}}:9980"], ports: ['{{PORT}}:9980'],
volumes: [], volumes: [],
environment: { environment: {
"aliasgroup1": "https://{{NEXTCLOUD_DOMAIN}}", 'aliasgroup1': 'https://{{NEXTCLOUD_DOMAIN}}',
"extra_params": "--o:ssl.enable=false --o:ssl.termination=true" 'extra_params': '--o:ssl.enable=false --o:ssl.termination=true',
}
}, },
subdomain: "office", },
subdomain: 'office',
defaultPort: 9980, defaultPort: 9980,
healthCheck: "/" healthCheck: '/',
} },
], ],
network: { network: {
name: "dashcaddy-nextcloud", name: 'dashcaddy-nextcloud',
driver: "bridge" driver: 'bridge',
}, },
sharedVolumes: { sharedVolumes: {
data: { data: {
label: "Cloud Storage", label: 'Cloud Storage',
description: "Nextcloud data directory for user files", description: 'Nextcloud data directory for user files',
defaultPath: "/opt/nextcloud/data", defaultPath: '/opt/nextcloud/data',
usedBy: ["nextcloud"] usedBy: ['nextcloud'],
} },
}, },
setupInstructions: [ setupInstructions: [
"Complete the Nextcloud initial setup wizard in the browser", 'Complete the Nextcloud initial setup wizard in the browser',
"MariaDB and Redis are pre-configured and connected", 'MariaDB and Redis are pre-configured and connected',
"If Collabora is enabled, configure it in Nextcloud: Settings \u2192 Nextcloud Office", 'If Collabora is enabled, configure it in Nextcloud: Settings \u2192 Nextcloud Office',
"Point Nextcloud Office to your Collabora URL (e.g., https://office.sami)", 'Point Nextcloud Office to your Collabora URL (e.g., https://office.sami)',
"Configure email, 2FA, and other settings in Nextcloud admin panel" 'Configure email, 2FA, and other settings in Nextcloud admin panel',
] ],
}, },
// === DEVELOPMENT === // === DEVELOPMENT ===
"dev-environment": { 'dev-environment': {
name: "Dev Environment", name: 'Dev Environment',
description: "Self-hosted development workflow: Git, CI/CD, IDE, and database", description: 'Self-hosted development workflow: Git, CI/CD, IDE, and database',
icon: "\uD83D\uDCBB", icon: '\uD83D\uDCBB',
category: "Development", category: 'Development',
type: "recipe", type: 'recipe',
difficulty: "Advanced", difficulty: 'Advanced',
popularity: 82, popularity: 82,
components: [ components: [
{ {
id: "dev-postgres", id: 'dev-postgres',
role: "Database", role: 'Database',
required: true, required: true,
order: 0, order: 0,
docker: { docker: {
image: "postgres:16-alpine", image: 'postgres:16-alpine',
ports: [], ports: [],
volumes: ["/opt/dev-postgres/data:/var/lib/postgresql/data"], volumes: ['/opt/dev-postgres/data:/var/lib/postgresql/data'],
environment: { environment: {
"POSTGRES_DB": "gitea", 'POSTGRES_DB': 'gitea',
"POSTGRES_USER": "gitea", 'POSTGRES_USER': 'gitea',
"POSTGRES_PASSWORD": "{{GENERATED_PASSWORD}}" 'POSTGRES_PASSWORD': '{{GENERATED_PASSWORD}}',
}
}, },
internal: true },
internal: true,
}, },
{ {
id: "gitea", id: 'gitea',
role: "Git Server", role: 'Git Server',
templateRef: "gitea", templateRef: 'gitea',
required: true, required: true,
order: 1, order: 1,
envOverrides: { envOverrides: {
"GITEA__database__DB_TYPE": "postgres", 'GITEA__database__DB_TYPE': 'postgres',
"GITEA__database__HOST": "dashcaddy-dev-postgres:5432", 'GITEA__database__HOST': 'dashcaddy-dev-postgres:5432',
"GITEA__database__NAME": "gitea", 'GITEA__database__NAME': 'gitea',
"GITEA__database__USER": "gitea", 'GITEA__database__USER': 'gitea',
"GITEA__database__PASSWD": "{{GENERATED_PASSWORD}}" 'GITEA__database__PASSWD': '{{GENERATED_PASSWORD}}',
} },
}, },
{ {
id: "drone", id: 'drone',
role: "CI/CD Pipeline", role: 'CI/CD Pipeline',
templateRef: "drone", templateRef: 'drone',
required: false, required: false,
order: 2 order: 2,
}, },
{ {
id: "vscode-server", id: 'vscode-server',
role: "Web IDE", role: 'Web IDE',
templateRef: "vscode-server", templateRef: 'vscode-server',
required: false, required: false,
order: 3 order: 3,
} },
], ],
network: { network: {
name: "dashcaddy-dev", name: 'dashcaddy-dev',
driver: "bridge" driver: 'bridge',
}, },
setupInstructions: [ setupInstructions: [
"Gitea is pre-configured with PostgreSQL database", 'Gitea is pre-configured with PostgreSQL database',
"Complete the Gitea initial setup wizard in the browser", 'Complete the Gitea initial setup wizard in the browser',
"If Drone CI is enabled, connect it to Gitea via OAuth application", 'If Drone CI is enabled, connect it to Gitea via OAuth application',
"VS Code Server provides a full IDE in your browser", 'VS Code Server provides a full IDE in your browser',
"All development services share a Docker network for inter-service communication" 'All development services share a Docker network for inter-service communication',
] ],
}, },
// === HOME AUTOMATION === // === HOME AUTOMATION ===
"smart-home": { 'smart-home': {
name: "Smart Home Hub", name: 'Smart Home Hub',
description: "Home automation: control, automate, and monitor IoT devices", description: 'Home automation: control, automate, and monitor IoT devices',
icon: "\uD83C\uDFE0", icon: '\uD83C\uDFE0',
category: "Home Automation", category: 'Home Automation',
type: "recipe", type: 'recipe',
difficulty: "Intermediate", difficulty: 'Intermediate',
popularity: 88, popularity: 88,
components: [ components: [
{ {
id: "mosquitto", id: 'mosquitto',
role: "MQTT Broker", role: 'MQTT Broker',
required: true, required: true,
order: 0, order: 0,
docker: { docker: {
image: "eclipse-mosquitto:2", image: 'eclipse-mosquitto:2',
ports: ["1883:1883", "9001:9001"], ports: ['1883:1883', '9001:9001'],
volumes: [ volumes: [
"/opt/mosquitto/config:/mosquitto/config", '/opt/mosquitto/config:/mosquitto/config',
"/opt/mosquitto/data:/mosquitto/data", '/opt/mosquitto/data:/mosquitto/data',
"/opt/mosquitto/log:/mosquitto/log" '/opt/mosquitto/log:/mosquitto/log',
], ],
environment: {} environment: {},
}, },
subdomain: "mqtt", subdomain: 'mqtt',
defaultPort: 1883, defaultPort: 1883,
internal: false, internal: false,
setupNote: "MQTT broker for IoT device communication" setupNote: 'MQTT broker for IoT device communication',
}, },
{ {
id: "homeassistant", id: 'homeassistant',
role: "Automation Hub", role: 'Automation Hub',
templateRef: "homeassistant", templateRef: 'homeassistant',
required: true, required: true,
order: 1 order: 1,
}, },
{ {
id: "nodered", id: 'nodered',
role: "Flow Automation", role: 'Flow Automation',
templateRef: "nodered", templateRef: 'nodered',
required: true, required: true,
order: 2 order: 2,
}, },
{ {
id: "zigbee2mqtt", id: 'zigbee2mqtt',
role: "Zigbee Bridge", role: 'Zigbee Bridge',
required: false, required: false,
order: 3, order: 3,
docker: { docker: {
image: "koenkk/zigbee2mqtt:latest", image: 'koenkk/zigbee2mqtt:latest',
ports: ["{{PORT}}:8080"], ports: ['{{PORT}}:8080'],
volumes: ["/opt/zigbee2mqtt/data:/app/data"], volumes: ['/opt/zigbee2mqtt/data:/app/data'],
environment: { environment: {
"TZ": "{{TIMEZONE}}" 'TZ': '{{TIMEZONE}}',
}
}, },
subdomain: "zigbee", },
subdomain: 'zigbee',
defaultPort: 8080, defaultPort: 8080,
healthCheck: "/", healthCheck: '/',
note: "Requires a Zigbee USB adapter (e.g., Sonoff Zigbee 3.0 USB Dongle Plus)" note: 'Requires a Zigbee USB adapter (e.g., Sonoff Zigbee 3.0 USB Dongle Plus)',
} },
], ],
network: { network: {
name: "dashcaddy-smarthome", name: 'dashcaddy-smarthome',
driver: "bridge" driver: 'bridge',
}, },
setupInstructions: [ setupInstructions: [
"Mosquitto MQTT broker is ready for IoT device connections on port 1883", 'Mosquitto MQTT broker is ready for IoT device connections on port 1883',
"Complete the Home Assistant onboarding wizard in the browser", 'Complete the Home Assistant onboarding wizard in the browser',
"Connect Home Assistant to MQTT: Settings \u2192 Integrations \u2192 MQTT", 'Connect Home Assistant to MQTT: Settings \u2192 Integrations \u2192 MQTT',
"Node-RED provides visual flow automation \u2014 connect it to MQTT for device control", 'Node-RED provides visual flow automation \u2014 connect it to MQTT for device control',
"If Zigbee2MQTT is enabled, it requires a physical Zigbee USB adapter" 'If Zigbee2MQTT is enabled, it requires a physical Zigbee USB adapter',
] ],
} },
}; };
// Recipe category metadata (separate from app categories) // Recipe category metadata (separate from app categories)
const RECIPE_CATEGORIES = { const RECIPE_CATEGORIES = {
"Media": { icon: "\uD83C\uDFAC", color: "#e74c3c", description: "Media streaming and automation stacks" }, 'Media': { icon: '\uD83C\uDFAC', color: '#e74c3c', description: 'Media streaming and automation stacks' },
"Productivity": { icon: "\u2601\uFE0F", color: "#3498db", description: "Cloud storage and office suites" }, 'Productivity': { icon: '\u2601\uFE0F', color: '#3498db', description: 'Cloud storage and office suites' },
"Development": { icon: "\uD83D\uDCBB", color: "#9b59b6", description: "Self-hosted development environments" }, 'Development': { icon: '\uD83D\uDCBB', color: '#9b59b6', description: 'Self-hosted development environments' },
"Home Automation": { icon: "\uD83C\uDFE0", color: "#27ae60", description: "IoT and smart home control" } 'Home Automation': { icon: '\uD83C\uDFE0', color: '#27ae60', description: 'IoT and smart home control' },
}; };
module.exports = { RECIPE_TEMPLATES, RECIPE_CATEGORIES }; module.exports = { RECIPE_TEMPLATES, RECIPE_CATEGORIES };

View File

@@ -144,28 +144,28 @@ class ResourceMonitor extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
cpu: { cpu: {
percent: Math.round(cpuPercent * 100) / 100, percent: Math.round(cpuPercent * 100) / 100,
usage: stats.cpu_stats.cpu_usage.total_usage usage: stats.cpu_stats.cpu_usage.total_usage,
}, },
memory: { memory: {
usage: memoryUsage, usage: memoryUsage,
limit: memoryLimit, limit: memoryLimit,
percent: Math.round(memoryPercent * 100) / 100, percent: Math.round(memoryPercent * 100) / 100,
usageMB: Math.round(memoryUsage / 1024 / 1024), usageMB: Math.round(memoryUsage / 1024 / 1024),
limitMB: Math.round(memoryLimit / 1024 / 1024) limitMB: Math.round(memoryLimit / 1024 / 1024),
}, },
network: { network: {
rxBytes: networkRx, rxBytes: networkRx,
txBytes: networkTx, txBytes: networkTx,
rxMB: Math.round(networkRx / 1024 / 1024 * 100) / 100, rxMB: Math.round(networkRx / 1024 / 1024 * 100) / 100,
txMB: Math.round(networkTx / 1024 / 1024 * 100) / 100 txMB: Math.round(networkTx / 1024 / 1024 * 100) / 100,
}, },
disk: { disk: {
readBytes: blockRead, readBytes: blockRead,
writeBytes: blockWrite, writeBytes: blockWrite,
readMB: Math.round(blockRead / 1024 / 1024 * 100) / 100, readMB: Math.round(blockRead / 1024 / 1024 * 100) / 100,
writeMB: Math.round(blockWrite / 1024 / 1024 * 100) / 100 writeMB: Math.round(blockWrite / 1024 / 1024 * 100) / 100,
}, },
pids: stats.pids_stats?.current || 0 pids: stats.pids_stats?.current || 0,
}); });
}); });
}); });
@@ -178,7 +178,7 @@ class ResourceMonitor extends EventEmitter {
if (!this.stats.has(containerId)) { if (!this.stats.has(containerId)) {
this.stats.set(containerId, { this.stats.set(containerId, {
name: containerName, name: containerName,
history: [] history: [],
}); });
} }
@@ -189,7 +189,7 @@ class ResourceMonitor extends EventEmitter {
// Keep only recent stats (based on retention policy) // Keep only recent stats (based on retention policy)
const cutoffTime = Date.now() - (STATS_RETENTION_HOURS * 60 * 60 * 1000); const cutoffTime = Date.now() - (STATS_RETENTION_HOURS * 60 * 60 * 1000);
containerStats.history = containerStats.history.filter(s => containerStats.history = containerStats.history.filter(s =>
new Date(s.timestamp).getTime() > cutoffTime new Date(s.timestamp).getTime() > cutoffTime,
); );
} }
@@ -216,7 +216,7 @@ class ResourceMonitor extends EventEmitter {
severity: 'warning', severity: 'warning',
message: `CPU usage ${stats.cpu.percent.toFixed(1)}% exceeds threshold ${alertConfig.cpuThreshold}%`, message: `CPU usage ${stats.cpu.percent.toFixed(1)}% exceeds threshold ${alertConfig.cpuThreshold}%`,
value: stats.cpu.percent, value: stats.cpu.percent,
threshold: alertConfig.cpuThreshold threshold: alertConfig.cpuThreshold,
}); });
} }
@@ -227,7 +227,7 @@ class ResourceMonitor extends EventEmitter {
severity: 'warning', severity: 'warning',
message: `Memory usage ${stats.memory.percent.toFixed(1)}% exceeds threshold ${alertConfig.memoryThreshold}%`, message: `Memory usage ${stats.memory.percent.toFixed(1)}% exceeds threshold ${alertConfig.memoryThreshold}%`,
value: stats.memory.percent, value: stats.memory.percent,
threshold: alertConfig.memoryThreshold threshold: alertConfig.memoryThreshold,
}); });
} }
@@ -240,7 +240,7 @@ class ResourceMonitor extends EventEmitter {
severity: 'warning', severity: 'warning',
message: `Disk I/O ${diskIO.toFixed(1)} MB/s exceeds threshold ${alertConfig.diskIOThreshold} MB/s`, message: `Disk I/O ${diskIO.toFixed(1)} MB/s exceeds threshold ${alertConfig.diskIOThreshold} MB/s`,
value: diskIO, value: diskIO,
threshold: alertConfig.diskIOThreshold threshold: alertConfig.diskIOThreshold,
}); });
} }
} }
@@ -254,7 +254,7 @@ class ResourceMonitor extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
alerts, alerts,
stats, stats,
config: alertConfig config: alertConfig,
}); });
// Auto-restart if configured // Auto-restart if configured
@@ -278,7 +278,7 @@ class ResourceMonitor extends EventEmitter {
containerId, containerId,
containerName, containerName,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
reason: alerts reason: alerts,
}); });
} catch (error) { } catch (error) {
console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message); console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message);
@@ -306,7 +306,7 @@ class ResourceMonitor extends EventEmitter {
const cutoffTime = Date.now() - (hours * 60 * 60 * 1000); const cutoffTime = Date.now() - (hours * 60 * 60 * 1000);
return containerStats.history.filter(s => return containerStats.history.filter(s =>
new Date(s.timestamp).getTime() > cutoffTime new Date(s.timestamp).getTime() > cutoffTime,
); );
} }
@@ -325,16 +325,16 @@ class ResourceMonitor extends EventEmitter {
current: cpuValues[cpuValues.length - 1], current: cpuValues[cpuValues.length - 1],
avg: cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length, avg: cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length,
max: Math.max(...cpuValues), max: Math.max(...cpuValues),
min: Math.min(...cpuValues) min: Math.min(...cpuValues),
}, },
memory: { memory: {
current: memoryValues[memoryValues.length - 1], current: memoryValues[memoryValues.length - 1],
avg: memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length, avg: memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length,
max: Math.max(...memoryValues), max: Math.max(...memoryValues),
min: Math.min(...memoryValues) min: Math.min(...memoryValues),
}, },
dataPoints: history.length, dataPoints: history.length,
timeRange: hours timeRange: hours,
}; };
} }
@@ -352,7 +352,7 @@ class ResourceMonitor extends EventEmitter {
name: data.name, name: data.name,
current, current,
aggregated, aggregated,
alertConfig: this.alerts.get(containerId) alertConfig: this.alerts.get(containerId),
}; };
} }
@@ -370,7 +370,7 @@ class ResourceMonitor extends EventEmitter {
diskIOThreshold: config.diskIOThreshold || null, diskIOThreshold: config.diskIOThreshold || null,
cooldownMinutes: config.cooldownMinutes || 15, cooldownMinutes: config.cooldownMinutes || 15,
autoRestart: config.autoRestart || false, autoRestart: config.autoRestart || false,
notificationChannels: config.notificationChannels || [] notificationChannels: config.notificationChannels || [],
}); });
this.saveAlertConfig(); this.saveAlertConfig();
@@ -400,7 +400,7 @@ class ResourceMonitor extends EventEmitter {
for (const [containerId, data] of this.stats.entries()) { for (const [containerId, data] of this.stats.entries()) {
data.history = data.history.filter(s => data.history = data.history.filter(s =>
new Date(s.timestamp).getTime() > cutoffTime new Date(s.timestamp).getTime() > cutoffTime,
); );
// Remove container stats if no recent data // Remove container stats if no recent data
@@ -471,7 +471,7 @@ class ResourceMonitor extends EventEmitter {
return { return {
stats: Object.fromEntries(this.stats), stats: Object.fromEntries(this.stats),
alerts: Object.fromEntries(this.alerts), alerts: Object.fromEntries(this.alerts),
exportedAt: new Date().toISOString() exportedAt: new Date().toISOString(),
}; };
} }

View File

@@ -62,7 +62,7 @@ module.exports = function(ctx, helpers) {
ctx.log.info('deploy', 'DashCA: Using existing index.html'); ctx.log.info('deploy', 'DashCA: Using existing index.html');
} }
ctx.log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath); ctx.log.info('deploy', `DashCA: For full features, copy certificate files to ${ destPath}`);
ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully'); ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully');
} catch (error) { } catch (error) {
ctx.log.error('deploy', 'DashCA deployment error', { error: error.message }); ctx.log.error('deploy', 'DashCA deployment error', { error: error.message });
@@ -121,14 +121,14 @@ module.exports = function(ctx, helpers) {
PortBindings: {}, PortBindings: {},
Binds: translatedVolumes, Binds: translatedVolumes,
RestartPolicy: { Name: 'unless-stopped' }, RestartPolicy: { Name: 'unless-stopped' },
LogConfig: DOCKER.LOG_CONFIG LogConfig: DOCKER.LOG_CONFIG,
}, },
Env: Object.entries(processedTemplate.docker.environment || {}).map(([k, v]) => `${k}=${v}`), Env: Object.entries(processedTemplate.docker.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: { Labels: {
'sami.managed': 'true', 'sami.app': appId, 'sami.managed': 'true', 'sami.app': appId,
'sami.subdomain': userConfig.subdomain, 'sami.subdomain': userConfig.subdomain,
'sami.deployed': new Date().toISOString() 'sami.deployed': new Date().toISOString(),
} },
}; };
processedTemplate.docker.ports.forEach(portMapping => { processedTemplate.docker.ports.forEach(portMapping => {
@@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) {
try { try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) { if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` });
} }
} catch (pruneErr) { } catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message }); ctx.log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message });
@@ -324,7 +324,7 @@ module.exports = function(ctx, helpers) {
tailscaleOnly: config.tailscaleOnly || false, tailscaleOnly: config.tailscaleOnly || false,
allowedIPs: config.allowedIPs || [], allowedIPs: config.allowedIPs || [],
customVolumes: config.customVolumes || undefined, customVolumes: config.customVolumes || undefined,
useExisting: false useExisting: false,
}, },
container: template.isStaticSite ? null : { container: template.isStaticSite ? null : {
image: processedTemplate.docker.image, image: processedTemplate.docker.image,
@@ -340,14 +340,14 @@ module.exports = function(ctx, helpers) {
} }
return env; return env;
})(), })(),
capabilities: processedTemplate.docker.capabilities || undefined capabilities: processedTemplate.docker.capabilities || undefined,
}, },
caddy: { caddy: {
tailscaleOnly: config.tailscaleOnly || false, tailscaleOnly: config.tailscaleOnly || false,
allowedIPs: config.allowedIPs || [], allowedIPs: config.allowedIPs || [],
subpathSupport: template.subpathSupport || 'strip', subpathSupport: template.subpathSupport || 'strip',
routingMode: ctx.siteConfig.routingMode routingMode: ctx.siteConfig.routingMode,
} },
}; };
await ctx.addServiceToConfig({ await ctx.addServiceToConfig({
@@ -358,7 +358,7 @@ module.exports = function(ctx, helpers) {
tailscaleOnly: config.tailscaleOnly || false, tailscaleOnly: config.tailscaleOnly || false,
routingMode: ctx.siteConfig.routingMode, routingMode: ctx.siteConfig.routingMode,
deployedAt: new Date().toISOString(), deployedAt: new Date().toISOString(),
deploymentManifest deploymentManifest,
}); });
ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain });
@@ -366,7 +366,7 @@ module.exports = function(ctx, helpers) {
success: true, containerId, usedExisting, success: true, containerId, usedExisting,
url: serviceUrl, url: serviceUrl,
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`, message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
setupInstructions: template.setupInstructions || [] setupInstructions: template.setupInstructions || [],
}; };
if (dnsWarning) response.warning = dnsWarning; if (dnsWarning) response.warning = dnsWarning;

View File

@@ -38,16 +38,16 @@ module.exports = function(ctx) {
const templateImage = template.docker.image.split(':')[0]; const templateImage = template.docker.image.split(':')[0];
for (const container of containers) { for (const container of containers) {
const containerImage = container.Image.split(':')[0]; const containerImage = container.Image.split(':')[0];
if (containerImage === templateImage || containerImage.endsWith('/' + templateImage)) { if (containerImage === templateImage || containerImage.endsWith(`/${ templateImage}`)) {
const ports = container.Ports.filter(p => p.PublicPort).map(p => ({ const ports = container.Ports.filter(p => p.PublicPort).map(p => ({
hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type,
})); }));
return { return {
id: container.Id, shortId: container.Id.slice(0, 12), id: container.Id, shortId: container.Id.slice(0, 12),
name: container.Names[0]?.replace(/^\//, '') || 'unknown', name: container.Names[0]?.replace(/^\//, '') || 'unknown',
image: container.Image, status: container.Status, state: container.State, image: container.Image, status: container.Status, state: container.State,
ports, primaryPort: ports.length > 0 ? ports[0].hostPort : null, ports, primaryPort: ports.length > 0 ? ports[0].hostPort : null,
labels: container.Labels || {} labels: container.Labels || {},
}; };
} }
} }
@@ -72,7 +72,7 @@ module.exports = function(ctx) {
'{{PORT}}': config.port || template.defaultPort, '{{PORT}}': config.port || template.defaultPort,
'{{MEDIA_PATH}}': mediaPaths[0] || '/media', '{{MEDIA_PATH}}': mediaPaths[0] || '/media',
'{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC', '{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC',
'{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex') '{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex'),
}; };
function replaceInObject(obj) { function replaceInObject(obj) {
@@ -117,7 +117,7 @@ module.exports = function(ctx) {
const basePath = `/${config.subdomain}`; const basePath = `/${config.subdomain}`;
// Some apps need the full URL, not just the path // Some apps need the full URL, not just the path
if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) { if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) {
processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/'; processed.docker.environment[template.urlBaseEnv] = `${ctx.buildServiceUrl(config.subdomain) }/`;
} else { } else {
processed.docker.environment[template.urlBaseEnv] = basePath; processed.docker.environment[template.urlBaseEnv] = basePath;
} }
@@ -137,7 +137,7 @@ module.exports = function(ctx) {
config.mediaPath.split(',').map(p => p.trim()).filter(Boolean).forEach(p => allowedRoots.push(path.resolve(p))); config.mediaPath.split(',').map(p => p.trim()).filter(Boolean).forEach(p => allowedRoots.push(path.resolve(p)));
} }
const isAllowed = allowedRoots.some(root => const isAllowed = allowedRoots.some(root =>
normalizedHost === root || normalizedHost.startsWith(root + path.sep) normalizedHost === root || normalizedHost.startsWith(root + path.sep),
); );
if (!isAllowed) { if (!isAllowed) {
ctx.log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots }); ctx.log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots });
@@ -162,76 +162,76 @@ module.exports = function(ctx) {
c += ` root * ${sitePath}\n\n`; c += ` root * ${sitePath}\n\n`;
if (tailscaleOnly) { if (tailscaleOnly) {
c += ` @blocked not remote_ip 100.64.0.0/10\n`; c += ' @blocked not remote_ip 100.64.0.0/10\n';
c += ` respond @blocked "Access denied. Tailscale connection required." 403\n\n`; c += ' respond @blocked "Access denied. Tailscale connection required." 403\n\n';
} }
if (apiProxy) { if (apiProxy) {
c += ` handle /api/* {\n`; c += ' handle /api/* {\n';
c += ` reverse_proxy ${apiProxy}\n`; c += ` reverse_proxy ${apiProxy}\n`;
c += ` }\n\n`; c += ' }\n\n';
} }
c += ` @crt path *.crt\n`; c += ' @crt path *.crt\n';
c += ` handle @crt {\n`; c += ' handle @crt {\n';
c += ` header Content-Type application/x-x509-ca-cert\n`; c += ' header Content-Type application/x-x509-ca-cert\n';
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ` header Cache-Control "public, max-age=86400"\n`; c += ' header Cache-Control "public, max-age=86400"\n';
c += ` file_server\n`; c += ' file_server\n';
c += ` }\n\n`; c += ' }\n\n';
c += ` @der path *.der\n`; c += ' @der path *.der\n';
c += ` handle @der {\n`; c += ' handle @der {\n';
c += ` header Content-Type application/x-x509-ca-cert\n`; c += ' header Content-Type application/x-x509-ca-cert\n';
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ` header Cache-Control "public, max-age=86400"\n`; c += ' header Cache-Control "public, max-age=86400"\n';
c += ` file_server\n`; c += ' file_server\n';
c += ` }\n\n`; c += ' }\n\n';
c += ` @mobileconfig path *.mobileconfig\n`; c += ' @mobileconfig path *.mobileconfig\n';
c += ` handle @mobileconfig {\n`; c += ' handle @mobileconfig {\n';
c += ` header Content-Type application/x-apple-aspen-config\n`; c += ' header Content-Type application/x-apple-aspen-config\n';
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ` header Cache-Control "public, max-age=86400"\n`; c += ' header Cache-Control "public, max-age=86400"\n';
c += ` file_server\n`; c += ' file_server\n';
c += ` }\n\n`; c += ' }\n\n';
c += ` @ps1 path *.ps1\n`; c += ' @ps1 path *.ps1\n';
c += ` handle @ps1 {\n`; c += ' handle @ps1 {\n';
c += ` header Content-Type text/plain\n`; c += ' header Content-Type text/plain\n';
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ` file_server\n`; c += ' file_server\n';
c += ` }\n\n`; c += ' }\n\n';
c += ` @sh path *.sh\n`; c += ' @sh path *.sh\n';
c += ` handle @sh {\n`; c += ' handle @sh {\n';
c += ` header Content-Type text/x-shellscript\n`; c += ' header Content-Type text/x-shellscript\n';
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ` file_server\n`; c += ' file_server\n';
c += ` }\n\n`; c += ' }\n\n';
c += ` # Static site with SPA fallback\n`; c += ' # Static site with SPA fallback\n';
c += ` handle {\n`; c += ' handle {\n';
c += ` @notFile not file {path}\n`; c += ' @notFile not file {path}\n';
c += ` rewrite @notFile /index.html\n`; c += ' rewrite @notFile /index.html\n';
c += ` file_server\n`; c += ' file_server\n';
c += ` }\n\n`; c += ' }\n\n';
c += ` # No cache for HTML\n`; c += ' # No cache for HTML\n';
c += ` @htmlfiles {\n`; c += ' @htmlfiles {\n';
c += ` path *.html\n`; c += ' path *.html\n';
c += ` path /\n`; c += ' path /\n';
c += ` }\n`; c += ' }\n';
c += ` header @htmlfiles Cache-Control "no-store"\n`; c += ' header @htmlfiles Cache-Control "no-store"\n';
return c; return c;
} }
// HTTPS block // HTTPS block
let config = `${domain} {\n`; let config = `${domain} {\n`;
config += ` tls internal\n\n`; config += ' tls internal\n\n';
config += siteBlockContent(); config += siteBlockContent();
config += `}`; config += '}';
// HTTP companion block for devices that haven't trusted the CA yet // HTTP companion block for devices that haven't trusted the CA yet
if (httpAccess) { if (httpAccess) {
config += `\n\n# HTTP access for first-time certificate installation\n`; config += '\n\n# HTTP access for first-time certificate installation\n';
config += `http://${domain} {\n`; config += `http://${domain} {\n`;
config += siteBlockContent(); config += siteBlockContent();
config += `}`; config += '}';
} }
return config; return config;
@@ -254,7 +254,7 @@ module.exports = function(ctx) {
} else if (healthPath && port && httpCheckFailed < 5) { } else if (healthPath && port && httpCheckFailed < 5) {
try { try {
const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, { const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, {
signal: AbortSignal.timeout(3000), redirect: 'manual' signal: AbortSignal.timeout(3000), redirect: 'manual',
}); });
if (response.ok || (response.status >= 300 && response.status < 400)) { if (response.ok || (response.status >= 300 && response.status < 400)) {
ctx.log.info('docker', 'Health check passed', { containerId, status: response.status }); ctx.log.info('docker', 'Health check passed', { containerId, status: response.status });
@@ -290,7 +290,7 @@ module.exports = function(ctx) {
await ctx.caddy.reload(existing); await ctx.caddy.reload(existing);
return; return;
} }
const result = await ctx.caddy.modify(c => c + `\n${config}\n`); const result = await ctx.caddy.modify(c => `${c }\n${config}\n`);
if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`); if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`);
await ctx.caddy.verifySite(domain); await ctx.caddy.verifySite(domain);
} }
@@ -405,6 +405,6 @@ module.exports = function(ctx) {
removeSubpathConfig, removeSubpathConfig,
ensureMainDomainBlock, ensureMainDomainBlock,
RESERVED_SUBPATHS, RESERVED_SUBPATHS,
generateStaticSiteConfig generateStaticSiteConfig,
}; };
}; };

View File

@@ -26,7 +26,7 @@ module.exports = function(ctx, helpers) {
try { try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) { if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` });
} }
} catch (pruneErr) { } catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message }); ctx.log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message });
@@ -42,7 +42,7 @@ module.exports = function(ctx, helpers) {
try { try {
const domain = ctx.buildDomain(subdomain); const domain = ctx.buildDomain(subdomain);
const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', { const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', {
token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true' token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true',
}); });
let recordIp = ip || 'localhost'; let recordIp = ip || 'localhost';
if (getResult.status === 'ok' && getResult.response?.records) { if (getResult.status === 'ok' && getResult.response?.records) {
@@ -50,7 +50,7 @@ module.exports = function(ctx, helpers) {
if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress; if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress;
} }
const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp,
}); });
results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed'); results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed');
ctx.log.info('dns', 'DNS record removal', { result: results.dns }); ctx.log.info('dns', 'DNS record removal', { result: results.dns });

View File

@@ -37,7 +37,7 @@ module.exports = function(ctx, helpers) {
return res.json({ return res.json({
success: true, success: true,
message: 'No services have deployment manifests to restore', message: 'No services have deployment manifests to restore',
results: [] results: [],
}); });
} }
@@ -51,7 +51,7 @@ module.exports = function(ctx, helpers) {
id: service.id, id: service.id,
name: service.name, name: service.name,
status: 'failed', status: 'failed',
error: error.message error: error.message,
}); });
} }
} }
@@ -63,7 +63,7 @@ module.exports = function(ctx, helpers) {
res.json({ res.json({
success: true, success: true,
message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`, message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`,
results results,
}); });
}, 'apps-restore-all')); }, 'apps-restore-all'));
@@ -81,7 +81,7 @@ module.exports = function(ctx, helpers) {
hasManifest: !!service.deploymentManifest, hasManifest: !!service.deploymentManifest,
templateId: service.deploymentManifest?.templateId || service.appTemplate || null, templateId: service.deploymentManifest?.templateId || service.appTemplate || null,
deployedAt: service.deployedAt || null, deployedAt: service.deployedAt || null,
containerRunning: false containerRunning: false,
}; };
// Check if container is currently running // Check if container is currently running
@@ -125,7 +125,7 @@ module.exports = function(ctx, helpers) {
name: service.name, name: service.name,
status: 'restored', status: 'restored',
type: 'static', type: 'static',
message: `Static site "${service.name}" config preserved` message: `Static site "${service.name}" config preserved`,
}; };
} }
@@ -140,7 +140,7 @@ module.exports = function(ctx, helpers) {
id: service.id, id: service.id,
name: service.name, name: service.name,
status: 'skipped', status: 'skipped',
message: 'Container already running' message: 'Container already running',
}; };
} }
} catch (e) { } catch (e) {
@@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) {
id: service.id, id: service.id,
name: service.name, name: service.name,
status: 'skipped', status: 'skipped',
message: 'Container already running (found by name)' message: 'Container already running (found by name)',
}; };
} }
// Exists but not running — remove stale container // Exists but not running — remove stale container
@@ -178,7 +178,7 @@ module.exports = function(ctx, helpers) {
id: service.id, id: service.id,
name: service.name, name: service.name,
status: 'failed', status: 'failed',
error: 'No container configuration in manifest' error: 'No container configuration in manifest',
}; };
} }
@@ -189,7 +189,7 @@ module.exports = function(ctx, helpers) {
} catch (e) { } catch (e) {
// Check if image exists locally // Check if image exists locally
const images = await ctx.docker.client.listImages({ const images = await ctx.docker.client.listImages({
filters: { reference: [manifest.container.image] } filters: { reference: [manifest.container.image] },
}); });
if (images.length === 0) { if (images.length === 0) {
throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`); throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`);
@@ -206,7 +206,7 @@ module.exports = function(ctx, helpers) {
PortBindings: {}, PortBindings: {},
Binds: manifest.container.volumes || [], Binds: manifest.container.volumes || [],
RestartPolicy: { Name: 'unless-stopped' }, RestartPolicy: { Name: 'unless-stopped' },
LogConfig: DOCKER.LOG_CONFIG LogConfig: DOCKER.LOG_CONFIG,
}, },
Env: Object.entries(manifest.container.environment || {}).map(([k, v]) => `${k}=${v}`), Env: Object.entries(manifest.container.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: { Labels: {
@@ -214,8 +214,8 @@ module.exports = function(ctx, helpers) {
'sami.app': manifest.templateId, 'sami.app': manifest.templateId,
'sami.subdomain': manifest.config.subdomain, 'sami.subdomain': manifest.config.subdomain,
'sami.deployed': new Date().toISOString(), 'sami.deployed': new Date().toISOString(),
'sami.restored': 'true' 'sami.restored': 'true',
} },
}; };
// Set up port bindings // Set up port bindings
@@ -287,7 +287,7 @@ module.exports = function(ctx, helpers) {
status: 'restored', status: 'restored',
type: 'container', type: 'container',
containerId: container.id, containerId: container.id,
message: `${service.name} restored successfully` message: `${service.name} restored successfully`,
}; };
} }

View File

@@ -11,7 +11,7 @@ module.exports = function(ctx, helpers) {
success: true, success: true,
templates: ctx.APP_TEMPLATES, templates: ctx.APP_TEMPLATES,
categories: ctx.TEMPLATE_CATEGORIES, categories: ctx.TEMPLATE_CATEGORIES,
difficultyLevels: ctx.DIFFICULTY_LEVELS difficultyLevels: ctx.DIFFICULTY_LEVELS,
}); });
}, 'apps-templates')); }, 'apps-templates'));
@@ -71,7 +71,7 @@ module.exports = function(ctx, helpers) {
try { try {
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain); const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost' token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost',
}); });
results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage; results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage;
ctx.log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); ctx.log.info('dns', 'Old DNS record deleted', { domain: oldDomain });
@@ -139,7 +139,7 @@ module.exports = function(ctx, helpers) {
success: true, success: true,
message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`, message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`,
newUrl: `https://${ctx.buildDomain(newSubdomain)}`, newUrl: `https://${ctx.buildDomain(newSubdomain)}`,
results results,
}); });
}, 'update-subdomain')); }, 'update-subdomain'));

View File

@@ -11,12 +11,12 @@ module.exports = function(ctx, helpers) {
const results = { radarr: null, sonarr: null }; const results = { radarr: null, sonarr: null };
// Step 1: Authenticate with Overseerr via Plex token // Step 1: Authenticate with Overseerr via Plex token
let overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`; const overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`;
const overseerrSession = await helpers.getOverseerrSession(); const overseerrSession = await helpers.getOverseerrSession();
if (!overseerrSession) { if (!overseerrSession) {
return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', { return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.' hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.',
}); });
} }
@@ -30,8 +30,8 @@ module.exports = function(ctx, helpers) {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cookie': overseerrSession.cookie, 'Cookie': overseerrSession.cookie,
...options.headers ...options.headers,
} },
}); });
return response; return response;
}; };
@@ -41,12 +41,12 @@ module.exports = function(ctx, helpers) {
const statusRes = await overseerrFetch('/api/v1/status'); const statusRes = await overseerrFetch('/api/v1/status');
if (!statusRes.ok) { if (!statusRes.ok) {
return ctx.errorResponse(res, 502, 'Cannot connect to Overseerr', { return ctx.errorResponse(res, 502, 'Cannot connect to Overseerr', {
hint: 'Make sure Overseerr is running on port 5055' hint: 'Make sure Overseerr is running on port 5055',
}); });
} }
} catch (e) { } catch (e) {
return ctx.errorResponse(res, 502, `Cannot reach Overseerr: ${e.message}`, { return ctx.errorResponse(res, 502, `Cannot reach Overseerr: ${e.message}`, {
hint: 'Check if Overseerr container is running' hint: 'Check if Overseerr container is running',
}); });
} }
@@ -59,14 +59,14 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles from Radarr // Fetch quality profiles from Radarr
const profilesRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/qualityprofile`, { const profilesRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': radarr.apiKey } headers: { 'X-Api-Key': radarr.apiKey },
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Radarr // Fetch root folders from Radarr
const rootFoldersRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, { const rootFoldersRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': radarr.apiKey } headers: { 'X-Api-Key': radarr.apiKey },
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies'; const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -87,12 +87,12 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released', minimumAvailability: 'released',
isDefault: true, isDefault: true,
externalUrl: radarr.url, externalUrl: radarr.url,
tags: [] tags: [],
}; };
const radarrRes = await overseerrFetch('/api/v1/settings/radarr', { const radarrRes = await overseerrFetch('/api/v1/settings/radarr', {
method: 'POST', method: 'POST',
body: JSON.stringify(radarrConfig) body: JSON.stringify(radarrConfig),
}); });
if (radarrRes.ok) { if (radarrRes.ok) {
@@ -115,14 +115,14 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles from Sonarr // Fetch quality profiles from Sonarr
const profilesRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/qualityprofile`, { const profilesRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': sonarr.apiKey } headers: { 'X-Api-Key': sonarr.apiKey },
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Sonarr // Fetch root folders from Sonarr
const rootFoldersRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/rootfolder`, { const rootFoldersRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': sonarr.apiKey } headers: { 'X-Api-Key': sonarr.apiKey },
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv'; const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -131,7 +131,7 @@ module.exports = function(ctx, helpers) {
let languageProfileId = 1; let languageProfileId = 1;
try { try {
const langRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, { const langRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': sonarr.apiKey } headers: { 'X-Api-Key': sonarr.apiKey },
}); });
if (langRes.ok) { if (langRes.ok) {
const langProfiles = await langRes.json(); const langProfiles = await langRes.json();
@@ -158,12 +158,12 @@ module.exports = function(ctx, helpers) {
isDefault: true, isDefault: true,
enableSeasonFolders: true, enableSeasonFolders: true,
externalUrl: sonarr.url, externalUrl: sonarr.url,
tags: [] tags: [],
}; };
const sonarrRes = await overseerrFetch('/api/v1/settings/sonarr', { const sonarrRes = await overseerrFetch('/api/v1/settings/sonarr', {
method: 'POST', method: 'POST',
body: JSON.stringify(sonarrConfig) body: JSON.stringify(sonarrConfig),
}); });
if (sonarrRes.ok) { if (sonarrRes.ok) {
@@ -182,7 +182,7 @@ module.exports = function(ctx, helpers) {
res.json({ res.json({
success: anyConfigured, success: anyConfigured,
message: anyConfigured ? 'Services configured in Overseerr' : 'Configuration failed', message: anyConfigured ? 'Services configured in Overseerr' : 'Configuration failed',
results results,
}); });
}, 'arr-configure-overseerr')); }, 'arr-configure-overseerr'));
@@ -210,7 +210,7 @@ module.exports = function(ctx, helpers) {
} }
// Normalize URL - remove trailing slash // Normalize URL - remove trailing slash
let baseUrl = url.replace(/\/+$/, ''); const baseUrl = url.replace(/\/+$/, '');
// Build the API endpoint // Build the API endpoint
let apiEndpoint; let apiEndpoint;
@@ -233,7 +233,7 @@ module.exports = function(ctx, helpers) {
const response = await ctx.fetchT(apiEndpoint, { const response = await ctx.fetchT(apiEndpoint, {
method: 'GET', method: 'GET',
headers, headers,
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
if (response.ok) { if (response.ok) {
@@ -244,7 +244,7 @@ module.exports = function(ctx, helpers) {
return res.json({ return res.json({
success: true, success: true,
version, version,
appName appName,
}); });
} else if (response.status === 401) { } else if (response.status === 401) {
return ctx.errorResponse(res, 401, 'Invalid API key'); return ctx.errorResponse(res, 401, 'Invalid API key');
@@ -288,7 +288,7 @@ module.exports = function(ctx, helpers) {
containerName: container.Names[0]?.replace(/^\//, ''), containerName: container.Names[0]?.replace(/^\//, ''),
port: exposedPort, port: exposedPort,
url: `http://host.docker.internal:${exposedPort}`, url: `http://host.docker.internal:${exposedPort}`,
localUrl: `http://localhost:${exposedPort}` localUrl: `http://localhost:${exposedPort}`,
}; };
// Extract API key for arr services // Extract API key for arr services
@@ -305,7 +305,7 @@ module.exports = function(ctx, helpers) {
radarrFound: !!detected.radarr?.apiKey, radarrFound: !!detected.radarr?.apiKey,
sonarrFound: !!detected.sonarr?.apiKey, sonarrFound: !!detected.sonarr?.apiKey,
lidarrFound: !!detected.lidarr?.apiKey, lidarrFound: !!detected.lidarr?.apiKey,
prowlarrFound: !!detected.prowlarr?.apiKey prowlarrFound: !!detected.prowlarr?.apiKey,
}; };
ctx.log.info('arr', 'Detected services', summary); ctx.log.info('arr', 'Detected services', summary);
@@ -313,14 +313,14 @@ module.exports = function(ctx, helpers) {
if (!summary.overseerrFound) { if (!summary.overseerrFound) {
return ctx.errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', { return ctx.errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', {
detected, detected,
summary summary,
}); });
} }
if (!summary.radarrFound && !summary.sonarrFound) { if (!summary.radarrFound && !summary.sonarrFound) {
return ctx.errorResponse(res, 400, 'No Radarr or Sonarr found with valid API keys. Deploy at least one first.', { return ctx.errorResponse(res, 400, 'No Radarr or Sonarr found with valid API keys. Deploy at least one first.', {
detected, detected,
summary summary,
}); });
} }
@@ -331,7 +331,7 @@ module.exports = function(ctx, helpers) {
return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', { return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
setupUrl: detected.overseerr.localUrl, setupUrl: detected.overseerr.localUrl,
detected, detected,
summary summary,
}); });
} }
@@ -344,8 +344,8 @@ module.exports = function(ctx, helpers) {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cookie': overseerrSession.cookie, 'Cookie': overseerrSession.cookie,
...options.headers ...options.headers,
} },
}); });
}; };
@@ -356,14 +356,14 @@ module.exports = function(ctx, helpers) {
try { try {
// Fetch quality profiles from Radarr // Fetch quality profiles from Radarr
const profilesRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/qualityprofile`, { const profilesRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': detected.radarr.apiKey } headers: { 'X-Api-Key': detected.radarr.apiKey },
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Radarr // Fetch root folders from Radarr
const rootFoldersRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/rootfolder`, { const rootFoldersRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': detected.radarr.apiKey } headers: { 'X-Api-Key': detected.radarr.apiKey },
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies'; const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -384,12 +384,12 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released', minimumAvailability: 'released',
isDefault: true, isDefault: true,
externalUrl: detected.radarr.localUrl, externalUrl: detected.radarr.localUrl,
tags: [] tags: [],
}; };
const resp = await overseerrFetch('/api/v1/settings/radarr', { const resp = await overseerrFetch('/api/v1/settings/radarr', {
method: 'POST', method: 'POST',
body: JSON.stringify(radarrConfig) body: JSON.stringify(radarrConfig),
}); });
configResults.radarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`; configResults.radarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`;
@@ -403,14 +403,14 @@ module.exports = function(ctx, helpers) {
try { try {
// Fetch quality profiles from Sonarr // Fetch quality profiles from Sonarr
const profilesRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/qualityprofile`, { const profilesRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey } headers: { 'X-Api-Key': detected.sonarr.apiKey },
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Sonarr // Fetch root folders from Sonarr
const rootFoldersRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/rootfolder`, { const rootFoldersRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey } headers: { 'X-Api-Key': detected.sonarr.apiKey },
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv'; const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -419,7 +419,7 @@ module.exports = function(ctx, helpers) {
let languageProfileId = 1; let languageProfileId = 1;
try { try {
const langRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, { const langRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey } headers: { 'X-Api-Key': detected.sonarr.apiKey },
}); });
if (langRes.ok) { if (langRes.ok) {
const langProfiles = await langRes.json(); const langProfiles = await langRes.json();
@@ -444,12 +444,12 @@ module.exports = function(ctx, helpers) {
isDefault: true, isDefault: true,
enableSeasonFolders: true, enableSeasonFolders: true,
externalUrl: detected.sonarr.localUrl, externalUrl: detected.sonarr.localUrl,
tags: [] tags: [],
}; };
const resp = await overseerrFetch('/api/v1/settings/sonarr', { const resp = await overseerrFetch('/api/v1/settings/sonarr', {
method: 'POST', method: 'POST',
body: JSON.stringify(sonarrConfig) body: JSON.stringify(sonarrConfig),
}); });
configResults.sonarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`; configResults.sonarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`;
@@ -466,7 +466,7 @@ module.exports = function(ctx, helpers) {
'deploymentSuccess', 'deploymentSuccess',
'Arr Stack Auto-Connected', 'Arr Stack Auto-Connected',
`Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`, `Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`,
'success' 'success',
); );
} }
@@ -475,7 +475,7 @@ module.exports = function(ctx, helpers) {
message: anyConfigured ? 'Auto-setup completed successfully!' : 'Configuration failed', message: anyConfigured ? 'Auto-setup completed successfully!' : 'Configuration failed',
detected, detected,
configResults, configResults,
summary summary,
}); });
}, 'arr-auto-setup')); }, 'arr-auto-setup'));

View File

@@ -39,7 +39,7 @@ module.exports = function(ctx, helpers) {
service, service,
source: url ? 'external' : 'local', source: url ? 'external' : 'local',
url: url || null, url: url || null,
storedAt: new Date().toISOString() storedAt: new Date().toISOString(),
}; };
// Test connection if URL is known // Test connection if URL is known
@@ -77,7 +77,7 @@ module.exports = function(ctx, helpers) {
return ctx.errorResponse(res, 400, 'Invalid seedbox base URL'); return ctx.errorResponse(res, 400, 'Invalid seedbox base URL');
} }
await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, { await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, {
storedAt: new Date().toISOString() storedAt: new Date().toISOString(),
}); });
} }
@@ -87,7 +87,7 @@ module.exports = function(ctx, helpers) {
success: true, success: true,
message: `${service} API key stored`, message: `${service} API key stored`,
connectionTest, connectionTest,
url: resolvedUrl url: resolvedUrl,
}); });
}, 'arr-credentials-store')); }, 'arr-credentials-store'));
@@ -106,7 +106,7 @@ module.exports = function(ctx, helpers) {
url: metadata?.url || null, url: metadata?.url || null,
lastVerified: metadata?.lastVerified || null, lastVerified: metadata?.lastVerified || null,
version: metadata?.version || null, version: metadata?.version || null,
source: metadata?.source || null source: metadata?.source || null,
}; };
} }

View File

@@ -13,7 +13,7 @@ module.exports = function(ctx, helpers) {
sonarr: null, sonarr: null,
overseerr: null, overseerr: null,
lidarr: null, lidarr: null,
prowlarr: null prowlarr: null,
}; };
// Service detection patterns // Service detection patterns
@@ -35,7 +35,7 @@ module.exports = function(ctx, helpers) {
image: container.Image, image: container.Image,
port: exposedPort, port: exposedPort,
status: container.State, status: container.State,
url: helpers.getServiceUrl(containerName, exposedPort) url: helpers.getServiceUrl(containerName, exposedPort),
}; };
// Get API key for arr services (not Plex or Overseerr) // Get API key for arr services (not Plex or Overseerr)
@@ -58,8 +58,8 @@ module.exports = function(ctx, helpers) {
plexReady: !!(detected.plex?.token), plexReady: !!(detected.plex?.token),
radarrReady: !!(detected.radarr?.apiKey), radarrReady: !!(detected.radarr?.apiKey),
sonarrReady: !!(detected.sonarr?.apiKey), sonarrReady: !!(detected.sonarr?.apiKey),
overseerrRunning: !!detected.overseerr overseerrRunning: !!detected.overseerr,
} },
}); });
}, 'arr-detect')); }, 'arr-detect'));
@@ -86,7 +86,7 @@ module.exports = function(ctx, helpers) {
containerId: container.Id, containerId: container.Id,
containerName: container.Names[0]?.replace(/^\//, ''), containerName: container.Names[0]?.replace(/^\//, ''),
port: portInfo?.PublicPort || config.port, port: portInfo?.PublicPort || config.port,
status: container.State status: container.State,
}; };
} }
} }
@@ -122,7 +122,7 @@ module.exports = function(ctx, helpers) {
hasToken: false, hasToken: false,
containerId: null, containerId: null,
containerName: null, containerName: null,
version: null version: null,
}; };
// Check Docker first // Check Docker first
@@ -143,7 +143,7 @@ module.exports = function(ctx, helpers) {
// Store for later use // Store for later use
await ctx.credentialManager.store('arr.plex.token', token, { await ctx.credentialManager.store('arr.plex.token', token, {
service: 'plex', source: 'local', url: entry.url, service: 'plex', source: 'local', url: entry.url,
lastVerified: new Date().toISOString() lastVerified: new Date().toISOString(),
}); });
} else { } else {
entry.status = 'needs_key'; entry.status = 'needs_key';
@@ -160,7 +160,7 @@ module.exports = function(ctx, helpers) {
try { try {
const radarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, { const radarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, {
headers: { 'Cookie': session.cookie }, headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000),
}); });
if (radarrCheck.ok) { if (radarrCheck.ok) {
const radarrSettings = await radarrCheck.json(); const radarrSettings = await radarrCheck.json();
@@ -170,7 +170,7 @@ module.exports = function(ctx, helpers) {
try { try {
const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, { const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, {
headers: { 'Cookie': session.cookie }, headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000),
}); });
if (sonarrCheck.ok) { if (sonarrCheck.ok) {
const sonarrSettings = await sonarrCheck.json(); const sonarrSettings = await sonarrCheck.json();
@@ -180,7 +180,7 @@ module.exports = function(ctx, helpers) {
try { try {
const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, { const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, {
headers: { 'Cookie': session.cookie }, headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000),
}); });
if (plexCheck.ok) { if (plexCheck.ok) {
const plexSettings = await plexCheck.json(); const plexSettings = await plexCheck.json();
@@ -273,7 +273,7 @@ module.exports = function(ctx, helpers) {
fullyConnected: statuses.filter(s => s.status === 'connected').length, fullyConnected: statuses.filter(s => s.status === 'connected').length,
needsApiKey: statuses.filter(s => s.status === 'needs_key').length, needsApiKey: statuses.filter(s => s.status === 'needs_key').length,
errors: statuses.filter(s => s.status === 'error').length, errors: statuses.filter(s => s.status === 'error').length,
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2 readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2,
}; };
res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary }); res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary });

View File

@@ -12,7 +12,7 @@ module.exports = function(ctx) {
const exec = await dockerContainer.exec({ const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/config.xml'], Cmd: ['cat', '/config/config.xml'],
AttachStdout: true, AttachStdout: true,
AttachStderr: true AttachStderr: true,
}); });
const stream = await exec.start(); const stream = await exec.start();
@@ -38,7 +38,7 @@ module.exports = function(ctx) {
try { try {
const containers = await ctx.docker.client.listContainers({ all: false }); const containers = await ctx.docker.client.listContainers({ all: false });
const container = containers.find(c => const container = containers.find(c =>
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')) c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')),
); );
if (!container) return null; if (!container) return null;
@@ -47,7 +47,7 @@ module.exports = function(ctx) {
const exec = await dockerContainer.exec({ const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'], Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'],
AttachStdout: true, AttachStdout: true,
AttachStderr: true AttachStderr: true,
}); });
const stream = await exec.start(); const stream = await exec.start();
@@ -97,7 +97,7 @@ module.exports = function(ctx) {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authToken: plexToken }), body: JSON.stringify({ authToken: plexToken }),
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
if (!authRes.ok) { if (!authRes.ok) {
@@ -125,7 +125,7 @@ module.exports = function(ctx) {
// 1. Get Plex server identity (for return info) // 1. Get Plex server identity (for return info)
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
if (!identityRes.ok) throw new Error('Cannot reach Plex server'); if (!identityRes.ok) throw new Error('Cannot reach Plex server');
const identity = await identityRes.json(); const identity = await identityRes.json();
@@ -136,16 +136,16 @@ module.exports = function(ctx) {
const plexConfig = { const plexConfig = {
ip: 'host.docker.internal', ip: 'host.docker.internal',
port: APP_PORTS.plex, port: APP_PORTS.plex,
useSsl: false useSsl: false,
}; };
const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cookie': sessionCookie 'Cookie': sessionCookie,
}, },
body: JSON.stringify(plexConfig) body: JSON.stringify(plexConfig),
}); });
if (!configRes.ok) { if (!configRes.ok) {
@@ -157,7 +157,7 @@ module.exports = function(ctx) {
await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, { await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, {
method: 'POST', method: 'POST',
headers: { 'Cookie': sessionCookie }, headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
} catch (e) { } catch (e) {
ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message }); ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message });
@@ -168,7 +168,7 @@ module.exports = function(ctx) {
try { try {
const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
headers: { 'Cookie': sessionCookie }, headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000),
}); });
if (libRes.ok) { if (libRes.ok) {
const plexSettings = await libRes.json(); const plexSettings = await libRes.json();
@@ -188,7 +188,7 @@ module.exports = function(ctx) {
try { try {
const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, {
headers: { 'X-Api-Key': prowlarrApiKey }, headers: { 'X-Api-Key': prowlarrApiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
existingApps = existingRes.ok ? await existingRes.json() : []; existingApps = existingRes.ok ? await existingRes.json() : [];
} catch (e) { } catch (e) {
@@ -217,8 +217,8 @@ module.exports = function(ctx) {
{ name: 'prowlarrUrl', value: prowlarrUrl }, { name: 'prowlarrUrl', value: prowlarrUrl },
{ name: 'baseUrl', value: config.url }, { name: 'baseUrl', value: config.url },
{ name: 'apiKey', value: config.apiKey }, { name: 'apiKey', value: config.apiKey },
{ name: 'syncCategories', value: syncCategories } { name: 'syncCategories', value: syncCategories },
] ],
}; };
try { try {
@@ -226,10 +226,10 @@ module.exports = function(ctx) {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Api-Key': prowlarrApiKey 'X-Api-Key': prowlarrApiKey,
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`; results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`;
} catch (e) { } catch (e) {
@@ -262,7 +262,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(apiEndpoint, { const response = await ctx.fetchT(apiEndpoint, {
method: 'GET', method: 'GET',
headers, headers,
signal: AbortSignal.timeout(15000) signal: AbortSignal.timeout(15000),
}); });
if (response.ok) { if (response.ok) {
@@ -297,6 +297,6 @@ module.exports = function(ctx) {
getOverseerrApiKey, getOverseerrApiKey,
connectPlexToOverseerr, connectPlexToOverseerr,
configureProwlarrApps, configureProwlarrApps,
testServiceConnection testServiceConnection,
}; };
}; };

View File

@@ -14,7 +14,7 @@ module.exports = function(ctx, helpers) {
if (!plexToken) { if (!plexToken) {
return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', { return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', {
hint: 'Deploy Plex with a claim token or manually configure it.' hint: 'Deploy Plex with a claim token or manually configure it.',
}); });
} }
@@ -32,7 +32,7 @@ module.exports = function(ctx, helpers) {
// Fetch libraries // Fetch libraries
const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, { const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
if (!libRes.ok) { if (!libRes.ok) {
@@ -45,7 +45,7 @@ module.exports = function(ctx, helpers) {
title: dir.title, title: dir.title,
type: dir.type, type: dir.type,
count: parseInt(dir.count) || 0, count: parseInt(dir.count) || 0,
scannedAt: dir.scannedAt scannedAt: dir.scannedAt,
})); }));
// Get server name // Get server name
@@ -54,7 +54,7 @@ module.exports = function(ctx, helpers) {
try { try {
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000),
}); });
if (identityRes.ok) { if (identityRes.ok) {
const identity = await identityRes.json(); const identity = await identityRes.json();
@@ -66,7 +66,7 @@ module.exports = function(ctx, helpers) {
// Store token for future use // Store token for future use
await ctx.credentialManager.store('arr.plex.token', plexToken, { await ctx.credentialManager.store('arr.plex.token', plexToken, {
service: 'plex', source: 'local', url: plexUrl, service: 'plex', source: 'local', url: plexUrl,
lastVerified: new Date().toISOString() lastVerified: new Date().toISOString(),
}); });
res.json({ success: true, serverName, version, libraries }); res.json({ success: true, serverName, version, libraries });

View File

@@ -44,7 +44,7 @@ module.exports = function(ctx, helpers) {
steps.push({ steps.push({
step: `Test ${svc.charAt(0).toUpperCase() + svc.slice(1)} connection`, step: `Test ${svc.charAt(0).toUpperCase() + svc.slice(1)} connection`,
status: test.success ? 'success' : 'failed', status: test.success ? 'success' : 'failed',
details: test.success ? `v${test.version}` : test.error details: test.success ? `v${test.version}` : test.error,
}); });
if (test.success) { if (test.success) {
@@ -55,12 +55,12 @@ module.exports = function(ctx, helpers) {
const stored = await ctx.credentialManager.store(`arr.${svc}.apikey`, apiKey, { const stored = await ctx.credentialManager.store(`arr.${svc}.apikey`, apiKey, {
service: svc, source: 'external', url, service: svc, source: 'external', url,
lastVerified: new Date().toISOString(), lastVerified: new Date().toISOString(),
version: test.version version: test.version,
}); });
steps.push({ steps.push({
step: `Save ${svc} credentials`, step: `Save ${svc} credentials`,
status: stored ? 'success' : 'failed', status: stored ? 'success' : 'failed',
details: stored ? 'Encrypted and saved' : 'Storage failed' details: stored ? 'Encrypted and saved' : 'Storage failed',
}); });
} }
} }
@@ -94,7 +94,7 @@ module.exports = function(ctx, helpers) {
steps.push({ steps.push({
step: 'Get Overseerr API key', step: 'Get Overseerr API key',
status: 'failed', status: 'failed',
details: 'Could not authenticate with Overseerr (Plex not running or not linked)' details: 'Could not authenticate with Overseerr (Plex not running or not linked)',
}); });
} else { } else {
steps.push({ step: 'Get Overseerr API key', status: 'success', details: 'Extracted from container' }); steps.push({ step: 'Get Overseerr API key', status: 'success', details: 'Extracted from container' });
@@ -110,7 +110,7 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles // Fetch quality profiles
const profilesRes = await ctx.fetchT(`${radarrUrl}/api/v3/qualityprofile`, { const profilesRes = await ctx.fetchT(`${radarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
@@ -118,7 +118,7 @@ module.exports = function(ctx, helpers) {
// Fetch root folders // Fetch root folders
const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, { const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies'; const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -141,20 +141,20 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released', minimumAvailability: 'released',
isDefault: true, isDefault: true,
externalUrl: connectedServices.radarr.url, externalUrl: connectedServices.radarr.url,
tags: [] tags: [],
}; };
const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, { const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie },
body: JSON.stringify(radarrConfig), body: JSON.stringify(radarrConfig),
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
steps.push({ steps.push({
step: 'Configure Radarr in Overseerr', step: 'Configure Radarr in Overseerr',
status: radarrRes.ok ? 'success' : 'failed', status: radarrRes.ok ? 'success' : 'failed',
details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text() details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text(),
}); });
} catch (e) { } catch (e) {
steps.push({ step: 'Configure Radarr in Overseerr', status: 'failed', details: e.message }); steps.push({ step: 'Configure Radarr in Overseerr', status: 'failed', details: e.message });
@@ -170,14 +170,14 @@ module.exports = function(ctx, helpers) {
const profilesRes = await ctx.fetchT(`${sonarrUrl}/api/v3/qualityprofile`, { const profilesRes = await ctx.fetchT(`${sonarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, { const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv'; const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -186,7 +186,7 @@ module.exports = function(ctx, helpers) {
try { try {
const langRes = await ctx.fetchT(`${sonarrUrl}/api/v3/languageprofile`, { const langRes = await ctx.fetchT(`${sonarrUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000),
}); });
if (langRes.ok) { if (langRes.ok) {
const langProfiles = await langRes.json(); const langProfiles = await langRes.json();
@@ -212,20 +212,20 @@ module.exports = function(ctx, helpers) {
isDefault: true, isDefault: true,
enableSeasonFolders: true, enableSeasonFolders: true,
externalUrl: connectedServices.sonarr.url, externalUrl: connectedServices.sonarr.url,
tags: [] tags: [],
}; };
const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, { const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie },
body: JSON.stringify(sonarrConfig), body: JSON.stringify(sonarrConfig),
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000),
}); });
steps.push({ steps.push({
step: 'Configure Sonarr in Overseerr', step: 'Configure Sonarr in Overseerr',
status: sonarrRes.ok ? 'success' : 'failed', status: sonarrRes.ok ? 'success' : 'failed',
details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text() details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text(),
}); });
} catch (e) { } catch (e) {
steps.push({ step: 'Configure Sonarr in Overseerr', status: 'failed', details: e.message }); steps.push({ step: 'Configure Sonarr in Overseerr', status: 'failed', details: e.message });
@@ -239,7 +239,7 @@ module.exports = function(ctx, helpers) {
steps.push({ steps.push({
step: 'Connect Plex to Overseerr', step: 'Connect Plex to Overseerr',
status: 'success', status: 'success',
details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced` details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced`,
}); });
} catch (e) { } catch (e) {
steps.push({ step: 'Connect Plex to Overseerr', status: 'failed', details: e.message }); steps.push({ step: 'Connect Plex to Overseerr', status: 'failed', details: e.message });
@@ -259,13 +259,13 @@ module.exports = function(ctx, helpers) {
const prowlarrResults = await helpers.configureProwlarrApps( const prowlarrResults = await helpers.configureProwlarrApps(
connectedServices.prowlarr.url.replace(/\/+$/, ''), connectedServices.prowlarr.url.replace(/\/+$/, ''),
connectedServices.prowlarr.apiKey, connectedServices.prowlarr.apiKey,
appsToConnect appsToConnect,
); );
for (const [app, status] of Object.entries(prowlarrResults)) { for (const [app, status] of Object.entries(prowlarrResults)) {
steps.push({ steps.push({
step: `Add ${app.charAt(0).toUpperCase() + app.slice(1)} to Prowlarr`, step: `Add ${app.charAt(0).toUpperCase() + app.slice(1)} to Prowlarr`,
status: status === 'configured' || status === 'already_configured' ? 'success' : 'failed', status: status === 'configured' || status === 'already_configured' ? 'success' : 'failed',
details: status details: status,
}); });
} }
} catch (e) { } catch (e) {
@@ -283,14 +283,14 @@ module.exports = function(ctx, helpers) {
'deploymentSuccess', 'deploymentSuccess',
'Smart Arr Connect Complete', 'Smart Arr Connect Complete',
`${succeeded}/${steps.length} steps completed successfully`, `${succeeded}/${steps.length} steps completed successfully`,
'success' 'success',
); );
} }
res.json({ res.json({
success: succeeded > 0, success: succeeded > 0,
steps, steps,
summary: { totalSteps: steps.length, succeeded, failed } summary: { totalSteps: steps.length, succeeded, failed },
}); });
}, 'smart-connect')); }, 'smart-connect'));

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
m: 60 * 1000, m: 60 * 1000,
h: 60 * 60 * 1000, h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000, d: 24 * 60 * 60 * 1000,
y: 365 * 24 * 60 * 60 * 1000 y: 365 * 24 * 60 * 60 * 1000,
}; };
return value * (multipliers[unit] || multipliers.h); return value * (multipliers[unit] || multipliers.h);
@@ -54,7 +54,7 @@ module.exports = function(ctx) {
const keyData = await ctx.authManager.generateAPIKey( const keyData = await ctx.authManager.generateAPIKey(
name.trim(), name.trim(),
scopes || ['read', 'write'] scopes || ['read', 'write'],
); );
res.json({ res.json({
@@ -64,7 +64,7 @@ module.exports = function(ctx) {
name: keyData.name, name: keyData.name,
scopes: keyData.scopes, scopes: keyData.scopes,
createdAt: keyData.createdAt, createdAt: keyData.createdAt,
warning: 'Save this key securely - it will not be shown again' warning: 'Save this key securely - it will not be shown again',
}); });
}, 'auth-keys-generate')); }, 'auth-keys-generate'));
@@ -109,9 +109,9 @@ module.exports = function(ctx) {
const token = await ctx.authManager.generateJWT( const token = await ctx.authManager.generateJWT(
{ {
sub: userId || 'dashcaddy-admin', sub: userId || 'dashcaddy-admin',
scope: ['admin'] // Session-generated JWTs have admin scope scope: ['admin'], // Session-generated JWTs have admin scope
}, },
expiresIn || '24h' expiresIn || '24h',
); );
// Calculate expiration timestamp // Calculate expiration timestamp
@@ -122,7 +122,7 @@ module.exports = function(ctx) {
success: true, success: true,
token, token,
expiresAt, expiresAt,
usage: 'Include in Authorization header as: Bearer <token>' usage: 'Include in Authorization header as: Bearer <token>',
}); });
}, 'auth-jwt-generate')); }, 'auth-jwt-generate'));

View File

@@ -29,7 +29,7 @@ module.exports = function(ctx) {
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const proc = spawnSync('wget', [ const proc = spawnSync('wget', [
'-q', '-S', `--post-data=${routerBody}`, '-O', '/dev/null', '-q', '-S', `--post-data=${routerBody}`, '-O', '/dev/null',
`${baseUrl}/cgi-bin/login.ha` `${baseUrl}/cgi-bin/login.ha`,
], { timeout: 5000, encoding: 'utf8' }); ], { timeout: 5000, encoding: 'utf8' });
const result = (proc.stderr || '').split('\n').slice(0, 2).join('\n'); const result = (proc.stderr || '').split('\n').slice(0, 2).join('\n');
const locationMatch = result.match(/Location:\s*(.+)/); const locationMatch = result.match(/Location:\s*(.+)/);

View File

@@ -10,8 +10,8 @@ module.exports = function(ctx) {
config: { config: {
enabled: ctx.totpConfig.enabled, enabled: ctx.totpConfig.enabled,
sessionDuration: ctx.totpConfig.sessionDuration, sessionDuration: ctx.totpConfig.sessionDuration,
isSetUp: ctx.totpConfig.isSetUp isSetUp: ctx.totpConfig.isSetUp,
} },
}); });
}, 'totp-config-get')); }, 'totp-config-get'));
@@ -35,7 +35,7 @@ module.exports = function(ctx) {
const otpauth = authenticator.keyuri('user', 'DashCaddy', secret); const otpauth = authenticator.keyuri('user', 'DashCaddy', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth, { const qrDataUrl = await QRCode.toDataURL(otpauth, {
width: 256, margin: 2, width: 256, margin: 2,
color: { dark: '#ffffff', light: '#00000000' } color: { dark: '#ffffff', light: '#00000000' },
}); });
res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret }); res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret });
@@ -166,7 +166,7 @@ module.exports = function(ctx) {
if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) { if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) {
return ctx.errorResponse(res, 400, 'Invalid session duration', { return ctx.errorResponse(res, 400, 'Invalid session duration', {
validOptions: Object.keys(ctx.session.durations) validOptions: Object.keys(ctx.session.durations),
}); });
} }
@@ -180,7 +180,7 @@ module.exports = function(ctx) {
await ctx.saveTotpConfig(); await ctx.saveTotpConfig();
res.json({ res.json({
success: true, success: true,
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp } config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp },
}); });
}, 'totp-config')); }, 'totp-config'));

View File

@@ -24,7 +24,7 @@ module.exports = function(ctx) {
const allRoots = BROWSE_ROOTS.map(r => ({ const allRoots = BROWSE_ROOTS.map(r => ({
name: r.hostPath, name: r.hostPath,
path: r.hostPath, path: r.hostPath,
containerPath: r.containerPath containerPath: r.containerPath,
})); }));
const roots = []; const roots = [];
@@ -45,7 +45,7 @@ module.exports = function(ctx) {
const allRoots = BROWSE_ROOTS.map(r => ({ const allRoots = BROWSE_ROOTS.map(r => ({
name: r.hostPath, name: r.hostPath,
path: r.hostPath, path: r.hostPath,
type: 'drive' type: 'drive',
})); }));
const roots = []; const roots = [];
for (const r of allRoots) { for (const r of allRoots) {
@@ -58,12 +58,12 @@ module.exports = function(ctx) {
} }
const matchingRoot = BROWSE_ROOTS.find(r => const matchingRoot = BROWSE_ROOTS.find(r =>
requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, '') requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, ''),
); );
if (!matchingRoot) { if (!matchingRoot) {
return ctx.errorResponse(res, 400, 'Path not in browseable roots', { return ctx.errorResponse(res, 400, 'Path not in browseable roots', {
availableRoots: BROWSE_ROOTS.map(r => r.hostPath) availableRoots: BROWSE_ROOTS.map(r => r.hostPath),
}); });
} }
@@ -80,7 +80,7 @@ module.exports = function(ctx) {
requestedPath, containerFullPath, allowedRoots, requestedPath, containerFullPath, allowedRoots,
error: error.message, error: error.message,
ip: req.ip, ip: req.ip,
userAgent: req.get('user-agent') userAgent: req.get('user-agent'),
}); });
return ctx.errorResponse(res, 403, 'Access denied - path traversal detected'); return ctx.errorResponse(res, 403, 'Access denied - path traversal detected');
} }
@@ -108,7 +108,7 @@ module.exports = function(ctx) {
.map(entry => ({ .map(entry => ({
name: entry.name, name: entry.name,
path: path.join(requestedPath, entry.name).replace(/\\/g, '/'), path: path.join(requestedPath, entry.name).replace(/\\/g, '/'),
type: 'folder' type: 'folder',
})) }))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
@@ -119,7 +119,7 @@ module.exports = function(ctx) {
path: requestedPath, path: requestedPath,
parent: path.dirname(requestedPath).replace(/\\/g, '/') || null, parent: path.dirname(requestedPath).replace(/\\/g, '/') || null,
items: result.data, items: result.data,
...(result.pagination && { pagination: result.pagination }) ...(result.pagination && { pagination: result.pagination }),
}); });
}, 'browse-dir')); }, 'browse-dir'));
@@ -128,12 +128,12 @@ module.exports = function(ctx) {
const mediaServerPatterns = [ const mediaServerPatterns = [
'plex', 'jellyfin', 'emby', 'kodi', 'navidrome', 'airsonic', 'plex', 'jellyfin', 'emby', 'kodi', 'navidrome', 'airsonic',
'subsonic', 'funkwhale', 'beets', 'lidarr', 'sonarr', 'radarr', 'subsonic', 'funkwhale', 'beets', 'lidarr', 'sonarr', 'radarr',
'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli' 'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli',
]; ];
const excludePatterns = [ const excludePatterns = [
'/config', '/cache', '/transcode', '/data/config', '/app', '/config', '/cache', '/transcode', '/data/config', '/app',
'/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile' '/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile',
]; ];
const containers = await ctx.docker.client.listContainers({ all: false }); const containers = await ctx.docker.client.listContainers({ all: false });
@@ -155,7 +155,7 @@ module.exports = function(ctx) {
let hostPath, containerPath; let hostPath, containerPath;
if (parts[0].length === 1 && /[A-Za-z]/.test(parts[0])) { if (parts[0].length === 1 && /[A-Za-z]/.test(parts[0])) {
hostPath = parts[0] + ':' + parts[1]; hostPath = `${parts[0] }:${ parts[1]}`;
containerPath = parts[2] || ''; containerPath = parts[2] || '';
} else { } else {
hostPath = parts[0]; hostPath = parts[0];
@@ -164,7 +164,7 @@ module.exports = function(ctx) {
const isExcluded = excludePatterns.some(p => const isExcluded = excludePatterns.some(p =>
containerPath.toLowerCase().includes(p.toLowerCase()) || containerPath.toLowerCase().includes(p.toLowerCase()) ||
hostPath.toLowerCase().includes(p.toLowerCase()) hostPath.toLowerCase().includes(p.toLowerCase()),
); );
if (isExcluded) continue; if (isExcluded) continue;
if (seenPaths.has(hostPath)) continue; if (seenPaths.has(hostPath)) continue;
@@ -175,7 +175,7 @@ module.exports = function(ctx) {
detectedMounts.push({ detectedMounts.push({
hostPath, containerPath, folderName, hostPath, containerPath, folderName,
sourceContainer: containerInfo.Names[0]?.replace('/', '') || containerInfo.Id.slice(0, 12), sourceContainer: containerInfo.Names[0]?.replace('/', '') || containerInfo.Id.slice(0, 12),
sourceImage: containerInfo.Image.split('/').pop().split(':')[0] sourceImage: containerInfo.Image.split('/').pop().split(':')[0],
}); });
} }
} }
@@ -185,7 +185,7 @@ module.exports = function(ctx) {
mounts: detectedMounts, mounts: detectedMounts,
message: detectedMounts.length > 0 message: detectedMounts.length > 0
? `Found ${detectedMounts.length} media mount(s) from existing containers` ? `Found ${detectedMounts.length} media mount(s) from existing containers`
: 'No existing media mounts detected' : 'No existing media mounts detected',
}); });
}, 'detect-media-mounts')); }, 'detect-media-mounts'));

View File

@@ -38,8 +38,8 @@ module.exports = function(ctx) {
daysUntilExpiration, daysUntilExpiration,
algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256', algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256',
serialNumber: certInfo.serialNumber, serialNumber: certInfo.serialNumber,
downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt` downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt`,
} },
}); });
}, 'ca-info')); }, 'ca-info'));
@@ -99,7 +99,7 @@ module.exports = function(ctx) {
// Look for template in multiple locations (packaged app vs dev) // Look for template in multiple locations (packaged app vs dev)
const templatePaths = [ const templatePaths = [
path.join(__dirname, '..', 'scripts', templateName), path.join(__dirname, '..', 'scripts', templateName),
path.join('/app', 'scripts', templateName) path.join('/app', 'scripts', templateName),
]; ];
let templateContent; let templateContent;
@@ -208,12 +208,12 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
const serverCertContent = await fsp.readFile(certFile, 'utf8'); const serverCertContent = await fsp.readFile(certFile, 'utf8');
const intermediateCertContent = await fsp.readFile(intermediateCert, 'utf8'); const intermediateCertContent = await fsp.readFile(intermediateCert, 'utf8');
const rootCertContent = await fsp.readFile(rootCert, 'utf8'); const rootCertContent = await fsp.readFile(rootCert, 'utf8');
await fsp.writeFile(fullChainFile, serverCertContent + '\n' + intermediateCertContent + '\n' + rootCertContent); await fsp.writeFile(fullChainFile, `${serverCertContent }\n${ intermediateCertContent }\n${ rootCertContent}`);
execSync(`openssl pkcs12 -export -out "${pfxFile}" -inkey "${keyFile}" -in "${certFile}" -certfile "${intermediateCert}" -password "pass:${password}"`, { stdio: 'pipe' }); execSync(`openssl pkcs12 -export -out "${pfxFile}" -inkey "${keyFile}" -in "${certFile}" -certfile "${intermediateCert}" -password "pass:${password}"`, { stdio: 'pipe' });
const keyContent = await fsp.readFile(keyFile, 'utf8'); const keyContent = await fsp.readFile(keyFile, 'utf8');
await fsp.writeFile(pemFile, keyContent + '\n' + serverCertContent + '\n' + intermediateCertContent); await fsp.writeFile(pemFile, `${keyContent }\n${ serverCertContent }\n${ intermediateCertContent}`);
} }
if (format === 'pfx') { if (format === 'pfx') {
@@ -274,7 +274,7 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
domain, subject, domain, subject,
validFrom: notBefore, validUntil: notAfter, validFrom: notBefore, validUntil: notAfter,
daysUntilExpiration, fingerprint, daysUntilExpiration, fingerprint,
status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid' status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid',
}; };
} catch { } catch {
return null; return null;

View File

@@ -56,7 +56,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
path: `/assets/${safeFilename}`, path: `/assets/${safeFilename}`,
message: `Logo saved to ${filePath}` message: `Logo saved to ${filePath}`,
}); });
}, 'assets-upload')); }, 'assets-upload'));
@@ -75,7 +75,7 @@ module.exports = function(ctx) {
customLogo: config.customLogo || config.customLogoDark || null, customLogo: config.customLogo || config.customLogoDark || null,
position: config.logoPosition || 'left', position: config.logoPosition || 'left',
dashboardTitle: config.dashboardTitle || 'DashCaddy', dashboardTitle: config.dashboardTitle || 'DashCaddy',
isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo,
}); });
}, 'logo-get')); }, 'logo-get'));
@@ -153,7 +153,7 @@ module.exports = function(ctx) {
path: pathDark || pathLight, path: pathDark || pathLight,
position: config.logoPosition || 'left', position: config.logoPosition || 'left',
dashboardTitle: config.dashboardTitle || 'DashCaddy', dashboardTitle: config.dashboardTitle || 'DashCaddy',
message: 'Branding settings saved' message: 'Branding settings saved',
}); });
}, 'logo-upload')); }, 'logo-upload'));
@@ -186,7 +186,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: 'Branding reset to defaults' message: 'Branding reset to defaults',
}); });
}, 'logo-delete')); }, 'logo-delete'));
@@ -199,7 +199,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
customFavicon: config.customFavicon || null, customFavicon: config.customFavicon || null,
isDefault: !config.customFavicon isDefault: !config.customFavicon,
}); });
}, 'favicon-get')); }, 'favicon-get'));
@@ -237,8 +237,8 @@ module.exports = function(ctx) {
sharp(buffer) sharp(buffer)
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png() .png()
.toBuffer() .toBuffer(),
) ),
); );
// Convert to ICO // Convert to ICO
@@ -261,7 +261,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
path: '/assets/favicon.ico', path: '/assets/favicon.ico',
message: 'Favicon created successfully' message: 'Favicon created successfully',
}); });
}, 'favicon')); }, 'favicon'));
@@ -285,7 +285,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: 'Favicon reset to default' message: 'Favicon reset to default',
}); });
}, 'favicon-delete')); }, 'favicon-delete'));

View File

@@ -34,7 +34,7 @@ module.exports = function(ctx) {
dashcaddyVersion: '1.0.0', dashcaddyVersion: '1.0.0',
files: {}, files: {},
themes: {}, themes: {},
assets: {} assets: {},
}; };
// Collect all configuration files (encryption key now included for self-contained restore) // Collect all configuration files (encryption key now included for self-contained restore)
@@ -48,7 +48,7 @@ module.exports = function(ctx) {
{ key: 'encryptionKey', path: ENCRYPTION_KEY_FILE, required: false }, { key: 'encryptionKey', path: ENCRYPTION_KEY_FILE, required: false },
{ key: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false }, { key: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false },
{ key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false }, { key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false },
{ key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false } { key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false },
]; ];
for (const file of filesToBackup) { for (const file of filesToBackup) {
@@ -59,12 +59,12 @@ module.exports = function(ctx) {
try { try {
backup.files[file.key] = { backup.files[file.key] = {
type: 'json', type: 'json',
data: JSON.parse(content) data: JSON.parse(content),
}; };
} catch { } catch {
backup.files[file.key] = { backup.files[file.key] = {
type: 'text', type: 'text',
data: content data: content,
}; };
} }
} else if (file.required) { } else if (file.required) {
@@ -85,7 +85,7 @@ module.exports = function(ctx) {
const otpauth = authenticator.keyuri('user', 'DashCaddy', secret); const otpauth = authenticator.keyuri('user', 'DashCaddy', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth, { const qrDataUrl = await QRCode.toDataURL(otpauth, {
width: 256, margin: 2, width: 256, margin: 2,
color: { dark: '#000000', light: '#ffffff' } color: { dark: '#000000', light: '#ffffff' },
}); });
backup.totp = { qrCode: qrDataUrl, issuer: 'DashCaddy' }; backup.totp = { qrCode: qrDataUrl, issuer: 'DashCaddy' };
} }
@@ -140,7 +140,7 @@ module.exports = function(ctx) {
valid: true, valid: true,
version: backup.version, version: backup.version,
exportedAt: backup.exportedAt, exportedAt: backup.exportedAt,
files: {} files: {},
}; };
// Check each file in the backup // Check each file in the backup
@@ -154,7 +154,7 @@ module.exports = function(ctx) {
encryptionKey: { path: ENCRYPTION_KEY_FILE, description: 'Encryption key (for credentials)' }, encryptionKey: { path: ENCRYPTION_KEY_FILE, description: 'Encryption key (for credentials)' },
totpConfig: { path: ctx.TOTP_CONFIG_FILE, description: 'TOTP authentication config' }, totpConfig: { path: ctx.TOTP_CONFIG_FILE, description: 'TOTP authentication config' },
tailscaleConfig: { path: ctx.TAILSCALE_CONFIG_FILE, description: 'Tailscale config' }, tailscaleConfig: { path: ctx.TAILSCALE_CONFIG_FILE, description: 'Tailscale config' },
notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' } notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' },
}; };
for (const [key, value] of Object.entries(backup.files)) { for (const [key, value] of Object.entries(backup.files)) {
@@ -167,7 +167,7 @@ module.exports = function(ctx) {
inBackup: true, inBackup: true,
currentExists, currentExists,
action: currentExists ? 'overwrite' : 'create', action: currentExists ? 'overwrite' : 'create',
type: value.type type: value.type,
}; };
} }
} }
@@ -204,7 +204,7 @@ module.exports = function(ctx) {
// Require TOTP verification for restores that include security-sensitive files // Require TOTP verification for restores that include security-sensitive files
const sensitiveKeys = ['credentials', 'totpConfig', 'encryptionKey']; const sensitiveKeys = ['credentials', 'totpConfig', 'encryptionKey'];
const restoresSensitive = sensitiveKeys.some(key => const restoresSensitive = sensitiveKeys.some(key =>
backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key) backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key),
); );
if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) { if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) {
if (!totpCode || !/^\d{6}$/.test(totpCode)) { if (!totpCode || !/^\d{6}$/.test(totpCode)) {
@@ -223,7 +223,7 @@ module.exports = function(ctx) {
const results = { const results = {
restored: [], restored: [],
skipped: [], skipped: [],
errors: [] errors: [],
}; };
const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key'); const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key');
@@ -236,7 +236,7 @@ module.exports = function(ctx) {
encryptionKey: ENCRYPTION_KEY_FILE, encryptionKey: ENCRYPTION_KEY_FILE,
totpConfig: ctx.TOTP_CONFIG_FILE, totpConfig: ctx.TOTP_CONFIG_FILE,
tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE, tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE,
notifications: ctx.NOTIFICATIONS_FILE notifications: ctx.NOTIFICATIONS_FILE,
}; };
// Restore each file // Restore each file
@@ -286,7 +286,7 @@ module.exports = function(ctx) {
const loadResponse = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, { const loadResponse = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE }, headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: caddyContent body: caddyContent,
}); });
if (loadResponse.ok) { if (loadResponse.ok) {
@@ -345,7 +345,7 @@ module.exports = function(ctx) {
if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true }); if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true });
for (const [slug, data] of Object.entries(backup.themes)) { for (const [slug, data] of Object.entries(backup.themes)) {
if (/^[a-z0-9-]+$/.test(slug)) { if (/^[a-z0-9-]+$/.test(slug)) {
fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(data, null, 2), 'utf8'); fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(data, null, 2), 'utf8');
} }
} }
results.restored.push(`themes:${Object.keys(backup.themes).length}`); results.restored.push(`themes:${Object.keys(backup.themes).length}`);
@@ -376,7 +376,7 @@ module.exports = function(ctx) {
message: success message: success
? `Restored ${results.restored.length} file(s) successfully` ? `Restored ${results.restored.length} file(s) successfully`
: `Restore completed with ${results.errors.length} error(s)`, : `Restore completed with ${results.errors.length} error(s)`,
results results,
}); });
ctx.log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length }); ctx.log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });

View File

@@ -75,16 +75,16 @@ module.exports = function(ctx) {
CapAdd: hostConfig.CapAdd, CapAdd: hostConfig.CapAdd,
CapDrop: hostConfig.CapDrop, CapDrop: hostConfig.CapDrop,
Devices: hostConfig.Devices, Devices: hostConfig.Devices,
LogConfig: DOCKER.LOG_CONFIG // Ensure log rotation on updated containers LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers
}, },
NetworkingConfig: {} NetworkingConfig: {},
}; };
// Get network settings if using a custom network // Get network settings if using a custom network
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
const networkName = hostConfig.NetworkMode; const networkName = hostConfig.NetworkMode;
config.NetworkingConfig.EndpointsConfig = { config.NetworkingConfig.EndpointsConfig = {
[networkName]: containerInfo.NetworkSettings.Networks[networkName] [networkName]: containerInfo.NetworkSettings.Networks[networkName],
}; };
} }
@@ -119,7 +119,7 @@ module.exports = function(ctx) {
try { try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) { if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` });
} }
} catch (pruneErr) { } catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
@@ -128,7 +128,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: `Container ${containerName} updated successfully`, message: `Container ${containerName} updated successfully`,
newContainerId: newContainerInfo.Id newContainerId: newContainerInfo.Id,
}); });
}, 'container-update')); }, 'container-update'));
@@ -148,7 +148,7 @@ module.exports = function(ctx) {
const pullStream = await ctx.docker.pull(imageName); const pullStream = await ctx.docker.pull(imageName);
const downloadedLayers = pullStream.filter(e => const downloadedLayers = pullStream.filter(e =>
e.status === 'Downloading' || e.status === 'Download complete' e.status === 'Downloading' || e.status === 'Download complete',
); );
updateAvailable = downloadedLayers.length > 0; updateAvailable = downloadedLayers.length > 0;
@@ -167,7 +167,7 @@ module.exports = function(ctx) {
success: true, success: true,
imageName, imageName,
updateAvailable, updateAvailable,
currentDigest: localDigest currentDigest: localDigest,
}); });
}, 'container-check-update')); }, 'container-check-update'));
@@ -178,7 +178,7 @@ module.exports = function(ctx) {
stdout: true, stdout: true,
stderr: true, stderr: true,
tail: 100, tail: 100,
timestamps: true timestamps: true,
}); });
res.json({ success: true, logs: logs.toString() }); res.json({ success: true, logs: logs.toString() });
}, 'container-logs')); }, 'container-logs'));
@@ -194,7 +194,7 @@ module.exports = function(ctx) {
router.get('/discover', ctx.asyncHandler(async (req, res) => { router.get('/discover', ctx.asyncHandler(async (req, res) => {
const containers = await ctx.docker.client.listContainers({ all: true }); const containers = await ctx.docker.client.listContainers({ all: true });
const samiContainers = containers.filter(container => const samiContainers = containers.filter(container =>
container.Labels && container.Labels['sami.managed'] === 'true' container.Labels && container.Labels['sami.managed'] === 'true',
); );
const discoveredContainers = samiContainers.map(container => ({ const discoveredContainers = samiContainers.map(container => ({
@@ -205,7 +205,7 @@ module.exports = function(ctx) {
status: container.Status, status: container.Status,
appTemplate: container.Labels['sami.app'], appTemplate: container.Labels['sami.app'],
subdomain: container.Labels['sami.subdomain'], subdomain: container.Labels['sami.subdomain'],
ports: container.Ports ports: container.Ports,
})); }));
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);

View File

@@ -113,7 +113,7 @@ module.exports = function(ctx) {
const zone = parts.slice(1).join('.') || ctx.siteConfig.tld.replace(/^\./, ''); const zone = parts.slice(1).join('.') || ctx.siteConfig.tld.replace(/^\./, '');
const result = await ctx.dns.call(dnsServer, '/api/zones/records/add', { const result = await ctx.dns.call(dnsServer, '/api/zones/records/add', {
token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true' token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true',
}); });
if (result.status === 'ok') { if (result.status === 'ok') {
@@ -151,7 +151,7 @@ module.exports = function(ctx) {
try { try {
const result = await ctx.dns.call(dnsServer, '/api/zones/records/get', { const result = await ctx.dns.call(dnsServer, '/api/zones/records/get', {
token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true' token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true',
}); });
if (result.status === 'ok' && result.response && result.response.records) { if (result.status === 'ok' && result.response && result.response.records) {
@@ -218,7 +218,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(technitiumUrl, { const response = await ctx.fetchT(technitiumUrl, {
method: 'GET', method: 'GET',
headers: { 'Accept': 'text/plain' }, headers: { 'Accept': 'text/plain' },
timeout: 10000 timeout: 10000,
}); });
if (!response.ok) { if (!response.ok) {
@@ -232,7 +232,7 @@ module.exports = function(ctx) {
server: server, server: server,
count: 0, count: 0,
logs: [], logs: [],
message: 'No logs available for this server' message: 'No logs available for this server',
}); });
} }
return ctx.errorResponse(res, response.status, ctx.safeErrorMessage(errorJson.errorMessage || errorText)); return ctx.errorResponse(res, response.status, ctx.safeErrorMessage(errorJson.errorMessage || errorText));
@@ -255,7 +255,7 @@ module.exports = function(ctx) {
server: server, server: server,
count: 0, count: 0,
logs: [], logs: [],
message: 'No logs available for this server' message: 'No logs available for this server',
}); });
} }
// Invalidate cached token on auth errors so next request re-authenticates // Invalidate cached token on auth errors so next request re-authenticates
@@ -287,7 +287,7 @@ module.exports = function(ctx) {
class: match[6].trim(), class: match[6].trim(),
rcode: match[7].trim(), rcode: match[7].trim(),
answer: match[8].trim() || null, answer: match[8].trim() || null,
raw: line raw: line,
}; };
} }
return { raw: line, parsed: false }; return { raw: line, parsed: false };
@@ -299,7 +299,7 @@ module.exports = function(ctx) {
server: server, server: server,
logFile: logFileName, logFile: logFileName,
count: parsedLogs.length, count: parsedLogs.length,
logs: parsedLogs logs: parsedLogs,
}); });
} catch (error) { } catch (error) {
@@ -319,7 +319,7 @@ module.exports = function(ctx) {
hasCredentials, hasCredentials,
hasToken, hasToken,
tokenExpiry: ctx.dns.getTokenExpiry(), tokenExpiry: ctx.dns.getTokenExpiry(),
isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null,
}); });
}, 'dns-token-status')); }, 'dns-token-status'));
@@ -394,7 +394,7 @@ module.exports = function(ctx) {
return res.json({ return res.json({
success: anySuccess, success: anySuccess,
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed', message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
results results,
}); });
} }
@@ -430,7 +430,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: 'DNS credentials saved and verified (encrypted)', message: 'DNS credentials saved and verified (encrypted)',
tokenExpiry: ctx.dns.getTokenExpiry() tokenExpiry: ctx.dns.getTokenExpiry(),
}); });
}, 'dns-credentials')); }, 'dns-credentials'));
@@ -495,7 +495,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: 'Token refreshed successfully', message: 'Token refreshed successfully',
tokenExpiry: ctx.dns.getTokenExpiry() tokenExpiry: ctx.dns.getTokenExpiry(),
}); });
} else { } else {
ctx.errorResponse(res, 401, result.error); ctx.errorResponse(res, 401, result.error);
@@ -529,8 +529,8 @@ module.exports = function(ctx) {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'User-Agent': APP.USER_AGENTS.API 'User-Agent': APP.USER_AGENTS.API,
} },
}); });
const text = await response.text(); const text = await response.text();
@@ -550,7 +550,7 @@ module.exports = function(ctx) {
updateTitle: result.response.updateTitle || null, updateTitle: result.response.updateTitle || null,
updateMessage: result.response.updateMessage || null, updateMessage: result.response.updateMessage || null,
downloadLink: result.response.downloadLink || null, downloadLink: result.response.downloadLink || null,
instructionsLink: result.response.instructionsLink || null instructionsLink: result.response.instructionsLink || null,
}); });
} else { } else {
ctx.errorResponse(res, 500, result.errorMessage || 'Check failed'); ctx.errorResponse(res, 500, result.errorMessage || 'Check failed');
@@ -586,7 +586,7 @@ module.exports = function(ctx) {
// Check if update is available // Check if update is available
const checkResponse = await ctx.fetchT( const checkResponse = await ctx.fetchT(
`http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`, `http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`,
{ method: 'GET', headers: { 'Accept': 'application/json' } } { method: 'GET', headers: { 'Accept': 'application/json' } },
); );
const checkText = await checkResponse.text(); const checkText = await checkResponse.text();
@@ -604,7 +604,7 @@ module.exports = function(ctx) {
success: true, success: true,
message: 'Already up to date', message: 'Already up to date',
currentVersion: checkResult.response.currentVersion, currentVersion: checkResult.response.currentVersion,
updated: false updated: false,
}); });
} }
@@ -620,7 +620,7 @@ module.exports = function(ctx) {
downloadLink: checkResult.response.downloadLink || null, downloadLink: checkResult.response.downloadLink || null,
instructionsLink: checkResult.response.instructionsLink || null, instructionsLink: checkResult.response.instructionsLink || null,
updated: false, updated: false,
manualUpdateRequired: true manualUpdateRequired: true,
}); });
} catch (error) { } catch (error) {
ctx.log.error('dns', 'DNS update error', { error: error.message }); ctx.log.error('dns', 'DNS update error', { error: error.message });

View File

@@ -25,7 +25,7 @@ module.exports = function(ctx) {
return { return {
timestamp: match[1], timestamp: match[1],
context: match[2], context: match[2],
error: match[3] error: match[3],
}; };
} }
return null; return null;

View File

@@ -34,7 +34,7 @@ module.exports = function(ctx) {
try { try {
let url = null; let url = null;
let checkType = 'http'; const checkType = 'http';
// Determine URL to check // Determine URL to check
url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl);
@@ -52,7 +52,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(url, { const response = await ctx.fetchT(url, {
method: 'HEAD', method: 'HEAD',
signal: controller.signal, signal: controller.signal,
redirect: 'follow' redirect: 'follow',
}); });
clearTimeout(timeout); clearTimeout(timeout);
@@ -60,7 +60,7 @@ module.exports = function(ctx) {
status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy',
statusCode: response.status, statusCode: response.status,
url, url,
checkedAt: new Date().toISOString() checkedAt: new Date().toISOString(),
}; };
} catch (fetchError) { } catch (fetchError) {
clearTimeout(timeout); clearTimeout(timeout);
@@ -73,7 +73,7 @@ module.exports = function(ctx) {
const getResponse = await ctx.fetchT(url, { const getResponse = await ctx.fetchT(url, {
method: 'GET', method: 'GET',
signal: getController.signal, signal: getController.signal,
redirect: 'follow' redirect: 'follow',
}); });
clearTimeout(getTimeout); clearTimeout(getTimeout);
@@ -81,14 +81,14 @@ module.exports = function(ctx) {
status: getResponse.ok || getResponse.status < 500 ? 'healthy' : 'unhealthy', status: getResponse.ok || getResponse.status < 500 ? 'healthy' : 'unhealthy',
statusCode: getResponse.status, statusCode: getResponse.status,
url, url,
checkedAt: new Date().toISOString() checkedAt: new Date().toISOString(),
}; };
} catch (e) { } catch (e) {
health[serviceId] = { health[serviceId] = {
status: 'unhealthy', status: 'unhealthy',
reason: e.name === 'AbortError' ? 'Timeout' : e.message, reason: e.name === 'AbortError' ? 'Timeout' : e.message,
url, url,
checkedAt: new Date().toISOString() checkedAt: new Date().toISOString(),
}; };
} }
} }
@@ -96,7 +96,7 @@ module.exports = function(ctx) {
health[serviceId] = { health[serviceId] = {
status: 'error', status: 'error',
reason: e.message, reason: e.message,
checkedAt: new Date().toISOString() checkedAt: new Date().toISOString(),
}; };
} }
})); }));
@@ -113,7 +113,7 @@ module.exports = function(ctx) {
success: true, success: true,
health: paginatedHealth, health: paginatedHealth,
checkedAt: lastHealthCheck, checkedAt: lastHealthCheck,
...(result.pagination && { pagination: result.pagination }) ...(result.pagination && { pagination: result.pagination }),
}); });
}, 'health-services')); }, 'health-services'));
@@ -123,7 +123,7 @@ module.exports = function(ctx) {
success: true, success: true,
health: serviceHealthCache, health: serviceHealthCache,
lastCheck: lastHealthCheck, lastCheck: lastHealthCheck,
cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null,
}); });
}, 'health-cached')); }, 'health-cached'));
@@ -157,7 +157,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(url, { const response = await ctx.fetchT(url, {
method: 'GET', method: 'GET',
signal: controller.signal, signal: controller.signal,
redirect: 'follow' redirect: 'follow',
}); });
clearTimeout(timeout); clearTimeout(timeout);
@@ -168,8 +168,8 @@ module.exports = function(ctx) {
status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy',
statusCode: response.status, statusCode: response.status,
url, url,
checkedAt: new Date().toISOString() checkedAt: new Date().toISOString(),
} },
}); });
} catch (e) { } catch (e) {
clearTimeout(timeout); clearTimeout(timeout);
@@ -180,8 +180,8 @@ module.exports = function(ctx) {
status: 'unhealthy', status: 'unhealthy',
reason: e.name === 'AbortError' ? 'Timeout' : e.message, reason: e.name === 'AbortError' ? 'Timeout' : e.message,
url, url,
checkedAt: new Date().toISOString() checkedAt: new Date().toISOString(),
} },
}); });
} }
}, 'health-service')); }, 'health-service'));
@@ -201,7 +201,7 @@ module.exports = function(ctx) {
return res.json({ return res.json({
status: 'error', status: 'error',
message: 'Root CA certificate not found', message: 'Root CA certificate not found',
daysUntilExpiration: null daysUntilExpiration: null,
}); });
} }
@@ -232,14 +232,14 @@ module.exports = function(ctx) {
status: status, status: status,
message: message, message: message,
daysUntilExpiration: daysUntilExpiration, daysUntilExpiration: daysUntilExpiration,
expiresAt: notAfter expiresAt: notAfter,
}); });
} catch (error) { } catch (error) {
await ctx.logError('GET /api/health/ca', error); await ctx.logError('GET /api/health/ca', error);
res.json({ res.json({
status: 'error', status: 'error',
message: error.message, message: error.message,
daysUntilExpiration: null daysUntilExpiration: null,
}); });
} }
}, 'health-ca')); }, 'health-ca'));

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: result.message, message: result.message,
license: result.activation license: result.activation,
}); });
} else { } else {
ctx.errorResponse(res, 400, result.message); ctx.errorResponse(res, 400, result.message);
@@ -53,8 +53,8 @@ module.exports = function(ctx) {
tier: status.tier, tier: status.tier,
...(available ? {} : { ...(available ? {} : {
upgradeUrl: '/settings#license', upgradeUrl: '/settings#license',
message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium` message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium`,
}) }),
}); });
}, 'license-feature-check')); }, 'license-feature-check'));

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
name: c.Names[0]?.replace(/^\//, '') || 'unknown', name: c.Names[0]?.replace(/^\//, '') || 'unknown',
image: c.Image, image: c.Image,
status: c.State, status: c.State,
created: c.Created created: c.Created,
})); }));
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
@@ -46,7 +46,7 @@ module.exports = function(ctx) {
const logs = await container.logs({ const logs = await container.logs({
stdout: true, stderr: true, stdout: true, stderr: true,
tail, since, timestamps tail, since, timestamps,
}); });
// Parse Docker log stream (demultiplex stdout/stderr) // Parse Docker log stream (demultiplex stdout/stderr)
@@ -65,7 +65,7 @@ module.exports = function(ctx) {
if (line) { if (line) {
lines.push({ lines.push({
stream: streamType === 2 ? 'stderr' : 'stdout', stream: streamType === 2 ? 'stderr' : 'stdout',
text: line text: line,
}); });
} }
offset += 8 + size; offset += 8 + size;
@@ -75,7 +75,7 @@ module.exports = function(ctx) {
success: true, success: true,
containerId, containerName, containerId, containerName,
logs: lines, logs: lines,
count: lines.length count: lines.length,
}); });
}, 'logs-container')); }, 'logs-container'));
@@ -100,7 +100,7 @@ module.exports = function(ctx) {
const logStream = await container.logs({ const logStream = await container.logs({
stdout: true, stderr: true, stdout: true, stderr: true,
follow: true, tail: 50, timestamps: true follow: true, tail: 50, timestamps: true,
}); });
let buffer = Buffer.alloc(0); let buffer = Buffer.alloc(0);
@@ -119,7 +119,7 @@ module.exports = function(ctx) {
const data = JSON.stringify({ const data = JSON.stringify({
stream: streamType === 2 ? 'stderr' : 'stdout', stream: streamType === 2 ? 'stderr' : 'stdout',
text: line, text: line,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
res.write(`data: ${data}\n\n`); res.write(`data: ${data}\n\n`);
} }
@@ -248,7 +248,7 @@ module.exports = function(ctx) {
const logs = tailLines.map(line => ({ const logs = tailLines.map(line => ({
stream: 'stdout', stream: 'stdout',
text: line, text: line,
timestamp: extractTimestamp(line) timestamp: extractTimestamp(line),
})); }));
res.json({ res.json({
@@ -256,7 +256,7 @@ module.exports = function(ctx) {
logPath: normalizedPath, logPath: normalizedPath,
logs, logs,
count: logs.length, count: logs.length,
totalLines: lines.length totalLines: lines.length,
}); });
}, 'logs-file')); }, 'logs-file'));

View File

@@ -96,17 +96,17 @@ module.exports = function(ctx) {
image: containerInfo.Image, image: containerInfo.Image,
status: containerInfo.State, status: containerInfo.State,
cpu: { cpu: {
percent: Math.round(cpuPercent * 100) / 100 percent: Math.round(cpuPercent * 100) / 100,
}, },
memory: { memory: {
used: memUsage, used: memUsage,
limit: memLimit, limit: memLimit,
percent: Math.round(memPercent * 100) / 100 percent: Math.round(memPercent * 100) / 100,
}, },
network: { network: {
rx: netRx, rx: netRx,
tx: netTx tx: netTx,
} },
}); });
} catch (e) { } catch (e) {
// Skip containers we can't get stats for // Skip containers we can't get stats for
@@ -151,15 +151,15 @@ module.exports = function(ctx) {
status: info.State.Status, status: info.State.Status,
started: info.State.StartedAt, started: info.State.StartedAt,
cpu: { cpu: {
percent: Math.round(cpuPercent * 100) / 100 percent: Math.round(cpuPercent * 100) / 100,
}, },
memory: { memory: {
used: memUsage, used: memUsage,
limit: memLimit, limit: memLimit,
percent: Math.round((memUsage / memLimit) * 100 * 100) / 100 percent: Math.round((memUsage / memLimit) * 100 * 100) / 100,
},
network: { rx: netRx, tx: netTx },
}, },
network: { rx: netRx, tx: netTx }
}
}); });
}, 'stats-container')); }, 'stats-container'));

View File

@@ -14,20 +14,20 @@ module.exports = function(ctx) {
providers: { providers: {
discord: { discord: {
enabled: notificationConfig.providers.discord?.enabled || false, enabled: notificationConfig.providers.discord?.enabled || false,
configured: !!notificationConfig.providers.discord?.webhookUrl configured: !!notificationConfig.providers.discord?.webhookUrl,
}, },
telegram: { telegram: {
enabled: notificationConfig.providers.telegram?.enabled || false, enabled: notificationConfig.providers.telegram?.enabled || false,
configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId) configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId),
}, },
ntfy: { ntfy: {
enabled: notificationConfig.providers.ntfy?.enabled || false, enabled: notificationConfig.providers.ntfy?.enabled || false,
configured: !!notificationConfig.providers.ntfy?.topic, configured: !!notificationConfig.providers.ntfy?.topic,
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh' serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh',
} },
}, },
events: notificationConfig.events, events: notificationConfig.events,
healthCheck: notificationConfig.healthCheck healthCheck: notificationConfig.healthCheck,
}; };
res.json({ success: true, config: safeConfig }); res.json({ success: true, config: safeConfig });
}, 'notifications-config-get')); }, 'notifications-config-get'));
@@ -78,19 +78,19 @@ module.exports = function(ctx) {
if (providers.discord) { if (providers.discord) {
notificationConfig.providers.discord = { notificationConfig.providers.discord = {
...notificationConfig.providers.discord, ...notificationConfig.providers.discord,
...providers.discord ...providers.discord,
}; };
} }
if (providers.telegram) { if (providers.telegram) {
notificationConfig.providers.telegram = { notificationConfig.providers.telegram = {
...notificationConfig.providers.telegram, ...notificationConfig.providers.telegram,
...providers.telegram ...providers.telegram,
}; };
} }
if (providers.ntfy) { if (providers.ntfy) {
notificationConfig.providers.ntfy = { notificationConfig.providers.ntfy = {
...notificationConfig.providers.ntfy, ...notificationConfig.providers.ntfy,
...providers.ntfy ...providers.ntfy,
}; };
} }
} }
@@ -159,7 +159,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
history: notificationHistory.slice(0, limit), history: notificationHistory.slice(0, limit),
total: notificationHistory.length total: notificationHistory.length,
}); });
} }
}, 'notifications-history')); }, 'notifications-history'));
@@ -177,7 +177,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
lastCheck: notificationConfig.healthCheck.lastCheck, lastCheck: notificationConfig.healthCheck.lastCheck,
containersMonitored: Object.keys(ctx.notification.getHealthState()).length containersMonitored: Object.keys(ctx.notification.getHealthState()).length,
}); });
}, 'notifications-health-check')); }, 'notifications-health-check'));

View File

@@ -42,7 +42,7 @@ module.exports = function(ctx) {
await ctx.docker.client.createNetwork({ await ctx.docker.client.createNetwork({
Name: networkName, Name: networkName,
Driver: recipe.network.driver || 'bridge', Driver: recipe.network.driver || 'bridge',
Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId } Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId },
}); });
ctx.log.info('recipe', 'Created Docker network', { networkName }); ctx.log.info('recipe', 'Created Docker network', { networkName });
} catch (e) { } catch (e) {
@@ -62,18 +62,18 @@ module.exports = function(ctx) {
try { try {
ctx.log.info('recipe', `Deploying component: ${component.id}`, { ctx.log.info('recipe', `Deploying component: ${component.id}`, {
role: component.role, role: component.role,
internal: component.internal || false internal: component.internal || false,
}); });
const result = await deployComponent(component, recipe, config, generatedPasswords, networkName); const result = await deployComponent(component, recipe, config, generatedPasswords, networkName);
deployedComponents.push(result); deployedComponents.push(result);
ctx.log.info('recipe', `Component deployed: ${component.id}`, { ctx.log.info('recipe', `Component deployed: ${component.id}`, {
containerId: result.containerId?.substring(0, 12) containerId: result.containerId?.substring(0, 12),
}); });
} catch (componentError) { } catch (componentError) {
ctx.log.error('recipe', `Component failed: ${component.id}`, { ctx.log.error('recipe', `Component failed: ${component.id}`, {
error: componentError.message error: componentError.message,
}); });
errors.push({ componentId: component.id, role: component.role, error: componentError.message }); errors.push({ componentId: component.id, role: component.role, error: componentError.message });
// Continue deploying other components — partial success is better than total failure // Continue deploying other components — partial success is better than total failure
@@ -96,7 +96,7 @@ module.exports = function(ctx) {
recipeId: recipeId, recipeId: recipeId,
recipeRole: deployed.role, recipeRole: deployed.role,
tailscaleOnly: config.sharedConfig?.tailscaleOnly || false, tailscaleOnly: config.sharedConfig?.tailscaleOnly || false,
deployedAt: new Date().toISOString() deployedAt: new Date().toISOString(),
}); });
} }
} }
@@ -119,18 +119,18 @@ module.exports = function(ctx) {
role: c.role, role: c.role,
containerId: c.containerId?.substring(0, 12), containerId: c.containerId?.substring(0, 12),
url: c.url, url: c.url,
internal: c.internal internal: c.internal,
})), })),
errors: errors.length > 0 ? errors : undefined, errors: errors.length > 0 ? errors : undefined,
message: errors.length > 0 message: errors.length > 0
? `${recipe.name} partially deployed (${deployedComponents.length}/${componentsToDeploy.length} components)` ? `${recipe.name} partially deployed (${deployedComponents.length}/${componentsToDeploy.length} components)`
: `${recipe.name} deployed successfully!`, : `${recipe.name} deployed successfully!`,
setupInstructions: recipe.setupInstructions setupInstructions: recipe.setupInstructions,
}; };
ctx.notification.send('deploymentSuccess', 'Recipe Deployed', ctx.notification.send('deploymentSuccess', 'Recipe Deployed',
`**${recipe.name}** recipe deployed (${deployedComponents.length} components).`, `**${recipe.name}** recipe deployed (${deployedComponents.length} components).`,
'success' 'success',
); );
res.json(response); res.json(response);
@@ -146,7 +146,7 @@ module.exports = function(ctx) {
} }
} catch (cleanupError) { } catch (cleanupError) {
ctx.log.warn('recipe', 'Cleanup failed for component', { ctx.log.warn('recipe', 'Cleanup failed for component', {
componentId: deployed.id, error: cleanupError.message componentId: deployed.id, error: cleanupError.message,
}); });
} }
} }
@@ -162,7 +162,7 @@ module.exports = function(ctx) {
} }
ctx.notification.send('deploymentFailed', 'Recipe Failed', ctx.notification.send('deploymentFailed', 'Recipe Failed',
`Failed to deploy **${recipe.name}**: ${error.message}`, 'error' `Failed to deploy **${recipe.name}**: ${error.message}`, 'error',
); );
ctx.errorResponse(res, 500, error.message); ctx.errorResponse(res, 500, error.message);
@@ -254,7 +254,7 @@ module.exports = function(ctx) {
HostConfig: { HostConfig: {
PortBindings: {}, PortBindings: {},
Binds: dockerConfig.volumes || [], Binds: dockerConfig.volumes || [],
RestartPolicy: { Name: 'unless-stopped' } RestartPolicy: { Name: 'unless-stopped' },
}, },
Env: Object.entries(dockerConfig.environment || {}).map(([k, v]) => `${k}=${v}`), Env: Object.entries(dockerConfig.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: { Labels: {
@@ -264,8 +264,8 @@ module.exports = function(ctx) {
'sami.recipe.component': component.id, 'sami.recipe.component': component.id,
'sami.recipe.role': component.role, 'sami.recipe.role': component.role,
'sami.subdomain': subdomain, 'sami.subdomain': subdomain,
'sami.deployed': new Date().toISOString() 'sami.deployed': new Date().toISOString(),
} },
}; };
// Configure ports // Configure ports
@@ -288,7 +288,7 @@ module.exports = function(ctx) {
} catch (e) { } catch (e) {
ctx.log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`); ctx.log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`);
const images = await ctx.docker.client.listImages({ const images = await ctx.docker.client.listImages({
filters: { reference: [dockerConfig.image] } filters: { reference: [dockerConfig.image] },
}); });
if (images.length === 0) throw new Error(`Image not found: ${dockerConfig.image}`); if (images.length === 0) throw new Error(`Image not found: ${dockerConfig.image}`);
} }
@@ -324,7 +324,7 @@ module.exports = function(ctx) {
const primaryPort = port || dockerConfig.ports[0].split(/[:/]/)[0]; const primaryPort = port || dockerConfig.ports[0].split(/[:/]/)[0];
const caddyConfig = ctx.caddy.generateConfig( const caddyConfig = ctx.caddy.generateConfig(
subdomain, hostIp, primaryPort, subdomain, hostIp, primaryPort,
{ tailscaleOnly: sharedConfig.tailscaleOnly || false } { tailscaleOnly: sharedConfig.tailscaleOnly || false },
); );
try { try {
const helpers = require('../apps/helpers')(ctx); const helpers = require('../apps/helpers')(ctx);
@@ -344,7 +344,7 @@ module.exports = function(ctx) {
internal: component.internal || false, internal: component.internal || false,
templateRef: component.templateRef, templateRef: component.templateRef,
logo, logo,
url url,
}; };
} }

View File

@@ -29,9 +29,9 @@ module.exports = function(ctx) {
required: c.required, required: c.required,
internal: c.internal || false, internal: c.internal || false,
templateRef: c.templateRef || null, templateRef: c.templateRef || null,
note: c.note || null note: c.note || null,
})), })),
setupInstructions: recipe.setupInstructions setupInstructions: recipe.setupInstructions,
})); }));
res.json({ success: true, templates, categories: RECIPE_CATEGORIES }); res.json({ success: true, templates, categories: RECIPE_CATEGORIES });

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
if (!recipeGroups[service.recipeId]) { if (!recipeGroups[service.recipeId]) {
recipeGroups[service.recipeId] = { recipeGroups[service.recipeId] = {
recipeId: service.recipeId, recipeId: service.recipeId,
components: [] components: [],
}; };
} }
recipeGroups[service.recipeId].components.push({ recipeGroups[service.recipeId].components.push({
@@ -25,7 +25,7 @@ module.exports = function(ctx) {
logo: service.logo, logo: service.logo,
containerId: service.containerId, containerId: service.containerId,
recipeRole: service.recipeRole, recipeRole: service.recipeRole,
deployedAt: service.deployedAt deployedAt: service.deployedAt,
}); });
} }
@@ -48,7 +48,7 @@ module.exports = function(ctx) {
// Check if this container is already listed (by containerId) // Check if this container is already listed (by containerId)
const existing = recipeGroups[recipeId].components.find( const existing = recipeGroups[recipeId].components.find(
c => c.containerId === containerInfo.Id c => c.containerId === containerInfo.Id,
); );
if (existing) continue; if (existing) continue;
@@ -59,7 +59,7 @@ module.exports = function(ctx) {
recipeRole: labels['sami.recipe.role'] || 'Unknown', recipeRole: labels['sami.recipe.role'] || 'Unknown',
internal: true, internal: true,
state: containerInfo.State, state: containerInfo.State,
status: containerInfo.Status status: containerInfo.Status,
}); });
} }
} catch (e) { } catch (e) {
@@ -242,7 +242,7 @@ module.exports = function(ctx) {
ctx.notification.send('recipeRemoved', 'Recipe Removed', ctx.notification.send('recipeRemoved', 'Recipe Removed',
`Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`, `Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`,
'info' 'info',
); );
ctx.log.info('recipe', 'Recipe removed', { recipeId, results }); ctx.log.info('recipe', 'Recipe removed', { recipeId, results });
@@ -271,7 +271,7 @@ module.exports = function(ctx) {
Id: c.Id, Id: c.Id,
component: c.Labels['sami.recipe.component'] || c.Names[0]?.replace('/', ''), component: c.Labels['sami.recipe.component'] || c.Names[0]?.replace('/', ''),
role: c.Labels['sami.recipe.role'] || 'Unknown', role: c.Labels['sami.recipe.role'] || 'Unknown',
state: c.State state: c.State,
})); }));
} }
@@ -293,7 +293,7 @@ module.exports = function(ctx) {
*/ */
async function removeCaddyBlock(subdomain) { async function removeCaddyBlock(subdomain) {
const domain = ctx.buildDomain(subdomain); const domain = ctx.buildDomain(subdomain);
let content = await ctx.caddy.read(); const content = await ctx.caddy.read();
// Find and remove the block for this domain // Find and remove the block for this domain
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

View File

@@ -99,7 +99,7 @@ module.exports = function(ctx) {
isUp: false, isUp: false,
statusCode: 502, statusCode: 502,
responseTime, responseTime,
error: error.message error: error.message,
}; };
} }
@@ -108,7 +108,7 @@ module.exports = function(ctx) {
isUp: isServiceUp(statusCode), isUp: isServiceUp(statusCode),
statusCode, statusCode,
responseTime, responseTime,
url url,
}; };
} }
@@ -169,7 +169,7 @@ module.exports = function(ctx) {
success: true, success: true,
hasApiKey: !!(arrKey || svcKey), hasApiKey: !!(arrKey || svcKey),
hasBasicAuth: !!username, hasBasicAuth: !!username,
username: username || null username: username || null,
}); });
} catch (error) { } catch (error) {
res.json({ success: true, hasApiKey: false, hasBasicAuth: false }); res.json({ success: true, hasApiKey: false, hasBasicAuth: false });
@@ -249,7 +249,7 @@ module.exports = function(ctx) {
services.forEach(service => addId(service.id)); services.forEach(service => addId(service.id));
const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) => const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) =>
probeServiceStatus(id, serviceMap.get(id)) probeServiceStatus(id, serviceMap.get(id)),
); );
const statuses = {}; const statuses = {};
@@ -261,7 +261,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
statuses statuses,
}); });
}, 'services-status')); }, 'services-status'));
@@ -343,7 +343,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: `Successfully imported ${services.length} services`, message: `Successfully imported ${services.length} services`,
count: services.length count: services.length,
}); });
}, 'services-import')); }, 'services-import'));
@@ -396,12 +396,12 @@ module.exports = function(ctx) {
const oldDomain = ctx.buildDomain(oldSubdomain); const oldDomain = ctx.buildDomain(oldSubdomain);
const newDomain = ctx.buildDomain(newSubdomain); const newDomain = ctx.buildDomain(newSubdomain);
let content = await ctx.caddy.read(); const content = await ctx.caddy.read();
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp( const siteBlockRegex = new RegExp(
`${escapedOldDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, `${escapedOldDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`,
's' 's',
); );
const oldBlockMatch = content.match(siteBlockRegex); const oldBlockMatch = content.match(siteBlockRegex);
@@ -414,7 +414,7 @@ module.exports = function(ctx) {
const finalPort = port || existingPort; const finalPort = port || existingPort;
const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, { const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, {
tailscaleOnly: tailscaleOnly || false tailscaleOnly: tailscaleOnly || false,
}); });
const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig)); const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig));
@@ -445,7 +445,7 @@ module.exports = function(ctx) {
id: newSubdomain, id: newSubdomain,
port: port || services[serviceIndex].port, port: port || services[serviceIndex].port,
ip: ip || services[serviceIndex].ip, ip: ip || services[serviceIndex].ip,
tailscaleOnly: tailscaleOnly || false tailscaleOnly: tailscaleOnly || false,
}; };
results.services = 'updated'; results.services = 'updated';
} else { } else {
@@ -459,7 +459,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`, message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,
results results,
}); });
}, 'services-update')); }, 'services-update'));

View File

@@ -25,7 +25,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, { const response = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE }, headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: caddyfileContent body: caddyfileContent,
}); });
if (!response.ok) { if (!response.ok) {
@@ -110,7 +110,7 @@ module.exports = function(ctx) {
const caList = cas.map(ca => ({ const caList = cas.map(ca => ({
id: ca.id || ca.name, id: ca.id || ca.name,
name: ca.name, name: ca.name,
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name,
})); }));
res.json({ status: 'success', data: { cas: caList } }); res.json({ status: 'success', data: { cas: caList } });
}, 'caddy-get-cas')); }, 'caddy-get-cas'));
@@ -123,7 +123,7 @@ module.exports = function(ctx) {
const result = await ctx.caddy.modify((content) => { const result = await ctx.caddy.modify((content) => {
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp( const siteBlockRegex = new RegExp(
`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g' `\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g',
); );
const modified = content.replace(siteBlockRegex, '\n'); const modified = content.replace(siteBlockRegex, '\n');
if (modified.length === content.length) return null; if (modified.length === content.length) return null;
@@ -149,7 +149,7 @@ module.exports = function(ctx) {
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i; const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
if (!upstreamRegex.test(upstream)) return ctx.errorResponse(res, 400, 'Invalid upstream format. Use host:port'); if (!upstreamRegex.test(upstream)) return ctx.errorResponse(res, 400, 'Invalid upstream format. Use host:port');
let content = await ctx.caddy.read(); const content = await ctx.caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g'); const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g');
if (siteBlockRegex.test(content)) { if (siteBlockRegex.test(content)) {
@@ -200,7 +200,7 @@ module.exports = function(ctx) {
} }
const sslConfig = sslType === 'letsencrypt' ? '' : 'tls internal'; const sslConfig = sslType === 'letsencrypt' ? '' : 'tls internal';
const hostHeader = preserveHost ? `\n header_up Host {upstream_hostport}` : ''; const hostHeader = preserveHost ? '\n header_up Host {upstream_hostport}' : '';
const urlObj = new URL(externalUrl); const urlObj = new URL(externalUrl);
@@ -238,7 +238,7 @@ module.exports = function(ctx) {
await ctx.addServiceToConfig({ await ctx.addServiceToConfig({
id: subdomain, name: serviceName, logo, id: subdomain, name: serviceName, logo,
isExternal: true, externalUrl, isExternal: true, externalUrl,
deployedAt: new Date().toISOString() deployedAt: new Date().toISOString(),
}); });
ctx.log.info('deploy', 'Service added to dashboard', { subdomain }); ctx.log.info('deploy', 'Service added to dashboard', { subdomain });
} catch (serviceError) { } catch (serviceError) {
@@ -248,7 +248,7 @@ module.exports = function(ctx) {
const response = { const response = {
success: true, success: true,
message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}` message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`,
}; };
if (dnsWarning) response.warning = dnsWarning; if (dnsWarning) response.warning = dnsWarning;
res.json(response); res.json(response);

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
success: true, success: true,
installed: false, installed: false,
connected: false, connected: false,
message: 'Tailscale not available or not running' message: 'Tailscale not available or not running',
}); });
} }
@@ -30,7 +30,7 @@ module.exports = function(ctx) {
os: peer.OS, os: peer.OS,
online: peer.Online, online: peer.Online,
lastSeen: peer.LastSeen, lastSeen: peer.LastSeen,
user: peer.UserID user: peer.UserID,
}); });
} }
} }
@@ -44,11 +44,11 @@ module.exports = function(ctx) {
hostname: status.Self?.HostName, hostname: status.Self?.HostName,
ip: localIP, ip: localIP,
tailnetName: status.MagicDNSSuffix, tailnetName: status.MagicDNSSuffix,
online: status.Self?.Online online: status.Self?.Online,
}, },
config: ctx.tailscale.config, config: ctx.tailscale.config,
devices, devices,
deviceCount: devices.length deviceCount: devices.length,
}); });
}, 'tailscale-status')); }, 'tailscale-status'));
@@ -65,7 +65,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: 'Tailscale configuration updated', message: 'Tailscale configuration updated',
config: ctx.tailscale.config config: ctx.tailscale.config,
}); });
}, 'tailscale-config')); }, 'tailscale-config'));
@@ -83,7 +83,7 @@ module.exports = function(ctx) {
isTailscale, isTailscale,
clientIP, clientIP,
forwardedFor: forwardedFor || null, forwardedFor: forwardedFor || null,
realIP: realIP || null realIP: realIP || null,
}); });
}, 'tailscale-check')); }, 'tailscale-check'));
@@ -102,7 +102,7 @@ module.exports = function(ctx) {
hostname: peer.HostName, hostname: peer.HostName,
ip: peer.TailscaleIPs?.[0], ip: peer.TailscaleIPs?.[0],
os: peer.OS, os: peer.OS,
user: peer.UserID user: peer.UserID,
}); });
} }
} }
@@ -114,7 +114,7 @@ module.exports = function(ctx) {
ip: status.Self.TailscaleIPs?.[0], ip: status.Self.TailscaleIPs?.[0],
os: status.Self.OS, os: status.Self.OS,
user: status.Self.UserID, user: status.Self.UserID,
isSelf: true isSelf: true,
}); });
} }
@@ -129,7 +129,7 @@ module.exports = function(ctx) {
return ctx.errorResponse(res, 400, 'subdomain is required'); return ctx.errorResponse(res, 400, 'subdomain is required');
} }
let content = await ctx.caddy.read(); const content = await ctx.caddy.read();
const domain = ctx.buildDomain(subdomain); const domain = ctx.buildDomain(subdomain);
const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's'); const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's');
@@ -149,7 +149,7 @@ module.exports = function(ctx) {
const newConfig = ctx.caddy.generateConfig(subdomain, ip, port || '80', { const newConfig = ctx.caddy.generateConfig(subdomain, ip, port || '80', {
tailscaleOnly: tailscaleOnly !== false, tailscaleOnly: tailscaleOnly !== false,
allowedIPs: allowedIPs || [] allowedIPs: allowedIPs || [],
}); });
const caddyResult = await ctx.caddy.modify(c => c.replace(blockRegex, newConfig)); const caddyResult = await ctx.caddy.modify(c => c.replace(blockRegex, newConfig));
@@ -170,7 +170,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`, message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`,
tailscaleOnly: tailscaleOnly !== false tailscaleOnly: tailscaleOnly !== false,
}); });
}, 'tailscale-protect')); }, 'tailscale-protect'));
@@ -188,7 +188,7 @@ module.exports = function(ctx) {
const tokenRes = await fetch(TAILSCALE.OAUTH_TOKEN_URL, { const tokenRes = await fetch(TAILSCALE.OAUTH_TOKEN_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials` body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`,
}); });
if (!tokenRes.ok) { if (!tokenRes.ok) {
@@ -199,7 +199,7 @@ module.exports = function(ctx) {
// Test with the device list to verify scopes // Test with the device list to verify scopes
const testRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, { const testRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, {
headers: { Authorization: `Bearer ${tokenData.access_token}` } headers: { Authorization: `Bearer ${tokenData.access_token}` },
}); });
if (!testRes.ok) { if (!testRes.ok) {
@@ -259,7 +259,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
devices: ctx.tailscale.config.devices || [], devices: ctx.tailscale.config.devices || [],
lastSync: ctx.tailscale.config.lastSync lastSync: ctx.tailscale.config.lastSync,
}); });
}, 'tailscale-api-devices')); }, 'tailscale-api-devices'));
@@ -274,7 +274,7 @@ module.exports = function(ctx) {
res.json({ res.json({
success: true, success: true,
devices: devices || [], devices: devices || [],
lastSync: ctx.tailscale.config.lastSync lastSync: ctx.tailscale.config.lastSync,
}); });
}, 'tailscale-sync')); }, 'tailscale-sync'));
@@ -287,7 +287,7 @@ module.exports = function(ctx) {
} }
const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, { const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
}); });
if (!aclRes.ok) { if (!aclRes.ok) {
return ctx.errorResponse(res, aclRes.status, `ACL fetch failed: HTTP ${aclRes.status}`); return ctx.errorResponse(res, aclRes.status, `ACL fetch failed: HTTP ${aclRes.status}`);
@@ -299,7 +299,7 @@ module.exports = function(ctx) {
groups: Object.keys(acl.groups || {}), groups: Object.keys(acl.groups || {}),
tagOwners: Object.keys(acl.tagOwners || {}), tagOwners: Object.keys(acl.tagOwners || {}),
aclRuleCount: (acl.acls || []).length, aclRuleCount: (acl.acls || []).length,
sshRuleCount: (acl.ssh || []).length sshRuleCount: (acl.ssh || []).length,
}; };
res.json({ success: true, acl, summary }); res.json({ success: true, acl, summary });

View File

@@ -46,15 +46,15 @@ module.exports = function(ctx) {
const themeData = { name, ...colors }; const themeData = { name, ...colors };
if (lightBg) themeData.lightBg = true; if (lightBg) themeData.lightBg = true;
fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(themeData, null, 2), 'utf8'); fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(themeData, null, 2), 'utf8');
res.json({ success: true, message: name + ' theme saved' }); res.json({ success: true, message: `${name } theme saved` });
}); });
// Delete a theme // Delete a theme
router.delete('/themes/:slug', (req, res) => { router.delete('/themes/:slug', (req, res) => {
const { slug } = req.params; const { slug } = req.params;
const filePath = path.join(THEMES_DIR, slug + '.json'); const filePath = path.join(THEMES_DIR, `${slug }.json`);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return res.status(404).json({ success: false, error: 'Theme not found' }); return res.status(404).json({ success: false, error: 'Theme not found' });
@@ -64,7 +64,7 @@ module.exports = function(ctx) {
const name = data.name || slug; const name = data.name || slug;
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
res.json({ success: true, message: name + ' theme deleted' }); res.json({ success: true, message: `${name } theme deleted` });
}); });
return router; return router;

View File

@@ -31,7 +31,7 @@ let buildRunning = false;
function log(msg) { function log(msg) {
const line = `[webhook] ${new Date().toISOString()} ${msg}`; const line = `[webhook] ${new Date().toISOString()} ${msg}`;
console.log(line); console.log(line);
fs.appendFileSync(LOG_FILE, line + '\n'); fs.appendFileSync(LOG_FILE, `${line }\n`);
} }
function verifySignature(body, signature) { function verifySignature(body, signature) {
@@ -39,7 +39,7 @@ function verifySignature(body, signature) {
const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex'); const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
return crypto.timingSafeEqual( return crypto.timingSafeEqual(
Buffer.from(signature), Buffer.from(signature),
Buffer.from(hmac) Buffer.from(hmac),
); );
} }
@@ -124,7 +124,7 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ accepted: true })); res.end(JSON.stringify({ accepted: true }));
} catch (e) { } catch (e) {
log('Failed to parse webhook payload: ' + e.message); log(`Failed to parse webhook payload: ${ e.message}`);
res.writeHead(400); res.writeHead(400);
res.end('Invalid payload'); res.end('Invalid payload');
} }

View File

@@ -185,7 +185,7 @@ class SelfUpdater extends EventEmitter {
const frontendSrc = this._findDir(stagingDir, 'status'); const frontendSrc = this._findDir(stagingDir, 'status');
if (frontendSrc) { if (frontendSrc) {
await this._copyDir(frontendSrc, this.config.frontendDir, [ await this._copyDir(frontendSrc, this.config.frontendDir, [
'dist', 'css', 'assets', 'vendor', 'index.html', 'sw.js' 'dist', 'css', 'assets', 'vendor', 'index.html', 'sw.js',
]); ]);
this.emit('update-progress', { step: 'frontend-updated', version: remoteInfo.version }); this.emit('update-progress', { step: 'frontend-updated', version: remoteInfo.version });
} }
@@ -209,7 +209,7 @@ class SelfUpdater extends EventEmitter {
}; };
await fsp.writeFile( await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'), path.join(this.config.updatesDir, 'trigger.json'),
JSON.stringify(trigger, null, 2) JSON.stringify(trigger, null, 2),
); );
// The host-side systemd service will handle the rest. // The host-side systemd service will handle the rest.
@@ -312,7 +312,7 @@ class SelfUpdater extends EventEmitter {
this.status = 'waiting'; this.status = 'waiting';
await fsp.writeFile( await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'), path.join(this.config.updatesDir, 'trigger.json'),
JSON.stringify(trigger, null, 2) JSON.stringify(trigger, null, 2),
); );
this._addToHistory({ this._addToHistory({
@@ -412,12 +412,12 @@ class SelfUpdater extends EventEmitter {
try { try {
resolve(JSON.parse(data)); resolve(JSON.parse(data));
} catch (e) { } catch (e) {
reject(new Error('Invalid JSON from ' + url)); reject(new Error(`Invalid JSON from ${ url}`));
} }
}); });
}); });
req.on('error', reject); req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout fetching ' + url)); }); req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout fetching ${ url}`)); });
}); });
} }
@@ -459,7 +459,7 @@ class SelfUpdater extends EventEmitter {
try { try {
execSync(`tar xzf "${tarballPath}" -C "${destDir}" --strip-components=1`, { stdio: 'pipe' }); execSync(`tar xzf "${tarballPath}" -C "${destDir}" --strip-components=1`, { stdio: 'pipe' });
} catch (e) { } catch (e) {
throw new Error('Failed to extract tarball: ' + e.message); throw new Error(`Failed to extract tarball: ${ e.message}`);
} }
} }

View File

@@ -10,7 +10,7 @@ const { execSync } = require('child_process');
const path = require('path'); const path = require('path');
const { const {
ValidationError, validateFilePath, validateURL, validateToken, ValidationError, validateFilePath, validateURL, validateToken,
validateServiceConfig, sanitizeString, isValidPort, validateSecurePath validateServiceConfig, sanitizeString, isValidPort, validateSecurePath,
} = require('./input-validator'); } = require('./input-validator');
const validatorLib = require('validator'); const validatorLib = require('validator');
const credentialManager = require('./credential-manager'); const credentialManager = require('./credential-manager');
@@ -128,7 +128,7 @@ licenseManager.loadSecret(LICENSE_SECRET_FILE);
// ===== Site configuration loaded from config.json (#5) ===== // ===== Site configuration loaded from config.json (#5) =====
// These are read at startup and refreshed on config save. // These are read at startup and refreshed on config save.
// All code should use these instead of hardcoded values. // All code should use these instead of hardcoded values.
let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' }; const siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' };
function loadSiteConfig() { function loadSiteConfig() {
try { try {
@@ -147,7 +147,7 @@ function loadSiteConfig() {
} }
siteConfig.tld = raw.tld || '.home'; siteConfig.tld = raw.tld || '.home';
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld; if (!siteConfig.tld.startsWith('.')) siteConfig.tld = `.${ siteConfig.tld}`;
siteConfig.caName = raw.caName || ''; siteConfig.caName = raw.caName || '';
siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || ''; siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || '';
siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT; siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT;
@@ -199,7 +199,7 @@ async function callDns(server, apiPath, params) {
const response = await fetchT(url, { const response = await fetchT(url, {
method: 'GET', method: 'GET',
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
agent: httpsAgent agent: httpsAgent,
}, TIMEOUTS.HTTP_LONG); }, TIMEOUTS.HTTP_LONG);
return response.json(); return response.json();
} }
@@ -323,7 +323,7 @@ async function getServiceById(serviceId) {
async function findContainerByName(name, opts = { all: false }) { async function findContainerByName(name, opts = { all: false }) {
const containers = await docker.listContainers(opts); const containers = await docker.listContainers(opts);
const match = containers.find(c => const match = containers.find(c =>
c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())) c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())),
); );
return match || null; return match || null;
} }
@@ -348,7 +348,7 @@ async function requireDnsToken(providedToken) {
if (providedToken) return providedToken; if (providedToken) return providedToken;
const result = await ensureValidDnsToken(); const result = await ensureValidDnsToken();
if (result.success) return result.token; if (result.success) return result.token;
const err = new Error('No valid DNS token available. ' + result.error); const err = new Error(`No valid DNS token available. ${ result.error}`);
err.statusCode = 401; err.statusCode = 401;
throw err; throw err;
} }
@@ -430,9 +430,9 @@ async function logError(context, error, additionalInfo = {}) {
error: { error: {
message: error.message || error, message: error.message || error,
stack: error.stack, stack: error.stack,
code: error.code code: error.code,
}, },
...additionalInfo ...additionalInfo,
}; };
// Format log line with request context // Format log line with request context
@@ -446,7 +446,7 @@ async function logError(context, error, additionalInfo = {}) {
try { try {
const stats = await fsp.stat(ERROR_LOG_FILE); const stats = await fsp.stat(ERROR_LOG_FILE);
if (stats.size > MAX_ERROR_LOG_SIZE) { if (stats.size > MAX_ERROR_LOG_SIZE) {
const rotated = ERROR_LOG_FILE + '.1'; const rotated = `${ERROR_LOG_FILE }.1`;
if (await exists(rotated)) await fsp.unlink(rotated); if (await exists(rotated)) await fsp.unlink(rotated);
await fsp.rename(ERROR_LOG_FILE, rotated); await fsp.rename(ERROR_LOG_FILE, rotated);
} }
@@ -519,7 +519,7 @@ let tailscaleConfig = {
oauthConfigured: false, // true when OAuth credentials are stored oauthConfigured: false, // true when OAuth credentials are stored
tailnet: null, // tailnet name for API calls (e.g., "example.com" or "-") tailnet: null, // tailnet name for API calls (e.g., "example.com" or "-")
syncInterval: 300, // seconds between API syncs (default 5 min) syncInterval: 300, // seconds between API syncs (default 5 min)
lastSync: null // ISO timestamp of last successful sync lastSync: null, // ISO timestamp of last successful sync
}; };
// Load Tailscale config from file // Load Tailscale config from file
@@ -605,7 +605,7 @@ async function getTailscaleAccessToken() {
const res = await fetch(TAILSCALE.OAUTH_TOKEN_URL, { const res = await fetch(TAILSCALE.OAUTH_TOKEN_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials` body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`,
}); });
if (!res.ok) { if (!res.ok) {
@@ -617,7 +617,7 @@ async function getTailscaleAccessToken() {
const data = await res.json(); const data = await res.json();
_tsTokenCache = { _tsTokenCache = {
token: data.access_token, token: data.access_token,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000 expiresAt: Date.now() + (data.expires_in || 3600) * 1000,
}; };
return data.access_token; return data.access_token;
} }
@@ -629,7 +629,7 @@ async function syncFromTailscaleAPI() {
if (!token || !tailnet) return null; if (!token || !tailnet) return null;
const res = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, { const res = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (!res.ok) throw new Error(`Tailscale API: HTTP ${res.status}`); if (!res.ok) throw new Error(`Tailscale API: HTTP ${res.status}`);
@@ -647,7 +647,7 @@ async function syncFromTailscaleAPI() {
tags: d.tags || [], tags: d.tags || [],
lastSeen: d.lastSeen, lastSeen: d.lastSeen,
clientVersion: d.clientVersion, clientVersion: d.clientVersion,
isExternal: d.isExternal || false isExternal: d.isExternal || false,
})); }));
tailscaleConfig.devices = devices; tailscaleConfig.devices = devices;
@@ -670,7 +670,7 @@ function startTailscaleSyncTimer() {
log.warn('tailscale', 'API sync failed', { error: error.message }); log.warn('tailscale', 'API sync failed', { error: error.message });
} }
}, interval); }, interval);
log.info('tailscale', 'API sync enabled', { interval: interval / 1000 + 's' }); log.info('tailscale', 'API sync enabled', { interval: `${interval / 1000 }s` });
} }
function stopTailscaleSyncTimer() { function stopTailscaleSyncTimer() {
@@ -681,10 +681,10 @@ function stopTailscaleSyncTimer() {
} }
// TOTP authentication configuration // TOTP authentication configuration
let totpConfig = { const totpConfig = {
enabled: false, enabled: false,
sessionDuration: 'never', // 'never' = disabled, or '15m','30m','1h','2h','4h','8h','12h','24h' sessionDuration: 'never', // 'never' = disabled, or '15m','30m','1h','2h','4h','8h','12h','24h'
isSetUp: false // true once a secret has been verified isSetUp: false, // true once a secret has been verified
}; };
async function loadTotpConfig() { async function loadTotpConfig() {
@@ -725,20 +725,20 @@ let notificationConfig = {
providers: { providers: {
discord: { enabled: false, webhookUrl: '' }, discord: { enabled: false, webhookUrl: '' },
telegram: { enabled: false, botToken: '', chatId: '' }, telegram: { enabled: false, botToken: '', chatId: '' },
ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' } ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' },
}, },
events: { events: {
containerDown: true, containerDown: true,
containerUp: true, containerUp: true,
deploymentSuccess: true, deploymentSuccess: true,
deploymentFailed: true, deploymentFailed: true,
serviceError: true serviceError: true,
}, },
healthCheck: { healthCheck: {
enabled: false, enabled: false,
intervalMinutes: 5, intervalMinutes: 5,
lastCheck: null lastCheck: null,
} },
}; };
// Notification history (in-memory, last 100 entries) // Notification history (in-memory, last 100 entries)
@@ -801,7 +801,7 @@ async function saveNotificationConfig() {
function addNotificationToHistory(notification) { function addNotificationToHistory(notification) {
notificationHistory.unshift({ notificationHistory.unshift({
...notification, ...notification,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
if (notificationHistory.length > MAX_NOTIFICATION_HISTORY) { if (notificationHistory.length > MAX_NOTIFICATION_HISTORY) {
notificationHistory = notificationHistory.slice(0, MAX_NOTIFICATION_HISTORY); notificationHistory = notificationHistory.slice(0, MAX_NOTIFICATION_HISTORY);
@@ -817,7 +817,7 @@ async function sendDiscordNotification(title, message, type = 'info') {
success: 0x00ff00, // Green success: 0x00ff00, // Green
error: 0xff0000, // Red error: 0xff0000, // Red
warning: 0xffff00, // Yellow warning: 0xffff00, // Yellow
info: 0x0099ff // Blue info: 0x0099ff, // Blue
}; };
const payload = { const payload = {
@@ -826,15 +826,15 @@ async function sendDiscordNotification(title, message, type = 'info') {
description: message, description: message,
color: colors[type] || colors.info, color: colors[type] || colors.info,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
footer: { text: 'DashCaddy Notifications' } footer: { text: 'DashCaddy Notifications' },
}] }],
}; };
try { try {
const response = await fetchT(webhookUrl, { const response = await fetchT(webhookUrl, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
@@ -857,7 +857,7 @@ async function sendTelegramNotification(title, message, type = 'info') {
success: '✅', success: '✅',
error: '❌', error: '❌',
warning: '⚠️', warning: '⚠️',
info: '' info: '',
}; };
const text = `${emoji[type] || emoji.info} *DashCaddy: ${title}*\n\n${message}`; const text = `${emoji[type] || emoji.info} *DashCaddy: ${title}*\n\n${message}`;
@@ -869,8 +869,8 @@ async function sendTelegramNotification(title, message, type = 'info') {
body: JSON.stringify({ body: JSON.stringify({
chat_id: chatId, chat_id: chatId,
text: text, text: text,
parse_mode: 'Markdown' parse_mode: 'Markdown',
}) }),
}); });
const result = await response.json(); const result = await response.json();
@@ -894,14 +894,14 @@ async function sendNtfyNotification(title, message, type = 'info') {
success: 3, // default success: 3, // default
error: 5, // max error: 5, // max
warning: 4, // high warning: 4, // high
info: 3 // default info: 3, // default
}; };
const tags = { const tags = {
success: 'white_check_mark', success: 'white_check_mark',
error: 'x', error: 'x',
warning: 'warning', warning: 'warning',
info: 'information_source' info: 'information_source',
}; };
try { try {
@@ -910,9 +910,9 @@ async function sendNtfyNotification(title, message, type = 'info') {
headers: { headers: {
'Title': `DashCaddy: ${title}`, 'Title': `DashCaddy: ${title}`,
'Priority': String(priority[type] || 3), 'Priority': String(priority[type] || 3),
'Tags': tags[type] || 'information_source' 'Tags': tags[type] || 'information_source',
}, },
body: message body: message,
}); });
if (!response.ok) { if (!response.ok) {
@@ -958,14 +958,14 @@ async function sendNotification(event, title, message, type = 'info') {
title, title,
message, message,
type, type,
results results,
}); });
return { sent: true, results }; return { sent: true, results };
} }
// Container health monitoring state // Container health monitoring state
let containerHealthState = {}; const containerHealthState = {};
let healthCheckInterval = null; let healthCheckInterval = null;
// Check container health and send notifications // Check container health and send notifications
@@ -1003,7 +1003,7 @@ async function checkContainerHealth() {
'containerUp', 'containerUp',
'Container Recovered', 'Container Recovered',
`**${serviceName}** is now running again.`, `**${serviceName}** is now running again.`,
'success' 'success',
); );
} else { } else {
// Container went down // Container went down
@@ -1011,7 +1011,7 @@ async function checkContainerHealth() {
'containerDown', 'containerDown',
'Container Down', 'Container Down',
`**${serviceName}** has stopped running.\nStatus: ${container.Status}`, `**${serviceName}** has stopped running.\nStatus: ${container.Status}`,
'error' 'error',
); );
} }
} }
@@ -1082,13 +1082,13 @@ const middlewareResult = configureMiddleware(app, {
siteConfig, totpConfig, tailscaleConfig, siteConfig, totpConfig, tailscaleConfig,
metrics, auditLogger, authManager, log, cryptoUtils, metrics, auditLogger, authManager, log, cryptoUtils,
isValidContainerId, isTailscaleIP, getTailscaleStatus, isValidContainerId, isTailscaleIP, getTailscaleStatus,
RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache,
}); });
const { const {
strictLimiter, SESSION_DURATIONS, ipSessions, strictLimiter, SESSION_DURATIONS, ipSessions,
getClientIP, createIPSession, setSessionCookie, getClientIP, createIPSession, setSessionCookie,
clearIPSession, clearSessionCookie, isSessionValid clearIPSession, clearSessionCookie, isSessionValid,
} = middlewareResult; } = middlewareResult;
// ── Populate route context and mount extracted route modules ── // ── Populate route context and mount extracted route modules ──
@@ -1280,7 +1280,7 @@ app.get('/probe/:id', asyncHandler(async (req, res) => {
const fReq = fLib.request({ const fReq = fLib.request({
hostname: fp.hostname, port: 443, path: '/', method: 'GET', hostname: fp.hostname, port: 443, path: '/', method: 'GET',
timeout: 5000, agent: httpsAgent, timeout: 5000, agent: httpsAgent,
headers: { 'User-Agent': APP.USER_AGENTS.PROBE } headers: { 'User-Agent': APP.USER_AGENTS.PROBE },
}, (fRes) => { fRes.resume(); resolve(fRes.statusCode); }); }, (fRes) => { fRes.resume(); resolve(fRes.statusCode); });
fReq.on('error', reject); fReq.on('error', reject);
fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); }); fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); });
@@ -1305,7 +1305,7 @@ app.get('/api/network/ips', (req, res) => {
localhost: '127.0.0.1', localhost: '127.0.0.1',
lan: envLan || null, lan: envLan || null,
tailscale: envTailscale || null, tailscale: envTailscale || null,
all: [] all: [],
}; };
// If env vars not set, try to detect from network interfaces // If env vars not set, try to detect from network interfaces
@@ -1364,7 +1364,7 @@ async function refreshDnsToken(username, password, server) {
const params = new URLSearchParams({ const params = new URLSearchParams({
user: username, user: username,
pass: password, pass: password,
includeInfo: 'false' includeInfo: 'false',
}); });
const response = await fetchT( const response = await fetchT(
@@ -1373,10 +1373,10 @@ async function refreshDnsToken(username, password, server) {
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded',
},
timeout: 10000,
}, },
timeout: 10000
}
); );
const result = await response.json(); const result = await response.json();
@@ -1436,7 +1436,7 @@ async function ensureValidDnsToken() {
return { return {
success: false, success: false,
error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials' error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials',
}; };
} }
@@ -1466,7 +1466,7 @@ async function getTokenForServer(targetServer, role = 'readonly') {
const params = new URLSearchParams({ const params = new URLSearchParams({
user: username, user: username,
pass: password, pass: password,
includeInfo: 'false' includeInfo: 'false',
}); });
const response = await fetchT( const response = await fetchT(
@@ -1475,9 +1475,9 @@ async function getTokenForServer(targetServer, role = 'readonly') {
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded',
} },
} },
); );
const result = await response.json(); const result = await response.json();
@@ -1485,7 +1485,7 @@ async function getTokenForServer(targetServer, role = 'readonly') {
if (result.status === 'ok' && result.token) { if (result.status === 'ok' && result.token) {
dnsServerTokens.set(cacheKey, { dnsServerTokens.set(cacheKey, {
token: result.token, token: result.token,
expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString() expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString(),
}); });
log.info('dns', 'DNS token obtained for server', { server: targetServer, role }); log.info('dns', 'DNS token obtained for server', { server: targetServer, role });
return { success: true, token: result.token }; return { success: true, token: result.token };
@@ -1575,13 +1575,13 @@ function generateCaddyConfig(subdomain, ip, port, options = {}) {
} }
if (tailscaleOnly) { if (tailscaleOnly) {
config += `\t\t@blocked not remote_ip 100.64.0.0/10`; config += '\t\t@blocked not remote_ip 100.64.0.0/10';
if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`;
config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`; config += '\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n';
} }
config += `\t\treverse_proxy ${ip}:${port}\n`; config += `\t\treverse_proxy ${ip}:${port}\n`;
config += `\t}`; config += '\t}';
return config; return config;
} }
@@ -1589,16 +1589,16 @@ function generateCaddyConfig(subdomain, ip, port, options = {}) {
let config = `${buildDomain(subdomain)} {\n`; let config = `${buildDomain(subdomain)} {\n`;
if (tailscaleOnly) { if (tailscaleOnly) {
config += ` @blocked not remote_ip 100.64.0.0/10`; config += ' @blocked not remote_ip 100.64.0.0/10';
if (allowedIPs.length > 0) { if (allowedIPs.length > 0) {
config += ` ${allowedIPs.join(' ')}`; config += ` ${allowedIPs.join(' ')}`;
} }
config += `\n respond @blocked "Access denied. Tailscale connection required." 403\n`; config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n';
} }
config += ` reverse_proxy ${ip}:${port}\n`; config += ` reverse_proxy ${ip}:${port}\n`;
config += ` tls internal\n`; config += ' tls internal\n';
config += `}`; config += '}';
return config; return config;
} }
@@ -1614,7 +1614,7 @@ async function reloadCaddy(content) {
const response = await fetchT(`${CADDY_ADMIN_URL}/load`, { const response = await fetchT(`${CADDY_ADMIN_URL}/load`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE }, headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: content body: content,
}); });
if (response.ok) { if (response.ok) {
@@ -1648,7 +1648,7 @@ async function verifySiteAccessible(domain, maxAttempts = 5) {
const response = await fetchT(`https://${domain}/`, { const response = await fetchT(`https://${domain}/`, {
method: 'HEAD', method: 'HEAD',
agent: httpsAgent, // Ignore cert errors for internal CA agent: httpsAgent, // Ignore cert errors for internal CA
timeout: 5000 timeout: 5000,
}); });
// Any response (even 4xx) means Caddy is serving the site // Any response (even 4xx) means Caddy is serving the site
@@ -1782,14 +1782,14 @@ app.use((err, req, res, next) => {
success: false, success: false,
error: err.message, error: err.message,
code: err.code, code: err.code,
...(err.details ? { details: err.details } : {}) ...(err.details ? { details: err.details } : {}),
}); });
} }
if (err instanceof ValidationError) { if (err instanceof ValidationError) {
return res.status(err.statusCode || 400).json({ return res.status(err.statusCode || 400).json({
success: false, success: false,
error: err.message, error: err.message,
errors: err.errors || undefined errors: err.errors || undefined,
}); });
} }
// Catch-all: never leak stack traces or internal paths // Catch-all: never leak stack traces or internal paths
@@ -1870,7 +1870,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
ctx.notification.send('system.update', ctx.notification.send('system.update',
result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed', result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed',
result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`, result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`,
result.success ? 'info' : 'error' result.success ? 'info' : 'error',
); );
} }
} }
@@ -1889,7 +1889,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
log.info('maintenance', 'Docker maintenance completed', { log.info('maintenance', 'Docker maintenance completed', {
spaceReclaimedMB: saved, spaceReclaimedMB: saved,
pruned: result.pruned, pruned: result.pruned,
warnings: result.warnings.length warnings: result.warnings.length,
}); });
} }
if (result.warnings.length > 0) { if (result.warnings.length > 0) {

View File

@@ -108,7 +108,7 @@ async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFI
port: urlObj.port, port: urlObj.port,
path: '/config/', path: '/config/',
method: 'GET', method: 'GET',
timeout: 2000 timeout: 2000,
}, (res) => { }, (res) => {
resolve(res.statusCode >= 200 && res.statusCode < 500); resolve(res.statusCode >= 200 && res.statusCode < 500);
}); });

View File

@@ -27,9 +27,9 @@ class StateManager {
retries: { retries: {
retries: options.lockRetries || 10, retries: options.lockRetries || 10,
minTimeout: options.lockRetryInterval || 100, minTimeout: options.lockRetryInterval || 100,
maxTimeout: (options.lockRetryInterval || 100) * 3 maxTimeout: (options.lockRetryInterval || 100) * 3,
}, },
stale: options.lockTimeout || 30000 // 30 seconds stale: options.lockTimeout || 30000, // 30 seconds
}; };
// Ensure file exists // Ensure file exists

View File

@@ -26,7 +26,7 @@ const colors = {
red: '\x1b[31m', red: '\x1b[31m',
yellow: '\x1b[33m', yellow: '\x1b[33m',
blue: '\x1b[34m', blue: '\x1b[34m',
cyan: '\x1b[36m' cyan: '\x1b[36m',
}; };
function log(message, color = 'reset') { function log(message, color = 'reset') {
@@ -56,7 +56,7 @@ async function makeRequest(path, options = {}) {
path: url.pathname + url.search, path: url.pathname + url.search,
method: options.method || 'GET', method: options.method || 'GET',
headers: options.headers || {}, headers: options.headers || {},
...options ...options,
}; };
const req = client.request(requestOptions, (res) => { const req = client.request(requestOptions, (res) => {
@@ -67,7 +67,7 @@ async function makeRequest(path, options = {}) {
statusCode: res.statusCode, statusCode: res.statusCode,
headers: res.headers, headers: res.headers,
body: data, body: data,
data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null,
}); });
}); });
}); });
@@ -90,7 +90,7 @@ async function testPathTraversal() {
{ path: '/api/browse/directories?path=../../../../../../etc/passwd', desc: 'Unix path traversal' }, { path: '/api/browse/directories?path=../../../../../../etc/passwd', desc: 'Unix path traversal' },
{ path: '/api/browse/directories?path=..\\..\\..\\Windows\\System32', desc: 'Windows path traversal' }, { path: '/api/browse/directories?path=..\\..\\..\\Windows\\System32', desc: 'Windows path traversal' },
{ path: '/api/browse/directories?path=%2e%2e%2f%2e%2e%2fetc%2fpasswd', desc: 'URL-encoded traversal' }, { path: '/api/browse/directories?path=%2e%2e%2f%2e%2e%2fetc%2fpasswd', desc: 'URL-encoded traversal' },
{ path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' } { path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' },
]; ];
for (const attack of attacks) { for (const attack of attacks) {
@@ -117,7 +117,7 @@ async function testRequestSizeLimits() {
const response = await makeRequest('/api/services', { const response = await makeRequest('/api/services', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(smallPayload) body: JSON.stringify(smallPayload),
}); });
logResult(true, 'Small payload accepted (100 bytes)'); logResult(true, 'Small payload accepted (100 bytes)');
} catch (error) { } catch (error) {
@@ -130,7 +130,7 @@ async function testRequestSizeLimits() {
const response = await makeRequest('/api/services', { const response = await makeRequest('/api/services', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(largePayload) body: JSON.stringify(largePayload),
}); });
if (response.statusCode === 413 || response.statusCode === 400) { if (response.statusCode === 413 || response.statusCode === 400) {
logResult(true, 'Large payload rejected on general endpoint (2MB)'); logResult(true, 'Large payload rejected on general endpoint (2MB)');
@@ -151,7 +151,7 @@ async function testRequestSizeLimits() {
const response = await makeRequest('/api/logo', { const response = await makeRequest('/api/logo', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ logo: largeImage }) body: JSON.stringify({ logo: largeImage }),
}); });
if (response.statusCode !== 413) { if (response.statusCode !== 413) {
logResult(true, 'Large payload accepted on logo endpoint (5MB)'); logResult(true, 'Large payload accepted on logo endpoint (5MB)');

View File

@@ -83,7 +83,7 @@ class UpdateManager extends EventEmitter {
currentDigest: currentDigest.substring(0, 12), currentDigest: currentDigest.substring(0, 12),
latestDigest: latestDigest.substring(0, 12), latestDigest: latestDigest.substring(0, 12),
currentTag: this.extractTag(imageName), currentTag: this.extractTag(imageName),
detectedAt: new Date().toISOString() detectedAt: new Date().toISOString(),
}); });
this.emit('update-available', this.availableUpdates.get(containerInfo.Id)); this.emit('update-available', this.availableUpdates.get(containerInfo.Id));
@@ -137,8 +137,8 @@ class UpdateManager extends EventEmitter {
path: `/v2/${repo}/manifests/${tag}`, path: `/v2/${repo}/manifests/${tag}`,
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json' 'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
} },
}; };
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
@@ -206,8 +206,8 @@ class UpdateManager extends EventEmitter {
...originalOptions, ...originalOptions,
headers: { headers: {
...originalOptions.headers, ...originalOptions.headers,
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`,
} },
}; };
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
@@ -271,7 +271,7 @@ class UpdateManager extends EventEmitter {
config: inspect.Config, config: inspect.Config,
hostConfig: inspect.HostConfig, hostConfig: inspect.HostConfig,
networkSettings: inspect.NetworkSettings, networkSettings: inspect.NetworkSettings,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}; };
// Pull latest image // Pull latest image
@@ -292,7 +292,7 @@ class UpdateManager extends EventEmitter {
name: containerName, name: containerName,
Image: imageName, Image: imageName,
...backup.config, ...backup.config,
HostConfig: backup.hostConfig HostConfig: backup.hostConfig,
}); });
// Start new container // Start new container
@@ -300,7 +300,7 @@ class UpdateManager extends EventEmitter {
await newContainer.start(); await newContainer.start();
// Extended verification with health checks and port accessibility // Extended verification with health checks and port accessibility
console.log(`[UpdateManager] Performing extended verification...`); console.log('[UpdateManager] Performing extended verification...');
await this.verifyContainerExtended(newContainer, inspect, options.verifyTimeout || 60000); await this.verifyContainerExtended(newContainer, inspect, options.verifyTimeout || 60000);
// Get new image ID // Get new image ID
@@ -313,7 +313,7 @@ class UpdateManager extends EventEmitter {
console.log(`[UpdateManager] Removing old image: ${oldImageId.substring(0, 12)}`); console.log(`[UpdateManager] Removing old image: ${oldImageId.substring(0, 12)}`);
const oldImage = docker.getImage(oldImageId); const oldImage = docker.getImage(oldImageId);
await oldImage.remove({ force: false }); await oldImage.remove({ force: false });
console.log(`[UpdateManager] Old image removed successfully`); console.log('[UpdateManager] Old image removed successfully');
} catch (error) { } catch (error) {
console.warn(`[UpdateManager] Could not remove old image (may be in use): ${error.message}`); console.warn(`[UpdateManager] Could not remove old image (may be in use): ${error.message}`);
} }
@@ -330,7 +330,7 @@ class UpdateManager extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
duration, duration,
status: 'success', status: 'success',
backup backup,
}; };
this.addToHistory(historyEntry); this.addToHistory(historyEntry);
@@ -348,7 +348,7 @@ class UpdateManager extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
duration, duration,
status: 'failed', status: 'failed',
error: error.message error: error.message,
}; };
this.addToHistory(historyEntry); this.addToHistory(historyEntry);
@@ -360,7 +360,7 @@ class UpdateManager extends EventEmitter {
try { try {
await this.rollbackUpdate(containerId); await this.rollbackUpdate(containerId);
} catch (rollbackError) { } catch (rollbackError) {
console.error(`[UpdateManager] Rollback failed:`, rollbackError.message); console.error('[UpdateManager] Rollback failed:', rollbackError.message);
} }
} }
@@ -448,7 +448,7 @@ class UpdateManager extends EventEmitter {
// Step 2: Check Docker health check if available // Step 2: Check Docker health check if available
if (inspect.State.Health) { if (inspect.State.Health) {
if (inspect.State.Health.Status === 'healthy') { if (inspect.State.Health.Status === 'healthy') {
console.log(`[UpdateManager] Container health check: healthy`); console.log('[UpdateManager] Container health check: healthy');
return true; return true;
} else if (inspect.State.Health.Status === 'unhealthy') { } else if (inspect.State.Health.Status === 'unhealthy') {
lastError = 'Container health check failed (unhealthy)'; lastError = 'Container health check failed (unhealthy)';
@@ -468,7 +468,7 @@ class UpdateManager extends EventEmitter {
try { try {
const response = await fetch(testUrl, { const response = await fetch(testUrl, {
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
redirect: 'manual' redirect: 'manual',
}); });
// Accept 2xx, 3xx, 4xx as "accessible" (server is responding) // Accept 2xx, 3xx, 4xx as "accessible" (server is responding)
@@ -477,7 +477,7 @@ class UpdateManager extends EventEmitter {
// Wait a bit more to ensure stability // Wait a bit more to ensure stability
if (attempt >= 2) { if (attempt >= 2) {
console.log(`[UpdateManager] Container verified successfully`); console.log('[UpdateManager] Container verified successfully');
return true; return true;
} }
} }
@@ -488,7 +488,7 @@ class UpdateManager extends EventEmitter {
} else { } else {
// No ports exposed - just verify it's running for a few cycles // No ports exposed - just verify it's running for a few cycles
if (attempt >= 5) { if (attempt >= 5) {
console.log(`[UpdateManager] Container running without exposed ports (verified)`); console.log('[UpdateManager] Container running without exposed ports (verified)');
return true; return true;
} }
} }
@@ -529,7 +529,7 @@ class UpdateManager extends EventEmitter {
ports.push({ ports.push({
containerPort: containerPort.split('/')[0], containerPort: containerPort.split('/')[0],
hostPort: binding.HostPort, hostPort: binding.HostPort,
protocol: containerPort.split('/')[1] || 'tcp' protocol: containerPort.split('/')[1] || 'tcp',
}); });
} }
} }
@@ -572,7 +572,7 @@ class UpdateManager extends EventEmitter {
name: backup.containerName, name: backup.containerName,
Image: backup.imageName, Image: backup.imageName,
...backup.config, ...backup.config,
HostConfig: backup.hostConfig HostConfig: backup.hostConfig,
}); });
await newContainer.start(); await newContainer.start();
@@ -582,7 +582,7 @@ class UpdateManager extends EventEmitter {
return true; return true;
} catch (error) { } catch (error) {
console.error(`[UpdateManager] Rollback failed:`, error.message); console.error('[UpdateManager] Rollback failed:', error.message);
throw error; throw error;
} }
} }
@@ -599,7 +599,7 @@ class UpdateManager extends EventEmitter {
setTimeout(() => { setTimeout(() => {
this.updateContainer(containerId).catch(error => { this.updateContainer(containerId).catch(error => {
console.error(`[UpdateManager] Scheduled update failed:`, error.message); console.error('[UpdateManager] Scheduled update failed:', error.message);
}); });
}, delay); }, delay);
@@ -663,20 +663,20 @@ class UpdateManager extends EventEmitter {
shortDescription: repoInfo?.description?.substring(0, 200) || '', shortDescription: repoInfo?.description?.substring(0, 200) || '',
starCount: repoInfo?.star_count || 0, starCount: repoInfo?.star_count || 0,
pullCount: repoInfo?.pull_count || 0, pullCount: repoInfo?.pull_count || 0,
lastUpdated: repoInfo?.last_updated || null lastUpdated: repoInfo?.last_updated || null,
}, },
tags: tags.slice(0, 10).map(t => ({ tags: tags.slice(0, 10).map(t => ({
name: t.name, name: t.name,
lastPushed: t.last_pushed || t.tag_last_pushed, lastPushed: t.last_pushed || t.tag_last_pushed,
digest: t.digest?.substring(0, 12) || 'unknown', digest: t.digest?.substring(0, 12) || 'unknown',
size: t.full_size || t.size || 0 size: t.full_size || t.size || 0,
})), })),
urls: { urls: {
dockerHub: hubUrl, dockerHub: hubUrl,
tags: `${hubUrl}/tags`, tags: `${hubUrl}/tags`,
dockerfile: repoInfo?.dockerfile_url || null dockerfile: repoInfo?.dockerfile_url || null,
}, },
changelog: this.formatChangelog(repoInfo, tags, imageTag) changelog: this.formatChangelog(repoInfo, tags, imageTag),
}; };
} catch (error) { } catch (error) {
console.error(`[UpdateManager] Error fetching changelog for ${imageName}:`, error.message); console.error(`[UpdateManager] Error fetching changelog for ${imageName}:`, error.message);
@@ -691,7 +691,7 @@ class UpdateManager extends EventEmitter {
urls: { urls: {
dockerHub: `https://hub.docker.com/r/${repoPath.replace('library/', '_/')}`, dockerHub: `https://hub.docker.com/r/${repoPath.replace('library/', '_/')}`,
}, },
changelog: 'Unable to fetch changelog. Visit Docker Hub for details.' changelog: 'Unable to fetch changelog. Visit Docker Hub for details.',
}; };
} }
} }
@@ -711,8 +711,8 @@ class UpdateManager extends EventEmitter {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'User-Agent': 'DashCaddy/1.0' 'User-Agent': 'DashCaddy/1.0',
} },
}; };
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
@@ -755,8 +755,8 @@ class UpdateManager extends EventEmitter {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'User-Agent': 'DashCaddy/1.0' 'User-Agent': 'DashCaddy/1.0',
} },
}; };
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
@@ -836,7 +836,7 @@ class UpdateManager extends EventEmitter {
schedule: config.schedule || 'weekly', schedule: config.schedule || 'weekly',
maintenanceWindow: config.maintenanceWindow, maintenanceWindow: config.maintenanceWindow,
autoRollback: config.autoRollback !== false, autoRollback: config.autoRollback !== false,
securityOnly: config.securityOnly || false securityOnly: config.securityOnly || false,
}; };
this.saveConfig(); this.saveConfig();