Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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;
},
};
168 changes: 168 additions & 0 deletions test/service.tls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Copyright 2026 GitProxy Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

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();
});
});
});
10 changes: 8 additions & 2 deletions website/docs/configuration/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,24 @@ npx -- @finos/git-proxy --config ./config.json
### Set ports with ENV variables

By default, GitProxy uses port 8000 to expose the Git Server and 8080 for the frontend application.
The ports can be changed by setting the `GIT_PROXY_SERVER_PORT`, `GIT_PROXY_HTTPS_SERVER_PORT` (optional) and `GIT_PROXY_UI_PORT`
environment variables:
The ports can be changed by setting the `GIT_PROXY_SERVER_PORT`, `GIT_PROXY_HTTPS_SERVER_PORT` (optional),
`GIT_PROXY_UI_PORT` and `GIT_PROXY_HTTPS_UI_PORT` (optional) environment variables:

```
export GIT_PROXY_UI_PORT="5000"
export GIT_PROXY_HTTPS_UI_PORT="5443"
export GIT_PROXY_SERVER_PORT="9090"
export GIT_PROXY_HTTPS_SERVER_PORT="9443"
```

Note that `GIT_PROXY_UI_PORT` is needed for both server and UI Node processes,
whereas `GIT_PROXY_SERVER_PORT` (and `GIT_PROXY_HTTPS_SERVER_PORT`) is only needed by the server process.

When [TLS is enabled](./reference#tls) via `tls.enabled` with valid `tls.key` and `tls.cert` paths,
the UI/API server also listens on HTTPS on `GIT_PROXY_HTTPS_UI_PORT` (default `8444`) in parallel to
the plain HTTP port. The same TLS credentials configured for the proxy are reused for the UI/API
server.

By default, GitProxy CLI connects to GitProxy running on localhost and default port. This can be
changed by setting the `GIT_PROXY_UI_HOST` and `GIT_PROXY_UI_PORT` environment variables:

Expand Down
4 changes: 3 additions & 1 deletion website/docs/deployment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ The following environment variables can be set at container runtime:
| Variable | Default | Description |
| ------------------------ | ------------ | -------------------------------------------------------- |
| `GIT_PROXY_SERVER_PORT` | `8000` | Proxy server port |
| `GIT_PROXY_UI_PORT` | `8080` | UI/API server port |
| `GIT_PROXY_UI_PORT` | `8080` | UI/API server port (HTTP) |
| `GIT_PROXY_HTTPS_UI_PORT`| `8444` | UI/API server port (HTTPS, active when `tls.enabled`) |
| `ALLOWED_ORIGINS` | (empty) | CORS allowed origins (comma-separated, or `*` for all) |
| `API_URL` | (empty) | API URL for the UI (leave empty for same-origin) |
| `NODE_ENV` | `production` | Node environment |
Expand All @@ -183,6 +184,7 @@ docker run -d \
--name git-proxy \
-p 8000:8000 \
-p 8080:8080 \
-p 8444:8444 \
-e GIT_PROXY_UI_PORT=8080 \
-e ALLOWED_ORIGINS="https://gitproxy.example.com" \
-e NODE_ENV=production \
Expand Down
3 changes: 2 additions & 1 deletion website/docs/quickstart/intercept.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ git remote set-url origin http://localhost:8000/<YOUR-GITHUB-USERNAME>/git-proxy
git remote -v
```

You can also try HTTPS with `git -c http.sslVerify=false remote set-url origin https://localhost:8443/<YOUR-GITHUB-USERNAME>/git-proxy.git`
You can also try HTTPS with `git -c http.sslVerify=false remote set-url origin https://localhost:8443/<YOUR-GITHUB-USERNAME>/git-proxy.git`.
When TLS is enabled, the UI and REST API are also served over HTTPS on port `8444` (configurable via `GIT_PROXY_HTTPS_UI_PORT`) in parallel to the plain HTTP port `8080`.
:::note

SSH protocol is currently not supported, see [#27](https://github.com/finos/git-proxy/issues/27).
Expand Down
Loading