Skip to content

Commit 8a6efe1

Browse files
committed
fix(api): restore controller ssh sessions
1 parent f964400 commit 8a6efe1

File tree

3 files changed

+143
-4
lines changed

3 files changed

+143
-4
lines changed

packages/api/src/services/project-authorized-keys.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,17 @@ const resolvePublicKeyFromPrivate = (privateKeyPath: string) =>
4747
})
4848
)
4949

50+
const ensurePrivateKeyPermissions = (privateKeyPath: string) =>
51+
withFsPathContext(({ fs }) =>
52+
fs.chmod(privateKeyPath, 0o600).pipe(Effect.orElseSucceed(() => void 0))
53+
)
54+
5055
const resolveHostPrivateKeyPath = () =>
5156
withFsPathContext(({ fs, path }) =>
5257
Effect.gen(function*(_) {
5358
const existing = yield* _(findSshPrivateKey(fs, path, process.cwd()))
5459
if (existing !== null) {
60+
yield* _(ensurePrivateKeyPermissions(existing))
5561
return existing
5662
}
5763

@@ -78,6 +84,7 @@ const resolveHostPrivateKeyPath = () =>
7884
)
7985
)
8086

87+
yield* _(ensurePrivateKeyPermissions(managedKeyPath))
8188
return managedKeyPath
8289
})
8390
)

packages/api/src/services/terminal-sessions.ts

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { prepareProjectSsh, waitForProjectSshReady } from "@effect-template/lib"
1+
import { type AppError, prepareProjectSsh, renderError, waitForProjectSshReady } from "@effect-template/lib"
2+
import { runCommandCapture } from "@effect-template/lib/shell/command-runner"
3+
import { parseInspectNetworkEntry } from "@effect-template/lib/shell/docker-inspect-parse"
4+
import { CommandFailedError } from "@effect-template/lib/shell/errors"
5+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
6+
import * as FileSystem from "@effect/platform/FileSystem"
27
import * as ParseResult from "@effect/schema/ParseResult"
38
import * as Schema from "@effect/schema/Schema"
49
import { Effect, Either } from "effect"
@@ -56,6 +61,9 @@ const TerminalClientMessageSchema = Schema.parseJson(
5661

5762
const nowIso = (): string => new Date().toISOString()
5863

64+
const isAppError = (value: unknown): value is AppError =>
65+
typeof value === "object" && value !== null && "_tag" in value
66+
5967
const updateSession = (
6068
record: TerminalRecord,
6169
patch: Partial<TerminalSession>
@@ -71,12 +79,100 @@ const toApiInternalError = (error: unknown): ApiInternalError =>
7179
error instanceof ApiInternalError
7280
? error
7381
: new ApiInternalError({
74-
message: describeUnknown(error),
82+
message: isAppError(error) ? renderError(error) : describeUnknown(error),
7583
cause: error
7684
})
7785

86+
const normalizeSshKeyPermissions = (sshKeyPath: string | null) =>
87+
sshKeyPath === null
88+
? Effect.void
89+
: FileSystem.FileSystem.pipe(
90+
Effect.flatMap((fs) => fs.chmod(sshKeyPath, 0o600).pipe(Effect.orElseSucceed(() => void 0)))
91+
)
92+
93+
type ContainerNetworkEntry = {
94+
readonly ipAddress: string
95+
readonly name: string
96+
}
97+
98+
const dockerGitApiContainerName = (): string => process.env["DOCKER_GIT_API_CONTAINER_NAME"]?.trim() || "docker-git-api"
99+
100+
const parseContainerNetworkEntries = (output: string): ReadonlyArray<ContainerNetworkEntry> =>
101+
output
102+
.trim()
103+
.split(/\r?\n/u)
104+
.flatMap((line) => parseInspectNetworkEntry(line))
105+
.map(([name, ipAddress]) => ({ name, ipAddress }))
106+
107+
const selectReachableProjectNetwork = (
108+
entries: ReadonlyArray<ContainerNetworkEntry>
109+
): ContainerNetworkEntry | null =>
110+
entries.find((entry) => entry.name !== "bridge") ?? entries[0] ?? null
111+
112+
const inspectContainerNetworks = (
113+
containerName: string
114+
) =>
115+
runCommandCapture(
116+
{
117+
cwd: process.cwd(),
118+
command: "docker",
119+
args: [
120+
"inspect",
121+
"-f",
122+
String.raw`{{range $k,$v := .NetworkSettings.Networks}}{{printf "%s=%s\n" $k $v.IPAddress}}{{end}}`,
123+
containerName
124+
]
125+
},
126+
[0],
127+
(exitCode) => new CommandFailedError({ command: "docker inspect networks", exitCode })
128+
).pipe(Effect.map(parseContainerNetworkEntries))
129+
130+
const connectContainerToNetwork = (
131+
networkName: string,
132+
containerName: string
133+
) =>
134+
networkName === "bridge"
135+
? Effect.void
136+
: runCommandCapture(
137+
{
138+
cwd: process.cwd(),
139+
command: "docker",
140+
args: ["network", "connect", networkName, containerName]
141+
},
142+
[0],
143+
(exitCode) => new CommandFailedError({ command: `docker network connect ${networkName}`, exitCode })
144+
).pipe(
145+
Effect.asVoid,
146+
Effect.orElseSucceed(() => void 0)
147+
)
148+
149+
const resolveControllerReachableProject = (
150+
projectItem: ProjectItem
151+
) =>
152+
Effect.gen(function*(_) {
153+
const networkEntries = yield* _(inspectContainerNetworks(projectItem.containerName).pipe(Effect.orElseSucceed(() => [])))
154+
yield* _(
155+
Effect.forEach(
156+
networkEntries.filter((entry) => entry.name !== "bridge"),
157+
(entry) => connectContainerToNetwork(entry.name, dockerGitApiContainerName()),
158+
{ discard: true }
159+
)
160+
)
161+
const preferredNetwork = selectReachableProjectNetwork(networkEntries)
162+
if (preferredNetwork === null) {
163+
return projectItem
164+
}
165+
return {
166+
...projectItem,
167+
ipAddress: preferredNetwork.ipAddress
168+
}
169+
})
170+
78171
const encodeServerMessage = (message: TerminalServerMessage): string => JSON.stringify(message)
79172

173+
const renderPreparedSshCommand = (prepared: ReturnType<typeof prepareProjectSsh>): string =>
174+
[prepared.command, ...prepared.args].join(" ")
175+
80176
const sendServerMessage = (socket: WebSocket | null, message: TerminalServerMessage): void => {
81177
if (socket === null || socket.readyState !== WebSocket.OPEN) {
82178
return
@@ -196,7 +292,7 @@ const registerRecord = (
196292
createdAt: nowIso(),
197293
id: randomUUID(),
198294
projectId,
199-
sshCommand: prepared.item.sshCommand,
295+
sshCommand: renderPreparedSshCommand(prepared),
200296
status: "ready"
201297
}
202298
const record: TerminalRecord = {
@@ -225,7 +321,9 @@ export const createTerminalSession = (
225321
})
226322
)
227323
const project = yield* _(upProject(projectId, undefined, true))
228-
const projectItem = yield* _(getProjectItemById(projectId))
324+
const loadedProjectItem = yield* _(getProjectItemById(projectId))
325+
const projectItem = yield* _(resolveControllerReachableProject(loadedProjectItem))
326+
yield* _(normalizeSshKeyPermissions(projectItem.sshKeyPath))
229327
yield* _(
230328
Effect.sync(() => {
231329
emitProjectEvent(projectId, "project.deployment.status", {

packages/api/tests/projects.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Effect } from "effect"
77
import * as Scope from "effect/Scope"
88

99
import { ApiConflictError, ApiInternalError } from "../src/api/errors.js"
10+
import { resolveManagedAuthorizedKeysContents } from "../src/services/project-authorized-keys.js"
1011
import { createProjectFromRequest, seedAuthorizedKeysForCreate } from "../src/services/projects.js"
1112

1213
const withTempDir = <A, E, R>(
@@ -119,6 +120,39 @@ describe("projects service", () => {
119120
})
120121
).pipe(Effect.provide(NodeContext.layer)))
121122

123+
it.effect("normalizes managed dev ssh private key permissions to 0600", () =>
124+
withTempDir((root) =>
125+
Effect.gen(function*(_) {
126+
const fs = yield* _(FileSystem.FileSystem)
127+
const path = yield* _(Path.Path)
128+
const projectsRoot = path.join(root, ".docker-git")
129+
const privateKeyPath = path.join(projectsRoot, "dev_ssh_key")
130+
const publicKeyPath = `${privateKeyPath}.pub`
131+
132+
yield* _(fs.makeDirectory(projectsRoot, { recursive: true }))
133+
yield* _(fs.writeFileString(privateKeyPath, "PRIVATE KEY"))
134+
yield* _(fs.writeFileString(publicKeyPath, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest docker-git@test\n"))
135+
yield* _(fs.chmod(privateKeyPath, 0o644))
136+
137+
yield* _(
138+
withEnvVar(
139+
"DOCKER_GIT_SSH_KEY",
140+
undefined,
141+
withProjectsRoot(
142+
projectsRoot,
143+
withWorkingDirectory(
144+
root,
145+
resolveManagedAuthorizedKeysContents()
146+
)
147+
)
148+
)
149+
)
150+
151+
const info = yield* _(fs.stat(privateKeyPath))
152+
expect(Number(info.mode ?? 0) & 0o777).toBe(0o600)
153+
})
154+
).pipe(Effect.provide(NodeContext.layer)))
155+
122156
it.effect("renders docker access failures for API create without leaking stack traces", () =>
123157
withTempDir((root) =>
124158
Effect.gen(function*(_) {

0 commit comments

Comments
 (0)