|
| 1 | +import { createHash } from "node:crypto" |
| 2 | + |
| 3 | +import type { ProjectPortForward, ProjectPortForwardStatus } from "../api/contracts.js" |
| 4 | +import { projectShortKey, renderForwardProxyPath } from "./project-port-proxy-core.js" |
| 5 | + |
| 6 | +export const portForwardKindLabel = "ai.docker-git.kind" |
| 7 | +export const portForwardKindValue = "port-forward" |
| 8 | +export const portForwardProjectLabel = "ai.docker-git.project-id" |
| 9 | +export const portForwardTargetPortLabel = "ai.docker-git.target-port" |
| 10 | +export const portForwardHostPortLabel = "ai.docker-git.host-port" |
| 11 | +export const portForwardBindHostLabel = "ai.docker-git.bind-host" |
| 12 | +export const portForwardPublicHostLabel = "ai.docker-git.public-host" |
| 13 | +export const portForwardTargetContainerLabel = "ai.docker-git.target-container" |
| 14 | + |
| 15 | +const dockerNameMaxLength = 63 |
| 16 | +const defaultHostPortSearchLimit = 200 |
| 17 | + |
| 18 | +export type PortForwardRow = { |
| 19 | + readonly id: string |
| 20 | + readonly name: string |
| 21 | + readonly state: string |
| 22 | + readonly createdAt: string |
| 23 | + readonly projectId: string |
| 24 | + readonly targetPort: string |
| 25 | + readonly hostPort: string |
| 26 | + readonly bindHost: string |
| 27 | + readonly publicHost: string |
| 28 | + readonly targetContainerName: string |
| 29 | +} |
| 30 | + |
| 31 | +export type PortForwardRequestPorts = { |
| 32 | + readonly targetPort: number |
| 33 | + readonly hostPort: number | undefined |
| 34 | +} |
| 35 | + |
| 36 | +type PortValidationResult = |
| 37 | + | { readonly ok: true; readonly port: number } |
| 38 | + | { readonly ok: false; readonly message: string } |
| 39 | + |
| 40 | +type PortRequestValidationResult = |
| 41 | + | { readonly ok: true; readonly ports: PortForwardRequestPorts } |
| 42 | + | { readonly ok: false; readonly message: string } |
| 43 | + |
| 44 | +const sanitizeNameChunk = (value: string): string => |
| 45 | + value |
| 46 | + .toLowerCase() |
| 47 | + .replace(/[^a-z0-9_.-]+/g, "-") |
| 48 | + .replace(/^-+|-+$/g, "") |
| 49 | + |
| 50 | +const shortHash = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 12) |
| 51 | + |
| 52 | +const normalizeDockerName = (value: string): string => { |
| 53 | + const sanitized = sanitizeNameChunk(value) |
| 54 | + const name = sanitized.length === 0 ? "port" : sanitized |
| 55 | + return name.length <= dockerNameMaxLength ? name : name.slice(0, dockerNameMaxLength) |
| 56 | +} |
| 57 | + |
| 58 | +export const buildPortForwardContainerName = ( |
| 59 | + projectId: string, |
| 60 | + targetPort: number |
| 61 | +): string => normalizeDockerName(`dg-port-${shortHash(projectId)}-${targetPort}`) |
| 62 | + |
| 63 | +export const validatePort = (value: number, label: string): PortValidationResult => |
| 64 | + Number.isInteger(value) && value > 0 && value <= 65_535 |
| 65 | + ? { ok: true, port: value } |
| 66 | + : { ok: false, message: `${label} must be an integer between 1 and 65535.` } |
| 67 | + |
| 68 | +export const normalizePortForwardRequest = ( |
| 69 | + targetPort: number, |
| 70 | + hostPort: number | undefined |
| 71 | +): PortRequestValidationResult => { |
| 72 | + const target = validatePort(targetPort, "targetPort") |
| 73 | + if (!target.ok) { |
| 74 | + return target |
| 75 | + } |
| 76 | + if (hostPort === undefined) { |
| 77 | + return { ok: true, ports: { targetPort: target.port, hostPort: undefined } } |
| 78 | + } |
| 79 | + const host = validatePort(hostPort, "hostPort") |
| 80 | + return host.ok |
| 81 | + ? { ok: true, ports: { targetPort: target.port, hostPort: host.port } } |
| 82 | + : host |
| 83 | +} |
| 84 | + |
| 85 | +export const selectHostPort = ( |
| 86 | + preferredPort: number, |
| 87 | + requestedPort: number | undefined, |
| 88 | + usedPorts: ReadonlySet<number>, |
| 89 | + searchLimit = defaultHostPortSearchLimit |
| 90 | +): number | null => { |
| 91 | + if (requestedPort !== undefined) { |
| 92 | + return usedPorts.has(requestedPort) ? null : requestedPort |
| 93 | + } |
| 94 | + for (let port = preferredPort; port <= 65_535 && port < preferredPort + searchLimit; port += 1) { |
| 95 | + if (!usedPorts.has(port)) { |
| 96 | + return port |
| 97 | + } |
| 98 | + } |
| 99 | + return null |
| 100 | +} |
| 101 | + |
| 102 | +export const publicHostFromEnv = (fallback = "localhost"): string => |
| 103 | + process.env["DOCKER_GIT_PUBLIC_HOST"]?.trim() || fallback |
| 104 | + |
| 105 | +export const bindHostFromEnv = (): string => |
| 106 | + process.env["DOCKER_GIT_PUBLIC_BIND_HOST"]?.trim() || "0.0.0.0" |
| 107 | + |
| 108 | +export const projectsRootVolumeFromEnv = (): string => |
| 109 | + process.env["DOCKER_GIT_PROJECTS_ROOT_VOLUME"]?.trim() || "docker-git-projects" |
| 110 | + |
| 111 | +export const renderForwardUrl = (publicHost: string, hostPort: number): string => |
| 112 | + `http://${publicHost}:${hostPort}` |
| 113 | + |
| 114 | +export const statusFromDockerState = (state: string): ProjectPortForwardStatus => { |
| 115 | + const normalized = state.trim().toLowerCase() |
| 116 | + if (normalized === "running") { |
| 117 | + return "running" |
| 118 | + } |
| 119 | + if (normalized === "exited" || normalized === "created" || normalized === "dead" || normalized === "removing") { |
| 120 | + return "stopped" |
| 121 | + } |
| 122 | + return "unknown" |
| 123 | +} |
| 124 | + |
| 125 | +const parseRowPort = (value: string): number => { |
| 126 | + const parsed = Number.parseInt(value, 10) |
| 127 | + return Number.isInteger(parsed) ? parsed : 0 |
| 128 | +} |
| 129 | + |
| 130 | +export const parsePortForwardRows = (output: string): ReadonlyArray<PortForwardRow> => |
| 131 | + output |
| 132 | + .split(/\r?\n/u) |
| 133 | + .map((line) => line.trimEnd()) |
| 134 | + .filter((line) => line.length > 0) |
| 135 | + .flatMap((line) => { |
| 136 | + const parts = line.split("\t") |
| 137 | + if (parts.length < 10) { |
| 138 | + return [] |
| 139 | + } |
| 140 | + const [id, name, state, createdAt, projectId, targetPort, hostPort, bindHost, publicHost, targetContainerName] = parts |
| 141 | + return id === undefined || |
| 142 | + name === undefined || |
| 143 | + state === undefined || |
| 144 | + createdAt === undefined || |
| 145 | + projectId === undefined || |
| 146 | + targetPort === undefined || |
| 147 | + hostPort === undefined || |
| 148 | + bindHost === undefined || |
| 149 | + publicHost === undefined || |
| 150 | + targetContainerName === undefined |
| 151 | + ? [] |
| 152 | + : [{ id, name, state, createdAt, projectId, targetPort, hostPort, bindHost, publicHost, targetContainerName }] |
| 153 | + }) |
| 154 | + |
| 155 | +export const rowToProjectPortForward = (row: PortForwardRow): ProjectPortForward => { |
| 156 | + const hostPort = parseRowPort(row.hostPort) |
| 157 | + const publicHost = row.publicHost.trim().length > 0 ? row.publicHost : "localhost" |
| 158 | + return { |
| 159 | + bindHost: row.bindHost, |
| 160 | + containerName: row.name, |
| 161 | + createdAt: row.createdAt.trim().length === 0 ? null : row.createdAt, |
| 162 | + hostPort, |
| 163 | + id: row.id, |
| 164 | + projectId: row.projectId, |
| 165 | + projectKey: projectShortKey(row.projectId), |
| 166 | + proxyPath: renderForwardProxyPath(row.projectId, parseRowPort(row.targetPort)), |
| 167 | + publicHost, |
| 168 | + status: statusFromDockerState(row.state), |
| 169 | + targetContainerName: row.targetContainerName, |
| 170 | + targetPort: parseRowPort(row.targetPort), |
| 171 | + url: renderForwardUrl(publicHost, hostPort) |
| 172 | + } |
| 173 | +} |
| 174 | + |
| 175 | +export const rowsToProjectPortForwards = (rows: ReadonlyArray<PortForwardRow>): ReadonlyArray<ProjectPortForward> => |
| 176 | + rows.map(rowToProjectPortForward) |
| 177 | + |
| 178 | +export const buildForwardSshScript = ( |
| 179 | + targetIp: string, |
| 180 | + sshUser: string, |
| 181 | + targetPort: number |
| 182 | +): string => [ |
| 183 | + "set -euo pipefail", |
| 184 | + "KEY=/tmp/docker-git-dev-ssh-key", |
| 185 | + "cp /docker-git-projects/dev_ssh_key \"$KEY\"", |
| 186 | + "chmod 600 \"$KEY\"", |
| 187 | + "exec ssh -N \\", |
| 188 | + " -i \"$KEY\" \\", |
| 189 | + " -o ExitOnForwardFailure=yes \\", |
| 190 | + " -o ServerAliveInterval=30 \\", |
| 191 | + " -o ServerAliveCountMax=3 \\", |
| 192 | + " -o StrictHostKeyChecking=no \\", |
| 193 | + " -o UserKnownHostsFile=/dev/null \\", |
| 194 | + " -o LogLevel=ERROR \\", |
| 195 | + ` -L 0.0.0.0:${targetPort}:127.0.0.1:${targetPort} \\`, |
| 196 | + ` -p 22 ${sshUser}@${targetIp}` |
| 197 | +].join("\n") |
0 commit comments