diff --git a/Dockerfile b/Dockerfile index fb648cd9e..253e4a3d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,7 +39,7 @@ USER 1000 WORKDIR /app -EXPOSE 8080 8000 +EXPOSE 8080 8000 8444 ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] CMD ["node", "--enable-source-maps", "dist/index.js"] diff --git a/src/config/env.ts b/src/config/env.ts index 1534dad96..91b5b6b61 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -21,6 +21,7 @@ const { GIT_PROXY_HTTPS_SERVER_PORT = 8443, GIT_PROXY_UI_HOST = 'http://localhost', GIT_PROXY_UI_PORT = 8080, + GIT_PROXY_HTTPS_UI_PORT = 8444, GIT_PROXY_COOKIE_SECRET, GIT_PROXY_MONGO_CONNECTION_STRING = 'mongodb://localhost:27017/git-proxy', } = process.env; @@ -30,6 +31,7 @@ export const serverConfig: ServerConfig = { GIT_PROXY_HTTPS_SERVER_PORT, GIT_PROXY_UI_HOST, GIT_PROXY_UI_PORT, + GIT_PROXY_HTTPS_UI_PORT, GIT_PROXY_COOKIE_SECRET, GIT_PROXY_MONGO_CONNECTION_STRING, }; diff --git a/src/config/types.ts b/src/config/types.ts index 300deb4cf..534415a5c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -21,6 +21,7 @@ export type ServerConfig = { GIT_PROXY_HTTPS_SERVER_PORT: string | number; GIT_PROXY_UI_HOST: string; GIT_PROXY_UI_PORT: string | number; + GIT_PROXY_HTTPS_UI_PORT: string | number; GIT_PROXY_COOKIE_SECRET: string | undefined; GIT_PROXY_MONGO_CONNECTION_STRING: string; }; diff --git a/src/service/index.ts b/src/service/index.ts index b8ee756b8..25cfc2731 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -17,6 +17,8 @@ import express, { Express } from 'express'; import session from 'express-session'; import http from 'http'; +import https from 'https'; +import fs from 'fs'; import cors from 'cors'; import path from 'path'; import rateLimit from 'express-rate-limit'; @@ -31,12 +33,24 @@ import { configure } from './passport'; const limiter = rateLimit(config.getRateLimit()); -const { GIT_PROXY_UI_PORT: uiPort } = serverConfig; +const { GIT_PROXY_UI_PORT: uiPort, GIT_PROXY_HTTPS_UI_PORT: uiHttpsPort } = serverConfig; const DEFAULT_SESSION_MAX_AGE_HOURS = 12; const app: Express = express(); let _httpServer: http.Server | null = null; +let _httpsServer: https.Server | null = null; + +const getServiceTLSOptions = () => ({ + key: + config.getTLSEnabled() && config.getTLSKeyPemPath() + ? fs.readFileSync(config.getTLSKeyPemPath()!) + : undefined, + cert: + config.getTLSEnabled() && config.getTLSCertPemPath() + ? fs.readFileSync(config.getTLSCertPemPath()!) + : undefined, +}); /** * CORS Configuration @@ -192,6 +206,18 @@ async function start(proxy: Proxy) { console.log(`Service Listening on ${uiPort}`); app.emit('ready'); + if (config.getTLSEnabled()) { + await new Promise((resolve, reject) => { + const server = https.createServer(getServiceTLSOptions(), app); + server.on('error', reject); + server.listen(uiHttpsPort, () => { + console.log(`HTTPS Service Listening on ${uiHttpsPort}`); + resolve(); + }); + _httpsServer = server; + }); + } + return app; } @@ -199,22 +225,42 @@ async function start(proxy: Proxy) { * Stops the proxy service. */ async function stop(): Promise { - if (!_httpServer) { - return Promise.resolve(); + const closePromises: Promise[] = []; + + if (_httpServer) { + closePromises.push( + new Promise((resolve, reject) => { + console.log(`Stopping Service Listening on ${uiPort}`); + _httpServer!.close((err) => { + if (err) { + reject(err); + } else { + console.log('Service stopped'); + _httpServer = null; + resolve(); + } + }); + }), + ); } - return new Promise((resolve, reject) => { - console.log(`Stopping Service Listening on ${uiPort}`); - _httpServer!.close((err) => { - if (err) { - reject(err); - } else { - console.log('Service stopped'); - _httpServer = null; - resolve(); - } - }); - }); + if (_httpsServer) { + closePromises.push( + new Promise((resolve, reject) => { + _httpsServer!.close((err) => { + if (err) { + reject(err); + } else { + console.log('HTTPS Service stopped'); + _httpsServer = null; + resolve(); + } + }); + }), + ); + } + + return Promise.all(closePromises).then(() => {}); } export const Service = { @@ -223,4 +269,7 @@ export const Service = { get httpServer() { return _httpServer; }, + get httpsServer() { + return _httpsServer; + }, }; diff --git a/test/service.tls.test.ts b/test/service.tls.test.ts new file mode 100644 index 000000000..105b4c725 --- /dev/null +++ b/test/service.tls.test.ts @@ -0,0 +1,152 @@ +import http from 'http'; +import https from 'https'; +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import fs from 'fs'; + +describe('Service Module TLS', () => { + let serviceModule: any; + let mockConfig: any; + let mockHttpServer: any; + let mockHttpsServer: any; + let mockProxy: any; + + beforeEach(async () => { + vi.resetModules(); + + mockConfig = { + getTLSEnabled: vi.fn(), + getTLSKeyPemPath: vi.fn(), + getTLSCertPemPath: vi.fn(), + getRateLimit: vi.fn().mockReturnValue({ windowMs: 15 * 60 * 1000, max: 100 }), + getCookieSecret: vi.fn().mockReturnValue('test-secret'), + getSessionMaxAgeHours: vi.fn().mockReturnValue(12), + getCSRFProtection: vi.fn().mockReturnValue(false), + }; + + mockHttpServer = { + listen: vi.fn().mockReturnThis(), + close: vi.fn().mockImplementation((cb) => { + if (cb) cb(); + }), + on: vi.fn().mockReturnThis(), + }; + + mockHttpsServer = { + listen: vi.fn().mockImplementation((_port: any, cb: any) => { + if (cb) cb(); + return mockHttpsServer; + }), + close: vi.fn().mockImplementation((cb: any) => { + if (cb) cb(); + }), + on: vi.fn().mockReturnThis(), + }; + + mockProxy = {}; + + vi.doMock('../src/config', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + getTLSEnabled: mockConfig.getTLSEnabled, + getTLSKeyPemPath: mockConfig.getTLSKeyPemPath, + getTLSCertPemPath: mockConfig.getTLSCertPemPath, + getRateLimit: mockConfig.getRateLimit, + getCookieSecret: mockConfig.getCookieSecret, + getSessionMaxAgeHours: mockConfig.getSessionMaxAgeHours, + getCSRFProtection: mockConfig.getCSRFProtection, + }; + }); + + vi.doMock('../src/db', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + getSessionStore: vi.fn().mockReturnValue(undefined), + }; + }); + + vi.doMock('../src/service/passport', () => ({ + configure: vi.fn().mockResolvedValue({ + initialize: vi.fn().mockReturnValue((_req: any, _res: any, next: any) => next()), + session: vi.fn().mockReturnValue((_req: any, _res: any, next: any) => next()), + }), + })); + + vi.doMock('../src/service/routes', () => ({ + default: vi.fn().mockReturnValue((_req: any, _res: any, next: any) => next()), + })); + + vi.spyOn(http, 'createServer').mockReturnValue(mockHttpServer as any); + vi.spyOn(https, 'createServer').mockReturnValue(mockHttpsServer as any); + + serviceModule = await import('../src/service/index'); + }); + + afterEach(async () => { + try { + await serviceModule.Service.stop(); + } catch (err) { + console.error('Error occurred when stopping the service: ', err); + } + vi.restoreAllMocks(); + }); + + describe('TLS certificate file reading', () => { + it('should start HTTPS server and read TLS files when TLS is enabled and paths are provided', async () => { + const mockKeyContent = Buffer.from('mock-key-content'); + const mockCertContent = Buffer.from('mock-cert-content'); + + mockConfig.getTLSEnabled.mockReturnValue(true); + mockConfig.getTLSKeyPemPath.mockReturnValue('/path/to/key.pem'); + mockConfig.getTLSCertPemPath.mockReturnValue('/path/to/cert.pem'); + + const fsStub = vi.spyOn(fs, 'readFileSync'); + fsStub.mockImplementation((path: any) => { + if (path === '/path/to/key.pem') return mockKeyContent; + if (path === '/path/to/cert.pem') return mockCertContent; + return Buffer.from('default'); + }); + + await serviceModule.Service.start(mockProxy); + + expect(https.createServer).toHaveBeenCalled(); + expect(fsStub).toHaveBeenCalledWith('/path/to/key.pem'); + expect(fsStub).toHaveBeenCalledWith('/path/to/cert.pem'); + }); + + it('should not start HTTPS server when TLS is disabled', async () => { + mockConfig.getTLSEnabled.mockReturnValue(false); + + await serviceModule.Service.start(mockProxy); + + expect(https.createServer).not.toHaveBeenCalled(); + }); + + it('should not read TLS files when paths are not provided', async () => { + mockConfig.getTLSEnabled.mockReturnValue(true); + mockConfig.getTLSKeyPemPath.mockReturnValue(null); + mockConfig.getTLSCertPemPath.mockReturnValue(null); + + const fsStub = vi.spyOn(fs, 'readFileSync'); + + await serviceModule.Service.start(mockProxy); + + expect(fsStub).not.toHaveBeenCalled(); + }); + + it('should close both HTTP and HTTPS servers on stop() when TLS is enabled', async () => { + mockConfig.getTLSEnabled.mockReturnValue(true); + mockConfig.getTLSKeyPemPath.mockReturnValue('/path/to/key.pem'); + mockConfig.getTLSCertPemPath.mockReturnValue('/path/to/cert.pem'); + + vi.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('mock-content')); + + await serviceModule.Service.start(mockProxy); + await serviceModule.Service.stop(); + + expect(mockHttpServer.close).toHaveBeenCalled(); + expect(mockHttpsServer.close).toHaveBeenCalled(); + }); + }); +});