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