| 1 | import { |
| 2 | Injectable, |
| 3 | Logger, |
| 4 | type OnApplicationShutdown, |
| 5 | type OnModuleInit, |
| 6 | } from '@nestjs/common'; |
| 7 | import { ConfigService } |
| 8 | import ssh2, { |
| 9 | type Connection, |
| 10 | type PublicKeyAuthContext, |
| 11 | type ServerChannel, |
| 12 | type Session, |
| 13 | } from 'ssh2'; |
| 14 | |
| 15 | // ssh2 is CommonJS: a named import type-checks and then fails to resolve at runtime under ESM. |
| 16 | const { Server } |
| 17 | type SshServer |
| 18 | |
| 19 | import { DomainError } |
| 20 | import { |
| 21 | type GitServiceName, |
| 22 | toGitBinary, |
| 23 | } from '../../../git/git.constants.js'; |
| 24 | import { GitService } |
| 25 | import { spoolToFile } |
| 26 | import { greeting, replyTo |
| 27 | import { parseGitCommand } |
| 28 | import { |
| 29 | fingerprintOf, |
| 30 | parseStoredKey, |
| 31 | } from '../../../lib/git/ssh/public-key.js'; |
| 32 | import { SshKeysService } |
| 33 | import { PackProcessService } |
| 34 | import { RefAdvertisementService } |
| 35 | import { RepositoryAccessService } |
| 36 | import { |
| 37 | type Actor, |
| 38 | type Repository, |
| 39 | } from '../../../lib/git/repository-access/repository-access.js'; |
| 40 | |
| 41 | /** 10-31. Railway already answers on 2222, and a ghost may as well keep Halloween. */ |
| 42 | const DEFAULT_PORT = 1031 |
| 43 | const MAX_AUTH_ATTEMPTS = 6 |
| 44 | const GIT_FATAL_EXIT = 128 |
| 45 | /** A restart can race the previous process's socket release. */ |
| 46 | const LISTEN_RETRIES = 10 |
| 47 | const LISTEN_RETRY_MS = 1_000 |
| 48 | /** A stuck peer must not hold the process after HTTP has already closed. */ |
| 49 | const CLOSE_TIMEOUT_MS = 5_000 |
| 50 | |
| 51 | interface SessionActor { |
| 52 | actor: Actor |
| 53 | /** The account the key belongs to, not the login the client typed - every connection logs in as `git`. */ |
| 54 | username: string; |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * The SSH transport. It authenticates the connection, then runs exactly one of two git binaries on it. |
| 59 | * |
| 60 | * Nothing here is a shell: a command is matched against a regex, the repository comes from the database, and git is spawned with an argv array. A session that asks for anything else gets text and a non-zero exit. |
| 61 | */ |
| 62 | @Injectable() |
| 63 | export class SshServerService |
| 64 | private readonly logger |
| 65 | private server?: |
| 66 | /** net.Server.close() leaves open connections alone, so shutdown ends them itself. */ |
| 67 | private readonly clients |
| 68 | private retryTimer?: |
| 69 | |
| 70 | constructor( |
| 71 | private readonly config |
| 72 | private readonly keys |
| 73 | private readonly access |
| 74 | private readonly git |
| 75 | private readonly packProcess |
| 76 | private readonly refAdvertisement |
| 77 | ) {} |
| 78 | |
| 79 | onModuleInit() { |
| 80 | const hostKey = |
| 81 | if (!hostKey) |
| 82 | this.logger.log |
| 83 | return; |
| 84 | } |
| 85 | |
| 86 | const port = |
| 87 | this.server = |
| 88 | { |
| 89 | hostKeys: [readHostKey(hostKey |
| 90 | ident: 'ghost', |
| 91 | // A clone can idle while the server packs objects, so the liveness check has to be generous enough not to cut one off. |
| 92 | keepaliveInterval: 15_000, |
| 93 | keepaliveCountMax: 8, |
| 94 | }, |
| 95 | (client) => |
| 96 | ); |
| 97 | |
| 98 | this.server.on |
| 99 | this.logger.log |
| 100 | ); |
| 101 | let retries = |
| 102 | this.server.on |
| 103 | if (error.code |
| 104 | this.logger.warn |
| 105 | `SSH port ${port} busy, retry ${ |
| 106 | ); |
| 107 | this.retryTimer = |
| 108 | () => this. |
| 109 | LISTEN_RETRY_MS, |
| 110 | ); |
| 111 | return; |
| 112 | } |
| 113 | this.logger.error |
| 114 | }); |
| 115 | this.server.listen |
| 116 | } |
| 117 | |
| 118 | async onApplicationShutdown() { |
| 119 | clearTimeout(this.retryTimer |
| 120 | const server = |
| 121 | this.server = |
| 122 | if (!server?.listening |
| 123 | await new Promise |
| 124 | setTimeout(resolve, CLOSE_TIMEOUT_MS |
| 125 | server.close(() |
| 126 | for (const client |
| 127 | }); |
| 128 | } |
| 129 | |
| 130 | private handle(client |
| 131 | this.clients.add |
| 132 | client.on('close |
| 133 | const state: |
| 134 | let attempts = |
| 135 | |
| 136 | client.on('authentication |
| 137 | if (ctx.method |
| 138 | // Password and keyboard-interactive are never offered: a key is the only identity this server understands. |
| 139 | if (++attempts >= |
| 140 | return ctx.reject |
| 141 | } |
| 142 | |
| 143 | void this.authenticate( |
| 144 | this.logger.warn |
| 145 | ctx.reject(); |
| 146 | }); |
| 147 | }); |
| 148 | |
| 149 | client.on('session |
| 150 | // No forwarding of any kind: this connection carries git, or it carries nothing. |
| 151 | client.on('request |
| 152 | client.on('tcpip |
| 153 | client.on('openssh.streamlocal |
| 154 | client.on('error |
| 155 | this.logger.debug |
| 156 | ); |
| 157 | } |
| 158 | |
| 159 | private async authenticate |
| 160 | const row = |
| 161 | if (!row) |
| 162 | |
| 163 | // No signature means the client is only asking whether the key would be accepted; the signed round follows. |
| 164 | if (!ctx.signature |
| 165 | |
| 166 | if ( |
| 167 | !parseStoredKey(row.publicKey |
| 168 | ctx.blob, |
| 169 | ctx.signature, |
| 170 | ctx.hashAlgo, |
| 171 | ) |
| 172 | ) { |
| 173 | return ctx.reject |
| 174 | } |
| 175 | |
| 176 | state.actor = |
| 177 | state.username = |
| 178 | ctx.accept(); |
| 179 | this.keys |
| 180 | .markUsed(row.id |
| 181 | .catch((error: |
| 182 | this.logger.warn |
| 183 | ); |
| 184 | } |
| 185 | |
| 186 | private session(session |
| 187 | session.on('exec |
| 188 | const channel = |
| 189 | // The connection outlives the channel on purpose: ending it while megabytes are still queued behind the flow-control window truncates the fetch. |
| 190 | channel.on('close |
| 191 | void this.exec( |
| 192 | this.logger.error |
| 193 | ); |
| 194 | }); |
| 195 | |
| 196 | session.on('shell |
| 197 | const channel = |
| 198 | channel.on('close |
| 199 | channel.write(greeting |
| 200 | end(channel, 1 |
| 201 | }); |
| 202 | |
| 203 | // A pty is refused rather than answered: OpenSSH prints one line about it and carries on to the greeting. |
| 204 | session.on('pty |
| 205 | session.on('sftp |
| 206 | session.on('subsystem |
| 207 | session.on('x11 |
| 208 | } |
| 209 | |
| 210 | private async exec |
| 211 | channel: ServerChannel |
| 212 | command: string, |
| 213 | { actor, username |
| 214 | ) { |
| 215 | this.logger.debug |
| 216 | const parsed = |
| 217 | if (!parsed) |
| 218 | channel.stderr.write |
| 219 | return end(channel |
| 220 | } |
| 221 | |
| 222 | try { |
| 223 | const repository = |
| 224 | username: parsed.username |
| 225 | repo: parsed.repo |
| 226 | actor, |
| 227 | operation: parsed.service |
| 228 | }); |
| 229 | const repoDirectory = |
| 230 | repository.id, |
| 231 | repository.defaultBranch, |
| 232 | ); |
| 233 | |
| 234 | if (parsed.service |
| 235 | await this.push |
| 236 | return; |
| 237 | } |
| 238 | await this.fetch |
| 239 | } catch (error) |
| 240 | // SSH has no status line to carry this, so the reason goes where git prints remote errors. |
| 241 | const message = |
| 242 | error instanceof DomainError |
| 243 | ? error.message |
| 244 | : 'the server could not complete that request'; |
| 245 | if (!(error |
| 246 | this.logger.error |
| 247 | } |
| 248 | channel.stderr.write |
| 249 | end(channel, GIT_FATAL_EXIT |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | /** A fetch is a straight duplex: the client's wants go to git, git's packfile comes back, and nothing is buffered on the way. */ |
| 254 | private fetch( |
| 255 | channel: ServerChannel |
| 256 | repoDirectory: string, |
| 257 | service: GitServiceName |
| 258 | ) { |
| 259 | const binary = |
| 260 | |
| 261 | return new Promise |
| 262 | const child = |
| 263 | repoDirectory, |
| 264 | service, |
| 265 | }); |
| 266 | let running = |
| 267 | |
| 268 | channel.pipe(child |
| 269 | // `end: false`: a pipe that closes the channel itself gets there before the exit status does, and a client that never reads one calls the whole fetch a transport error. |
| 270 | child.stdout.pipe |
| 271 | // Teed rather than piped: git's diagnosis belongs on the client's terminal, and a server log holding nothing but `ECONNRESET` cannot answer why a clone failed. |
| 272 | child.stderr.on |
| 273 | const message = |
| 274 | // git names the directory it failed on, which is a local cache path the client has no business learning. |
| 275 | channel.stderr.write |
| 276 | this.logger.warn |
| 277 | }); |
| 278 | |
| 279 | // A client that hangs up mid-clone must not leave upload-pack packing objects nobody will read. |
| 280 | channel.on('close |
| 281 | if (!running) |
| 282 | this.logger.debug |
| 283 | child.kill('SIGTERM |
| 284 | }); |
| 285 | |
| 286 | child.on('error |
| 287 | this.logger.error |
| 288 | channel.stderr.write |
| 289 | }); |
| 290 | |
| 291 | child.on('close |
| 292 | running = false; |
| 293 | if (code) this |
| 294 | end(channel, code |
| 295 | resolve(); |
| 296 | }); |
| 297 | }); |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * A push is spooled, committed to the log, and only then replayed into git - the same order the HTTP transport uses, so both share one commit point. |
| 302 | * |
| 303 | * The spool starts before the advertisement is written: the client may answer the moment it reads the refs, and an unattended channel drops whatever arrives first. |
| 304 | */ |
| 305 | private async push |
| 306 | channel: ServerChannel |
| 307 | repository: Pick |
| 308 | repoDirectory: string, |
| 309 | actor: Actor |
| 310 | ) { |
| 311 | const spooling = |
| 312 | this.refAdvertisement |
| 313 | .advertiseRefs({ repoDirectory |
| 314 | .pipe(channel, |
| 315 | |
| 316 | const spooled = |
| 317 | try { |
| 318 | const { body |
| 319 | repositoryId: repository.id |
| 320 | defaultBranch: repository.defaultBranch |
| 321 | isPublic: repository.visibility |
| 322 | body: spooled.body |
| 323 | pushedBy: actor?.userId |
| 324 | }); |
| 325 | |
| 326 | await new Promise |
| 327 | body.on('error |
| 328 | body.on('end |
| 329 | body.pipe(channel |
| 330 | }); |
| 331 | end(channel, 0 |
| 332 | } finally { |
| 333 | await spooled.discard |
| 334 | } |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | function end(channel: |
| 339 | channel.exit(code |
| 340 | channel.end(); |
| 341 | } |
| 342 | |
| 343 | /** The host key is one value in the environment, either PEM or the same PEM base64-encoded so it survives a single-line config field. */ |
| 344 | function readHostKey(value: |
| 345 | return value.includes |
| 346 | ? value.replace( |
| 347 | : Buffer.from( |
| 348 | } |