-
Notifications
You must be signed in to change notification settings - Fork 2
SSH agent forwarding #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: denis-coric/ssh-flow
Are you sure you want to change the base?
Changes from 1 commit
a1bab38
28b570b
143a9eb
1ed9bd5
e6cec75
960bad5
5af1982
a2d3ffd
f2c0c60
8016d83
bae44da
8dd60cb
e1a6008
692531b
c970f67
78778ca
5316a81
14ce0b6
7ac5912
fa6c123
67b85b2
5c4329c
473c982
5f81021
3974e2d
2472f39
e174392
0908706
dfa1d88
7ee282c
28aac41
6713757
0bc60bf
850ea55
2c2b093
b937878
48614fb
ad24af3
a56700f
be7759a
c4f36b7
79b6f7c
021e901
bc8eedc
977158b
0c1b077
a28ce83
e828482
d559cab
a08379a
a1e62d3
3392dd0
097d2ff
cd14cec
f885211
7fb692e
ea19387
9bb8059
a685438
730b5d1
363e3e1
ec728a0
186c984
fb025a0
9e0ceec
2105335
dd291b0
96d1dd3
bd82eaa
f938c42
23b69c8
25277e3
54293b7
5b992bd
019c8a6
a177b01
3872b14
0a25231
0448190
28b337c
53c9353
f0478ac
1bda10c
bfa0e72
2d0a092
b9b684a
887a669
f3b9e4e
cd90d1b
8606a21
5445793
2949e9e
95d2a8d
0c76acd
5a58db5
1e272b4
2f654fb
bd985e2
a3b612d
9b8bfed
c880780
d33b3ab
6d26ccf
2afedcc
39fe246
3c43652
698e39f
fd151da
7b504f5
6706274
8f95c5c
ac353de
9fa39cf
3fcbc58
da95d85
5f77ec5
0d378ec
5e9adf1
ca6114c
24d1a59
c6a578e
9e287a1
308d747
492ce79
dbd797b
3c68dd9
90df884
adf28a1
6a2cefe
b2126db
bab1881
6f2d840
cdd9335
ccef965
1c4ba16
2dfb917
93c5bb6
c37cbab
7486aaa
833543e
4b7d295
2ee4f68
c3bd14e
5e2d0a9
a4f12d4
c9a8bb3
a94864a
17dc7f2
1c6c541
35adcb6
93286e8
b744a20
9f148a4
7a1ca00
d65c9b8
cd42eeb
c6c703e
864559e
f52760d
cc5e5f3
264223d
072563b
142223e
e75310d
6459080
a13f35c
758b2db
ec1a77d
f957ef8
868652d
4fa50d3
00b6a06
ec0a993
244686a
7bbce24
a635ae4
68bf3db
6ed9a5e
6f9a16d
06bbe90
fca94af
c0ba816
2cdd607
f097a06
7d61115
212ab9d
0cf32c2
aecfa3d
d48e3e0
8eb40f1
e2bfd5f
50f88d7
8089983
0332a30
232980a
42e5131
01c544f
4e2eea8
6529fc3
e788ab7
579345b
1c57272
6ecdfc0
4b47144
5e81660
3328267
1d41221
084a77a
95110da
a7936c6
fc085d4
1c45104
6900045
330a12a
c0b4a8d
d4a2c38
848746e
fda6e3c
8b606df
064a955
b9bf5c2
0e42bd4
1b3e695
e389ced
f2a6118
103b4f1
488b22b
5783c6c
5696ebf
4d337c4
ba4f08e
0821c36
25d1239
ab44342
70d42d5
b8f0d0b
a827aa3
2019faf
53c9fc3
bec60df
1dbb249
e4715f2
4469ed7
6981427
53a3f3a
6056c34
fac846d
2452a1e
ccf8b63
d9fffe3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,305 @@ | ||
| /** | ||
| * Git Protocol Handling for SSH | ||
| * | ||
| * This module handles the git pack protocol communication with remote Git servers (such as GitHub). | ||
| * It manages: | ||
| * - Fetching capabilities and refs from remote | ||
| * - Forwarding pack data for push operations | ||
| * - Setting up bidirectional streams for pull operations | ||
| */ | ||
|
|
||
| import * as ssh2 from 'ssh2'; | ||
| import { ClientWithUser } from './types'; | ||
| import { validateSSHPrerequisites, createSSHConnectionOptions } from './sshHelpers'; | ||
|
|
||
| /** | ||
| * Parser for Git pkt-line protocol | ||
| * Git uses pkt-line format: [4 byte hex length][payload] | ||
| * Special packet "0000" (flush packet) indicates end of section | ||
| */ | ||
| class PktLineParser { | ||
| private buffer: Buffer = Buffer.alloc(0); | ||
|
|
||
| /** | ||
| * Append data to internal buffer | ||
| */ | ||
| append(data: Buffer): void { | ||
| this.buffer = Buffer.concat([this.buffer, data]); | ||
| } | ||
|
|
||
| /** | ||
| * Check if we've received a flush packet (0000) indicating end of capabilities | ||
| * The flush packet appears after the capabilities/refs section | ||
| */ | ||
| hasFlushPacket(): boolean { | ||
| const bufStr = this.buffer.toString('utf8'); | ||
| return bufStr.includes('0000'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It feels like this is too rudimentary, take the Linux kernel repo: and that's just prefixes, in this case a 0000 anywhere in a head's commit ID would collide. There's not a security problem here I don't think, just breaks the protocol, but we implemented a better parser before (38915e7) so it would be good to use it (move it somewhere common?). (https://github.com/not-an-aardvark/lucky-commit could be useful to test this, if needed.)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Moved the proper pkt-line parser from parsePush.ts to a shared module (src/proxy/processors/pktLineParser.ts) |
||
| } | ||
|
|
||
| /** | ||
| * Get the complete buffer | ||
| */ | ||
| getBuffer(): Buffer { | ||
| return this.buffer; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Fetch capabilities and refs from GitHub without sending any data | ||
| * This allows us to validate data BEFORE sending to GitHub | ||
| */ | ||
| export async function fetchGitHubCapabilities( | ||
| command: string, | ||
| client: ClientWithUser, | ||
| ): Promise<Buffer> { | ||
| validateSSHPrerequisites(client); | ||
| const connectionOptions = createSSHConnectionOptions(client); | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| const remoteGitSsh = new ssh2.Client(); | ||
| const parser = new PktLineParser(); | ||
|
|
||
| // Safety timeout (should never be reached) | ||
| const timeout = setTimeout(() => { | ||
| console.error(`[fetchCapabilities] Timeout waiting for capabilities`); | ||
| remoteGitSsh.end(); | ||
| reject(new Error('Timeout waiting for capabilities from remote')); | ||
| }, 30000); // 30 seconds | ||
|
|
||
| remoteGitSsh.on('ready', () => { | ||
| console.log(`[fetchCapabilities] Connected to GitHub`); | ||
|
|
||
| remoteGitSsh.exec(command, (err: Error | undefined, remoteStream: ssh2.ClientChannel) => { | ||
| if (err) { | ||
| console.error(`[fetchCapabilities] Error executing command:`, err); | ||
| clearTimeout(timeout); | ||
| remoteGitSsh.end(); | ||
| reject(err); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`[fetchCapabilities] Command executed, waiting for capabilities`); | ||
|
|
||
| // Single data handler that checks for flush packet | ||
| remoteStream.on('data', (data: Buffer) => { | ||
| parser.append(data); | ||
| console.log(`[fetchCapabilities] Received ${data.length} bytes`); | ||
|
|
||
| if (parser.hasFlushPacket()) { | ||
| console.log(`[fetchCapabilities] Flush packet detected, capabilities complete`); | ||
| clearTimeout(timeout); | ||
| remoteStream.end(); | ||
| remoteGitSsh.end(); | ||
| resolve(parser.getBuffer()); | ||
| } | ||
| }); | ||
|
|
||
| remoteStream.on('error', (err: Error) => { | ||
| console.error(`[fetchCapabilities] Stream error:`, err); | ||
| clearTimeout(timeout); | ||
| remoteGitSsh.end(); | ||
| reject(err); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| remoteGitSsh.on('error', (err: Error) => { | ||
| console.error(`[fetchCapabilities] Connection error:`, err); | ||
| clearTimeout(timeout); | ||
| reject(err); | ||
| }); | ||
|
|
||
| remoteGitSsh.connect(connectionOptions); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Base function for executing Git commands on remote server | ||
| * Handles all common SSH connection logic, error handling, and cleanup | ||
| * Delegates stream-specific behavior to the provided callback | ||
| * | ||
| * @param command - The Git command to execute | ||
| * @param clientStream - The SSH stream to the client | ||
| * @param client - The authenticated client connection | ||
| * @param onRemoteStreamReady - Callback invoked when remote stream is ready | ||
| */ | ||
| async function executeGitCommandOnRemote( | ||
| command: string, | ||
| clientStream: ssh2.ServerChannel, | ||
| client: ClientWithUser, | ||
| onRemoteStreamReady: (remoteStream: ssh2.ClientChannel) => void, | ||
| ): Promise<void> { | ||
| validateSSHPrerequisites(client); | ||
|
|
||
| const userName = client.authenticatedUser?.username || 'unknown'; | ||
| const connectionOptions = createSSHConnectionOptions(client, { debug: true, keepalive: true }); | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| const remoteGitSsh = new ssh2.Client(); | ||
|
|
||
| const connectTimeout = setTimeout(() => { | ||
| console.error(`[SSH] Connection timeout to remote for user ${userName}`); | ||
| remoteGitSsh.end(); | ||
| clientStream.stderr.write('Connection timeout to remote server\n'); | ||
| clientStream.exit(1); | ||
| clientStream.end(); | ||
| reject(new Error('Connection timeout')); | ||
| }, 30000); | ||
|
|
||
| remoteGitSsh.on('ready', () => { | ||
| clearTimeout(connectTimeout); | ||
| console.log(`[SSH] Connected to remote Git server for user: ${userName}`); | ||
|
|
||
| remoteGitSsh.exec(command, (err: Error | undefined, remoteStream: ssh2.ClientChannel) => { | ||
| if (err) { | ||
| console.error(`[SSH] Error executing command on remote for user ${userName}:`, err); | ||
| clientStream.stderr.write(`Remote execution error: ${err.message}\n`); | ||
| clientStream.exit(1); | ||
| clientStream.end(); | ||
| remoteGitSsh.end(); | ||
| reject(err); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`[SSH] Command executed on remote for user ${userName}`); | ||
|
|
||
| remoteStream.on('close', () => { | ||
| console.log(`[SSH] Remote stream closed for user: ${userName}`); | ||
| clientStream.end(); | ||
| remoteGitSsh.end(); | ||
| console.log(`[SSH] Remote connection closed for user: ${userName}`); | ||
| resolve(); | ||
| }); | ||
|
|
||
| remoteStream.on('exit', (code: number, signal?: string) => { | ||
| console.log( | ||
| `[SSH] Remote command exited for user ${userName} with code: ${code}, signal: ${signal || 'none'}`, | ||
| ); | ||
| clientStream.exit(code || 0); | ||
| resolve(); | ||
| }); | ||
|
|
||
| remoteStream.on('error', (err: Error) => { | ||
| console.error(`[SSH] Remote stream error for user ${userName}:`, err); | ||
| clientStream.stderr.write(`Stream error: ${err.message}\n`); | ||
| clientStream.exit(1); | ||
| clientStream.end(); | ||
| remoteGitSsh.end(); | ||
| reject(err); | ||
| }); | ||
|
|
||
| try { | ||
| onRemoteStreamReady(remoteStream); | ||
| } catch (callbackError) { | ||
| console.error(`[SSH] Error in stream callback for user ${userName}:`, callbackError); | ||
| clientStream.stderr.write(`Internal error: ${callbackError}\n`); | ||
| clientStream.exit(1); | ||
| clientStream.end(); | ||
| remoteGitSsh.end(); | ||
| reject(callbackError); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| remoteGitSsh.on('error', (err: Error) => { | ||
| console.error(`[SSH] Remote connection error for user ${userName}:`, err); | ||
| clearTimeout(connectTimeout); | ||
| clientStream.stderr.write(`Connection error: ${err.message}\n`); | ||
| clientStream.exit(1); | ||
| clientStream.end(); | ||
| reject(err); | ||
| }); | ||
|
|
||
| remoteGitSsh.connect(connectionOptions); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Forward pack data to remote Git server (used for push operations) | ||
| * This connects to GitHub, sends the validated pack data, and forwards responses | ||
| */ | ||
| export async function forwardPackDataToRemote( | ||
| command: string, | ||
| stream: ssh2.ServerChannel, | ||
| client: ClientWithUser, | ||
| packData: Buffer | null, | ||
| capabilitiesSize?: number, | ||
| ): Promise<void> { | ||
| const userName = client.authenticatedUser?.username || 'unknown'; | ||
|
|
||
| await executeGitCommandOnRemote(command, stream, client, (remoteStream) => { | ||
| console.log(`[SSH] Forwarding pack data for user ${userName}`); | ||
|
|
||
| // Send pack data to GitHub | ||
| if (packData && packData.length > 0) { | ||
| console.log(`[SSH] Writing ${packData.length} bytes of pack data to remote`); | ||
| remoteStream.write(packData); | ||
| } | ||
| remoteStream.end(); | ||
|
|
||
| // Skip duplicate capabilities that we already sent to client | ||
| let bytesSkipped = 0; | ||
| const CAPABILITY_BYTES_TO_SKIP = capabilitiesSize || 0; | ||
|
|
||
| remoteStream.on('data', (data: Buffer) => { | ||
| if (CAPABILITY_BYTES_TO_SKIP > 0 && bytesSkipped < CAPABILITY_BYTES_TO_SKIP) { | ||
| const remainingToSkip = CAPABILITY_BYTES_TO_SKIP - bytesSkipped; | ||
|
|
||
| if (data.length <= remainingToSkip) { | ||
| bytesSkipped += data.length; | ||
| console.log( | ||
| `[SSH] Skipping ${data.length} bytes of capabilities (${bytesSkipped}/${CAPABILITY_BYTES_TO_SKIP})`, | ||
| ); | ||
| return; | ||
| } else { | ||
| const actualResponse = data.slice(remainingToSkip); | ||
| bytesSkipped = CAPABILITY_BYTES_TO_SKIP; | ||
| console.log( | ||
| `[SSH] Capabilities skipped (${CAPABILITY_BYTES_TO_SKIP} bytes), forwarding response (${actualResponse.length} bytes)`, | ||
| ); | ||
| stream.write(actualResponse); | ||
| return; | ||
| } | ||
| } | ||
| // Forward all data after capabilities | ||
| stream.write(data); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Connect to remote Git server and set up bidirectional stream (used for pull operations) | ||
| * This creates a simple pipe between client and remote for pull/clone operations | ||
| */ | ||
| export async function connectToRemoteGitServer( | ||
| command: string, | ||
| stream: ssh2.ServerChannel, | ||
| client: ClientWithUser, | ||
| ): Promise<void> { | ||
| const userName = client.authenticatedUser?.username || 'unknown'; | ||
|
|
||
| await executeGitCommandOnRemote(command, stream, client, (remoteStream) => { | ||
| console.log(`[SSH] Setting up bidirectional piping for user ${userName}`); | ||
|
|
||
| // Pipe client data to remote | ||
| stream.on('data', (data: Buffer) => { | ||
| remoteStream.write(data); | ||
| }); | ||
|
|
||
| // Pipe remote data to client | ||
| remoteStream.on('data', (data: Buffer) => { | ||
| stream.write(data); | ||
| }); | ||
|
|
||
| remoteStream.on('error', (err: Error) => { | ||
| if (err.message.includes('early EOF') || err.message.includes('unexpected disconnect')) { | ||
| console.log( | ||
| `[SSH] Detected early EOF for user ${userName}, this is usually harmless during Git operations`, | ||
| ); | ||
| return; | ||
| } | ||
| // Re-throw other errors | ||
| throw err; | ||
| }); | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: rename to
PacketLineParserfor searchability/consistency