Skip to content

Commit dc794db

Browse files
authored
Merge pull request #156 from konard/issue-155-065209a7c9ad
fix(shell): sync authorized_keys with active SSH public key
2 parents aae1ff1 + b9f4472 commit dc794db

File tree

2 files changed

+137
-5
lines changed

2 files changed

+137
-5
lines changed

packages/lib/src/usecases/actions/prepare-files.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import {
1212
migrateLegacyOrchLayout,
1313
syncAuthArtifacts
1414
} from "../auth-sync.js"
15-
import { findAuthorizedKeysSource, resolveAuthorizedKeysPath } from "../path-helpers.js"
15+
import {
16+
defaultProjectsRoot,
17+
findAuthorizedKeysSource,
18+
findExistingPath,
19+
findSshPrivateKey,
20+
resolveAuthorizedKeysPath
21+
} from "../path-helpers.js"
1622
import { withFsPathContext } from "../runtime.js"
1723
import { resolvePathFromBase } from "./paths.js"
1824

@@ -40,13 +46,53 @@ const ensureFileReady = (
4046
return "exists"
4147
})
4248

49+
const appendKeyIfMissing = (
50+
fs: FileSystem.FileSystem,
51+
resolved: string,
52+
source: string,
53+
desiredContents: string
54+
): Effect.Effect<void, PlatformError> =>
55+
Effect.gen(function*(_) {
56+
const currentContents = yield* _(fs.readFileString(resolved))
57+
const currentLines = currentContents
58+
.split(/\r?\n/)
59+
.map((line) => line.trim())
60+
.filter((line) => line.length > 0)
61+
62+
if (currentLines.includes(desiredContents)) {
63+
return
64+
}
65+
66+
const normalizedCurrent = currentContents.trimEnd()
67+
const nextContents = normalizedCurrent.length === 0
68+
? `${desiredContents}\n`
69+
: `${normalizedCurrent}\n${desiredContents}\n`
70+
71+
yield* _(fs.writeFileString(resolved, nextContents))
72+
yield* _(Effect.log(`Authorized keys appended from ${source} to ${resolved}`))
73+
})
74+
75+
const resolveAuthorizedKeysSource = (
76+
fs: FileSystem.FileSystem,
77+
path: Path.Path,
78+
cwd: string
79+
): Effect.Effect<string | null, PlatformError, FileSystem.FileSystem | Path.Path> =>
80+
Effect.gen(function*(_) {
81+
const sshPrivateKey = yield* _(findSshPrivateKey(fs, path, cwd))
82+
const matchingPublicKey = sshPrivateKey === null ? null : yield* _(findExistingPath(fs, `${sshPrivateKey}.pub`))
83+
return matchingPublicKey === null
84+
? yield* _(findAuthorizedKeysSource(fs, path, cwd))
85+
: matchingPublicKey
86+
})
87+
4388
const ensureAuthorizedKeys = (
4489
baseDir: string,
4590
authorizedKeysPath: string
4691
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
4792
withFsPathContext(({ fs, path }) =>
4893
Effect.gen(function*(_) {
4994
const resolved = resolveAuthorizedKeysPath(path, baseDir, authorizedKeysPath)
95+
const managedDefaultAuthorizedKeys = path.join(defaultProjectsRoot(process.cwd()), "authorized_keys")
5096
const state = yield* _(
5197
ensureFileReady(
5298
fs,
@@ -55,11 +101,8 @@ const ensureAuthorizedKeys = (
55101
`Authorized keys was a directory, moved to ${backupPath}. Creating a file at ${resolvedPath}.`
56102
)
57103
)
58-
if (state === "exists") {
59-
return
60-
}
61104

62-
const source = yield* _(findAuthorizedKeysSource(fs, path, process.cwd()))
105+
const source = yield* _(resolveAuthorizedKeysSource(fs, path, process.cwd()))
63106
if (source === null) {
64107
yield* _(
65108
Effect.logError(
@@ -69,6 +112,19 @@ const ensureAuthorizedKeys = (
69112
return
70113
}
71114

115+
const desiredContents = (yield* _(fs.readFileString(source))).trim()
116+
if (desiredContents.length === 0) {
117+
yield* _(Effect.logWarning(`Authorized keys source ${source} is empty. Skipping SSH key sync.`))
118+
return
119+
}
120+
121+
if (state === "exists") {
122+
if (resolved === managedDefaultAuthorizedKeys) {
123+
yield* _(appendKeyIfMissing(fs, resolved, source, desiredContents))
124+
}
125+
return
126+
}
127+
72128
yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true }))
73129
yield* _(fs.copyFile(source, resolved))
74130
yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`))

packages/lib/tests/usecases/prepare-files.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,36 @@ const withTempDir = <A, E, R>(
2323
})
2424
)
2525

26+
const withPatchedEnv = <A, E, R>(
27+
patch: Readonly<Record<string, string | undefined>>,
28+
effect: Effect.Effect<A, E, R>
29+
): Effect.Effect<A, E, R> =>
30+
Effect.acquireUseRelease(
31+
Effect.sync(() => {
32+
const previous = new Map<string, string | undefined>()
33+
for (const [key, value] of Object.entries(patch)) {
34+
previous.set(key, process.env[key])
35+
if (value === undefined) {
36+
delete process.env[key]
37+
} else {
38+
process.env[key] = value
39+
}
40+
}
41+
return previous
42+
}),
43+
() => effect,
44+
(previous) =>
45+
Effect.sync(() => {
46+
for (const [key, value] of previous.entries()) {
47+
if (value === undefined) {
48+
delete process.env[key]
49+
} else {
50+
process.env[key] = value
51+
}
52+
}
53+
})
54+
)
55+
2656
const makeGlobalConfig = (root: string, path: Path.Path): TemplateConfig => ({
2757
containerName: "dg-test",
2858
serviceName: "dg-test",
@@ -209,4 +239,50 @@ describe("prepareProjectFiles", () => {
209239
expect(compose).not.toContain("external: true")
210240
})
211241
).pipe(Effect.provide(NodeContext.layer)))
242+
243+
it.effect("appends the active public key to the managed authorized_keys file", () =>
244+
withTempDir((root) =>
245+
Effect.gen(function*(_) {
246+
const fs = yield* _(FileSystem.FileSystem)
247+
const path = yield* _(Path.Path)
248+
const homeDir = path.join(root, "home")
249+
const projectsRoot = path.join(homeDir, ".docker-git")
250+
const outDir = path.join(projectsRoot, "org", "repo")
251+
const authorizedKeysPath = path.join(projectsRoot, "authorized_keys")
252+
const sshPrivateKeyPath = path.join(homeDir, ".ssh", "id_ed25519")
253+
const sshPublicKeyPath = `${sshPrivateKeyPath}.pub`
254+
const staleKey = "ssh-ed25519 AAAA-stale stale@example\n"
255+
const currentKey = "ssh-ed25519 AAAA-current current@example\n"
256+
const globalConfig = makeGlobalConfig(projectsRoot, path)
257+
const projectConfig = {
258+
...makeProjectConfig(outDir, false, path),
259+
authorizedKeysPath: "../../authorized_keys"
260+
}
261+
262+
yield* _(fs.makeDirectory(path.dirname(authorizedKeysPath), { recursive: true }))
263+
yield* _(fs.makeDirectory(path.dirname(sshPrivateKeyPath), { recursive: true }))
264+
yield* _(fs.writeFileString(authorizedKeysPath, staleKey))
265+
yield* _(fs.writeFileString(sshPrivateKeyPath, "PRIVATE\n"))
266+
yield* _(fs.writeFileString(sshPublicKeyPath, currentKey))
267+
268+
yield* _(
269+
withPatchedEnv(
270+
{
271+
HOME: homeDir,
272+
DOCKER_GIT_PROJECTS_ROOT: projectsRoot,
273+
DOCKER_GIT_AUTHORIZED_KEYS: undefined,
274+
DOCKER_GIT_SSH_KEY: undefined
275+
},
276+
prepareProjectFiles(outDir, projectsRoot, globalConfig, projectConfig, {
277+
force: false,
278+
forceEnv: false
279+
})
280+
)
281+
)
282+
283+
const synchronizedAuthorizedKeys = yield* _(fs.readFileString(authorizedKeysPath))
284+
expect(synchronizedAuthorizedKeys).toContain(staleKey.trim())
285+
expect(synchronizedAuthorizedKeys).toContain(currentKey.trim())
286+
})
287+
).pipe(Effect.provide(NodeContext.layer)))
212288
})

0 commit comments

Comments
 (0)