Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 2 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
};
1 change: 1 addition & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
79 changes: 64 additions & 15 deletions src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it okay to reuse the proxy TLS credentials for the service and UI or should these be separate configurable parameters 🤔

});

/**
* CORS Configuration
Expand Down Expand Up @@ -192,29 +206,61 @@ async function start(proxy: Proxy) {
console.log(`Service Listening on ${uiPort}`);
app.emit('ready');

if (config.getTLSEnabled()) {
await new Promise<void>((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;
}

/**
* Stops the proxy service.
*/
async function stop(): Promise<void> {
if (!_httpServer) {
return Promise.resolve();
const closePromises: Promise<void>[] = [];

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 = {
Expand All @@ -223,4 +269,7 @@ export const Service = {
get httpServer() {
return _httpServer;
},
get httpsServer() {
return _httpsServer;
},
};
152 changes: 152 additions & 0 deletions test/service.tls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import http from 'http';

Check failure on line 1 in test/service.tls.test.ts

View workflow job for this annotation

GitHub Actions / Linting

Missing license header
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();
});
});
});
Loading