Skip to content

Commit e0d7b0f

Browse files
authored
Merge pull request #1414 from fabiovincenzi/feat/tls-ui-api-server
feat: add TLS support to API/UI service
2 parents ad58c5a + e02b146 commit e0d7b0f

8 files changed

Lines changed: 249 additions & 20 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ USER 1000
3939

4040
WORKDIR /app
4141

42-
EXPOSE 8080 8000
42+
EXPOSE 8080 8000 8444
4343

4444
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
4545
CMD ["node", "--enable-source-maps", "dist/index.js"]

src/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
GIT_PROXY_HTTPS_SERVER_PORT = 8443,
2222
GIT_PROXY_UI_HOST = 'http://localhost',
2323
GIT_PROXY_UI_PORT = 8080,
24+
GIT_PROXY_HTTPS_UI_PORT = 8444,
2425
GIT_PROXY_COOKIE_SECRET,
2526
GIT_PROXY_MONGO_CONNECTION_STRING = 'mongodb://localhost:27017/git-proxy',
2627
} = process.env;
@@ -30,6 +31,7 @@ export const serverConfig: ServerConfig = {
3031
GIT_PROXY_HTTPS_SERVER_PORT,
3132
GIT_PROXY_UI_HOST,
3233
GIT_PROXY_UI_PORT,
34+
GIT_PROXY_HTTPS_UI_PORT,
3335
GIT_PROXY_COOKIE_SECRET,
3436
GIT_PROXY_MONGO_CONNECTION_STRING,
3537
};

src/config/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type ServerConfig = {
2121
GIT_PROXY_HTTPS_SERVER_PORT: string | number;
2222
GIT_PROXY_UI_HOST: string;
2323
GIT_PROXY_UI_PORT: string | number;
24+
GIT_PROXY_HTTPS_UI_PORT: string | number;
2425
GIT_PROXY_COOKIE_SECRET: string | undefined;
2526
GIT_PROXY_MONGO_CONNECTION_STRING: string;
2627
};

src/service/index.ts

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import express, { Express } from 'express';
1818
import session from 'express-session';
1919
import http from 'http';
20+
import https from 'https';
21+
import fs from 'fs';
2022
import cors from 'cors';
2123
import path from 'path';
2224
import rateLimit from 'express-rate-limit';
@@ -31,12 +33,24 @@ import { configure } from './passport';
3133

3234
const limiter = rateLimit(config.getRateLimit());
3335

34-
const { GIT_PROXY_UI_PORT: uiPort } = serverConfig;
36+
const { GIT_PROXY_UI_PORT: uiPort, GIT_PROXY_HTTPS_UI_PORT: uiHttpsPort } = serverConfig;
3537

3638
const DEFAULT_SESSION_MAX_AGE_HOURS = 12;
3739

3840
const app: Express = express();
3941
let _httpServer: http.Server | null = null;
42+
let _httpsServer: https.Server | null = null;
43+
44+
const getServiceTLSOptions = () => ({
45+
key:
46+
config.getTLSEnabled() && config.getTLSKeyPemPath()
47+
? fs.readFileSync(config.getTLSKeyPemPath()!)
48+
: undefined,
49+
cert:
50+
config.getTLSEnabled() && config.getTLSCertPemPath()
51+
? fs.readFileSync(config.getTLSCertPemPath()!)
52+
: undefined,
53+
});
4054

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

209+
if (config.getTLSEnabled()) {
210+
await new Promise<void>((resolve, reject) => {
211+
const server = https.createServer(getServiceTLSOptions(), app);
212+
server.on('error', reject);
213+
server.listen(uiHttpsPort, () => {
214+
console.log(`HTTPS Service Listening on ${uiHttpsPort}`);
215+
resolve();
216+
});
217+
_httpsServer = server;
218+
});
219+
}
220+
195221
return app;
196222
}
197223

198224
/**
199225
* Stops the proxy service.
200226
*/
201227
async function stop(): Promise<void> {
202-
if (!_httpServer) {
203-
return Promise.resolve();
228+
const closePromises: Promise<void>[] = [];
229+
230+
if (_httpServer) {
231+
closePromises.push(
232+
new Promise((resolve, reject) => {
233+
console.log(`Stopping Service Listening on ${uiPort}`);
234+
_httpServer!.close((err) => {
235+
if (err) {
236+
reject(err);
237+
} else {
238+
console.log('Service stopped');
239+
_httpServer = null;
240+
resolve();
241+
}
242+
});
243+
}),
244+
);
204245
}
205246

206-
return new Promise((resolve, reject) => {
207-
console.log(`Stopping Service Listening on ${uiPort}`);
208-
_httpServer!.close((err) => {
209-
if (err) {
210-
reject(err);
211-
} else {
212-
console.log('Service stopped');
213-
_httpServer = null;
214-
resolve();
215-
}
216-
});
217-
});
247+
if (_httpsServer) {
248+
closePromises.push(
249+
new Promise((resolve, reject) => {
250+
_httpsServer!.close((err) => {
251+
if (err) {
252+
reject(err);
253+
} else {
254+
console.log('HTTPS Service stopped');
255+
_httpsServer = null;
256+
resolve();
257+
}
258+
});
259+
}),
260+
);
261+
}
262+
263+
return Promise.all(closePromises).then(() => {});
218264
}
219265

220266
export const Service = {
@@ -223,4 +269,7 @@ export const Service = {
223269
get httpServer() {
224270
return _httpServer;
225271
},
272+
get httpsServer() {
273+
return _httpsServer;
274+
},
226275
};

test/service.tls.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import http from 'http';
18+
import https from 'https';
19+
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
20+
import fs from 'fs';
21+
22+
describe('Service Module TLS', () => {
23+
let serviceModule: any;
24+
let mockConfig: any;
25+
let mockHttpServer: any;
26+
let mockHttpsServer: any;
27+
let mockProxy: any;
28+
29+
beforeEach(async () => {
30+
vi.resetModules();
31+
32+
mockConfig = {
33+
getTLSEnabled: vi.fn(),
34+
getTLSKeyPemPath: vi.fn(),
35+
getTLSCertPemPath: vi.fn(),
36+
getRateLimit: vi.fn().mockReturnValue({ windowMs: 15 * 60 * 1000, max: 100 }),
37+
getCookieSecret: vi.fn().mockReturnValue('test-secret'),
38+
getSessionMaxAgeHours: vi.fn().mockReturnValue(12),
39+
getCSRFProtection: vi.fn().mockReturnValue(false),
40+
};
41+
42+
mockHttpServer = {
43+
listen: vi.fn().mockReturnThis(),
44+
close: vi.fn().mockImplementation((cb) => {
45+
if (cb) cb();
46+
}),
47+
on: vi.fn().mockReturnThis(),
48+
};
49+
50+
mockHttpsServer = {
51+
listen: vi.fn().mockImplementation((_port: any, cb: any) => {
52+
if (cb) cb();
53+
return mockHttpsServer;
54+
}),
55+
close: vi.fn().mockImplementation((cb: any) => {
56+
if (cb) cb();
57+
}),
58+
on: vi.fn().mockReturnThis(),
59+
};
60+
61+
mockProxy = {};
62+
63+
vi.doMock('../src/config', async (importOriginal) => {
64+
const actual: any = await importOriginal();
65+
return {
66+
...actual,
67+
getTLSEnabled: mockConfig.getTLSEnabled,
68+
getTLSKeyPemPath: mockConfig.getTLSKeyPemPath,
69+
getTLSCertPemPath: mockConfig.getTLSCertPemPath,
70+
getRateLimit: mockConfig.getRateLimit,
71+
getCookieSecret: mockConfig.getCookieSecret,
72+
getSessionMaxAgeHours: mockConfig.getSessionMaxAgeHours,
73+
getCSRFProtection: mockConfig.getCSRFProtection,
74+
};
75+
});
76+
77+
vi.doMock('../src/db', async (importOriginal) => {
78+
const actual: any = await importOriginal();
79+
return {
80+
...actual,
81+
getSessionStore: vi.fn().mockReturnValue(undefined),
82+
};
83+
});
84+
85+
vi.doMock('../src/service/passport', () => ({
86+
configure: vi.fn().mockResolvedValue({
87+
initialize: vi.fn().mockReturnValue((_req: any, _res: any, next: any) => next()),
88+
session: vi.fn().mockReturnValue((_req: any, _res: any, next: any) => next()),
89+
}),
90+
}));
91+
92+
vi.doMock('../src/service/routes', () => ({
93+
default: vi.fn().mockReturnValue((_req: any, _res: any, next: any) => next()),
94+
}));
95+
96+
vi.spyOn(http, 'createServer').mockReturnValue(mockHttpServer as any);
97+
vi.spyOn(https, 'createServer').mockReturnValue(mockHttpsServer as any);
98+
99+
serviceModule = await import('../src/service/index');
100+
});
101+
102+
afterEach(async () => {
103+
try {
104+
await serviceModule.Service.stop();
105+
} catch (err) {
106+
console.error('Error occurred when stopping the service: ', err);
107+
}
108+
vi.restoreAllMocks();
109+
});
110+
111+
describe('TLS certificate file reading', () => {
112+
it('should start HTTPS server and read TLS files when TLS is enabled and paths are provided', async () => {
113+
const mockKeyContent = Buffer.from('mock-key-content');
114+
const mockCertContent = Buffer.from('mock-cert-content');
115+
116+
mockConfig.getTLSEnabled.mockReturnValue(true);
117+
mockConfig.getTLSKeyPemPath.mockReturnValue('/path/to/key.pem');
118+
mockConfig.getTLSCertPemPath.mockReturnValue('/path/to/cert.pem');
119+
120+
const fsStub = vi.spyOn(fs, 'readFileSync');
121+
fsStub.mockImplementation((path: any) => {
122+
if (path === '/path/to/key.pem') return mockKeyContent;
123+
if (path === '/path/to/cert.pem') return mockCertContent;
124+
return Buffer.from('default');
125+
});
126+
127+
await serviceModule.Service.start(mockProxy);
128+
129+
expect(https.createServer).toHaveBeenCalled();
130+
expect(fsStub).toHaveBeenCalledWith('/path/to/key.pem');
131+
expect(fsStub).toHaveBeenCalledWith('/path/to/cert.pem');
132+
});
133+
134+
it('should not start HTTPS server when TLS is disabled', async () => {
135+
mockConfig.getTLSEnabled.mockReturnValue(false);
136+
137+
await serviceModule.Service.start(mockProxy);
138+
139+
expect(https.createServer).not.toHaveBeenCalled();
140+
});
141+
142+
it('should not read TLS files when paths are not provided', async () => {
143+
mockConfig.getTLSEnabled.mockReturnValue(true);
144+
mockConfig.getTLSKeyPemPath.mockReturnValue(null);
145+
mockConfig.getTLSCertPemPath.mockReturnValue(null);
146+
147+
const fsStub = vi.spyOn(fs, 'readFileSync');
148+
149+
await serviceModule.Service.start(mockProxy);
150+
151+
expect(fsStub).not.toHaveBeenCalled();
152+
});
153+
154+
it('should close both HTTP and HTTPS servers on stop() when TLS is enabled', async () => {
155+
mockConfig.getTLSEnabled.mockReturnValue(true);
156+
mockConfig.getTLSKeyPemPath.mockReturnValue('/path/to/key.pem');
157+
mockConfig.getTLSCertPemPath.mockReturnValue('/path/to/cert.pem');
158+
159+
vi.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('mock-content'));
160+
161+
await serviceModule.Service.start(mockProxy);
162+
await serviceModule.Service.stop();
163+
164+
expect(mockHttpServer.close).toHaveBeenCalled();
165+
expect(mockHttpsServer.close).toHaveBeenCalled();
166+
});
167+
});
168+
});

website/docs/configuration/overview.mdx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,24 @@ npx -- @finos/git-proxy --config ./config.json
4747
### Set ports with ENV variables
4848

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

5353
```
5454
export GIT_PROXY_UI_PORT="5000"
55+
export GIT_PROXY_HTTPS_UI_PORT="5443"
5556
export GIT_PROXY_SERVER_PORT="9090"
5657
export GIT_PROXY_HTTPS_SERVER_PORT="9443"
5758
```
5859

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

63+
When [TLS is enabled](./reference#tls) via `tls.enabled` with valid `tls.key` and `tls.cert` paths,
64+
the UI/API server also listens on HTTPS on `GIT_PROXY_HTTPS_UI_PORT` (default `8444`) in parallel to
65+
the plain HTTP port. The same TLS credentials configured for the proxy are reused for the UI/API
66+
server.
67+
6268
By default, GitProxy CLI connects to GitProxy running on localhost and default port. This can be
6369
changed by setting the `GIT_PROXY_UI_HOST` and `GIT_PROXY_UI_PORT` environment variables:
6470

website/docs/deployment.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ The following environment variables can be set at container runtime:
172172
| Variable | Default | Description |
173173
| ------------------------ | ------------ | -------------------------------------------------------- |
174174
| `GIT_PROXY_SERVER_PORT` | `8000` | Proxy server port |
175-
| `GIT_PROXY_UI_PORT` | `8080` | UI/API server port |
175+
| `GIT_PROXY_UI_PORT` | `8080` | UI/API server port (HTTP) |
176+
| `GIT_PROXY_HTTPS_UI_PORT`| `8444` | UI/API server port (HTTPS, active when `tls.enabled`) |
176177
| `ALLOWED_ORIGINS` | (empty) | CORS allowed origins (comma-separated, or `*` for all) |
177178
| `API_URL` | (empty) | API URL for the UI (leave empty for same-origin) |
178179
| `NODE_ENV` | `production` | Node environment |
@@ -183,6 +184,7 @@ docker run -d \
183184
--name git-proxy \
184185
-p 8000:8000 \
185186
-p 8080:8080 \
187+
-p 8444:8444 \
186188
-e GIT_PROXY_UI_PORT=8080 \
187189
-e ALLOWED_ORIGINS="https://gitproxy.example.com" \
188190
-e NODE_ENV=production \

website/docs/quickstart/intercept.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ git remote set-url origin http://localhost:8000/<YOUR-GITHUB-USERNAME>/git-proxy
6262
git remote -v
6363
```
6464

65-
You can also try HTTPS with `git -c http.sslVerify=false remote set-url origin https://localhost:8443/<YOUR-GITHUB-USERNAME>/git-proxy.git`
65+
You can also try HTTPS with `git -c http.sslVerify=false remote set-url origin https://localhost:8443/<YOUR-GITHUB-USERNAME>/git-proxy.git`.
66+
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`.
6667
:::note
6768

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

0 commit comments

Comments
 (0)