Phase 1: Add ESLint/Prettier config + baseline auto-fixes
This commit is contained in:
4
dashcaddy-api/.eslintignore
Normal file
4
dashcaddy-api/.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
*.min.js
|
||||||
56
dashcaddy-api/.eslintrc.js
Normal file
56
dashcaddy-api/.eslintrc.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
8
dashcaddy-api/.prettierrc
Normal file
8
dashcaddy-api/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 120,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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` }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,5 +336,5 @@ module.exports = {
|
|||||||
deriveKey,
|
deriveKey,
|
||||||
rotateKey,
|
rotateKey,
|
||||||
decryptWithKey,
|
decryptWithKey,
|
||||||
clearCachedKey
|
clearCachedKey,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
877
dashcaddy-api/package-lock.json
generated
877
dashcaddy-api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,17 +47,17 @@ const paths = {
|
|||||||
// Log paths (for allowed log file access)
|
// Log paths (for allowed log file access)
|
||||||
allowedLogPaths: isWindows
|
allowedLogPaths: isWindows
|
||||||
? [
|
? [
|
||||||
process.env.LOCALAPPDATA || 'C:\\Users',
|
process.env.LOCALAPPDATA || 'C:\\Users',
|
||||||
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
|
||||||
isWindows,
|
isWindows,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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*(.+)/);
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -25,22 +25,22 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const certInfo = JSON.parse(await fsp.readFile(certInfoFile, 'utf8'));
|
const certInfo = JSON.parse(await fsp.readFile(certInfoFile, 'utf8'));
|
||||||
const expirationDate = new Date(certInfo.validUntil);
|
const expirationDate = new Date(certInfo.validUntil);
|
||||||
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
certificate: {
|
certificate: {
|
||||||
name: certInfo.name,
|
name: certInfo.name,
|
||||||
fingerprint: certInfo.fingerprint,
|
fingerprint: certInfo.fingerprint,
|
||||||
validFrom: certInfo.validFrom,
|
validFrom: certInfo.validFrom,
|
||||||
validUntil: certInfo.validUntil,
|
validUntil: certInfo.validUntil,
|
||||||
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'));
|
||||||
|
|
||||||
// Serve root CA certificate directly (works even without DashCA deployed)
|
// Serve root CA certificate directly (works even without DashCA deployed)
|
||||||
@@ -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') {
|
||||||
@@ -260,26 +260,26 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
|
|||||||
const certFile = path.join(certsDir, domain, 'server.crt');
|
const certFile = path.join(certsDir, domain, 'server.crt');
|
||||||
if (!await exists(certFile)) return null;
|
if (!await exists(certFile)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString();
|
const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString();
|
||||||
const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain;
|
const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain;
|
||||||
const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : '';
|
const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : '';
|
||||||
const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : '';
|
const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : '';
|
||||||
const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : '';
|
const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : '';
|
||||||
|
|
||||||
const expirationDate = new Date(notAfter);
|
const expirationDate = new Date(notAfter);
|
||||||
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
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;
|
||||||
}
|
}
|
||||||
}))).filter(Boolean);
|
}))).filter(Boolean);
|
||||||
|
|
||||||
res.json({ success: true, certificates });
|
res.json({ success: true, certificates });
|
||||||
}, 'ca-certs'));
|
}, 'ca-certs'));
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -46,90 +46,90 @@ module.exports = function(ctx) {
|
|||||||
const containerId = req.params.id;
|
const containerId = req.params.id;
|
||||||
const container = await getVerifiedContainer(containerId);
|
const container = await getVerifiedContainer(containerId);
|
||||||
|
|
||||||
// Get container info
|
// Get container info
|
||||||
const containerInfo = await container.inspect();
|
const containerInfo = await container.inspect();
|
||||||
const imageName = containerInfo.Config.Image;
|
const imageName = containerInfo.Config.Image;
|
||||||
const containerName = containerInfo.Name.replace(/^\//, '');
|
const containerName = containerInfo.Name.replace(/^\//, '');
|
||||||
|
|
||||||
ctx.log.info('docker', 'Updating container', { containerName, imageName });
|
ctx.log.info('docker', 'Updating container', { containerName, imageName });
|
||||||
|
|
||||||
// Pull the latest image
|
// Pull the latest image
|
||||||
ctx.log.info('docker', `Pulling latest image: ${imageName}`);
|
ctx.log.info('docker', `Pulling latest image: ${imageName}`);
|
||||||
await ctx.docker.pull(imageName);
|
await ctx.docker.pull(imageName);
|
||||||
|
|
||||||
// Get current container config for recreation
|
// Get current container config for recreation
|
||||||
const hostConfig = containerInfo.HostConfig;
|
const hostConfig = containerInfo.HostConfig;
|
||||||
const config = {
|
const config = {
|
||||||
Image: imageName,
|
Image: imageName,
|
||||||
name: containerName,
|
name: containerName,
|
||||||
Env: containerInfo.Config.Env,
|
Env: containerInfo.Config.Env,
|
||||||
ExposedPorts: containerInfo.Config.ExposedPorts,
|
ExposedPorts: containerInfo.Config.ExposedPorts,
|
||||||
Labels: containerInfo.Config.Labels,
|
Labels: containerInfo.Config.Labels,
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
Binds: hostConfig.Binds,
|
Binds: hostConfig.Binds,
|
||||||
PortBindings: hostConfig.PortBindings,
|
PortBindings: hostConfig.PortBindings,
|
||||||
RestartPolicy: hostConfig.RestartPolicy,
|
RestartPolicy: hostConfig.RestartPolicy,
|
||||||
NetworkMode: hostConfig.NetworkMode,
|
NetworkMode: hostConfig.NetworkMode,
|
||||||
ExtraHosts: hostConfig.ExtraHosts,
|
ExtraHosts: hostConfig.ExtraHosts,
|
||||||
Privileged: hostConfig.Privileged,
|
Privileged: hostConfig.Privileged,
|
||||||
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
|
||||||
|
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
|
||||||
|
const networkName = hostConfig.NetworkMode;
|
||||||
|
config.NetworkingConfig.EndpointsConfig = {
|
||||||
|
[networkName]: containerInfo.NetworkSettings.Networks[networkName],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Get network settings if using a custom network
|
// Stop and remove old container
|
||||||
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
|
ctx.log.info('docker', 'Stopping container', { containerName });
|
||||||
const networkName = hostConfig.NetworkMode;
|
await container.stop().catch(() => {}); // Ignore if already stopped
|
||||||
config.NetworkingConfig.EndpointsConfig = {
|
ctx.log.info('docker', 'Removing container', { containerName });
|
||||||
[networkName]: containerInfo.NetworkSettings.Networks[networkName]
|
await container.remove();
|
||||||
};
|
|
||||||
|
// Wait for port release (Windows/Docker Desktop can be slow to free ports)
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
// Create and start new container
|
||||||
|
ctx.log.info('docker', 'Creating new container', { containerName });
|
||||||
|
let newContainer;
|
||||||
|
try {
|
||||||
|
newContainer = await ctx.docker.client.createContainer(config);
|
||||||
|
ctx.log.info('docker', 'Starting container', { containerName });
|
||||||
|
await newContainer.start();
|
||||||
|
} catch (startError) {
|
||||||
|
// Clean up the failed container so it doesn't block future attempts
|
||||||
|
ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
|
||||||
|
if (newContainer) {
|
||||||
|
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
|
||||||
}
|
}
|
||||||
|
throw startError;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop and remove old container
|
const newContainerInfo = await newContainer.inspect();
|
||||||
ctx.log.info('docker', 'Stopping container', { containerName });
|
|
||||||
await container.stop().catch(() => {}); // Ignore if already stopped
|
|
||||||
ctx.log.info('docker', 'Removing container', { containerName });
|
|
||||||
await container.remove();
|
|
||||||
|
|
||||||
// Wait for port release (Windows/Docker Desktop can be slow to free ports)
|
// Prune dangling images after update
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
try {
|
||||||
|
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
|
||||||
// Create and start new container
|
if (pruneResult.SpaceReclaimed > 0) {
|
||||||
ctx.log.info('docker', 'Creating new container', { containerName });
|
ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` });
|
||||||
let newContainer;
|
|
||||||
try {
|
|
||||||
newContainer = await ctx.docker.client.createContainer(config);
|
|
||||||
ctx.log.info('docker', 'Starting container', { containerName });
|
|
||||||
await newContainer.start();
|
|
||||||
} catch (startError) {
|
|
||||||
// Clean up the failed container so it doesn't block future attempts
|
|
||||||
ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
|
|
||||||
if (newContainer) {
|
|
||||||
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
|
|
||||||
}
|
|
||||||
throw startError;
|
|
||||||
}
|
}
|
||||||
|
} catch (pruneErr) {
|
||||||
|
ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
|
||||||
|
}
|
||||||
|
|
||||||
const newContainerInfo = await newContainer.inspect();
|
res.json({
|
||||||
|
success: true,
|
||||||
// Prune dangling images after update
|
message: `Container ${containerName} updated successfully`,
|
||||||
try {
|
newContainerId: newContainerInfo.Id,
|
||||||
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
|
});
|
||||||
if (pruneResult.SpaceReclaimed > 0) {
|
|
||||||
ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
|
|
||||||
}
|
|
||||||
} catch (pruneErr) {
|
|
||||||
ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Container ${containerName} updated successfully`,
|
|
||||||
newContainerId: newContainerInfo.Id
|
|
||||||
});
|
|
||||||
}, 'container-update'));
|
}, 'container-update'));
|
||||||
|
|
||||||
// Check for available updates (compares local and remote image digests)
|
// Check for available updates (compares local and remote image digests)
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -14,22 +14,22 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logContent = await fsp.readFile(ctx.ERROR_LOG_FILE, 'utf8');
|
const logContent = await fsp.readFile(ctx.ERROR_LOG_FILE, 'utf8');
|
||||||
const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim());
|
const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim());
|
||||||
|
|
||||||
const logs = logEntries.map(entry => {
|
const logs = logEntries.map(entry => {
|
||||||
const lines = entry.trim().split('\n');
|
const lines = entry.trim().split('\n');
|
||||||
const firstLine = lines[0] || '';
|
const firstLine = lines[0] || '';
|
||||||
const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/);
|
const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return {
|
||||||
timestamp: match[1],
|
timestamp: match[1],
|
||||||
context: match[2],
|
context: match[2],
|
||||||
error: match[3]
|
error: match[3],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
|
|
||||||
res.json({ success: true, logs: logs.slice(-50).reverse() });
|
res.json({ success: true, logs: logs.slice(-50).reverse() });
|
||||||
}, 'error-logs-get'));
|
}, 'error-logs-get'));
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -7,116 +7,116 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// GET /config — Get notification configuration (sensitive data redacted)
|
// GET /config — Get notification configuration (sensitive data redacted)
|
||||||
router.get('/config', ctx.asyncHandler(async (req, res) => {
|
router.get('/config', ctx.asyncHandler(async (req, res) => {
|
||||||
const notificationConfig = ctx.notification.getConfig();
|
const notificationConfig = ctx.notification.getConfig();
|
||||||
// Return config without sensitive data
|
// Return config without sensitive data
|
||||||
const safeConfig = {
|
const safeConfig = {
|
||||||
enabled: notificationConfig.enabled,
|
enabled: notificationConfig.enabled,
|
||||||
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: {
|
|
||||||
enabled: notificationConfig.providers.telegram?.enabled || false,
|
|
||||||
configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId)
|
|
||||||
},
|
|
||||||
ntfy: {
|
|
||||||
enabled: notificationConfig.providers.ntfy?.enabled || false,
|
|
||||||
configured: !!notificationConfig.providers.ntfy?.topic,
|
|
||||||
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
events: notificationConfig.events,
|
telegram: {
|
||||||
healthCheck: notificationConfig.healthCheck
|
enabled: notificationConfig.providers.telegram?.enabled || false,
|
||||||
};
|
configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId),
|
||||||
res.json({ success: true, config: safeConfig });
|
},
|
||||||
|
ntfy: {
|
||||||
|
enabled: notificationConfig.providers.ntfy?.enabled || false,
|
||||||
|
configured: !!notificationConfig.providers.ntfy?.topic,
|
||||||
|
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: notificationConfig.events,
|
||||||
|
healthCheck: notificationConfig.healthCheck,
|
||||||
|
};
|
||||||
|
res.json({ success: true, config: safeConfig });
|
||||||
}, 'notifications-config-get'));
|
}, 'notifications-config-get'));
|
||||||
|
|
||||||
// POST /config — Update notification configuration
|
// POST /config — Update notification configuration
|
||||||
router.post('/config', ctx.asyncHandler(async (req, res) => {
|
router.post('/config', ctx.asyncHandler(async (req, res) => {
|
||||||
const { enabled, providers, events, healthCheck } = req.body;
|
const { enabled, providers, events, healthCheck } = req.body;
|
||||||
const notificationConfig = ctx.notification.getConfig();
|
const notificationConfig = ctx.notification.getConfig();
|
||||||
|
|
||||||
// Validate provider webhook URLs and tokens
|
// Validate provider webhook URLs and tokens
|
||||||
if (providers) {
|
if (providers) {
|
||||||
if (providers.discord?.webhookUrl) {
|
if (providers.discord?.webhookUrl) {
|
||||||
try {
|
try {
|
||||||
validateURL(providers.discord.webhookUrl);
|
validateURL(providers.discord.webhookUrl);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL');
|
return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL');
|
||||||
}
|
|
||||||
}
|
|
||||||
if (providers.telegram?.botToken) {
|
|
||||||
try {
|
|
||||||
validateToken(providers.telegram.botToken);
|
|
||||||
} catch (validationErr) {
|
|
||||||
return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (providers.ntfy?.serverUrl) {
|
|
||||||
try {
|
|
||||||
validateURL(providers.ntfy.serverUrl);
|
|
||||||
} catch (validationErr) {
|
|
||||||
return ctx.errorResponse(res, 400, 'Invalid ntfy server URL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (providers.ntfy?.topic) {
|
|
||||||
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
||||||
if (!topicRegex.test(providers.ntfy.topic)) {
|
|
||||||
return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (providers.telegram?.botToken) {
|
||||||
// Update enabled state
|
try {
|
||||||
if (typeof enabled === 'boolean') {
|
validateToken(providers.telegram.botToken);
|
||||||
notificationConfig.enabled = enabled;
|
} catch (validationErr) {
|
||||||
}
|
return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format');
|
||||||
|
|
||||||
// Update providers (only update provided fields)
|
|
||||||
if (providers) {
|
|
||||||
if (providers.discord) {
|
|
||||||
notificationConfig.providers.discord = {
|
|
||||||
...notificationConfig.providers.discord,
|
|
||||||
...providers.discord
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (providers.telegram) {
|
|
||||||
notificationConfig.providers.telegram = {
|
|
||||||
...notificationConfig.providers.telegram,
|
|
||||||
...providers.telegram
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (providers.ntfy) {
|
|
||||||
notificationConfig.providers.ntfy = {
|
|
||||||
...notificationConfig.providers.ntfy,
|
|
||||||
...providers.ntfy
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (providers.ntfy?.serverUrl) {
|
||||||
// Update events
|
try {
|
||||||
if (events) {
|
validateURL(providers.ntfy.serverUrl);
|
||||||
notificationConfig.events = { ...notificationConfig.events, ...events };
|
} catch (validationErr) {
|
||||||
}
|
return ctx.errorResponse(res, 400, 'Invalid ntfy server URL');
|
||||||
|
|
||||||
// Update health check settings
|
|
||||||
if (healthCheck) {
|
|
||||||
const wasEnabled = notificationConfig.healthCheck?.enabled;
|
|
||||||
notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck };
|
|
||||||
|
|
||||||
// Restart daemon if settings changed
|
|
||||||
if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) {
|
|
||||||
if (notificationConfig.healthCheck.enabled) {
|
|
||||||
ctx.notification.startHealthDaemon();
|
|
||||||
} else {
|
|
||||||
ctx.notification.stopHealthDaemon();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (providers.ntfy?.topic) {
|
||||||
|
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||||
|
if (!topicRegex.test(providers.ntfy.topic)) {
|
||||||
|
return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.notification.saveConfig();
|
// Update enabled state
|
||||||
res.json({ success: true, message: 'Notification config updated' });
|
if (typeof enabled === 'boolean') {
|
||||||
|
notificationConfig.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update providers (only update provided fields)
|
||||||
|
if (providers) {
|
||||||
|
if (providers.discord) {
|
||||||
|
notificationConfig.providers.discord = {
|
||||||
|
...notificationConfig.providers.discord,
|
||||||
|
...providers.discord,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (providers.telegram) {
|
||||||
|
notificationConfig.providers.telegram = {
|
||||||
|
...notificationConfig.providers.telegram,
|
||||||
|
...providers.telegram,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (providers.ntfy) {
|
||||||
|
notificationConfig.providers.ntfy = {
|
||||||
|
...notificationConfig.providers.ntfy,
|
||||||
|
...providers.ntfy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update events
|
||||||
|
if (events) {
|
||||||
|
notificationConfig.events = { ...notificationConfig.events, ...events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update health check settings
|
||||||
|
if (healthCheck) {
|
||||||
|
const wasEnabled = notificationConfig.healthCheck?.enabled;
|
||||||
|
notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck };
|
||||||
|
|
||||||
|
// Restart daemon if settings changed
|
||||||
|
if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) {
|
||||||
|
if (notificationConfig.healthCheck.enabled) {
|
||||||
|
ctx.notification.startHealthDaemon();
|
||||||
|
} else {
|
||||||
|
ctx.notification.stopHealthDaemon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.notification.saveConfig();
|
||||||
|
res.json({ success: true, message: 'Notification config updated' });
|
||||||
}, 'notifications-config-update'));
|
}, 'notifications-config-update'));
|
||||||
|
|
||||||
// POST /test — Test notification delivery
|
// POST /test — Test notification delivery
|
||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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, '\\$&');
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -39,80 +39,80 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// Get Certificate Authorities from Caddyfile
|
// Get Certificate Authorities from Caddyfile
|
||||||
router.get('/caddy/cas', ctx.asyncHandler(async (req, res) => {
|
router.get('/caddy/cas', ctx.asyncHandler(async (req, res) => {
|
||||||
const content = await ctx.caddy.read();
|
const content = await ctx.caddy.read();
|
||||||
const cas = [];
|
const cas = [];
|
||||||
|
|
||||||
const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
|
const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
|
||||||
let pkiMatch;
|
let pkiMatch;
|
||||||
while ((pkiMatch = pkiRegex.exec(content)) !== null) {
|
while ((pkiMatch = pkiRegex.exec(content)) !== null) {
|
||||||
const pkiBlock = pkiMatch[1];
|
const pkiBlock = pkiMatch[1];
|
||||||
let caMatch;
|
let caMatch;
|
||||||
const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
|
const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
|
||||||
while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) {
|
while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) {
|
||||||
const caName = caMatch[1];
|
const caName = caMatch[1];
|
||||||
const caBlock = caMatch[2];
|
const caBlock = caMatch[2];
|
||||||
const ca = { id: caName, name: caName, root: {}, intermediate: {} };
|
const ca = { id: caName, name: caName, root: {}, intermediate: {} };
|
||||||
|
|
||||||
const nameMatch = /name\s+"([^"]+)"/.exec(caBlock);
|
const nameMatch = /name\s+"([^"]+)"/.exec(caBlock);
|
||||||
if (nameMatch) ca.name = nameMatch[1];
|
if (nameMatch) ca.name = nameMatch[1];
|
||||||
|
|
||||||
const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock);
|
const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock);
|
||||||
const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock);
|
const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock);
|
||||||
if (rootCnMatch) ca.root_cn = rootCnMatch[1];
|
if (rootCnMatch) ca.root_cn = rootCnMatch[1];
|
||||||
if (intCnMatch) ca.intermediate_cn = intCnMatch[1];
|
if (intCnMatch) ca.intermediate_cn = intCnMatch[1];
|
||||||
|
|
||||||
const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock);
|
const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock);
|
||||||
if (rootMatch) {
|
if (rootMatch) {
|
||||||
const rootBlock = rootMatch[1];
|
const rootBlock = rootMatch[1];
|
||||||
const certMatch = /cert\s+(\S+)/.exec(rootBlock);
|
const certMatch = /cert\s+(\S+)/.exec(rootBlock);
|
||||||
const keyMatch = /key\s+(\S+)/.exec(rootBlock);
|
const keyMatch = /key\s+(\S+)/.exec(rootBlock);
|
||||||
if (certMatch) ca.root.cert = certMatch[1];
|
if (certMatch) ca.root.cert = certMatch[1];
|
||||||
if (keyMatch) ca.root.key = keyMatch[1];
|
if (keyMatch) ca.root.key = keyMatch[1];
|
||||||
}
|
|
||||||
|
|
||||||
const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock);
|
|
||||||
if (intMatch) {
|
|
||||||
const intBlock = intMatch[1];
|
|
||||||
const certMatch = /cert\s+(\S+)/.exec(intBlock);
|
|
||||||
const keyMatch = /key\s+(\S+)/.exec(intBlock);
|
|
||||||
if (certMatch) ca.intermediate.cert = certMatch[1];
|
|
||||||
if (keyMatch) ca.intermediate.key = keyMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
cas.push(ca);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g;
|
const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock);
|
||||||
let tlsMatch;
|
if (intMatch) {
|
||||||
while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) {
|
const intBlock = intMatch[1];
|
||||||
cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' });
|
const certMatch = /cert\s+(\S+)/.exec(intBlock);
|
||||||
}
|
const keyMatch = /key\s+(\S+)/.exec(intBlock);
|
||||||
|
if (certMatch) ca.intermediate.cert = certMatch[1];
|
||||||
const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || [];
|
if (keyMatch) ca.intermediate.key = keyMatch[1];
|
||||||
const tlsInternalCAs = new Set();
|
|
||||||
for (const block of siteBlocks) {
|
|
||||||
const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block);
|
|
||||||
if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]);
|
|
||||||
if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) {
|
|
||||||
tlsInternalCAs.add('local');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (const caName of tlsInternalCAs) {
|
|
||||||
if (!cas.find(c => c.name === caName)) {
|
|
||||||
cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cas.length === 0 && /tls\s+internal/.test(content)) {
|
|
||||||
cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const caList = cas.map(ca => ({
|
cas.push(ca);
|
||||||
id: ca.id || ca.name,
|
}
|
||||||
name: ca.name,
|
}
|
||||||
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name
|
|
||||||
}));
|
const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g;
|
||||||
res.json({ status: 'success', data: { cas: caList } });
|
let tlsMatch;
|
||||||
|
while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) {
|
||||||
|
cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || [];
|
||||||
|
const tlsInternalCAs = new Set();
|
||||||
|
for (const block of siteBlocks) {
|
||||||
|
const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block);
|
||||||
|
if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]);
|
||||||
|
if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) {
|
||||||
|
tlsInternalCAs.add('local');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const caName of tlsInternalCAs) {
|
||||||
|
if (!cas.find(c => c.name === caName)) {
|
||||||
|
cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cas.length === 0 && /tls\s+internal/.test(content)) {
|
||||||
|
cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const caList = cas.map(ca => ({
|
||||||
|
id: ca.id || ca.name,
|
||||||
|
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 } });
|
||||||
}, 'caddy-get-cas'));
|
}, 'caddy-get-cas'));
|
||||||
|
|
||||||
// Remove a site from Caddyfile
|
// Remove a site from Caddyfile
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1803,150 +1803,150 @@ module.exports = app;
|
|||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
// Validate configuration and wait for async config loads before starting server
|
// Validate configuration and wait for async config loads before starting server
|
||||||
(async () => {
|
|
||||||
await Promise.all([_configsReady, _notificationsReady]);
|
|
||||||
await licenseManager.load();
|
|
||||||
await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT });
|
|
||||||
|
|
||||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
|
||||||
log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE });
|
|
||||||
if (BROWSE_ROOTS.length > 0) {
|
|
||||||
log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new feature modules
|
|
||||||
log.info('server', 'Starting DashCaddy feature modules');
|
|
||||||
|
|
||||||
// Clean up stale port locks
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
await Promise.all([_configsReady, _notificationsReady]);
|
||||||
await portLockManager.cleanupStaleLocks();
|
await licenseManager.load();
|
||||||
log.info('server', 'Port lock cleanup completed');
|
await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT });
|
||||||
} catch (error) {
|
|
||||||
log.error('server', 'Port lock cleanup failed', { error: error.message });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||||
resourceMonitor.start();
|
log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE });
|
||||||
log.info('server', 'Resource monitoring started');
|
if (BROWSE_ROOTS.length > 0) {
|
||||||
} catch (error) {
|
log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) });
|
||||||
log.error('server', 'Resource monitoring failed to start', { error: error.message });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Start new feature modules
|
||||||
backupManager.start();
|
log.info('server', 'Starting DashCaddy feature modules');
|
||||||
log.info('server', 'Backup manager started');
|
|
||||||
} catch (error) {
|
|
||||||
log.error('server', 'Backup manager failed to start', { error: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
// Clean up stale port locks
|
||||||
try {
|
(async () => {
|
||||||
// Auto-configure health checker from services.json
|
try {
|
||||||
await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP });
|
await portLockManager.cleanupStaleLocks();
|
||||||
healthChecker.start();
|
log.info('server', 'Port lock cleanup completed');
|
||||||
log.info('server', 'Health checker started');
|
} catch (error) {
|
||||||
} catch (error) {
|
log.error('server', 'Port lock cleanup failed', { error: error.message });
|
||||||
log.error('server', 'Health checker failed to start', { error: error.message });
|
}
|
||||||
}
|
})();
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
updateManager.start();
|
resourceMonitor.start();
|
||||||
log.info('server', 'Update manager started');
|
log.info('server', 'Resource monitoring started');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('server', 'Update manager failed to start', { error: error.message });
|
log.error('server', 'Resource monitoring failed to start', { error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
selfUpdater.start();
|
backupManager.start();
|
||||||
log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl });
|
log.info('server', 'Backup manager started');
|
||||||
// Check for post-update result (did a previous update succeed or roll back?)
|
} catch (error) {
|
||||||
selfUpdater.checkPostUpdateResult().then(result => {
|
log.error('server', 'Backup manager failed to start', { error: error.message });
|
||||||
if (result) {
|
}
|
||||||
log.info('server', 'Post-update result', result);
|
|
||||||
if (typeof ctx.notification?.send === 'function') {
|
(async () => {
|
||||||
ctx.notification.send('system.update',
|
try {
|
||||||
result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed',
|
// Auto-configure health checker from services.json
|
||||||
result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`,
|
await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP });
|
||||||
result.success ? 'info' : 'error'
|
healthChecker.start();
|
||||||
);
|
log.info('server', 'Health checker started');
|
||||||
|
} catch (error) {
|
||||||
|
log.error('server', 'Health checker failed to start', { error: error.message });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateManager.start();
|
||||||
|
log.info('server', 'Update manager started');
|
||||||
|
} catch (error) {
|
||||||
|
log.error('server', 'Update manager failed to start', { error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
selfUpdater.start();
|
||||||
|
log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl });
|
||||||
|
// Check for post-update result (did a previous update succeed or roll back?)
|
||||||
|
selfUpdater.checkPostUpdateResult().then(result => {
|
||||||
|
if (result) {
|
||||||
|
log.info('server', 'Post-update result', result);
|
||||||
|
if (typeof ctx.notification?.send === 'function') {
|
||||||
|
ctx.notification.send('system.update',
|
||||||
|
result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed',
|
||||||
|
result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`,
|
||||||
|
result.success ? 'info' : 'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
log.error('server', 'Self-updater failed to start', { error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dockerMaintenance) {
|
||||||
|
try {
|
||||||
|
dockerMaintenance.start();
|
||||||
|
log.info('server', 'Docker maintenance started');
|
||||||
|
dockerMaintenance.on('maintenance-complete', (result) => {
|
||||||
|
const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024);
|
||||||
|
if (saved > 0 || result.warnings.length > 0) {
|
||||||
|
log.info('maintenance', 'Docker maintenance completed', {
|
||||||
|
spaceReclaimedMB: saved,
|
||||||
|
pruned: result.pruned,
|
||||||
|
warnings: result.warnings.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.warnings.length > 0) {
|
||||||
|
for (const w of result.warnings) log.warn('maintenance', w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error('server', 'Docker maintenance failed to start', { error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
|
||||||
} catch (error) {
|
|
||||||
log.error('server', 'Self-updater failed to start', { error: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dockerMaintenance) {
|
if (logDigest) {
|
||||||
try {
|
try {
|
||||||
dockerMaintenance.start();
|
logDigest.start(platformPaths.digestDir);
|
||||||
log.info('server', 'Docker maintenance started');
|
log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir });
|
||||||
dockerMaintenance.on('maintenance-complete', (result) => {
|
logDigest.on('digest-generated', ({ date }) => {
|
||||||
const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024);
|
log.info('digest', `Daily digest generated for ${date}`);
|
||||||
if (saved > 0 || result.warnings.length > 0) {
|
if (typeof ctx.notification?.send === 'function') {
|
||||||
log.info('maintenance', 'Docker maintenance completed', {
|
ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info');
|
||||||
spaceReclaimedMB: saved,
|
}
|
||||||
pruned: result.pruned,
|
|
||||||
warnings: result.warnings.length
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error('server', 'Log digest failed to start', { error: error.message });
|
||||||
}
|
}
|
||||||
if (result.warnings.length > 0) {
|
}
|
||||||
for (const w of result.warnings) log.warn('maintenance', w);
|
|
||||||
}
|
// Tailscale API sync (if OAuth configured)
|
||||||
|
if (tailscaleConfig.oauthConfigured) {
|
||||||
|
startTailscaleSyncTimer();
|
||||||
|
// Run initial sync
|
||||||
|
syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('server', 'All feature modules initialized');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown — drain connections before exiting
|
||||||
|
function shutdown(signal) {
|
||||||
|
log.info('shutdown', `${signal} received, draining connections...`);
|
||||||
|
resourceMonitor.stop();
|
||||||
|
backupManager.stop();
|
||||||
|
if (dockerMaintenance) dockerMaintenance.stop();
|
||||||
|
if (logDigest) logDigest.stop();
|
||||||
|
healthChecker.stop();
|
||||||
|
updateManager.stop();
|
||||||
|
selfUpdater.stop();
|
||||||
|
stopTailscaleSyncTimer();
|
||||||
|
server.close(() => {
|
||||||
|
log.info('shutdown', 'HTTP server closed');
|
||||||
|
process.exit(0);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
// Force exit after 5s if connections don't drain
|
||||||
log.error('server', 'Docker maintenance failed to start', { error: error.message });
|
setTimeout(() => process.exit(0), 5000).unref();
|
||||||
}
|
}
|
||||||
}
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
if (logDigest) {
|
})(); // end async startup
|
||||||
try {
|
|
||||||
logDigest.start(platformPaths.digestDir);
|
|
||||||
log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir });
|
|
||||||
logDigest.on('digest-generated', ({ date }) => {
|
|
||||||
log.info('digest', `Daily digest generated for ${date}`);
|
|
||||||
if (typeof ctx.notification?.send === 'function') {
|
|
||||||
ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.error('server', 'Log digest failed to start', { error: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tailscale API sync (if OAuth configured)
|
|
||||||
if (tailscaleConfig.oauthConfigured) {
|
|
||||||
startTailscaleSyncTimer();
|
|
||||||
// Run initial sync
|
|
||||||
syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message }));
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('server', 'All feature modules initialized');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Graceful shutdown — drain connections before exiting
|
|
||||||
function shutdown(signal) {
|
|
||||||
log.info('shutdown', `${signal} received, draining connections...`);
|
|
||||||
resourceMonitor.stop();
|
|
||||||
backupManager.stop();
|
|
||||||
if (dockerMaintenance) dockerMaintenance.stop();
|
|
||||||
if (logDigest) logDigest.stop();
|
|
||||||
healthChecker.stop();
|
|
||||||
updateManager.stop();
|
|
||||||
selfUpdater.stop();
|
|
||||||
stopTailscaleSyncTimer();
|
|
||||||
server.close(() => {
|
|
||||||
log.info('shutdown', 'HTTP server closed');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
// Force exit after 5s if connections don't drain
|
|
||||||
setTimeout(() => process.exit(0), 5000).unref();
|
|
||||||
}
|
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
||||||
})(); // end async startup
|
|
||||||
} // end if (require.main === module)
|
} // end if (require.main === module)
|
||||||
|
|
||||||
// #2: Catch unhandled errors so the process doesn't crash silently
|
// #2: Catch unhandled errors so the process doesn't crash silently
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user