|
1 | 1 | import { randomUUID } from 'crypto'; |
2 | 2 | import { createServer, type IncomingMessage, type ServerResponse } from 'http'; |
| 3 | +import { createServer as createHttpsServer } from 'https'; |
3 | 4 | import type { AddressInfo } from 'net'; |
4 | 5 | import * as os from 'os'; |
5 | 6 | import * as vscode from 'vscode'; |
@@ -56,6 +57,9 @@ export interface ApiServerConfig { |
56 | 57 | enabled: boolean |
57 | 58 | enableHttp: boolean |
58 | 59 | enableWebSocket: boolean |
| 60 | + enableHttps: boolean |
| 61 | + tlsCertPath: string |
| 62 | + tlsKeyPath: string |
59 | 63 | host: string |
60 | 64 | port: number |
61 | 65 | maxConcurrentRequests: number |
@@ -171,6 +175,7 @@ export class CopilotApiGateway implements vscode.Disposable { |
171 | 175 | private config: ApiServerConfig = getServerConfig(); |
172 | 176 | private disposed = false; |
173 | 177 | private activeRequests = 0; |
| 178 | + private isHttps = false; |
174 | 179 | private suppressRestart = false; |
175 | 180 | private readonly _onDidChangeStatus = new vscode.EventEmitter<void>(); |
176 | 181 | public readonly onDidChangeStatus = this._onDidChangeStatus.event; |
@@ -263,6 +268,7 @@ export class CopilotApiGateway implements vscode.Disposable { |
263 | 268 | public async getStatus() { |
264 | 269 | return { |
265 | 270 | running: !!this.httpServer, |
| 271 | + isHttps: this.isHttps, |
266 | 272 | config: this.config, |
267 | 273 | activeRequests: this.activeRequests, |
268 | 274 | networkInfo: this.getNetworkInfo(), |
@@ -518,6 +524,7 @@ export class CopilotApiGateway implements vscode.Disposable { |
518 | 524 | // Update stats every 5 seconds |
519 | 525 | this.statsInterval = setInterval(() => { |
520 | 526 | this.updateRealtimeStats(); |
| 527 | + this._onDidChangeStatus.fire(); |
521 | 528 | }, 5000); |
522 | 529 | } |
523 | 530 |
|
@@ -730,6 +737,10 @@ export class CopilotApiGateway implements vscode.Disposable { |
730 | 737 | await this.updateServerConfig({ enableLogging: !this.config.enableLogging }); |
731 | 738 | } |
732 | 739 |
|
| 740 | + public async toggleHttps(): Promise<void> { |
| 741 | + await this.updateServerConfig({ enableHttps: !this.config.enableHttps, enabled: true }); |
| 742 | + } |
| 743 | + |
733 | 744 | public async setApiKey(apiKey: string): Promise<void> { |
734 | 745 | const value = (apiKey ?? '').trim(); |
735 | 746 | await this.updateServerConfig({ apiKey: value }); |
@@ -809,7 +820,8 @@ export class CopilotApiGateway implements vscode.Disposable { |
809 | 820 | this.updateStatusBar('starting'); |
810 | 821 | this._onDidChangeStatus.fire(); |
811 | 822 |
|
812 | | - this.httpServer = createServer((req, res) => { |
| 823 | + // Create request handler function |
| 824 | + const requestHandler = (req: IncomingMessage, res: ServerResponse) => { |
813 | 825 | // Track active connections for graceful shutdown |
814 | 826 | this.connections.add(res); |
815 | 827 | res.on('close', () => this.connections.delete(res)); |
@@ -842,7 +854,60 @@ export class CopilotApiGateway implements vscode.Disposable { |
842 | 854 |
|
843 | 855 | this.activeRequests++; |
844 | 856 | this._onDidChangeStatus.fire(); |
845 | | - }); |
| 857 | + }; |
| 858 | + |
| 859 | + // Create HTTP or HTTPS server based on config |
| 860 | + let isHttps = false; |
| 861 | + if (this.config.enableHttps) { |
| 862 | + try { |
| 863 | + let certData: { cert: Buffer | string; key: Buffer | string } | null = null; |
| 864 | + |
| 865 | + // Check if user provided cert paths |
| 866 | + if (this.config.tlsCertPath && this.config.tlsKeyPath) { |
| 867 | + const certPath = this.config.tlsCertPath.startsWith('~') |
| 868 | + ? path.join(os.homedir(), this.config.tlsCertPath.slice(1)) |
| 869 | + : this.config.tlsCertPath; |
| 870 | + const keyPath = this.config.tlsKeyPath.startsWith('~') |
| 871 | + ? path.join(os.homedir(), this.config.tlsKeyPath.slice(1)) |
| 872 | + : this.config.tlsKeyPath; |
| 873 | + |
| 874 | + if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { |
| 875 | + certData = { |
| 876 | + cert: fs.readFileSync(certPath), |
| 877 | + key: fs.readFileSync(keyPath) |
| 878 | + }; |
| 879 | + this.logInfo(`HTTPS enabled with certificate from ${certPath}`); |
| 880 | + } |
| 881 | + } |
| 882 | + |
| 883 | + // Auto-generate self-signed cert if no cert configured |
| 884 | + if (!certData) { |
| 885 | + const selfsigned = require('selfsigned'); |
| 886 | + const attrs = [{ name: 'commonName', value: 'localhost' }]; |
| 887 | + const pems = selfsigned.generate(attrs, { |
| 888 | + days: 365, |
| 889 | + keySize: 2048, |
| 890 | + algorithm: 'sha256' |
| 891 | + }); |
| 892 | + certData = { |
| 893 | + cert: pems.cert, |
| 894 | + key: pems.private |
| 895 | + }; |
| 896 | + this.logInfo('HTTPS enabled with auto-generated self-signed certificate (valid 365 days)'); |
| 897 | + } |
| 898 | + |
| 899 | + this.httpServer = createHttpsServer(certData, requestHandler); |
| 900 | + isHttps = true; |
| 901 | + } catch (error) { |
| 902 | + this.logError('Failed to setup HTTPS, falling back to HTTP', error); |
| 903 | + this.httpServer = createServer(requestHandler); |
| 904 | + } |
| 905 | + } else { |
| 906 | + this.httpServer = createServer(requestHandler); |
| 907 | + } |
| 908 | + |
| 909 | + // Track actual runtime protocol |
| 910 | + this.isHttps = isHttps; |
846 | 911 |
|
847 | 912 | this.httpServer.on('error', error => { |
848 | 913 | this.logError('HTTP server error', error); |
@@ -883,9 +948,10 @@ export class CopilotApiGateway implements vscode.Disposable { |
883 | 948 |
|
884 | 949 | const address = this.httpServer.address() as AddressInfo | null; |
885 | 950 | if (address) { |
886 | | - const location = `http://${address.address}:${address.port}`; |
887 | | - this.logInfo(`HTTP server listening on ${location}`); |
888 | | - this.updateStatusBar('running', `HTTP${this.config.enableWebSocket ? '+WS' : ''} on ${location}`); |
| 951 | + const protocol = isHttps ? 'https' : 'http'; |
| 952 | + const location = `${protocol}://${address.address}:${address.port}`; |
| 953 | + this.logInfo(`${isHttps ? 'HTTPS' : 'HTTP'} server listening on ${location}`); |
| 954 | + this.updateStatusBar('running', `${isHttps ? 'HTTPS' : 'HTTP'}${this.config.enableWebSocket ? '+WS' : ''} on ${location}`); |
889 | 955 | this._onDidChangeStatus.fire(); |
890 | 956 | } |
891 | 957 | } |
@@ -1129,6 +1195,49 @@ export class CopilotApiGateway implements vscode.Disposable { |
1129 | 1195 | return; |
1130 | 1196 | } |
1131 | 1197 |
|
| 1198 | + // Prometheus metrics endpoint |
| 1199 | + if (req.method === 'GET' && url.pathname === '/metrics') { |
| 1200 | + const uptime = Math.floor((Date.now() - this.usageStats.startTime) / 1000); |
| 1201 | + const metrics = [ |
| 1202 | + '# HELP copilot_api_requests_total Total number of API requests', |
| 1203 | + '# TYPE copilot_api_requests_total counter', |
| 1204 | + `copilot_api_requests_total ${this.usageStats.totalRequests}`, |
| 1205 | + '', |
| 1206 | + '# HELP copilot_api_active_requests Current number of active requests', |
| 1207 | + '# TYPE copilot_api_active_requests gauge', |
| 1208 | + `copilot_api_active_requests ${this.activeRequests}`, |
| 1209 | + '', |
| 1210 | + '# HELP copilot_api_tokens_input_total Total input tokens consumed', |
| 1211 | + '# TYPE copilot_api_tokens_input_total counter', |
| 1212 | + `copilot_api_tokens_input_total ${this.usageStats.totalTokensIn}`, |
| 1213 | + '', |
| 1214 | + '# HELP copilot_api_tokens_output_total Total output tokens generated', |
| 1215 | + '# TYPE copilot_api_tokens_output_total counter', |
| 1216 | + `copilot_api_tokens_output_total ${this.usageStats.totalTokensOut}`, |
| 1217 | + '', |
| 1218 | + '# HELP copilot_api_uptime_seconds Server uptime in seconds', |
| 1219 | + '# TYPE copilot_api_uptime_seconds gauge', |
| 1220 | + `copilot_api_uptime_seconds ${uptime}`, |
| 1221 | + '', |
| 1222 | + '# HELP copilot_api_requests_per_minute Rate of requests per minute', |
| 1223 | + '# TYPE copilot_api_requests_per_minute gauge', |
| 1224 | + `copilot_api_requests_per_minute ${this.realtimeStats.requestsPerMinute}`, |
| 1225 | + '', |
| 1226 | + '# HELP copilot_api_latency_avg_ms Average request latency in milliseconds', |
| 1227 | + '# TYPE copilot_api_latency_avg_ms gauge', |
| 1228 | + `copilot_api_latency_avg_ms ${this.realtimeStats.avgLatencyMs}`, |
| 1229 | + '', |
| 1230 | + '# HELP copilot_api_error_rate_percent Error rate percentage', |
| 1231 | + '# TYPE copilot_api_error_rate_percent gauge', |
| 1232 | + `copilot_api_error_rate_percent ${this.realtimeStats.errorRate}`, |
| 1233 | + '' |
| 1234 | + ].join('\n'); |
| 1235 | + |
| 1236 | + res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }); |
| 1237 | + res.end(metrics); |
| 1238 | + return; |
| 1239 | + } |
| 1240 | + |
1132 | 1241 | // List all models |
1133 | 1242 | if (req.method === 'GET' && url.pathname === '/v1/models') { |
1134 | 1243 | const models = await this.getAvailableModels(); |
@@ -3807,6 +3916,9 @@ export class CopilotApiGateway implements vscode.Disposable { |
3807 | 3916 | if (patch.enableWebSocket !== undefined) { |
3808 | 3917 | updates.push(Promise.resolve(config.update('server.enableWebSocket', patch.enableWebSocket, vscode.ConfigurationTarget.Global))); |
3809 | 3918 | } |
| 3919 | + if (patch.enableHttps !== undefined) { |
| 3920 | + updates.push(Promise.resolve(config.update('server.enableHttps', patch.enableHttps, vscode.ConfigurationTarget.Global))); |
| 3921 | + } |
3810 | 3922 | if (patch.host !== undefined) { |
3811 | 3923 | updates.push(Promise.resolve(config.update('server.host', patch.host, vscode.ConfigurationTarget.Global))); |
3812 | 3924 | } |
@@ -4007,6 +4119,9 @@ function getServerConfig(): ApiServerConfig { |
4007 | 4119 | const enabled = configuration.get<boolean>('server.enabled', false); |
4008 | 4120 | const enableHttp = configuration.get<boolean>('server.enableHttp', true); |
4009 | 4121 | const enableWebSocket = configuration.get<boolean>('server.enableWebSocket', true); |
| 4122 | + const enableHttps = configuration.get<boolean>('server.enableHttps', false); |
| 4123 | + const tlsCertPath = configuration.get<string>('server.tlsCertPath', '').trim(); |
| 4124 | + const tlsKeyPath = configuration.get<string>('server.tlsKeyPath', '').trim(); |
4010 | 4125 | const host = configuration.get<string>('server.host', '127.0.0.1').trim() || '127.0.0.1'; |
4011 | 4126 | const rawPort = configuration.get<number>('server.port', 3030); |
4012 | 4127 | const port = Number.isFinite(rawPort) ? Math.max(1, Math.floor(rawPort)) : 3030; |
@@ -4065,7 +4180,7 @@ function getServerConfig(): ApiServerConfig { |
4065 | 4180 | const mcpEnabled = vscode.workspace.getConfiguration('githubCopilotApi.mcp').get<boolean>('enabled', true); |
4066 | 4181 |
|
4067 | 4182 | return { |
4068 | | - enabled, enableHttp, enableWebSocket, host, port, maxConcurrentRequests, |
| 4183 | + enabled, enableHttp, enableWebSocket, enableHttps, tlsCertPath, tlsKeyPath, host, port, maxConcurrentRequests, |
4069 | 4184 | defaultModel, apiKey, enableLogging, rateLimitPerMinute, defaultSystemPrompt, |
4070 | 4185 | redactionPatterns, ipAllowlist, requestTimeoutSeconds, maxPayloadSizeMb, maxConnectionsPerIp, |
4071 | 4186 | mcpEnabled |
|
0 commit comments