Skip to content

Commit cb82567

Browse files
committed
feat: implements a ssh flow
1 parent 9046dd4 commit cb82567

File tree

25 files changed

+1393
-37
lines changed

25 files changed

+1393
-37
lines changed

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,9 @@ yarn-error.log*
263263

264264
# Docusaurus website
265265
website/build
266-
website/.docusaurus
266+
website/.docusaurus
267+
268+
# IDE files
269+
.idea
270+
271+
.temp

SSH.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
### SSH Git Proxy Data Flow
2+
3+
1. **Client Connection:**
4+
5+
- An SSH client (e.g., `git` command line) connects to the proxy server's listening port.
6+
- The `ssh2.Server` instance receives the connection.
7+
8+
2. **Authentication:**
9+
10+
- The server requests authentication (`client.on('authentication', ...)`).
11+
- **Public Key Auth:**
12+
- Client sends its public key.
13+
- Proxy formats the key (`keyString = \`${keyType} ${keyData.toString('base64')}\``).
14+
- Proxy queries the `Database` (`db.findUserBySSHKey(keyString)`).
15+
- If a user is found, auth succeeds (`ctx.accept()`). The _public_ key info is temporarily stored (`client.userPrivateKey`).
16+
- **Password Auth:**
17+
- If _no_ public key was offered, the client sends username/password.
18+
- Proxy queries the `Database` (`db.findUser(ctx.username)`).
19+
- If user exists, proxy compares the hash (`bcrypt.compare(ctx.password, user.password)`).
20+
- If valid, auth succeeds (`ctx.accept()`).
21+
- **Failure:** If any auth step fails, the connection is rejected (`ctx.reject()`).
22+
23+
3. **Session Ready & Command Execution:**
24+
25+
- Client signals readiness (`client.on('ready', ...)`).
26+
- Client requests a session (`client.on('session', ...)`).
27+
- Client executes a command (`session.on('exec', ...)`), typically `git-upload-pack` or `git-receive-pack`.
28+
- Proxy extracts the repository path from the command.
29+
30+
4. **Internal Processing (Chain):**
31+
32+
- The proxy constructs a simulated request object (`req`).
33+
- It calls `chain.executeChain(req)` to apply internal rules/checks.
34+
- **Blocked/Error:** If the chain returns an error or blocks the action, an error message is sent directly back to the client (`stream.write(...)`, `stream.end()`), and the flow stops.
35+
36+
5. **Connect to Remote Git Server:**
37+
38+
- If the chain allows, the proxy initiates a _new_ SSH connection (`remoteGitSsh = new Client()`) to the actual remote Git server (e.g., GitHub), using the URL from `config.getProxyUrl()`.
39+
- **Key Selection:**
40+
- It initially intends to use the key from `client.userPrivateKey` (captured during client auth).
41+
- **Crucially:** Since `client.userPrivateKey` only contains the _public_ key details, the proxy cannot use it to authenticate _outbound_.
42+
- It **defaults** to using the **proxy's own private host key** (`config.getSSHConfig().hostKey.privateKeyPath`) for the connection to the remote server.
43+
- **Connection Options:** Sets host, port, username (`git`), timeouts, keepalives, and the selected private key.
44+
45+
6. **Remote Command Execution & Data Piping:**
46+
47+
- Once connected to the remote server (`remoteGitSsh.on('ready', ...)`), the proxy executes the _original_ Git command (`remoteGitSsh.exec(command, ...)`).
48+
- The core proxying begins:
49+
- Data from **Client -> Proxy** (`stream.on('data', ...)`): Forwarded to **Proxy -> Remote** (`remoteStream.write(data)`).
50+
- Data from **Remote -> Proxy** (`remoteStream.on('data', ...)`): Forwarded to **Proxy -> Client** (`stream.write(data)`).
51+
52+
7. **Error Handling & Fallback (Remote Connection):**
53+
54+
- If the initial connection attempt to the remote fails with an authentication error (`remoteGitSsh.on('error', ...)` message includes `All configured authentication methods failed`), _and_ it was attempting to use the (incorrectly identified) client key, it will explicitly **retry** the connection using the **proxy's private key**.
55+
- This retry logic handles the case where the initial key selection might have been ambiguous, ensuring it falls back to the guaranteed working key (the proxy's own).
56+
- If the retry also fails, or if the error was different, the error is sent to the client (`stream.write(err.toString())`, `stream.end()`).
57+
58+
8. **Stream Management & Teardown:**
59+
- Handles `close`, `end`, `error`, and `exit` events for both client (`stream`) and remote (`remoteStream`) streams.
60+
- Manages keepalives and timeouts for both connections.
61+
- When the client finishes sending data (`stream.on('end', ...)`), the proxy closes the connection to the remote server (`remoteGitSsh.end()`) after a brief delay.
62+
63+
### Data Flow Diagram (Sequence)
64+
65+
```mermaid
66+
sequenceDiagram
67+
participant C as Client (Git)
68+
participant P as Proxy Server (SSHServer)
69+
participant DB as Database
70+
participant R as Remote Git Server (e.g., GitHub)
71+
72+
C->>P: SSH Connect
73+
P-->>C: Request Authentication
74+
C->>P: Send Auth (PublicKey / Password)
75+
76+
alt Public Key Auth
77+
P->>DB: Verify Public Key (findUserBySSHKey)
78+
DB-->>P: User Found / Not Found
79+
else Password Auth
80+
P->>DB: Verify User/Password (findUser + bcrypt)
81+
DB-->>P: Valid / Invalid
82+
end
83+
84+
alt Authentication Successful
85+
P-->>C: Authentication Accepted
86+
C->>P: Execute Git Command (e.g., git-upload-pack repo)
87+
88+
P->>P: Execute Internal Chain (Check rules)
89+
alt Chain Blocked/Error
90+
P-->>C: Error Message
91+
Note right of P: End Flow
92+
else Chain Passed
93+
P->>R: SSH Connect (using Proxy's Private Key)
94+
R-->>P: Connection Ready
95+
P->>R: Execute Git Command
96+
97+
loop Data Transfer (Proxying)
98+
C->>P: Git Data Packet (Client Stream)
99+
P->>R: Forward Git Data Packet (Remote Stream)
100+
R->>P: Git Data Packet (Remote Stream)
101+
P->>C: Forward Git Data Packet (Client Stream)
102+
end
103+
104+
C->>P: End Client Stream
105+
P->>R: End Remote Connection (after delay)
106+
P-->>C: End Client Stream
107+
R-->>P: Remote Connection Closed
108+
C->>P: Close Client Connection
109+
end
110+
else Authentication Failed
111+
P-->>C: Authentication Rejected
112+
Note right of P: End Flow
113+
end
114+
115+
```
116+
117+
```
118+
119+
```

config.schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@
7878
"type": "object"
7979
}
8080
}
81+
},
82+
"ssh": {
83+
"enabled": true,
84+
"port": 2222,
85+
"hostKey": {
86+
"privateKeyPath": "./.ssh/host_key",
87+
"publicKeyPath": "./.ssh/host_key.pub"
88+
}
8189
}
8290
},
8391
"definitions": {

package-lock.json

Lines changed: 48 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"react-router-dom": "6.28.2",
7373
"simple-git": "^3.25.0",
7474
"uuid": "^11.0.0",
75-
"yargs": "^17.7.2"
75+
"yargs": "^17.7.2",
76+
"ssh2": "^1.16.0"
7677
},
7778
"devDependencies": {
7879
"@babel/core": "^7.23.2",

packages/git-proxy-cli/index.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,60 @@ async function logout() {
306306
console.log('Logout: OK');
307307
}
308308

309+
/**
310+
* Add SSH key for a user
311+
* @param {string} username The username to add the key for
312+
* @param {string} keyPath Path to the public key file
313+
*/
314+
async function addSSHKey(username, keyPath) {
315+
console.log('Add SSH key', { username, keyPath });
316+
if (!fs.existsSync(GIT_PROXY_COOKIE_FILE)) {
317+
console.error('Error: SSH key: Authentication required');
318+
process.exitCode = 1;
319+
return;
320+
}
321+
322+
try {
323+
const cookies = JSON.parse(fs.readFileSync(GIT_PROXY_COOKIE_FILE, 'utf8'));
324+
const publicKey = fs.readFileSync(keyPath, 'utf8').trim();
325+
326+
console.log('Adding SSH key', { username, publicKey });
327+
await axios.post(
328+
`${baseUrl}/api/v1/user/${username}/ssh-keys`,
329+
{ publicKey },
330+
{
331+
headers: {
332+
Cookie: cookies,
333+
'Content-Type': 'application/json',
334+
},
335+
withCredentials: true,
336+
},
337+
);
338+
339+
console.log(`SSH key added successfully for user ${username}`);
340+
} catch (error) {
341+
let errorMessage = `Error: SSH key: '${error.message}'`;
342+
process.exitCode = 2;
343+
344+
if (error.response) {
345+
switch (error.response.status) {
346+
case 401:
347+
errorMessage = 'Error: SSH key: Authentication required';
348+
process.exitCode = 3;
349+
break;
350+
case 404:
351+
errorMessage = `Error: SSH key: User '${username}' not found`;
352+
process.exitCode = 4;
353+
break;
354+
}
355+
} else if (error.code === 'ENOENT') {
356+
errorMessage = `Error: SSH key: Could not find key file at ${keyPath}`;
357+
process.exitCode = 5;
358+
}
359+
console.error(errorMessage);
360+
}
361+
}
362+
309363
// Parsing command line arguments
310364
yargs(hideBin(process.argv))
311365
.command({
@@ -436,6 +490,37 @@ yargs(hideBin(process.argv))
436490
rejectGitPush(argv.id);
437491
},
438492
})
493+
.command({
494+
command: 'ssh-key',
495+
describe: 'Manage SSH keys',
496+
builder: {
497+
action: {
498+
describe: 'Action to perform (add/remove)',
499+
demandOption: true,
500+
type: 'string',
501+
choices: ['add', 'remove'],
502+
},
503+
username: {
504+
describe: 'Username to manage keys for',
505+
demandOption: true,
506+
type: 'string',
507+
},
508+
keyPath: {
509+
describe: 'Path to the public key file',
510+
demandOption: true,
511+
type: 'string',
512+
},
513+
},
514+
handler(argv) {
515+
if (argv.action === 'add') {
516+
addSSHKey(argv.username, argv.keyPath);
517+
} else if (argv.action === 'remove') {
518+
// TODO: Implement remove SSH key
519+
console.error('Error: SSH key: Remove action not implemented yet');
520+
process.exitCode = 1;
521+
}
522+
},
523+
})
439524
.demandCommand(1, 'You need at least one command before moving on')
440525
.strict()
441526
.help().argv;

proxy.config.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,5 +97,13 @@
9797
"urlShortener": "",
9898
"contactEmail": "",
9999
"csrfProtection": true,
100-
"plugins": []
100+
"plugins": [],
101+
"ssh": {
102+
"enabled": true,
103+
"port": 2222,
104+
"hostKey": {
105+
"privateKeyPath": ".ssh/id_rsa",
106+
"publicKeyPath": ".ssh/id_rsa.pub"
107+
}
108+
}
101109
}

0 commit comments

Comments
 (0)