@ozyman42/ozy-cli 0.4.8-windows-x64.0 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ozy +191 -0
- package/bin/ozy-signing-agent +191 -0
- package/bin/ozy-ssh-keygen +191 -0
- package/bin/ozy-virtual-security-key +191 -0
- package/dist/commands/git/hosts.js +18 -0
- package/dist/commands/git/hosts.js.map +1 -0
- package/dist/commands/git/index.js +8 -0
- package/dist/commands/git/index.js.map +1 -0
- package/dist/commands/git/setup.js +145 -0
- package/dist/commands/git/setup.js.map +1 -0
- package/dist/commands/index.js +13 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/npm/index.js +7 -0
- package/dist/commands/npm/index.js.map +1 -0
- package/dist/commands/npm/setup/git-remote.js +66 -0
- package/dist/commands/npm/setup/git-remote.js.map +1 -0
- package/dist/commands/npm/setup/index.js +168 -0
- package/dist/commands/npm/setup/index.js.map +1 -0
- package/dist/commands/npm/setup/npm-auth.js +62 -0
- package/dist/commands/npm/setup/npm-auth.js.map +1 -0
- package/dist/commands/npm/setup/package-json-validate.js +47 -0
- package/dist/commands/npm/setup/package-json-validate.js.map +1 -0
- package/dist/commands/npm/setup/package-registry.js +20 -0
- package/dist/commands/npm/setup/package-registry.js.map +1 -0
- package/dist/commands/npm/setup/setup.test.js +95 -0
- package/dist/commands/npm/setup/setup.test.js.map +1 -0
- package/dist/commands/npm/setup/trusted-publishing.js +84 -0
- package/dist/commands/npm/setup/trusted-publishing.js.map +1 -0
- package/dist/commands/npm/setup/workflow.js +19 -0
- package/dist/commands/npm/setup/workflow.js.map +1 -0
- package/dist/commands/ssh/get-sk-credential.js +104 -0
- package/dist/commands/ssh/get-sk-credential.js.map +1 -0
- package/dist/commands/ssh/index.js +7 -0
- package/dist/commands/ssh/index.js.map +1 -0
- package/dist/commands/upgrade.js +199 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/common/command.js +15 -0
- package/dist/common/command.js.map +1 -0
- package/dist/common/constants.js +25 -0
- package/dist/common/constants.js.map +1 -0
- package/dist/common/effective-modules-extensions.js +13 -0
- package/dist/common/effective-modules-extensions.js.map +1 -0
- package/dist/common/log.js +4 -0
- package/dist/common/log.js.map +1 -0
- package/dist/common/render-caller-tree.js +20 -0
- package/dist/common/render-caller-tree.js.map +1 -0
- package/dist/entrypoints/ozy-signing-agent.js +48 -0
- package/dist/entrypoints/ozy-signing-agent.js.map +1 -0
- package/dist/entrypoints/ozy-ssh-keygen.js +9 -0
- package/dist/entrypoints/ozy-ssh-keygen.js.map +1 -0
- package/dist/entrypoints/ozy-virtual-security-key.js +18 -0
- package/dist/entrypoints/ozy-virtual-security-key.js.map +1 -0
- package/dist/entrypoints/ozy.js +3 -0
- package/dist/entrypoints/ozy.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/thing.js +4 -0
- package/dist/internal/thing.js.map +1 -0
- package/dist/modules/cli/agent-client/impl.js +109 -0
- package/dist/modules/cli/agent-client/impl.js.map +1 -0
- package/dist/modules/cli/agent-client/interface.js +11 -0
- package/dist/modules/cli/agent-client/interface.js.map +1 -0
- package/dist/modules/cli/git/impl.js +124 -0
- package/dist/modules/cli/git/impl.js.map +1 -0
- package/dist/modules/cli/git/interface.js +3 -0
- package/dist/modules/cli/git/interface.js.map +1 -0
- package/dist/modules/cli/git/submodule/fix.js +257 -0
- package/dist/modules/cli/git/submodule/fix.js.map +1 -0
- package/dist/modules/cli/git/submodule/git-state.js +154 -0
- package/dist/modules/cli/git/submodule/git-state.js.map +1 -0
- package/dist/modules/cli/git/submodule/gitmodules.js +113 -0
- package/dist/modules/cli/git/submodule/gitmodules.js.map +1 -0
- package/dist/modules/cli/git/submodule/printer.js +232 -0
- package/dist/modules/cli/git/submodule/printer.js.map +1 -0
- package/dist/modules/cli/git/submodule/resolve-git-dir.js +16 -0
- package/dist/modules/cli/git/submodule/resolve-git-dir.js.map +1 -0
- package/dist/modules/cli/github/impl.js +137 -0
- package/dist/modules/cli/github/impl.js.map +1 -0
- package/dist/modules/cli/github/interface.js +2 -0
- package/dist/modules/cli/github/interface.js.map +1 -0
- package/dist/modules/cli/index.js +9 -0
- package/dist/modules/cli/index.js.map +1 -0
- package/dist/modules/common/crypto/impl.js +133 -0
- package/dist/modules/common/crypto/impl.js.map +1 -0
- package/dist/modules/common/crypto/interface.js +2 -0
- package/dist/modules/common/crypto/interface.js.map +1 -0
- package/dist/modules/common/index.js +10 -0
- package/dist/modules/common/index.js.map +1 -0
- package/dist/modules/common/kep-map-store/impl.js +42 -0
- package/dist/modules/common/kep-map-store/impl.js.map +1 -0
- package/dist/modules/common/kep-map-store/interface.js +2 -0
- package/dist/modules/common/kep-map-store/interface.js.map +1 -0
- package/dist/modules/common/os-platform/caller-info.js +132 -0
- package/dist/modules/common/os-platform/caller-info.js.map +1 -0
- package/dist/modules/common/os-platform/impl.js +95 -0
- package/dist/modules/common/os-platform/impl.js.map +1 -0
- package/dist/modules/common/os-platform/interface.js +2 -0
- package/dist/modules/common/os-platform/interface.js.map +1 -0
- package/dist/modules/common/os-platform/virtual-hid/index.js +12 -0
- package/dist/modules/common/os-platform/virtual-hid/index.js.map +1 -0
- package/dist/modules/common/os-platform/virtual-hid/linux.js +5 -0
- package/dist/modules/common/os-platform/virtual-hid/linux.js.map +1 -0
- package/dist/modules/common/os-platform/virtual-hid/mac.js +119 -0
- package/dist/modules/common/os-platform/virtual-hid/mac.js.map +1 -0
- package/dist/modules/common/os-platform/virtual-hid/windows.js +5 -0
- package/dist/modules/common/os-platform/virtual-hid/windows.js.map +1 -0
- package/dist/modules/common/ssh-config/impl.js +116 -0
- package/dist/modules/common/ssh-config/impl.js.map +1 -0
- package/dist/modules/common/ssh-config/interface.js +10 -0
- package/dist/modules/common/ssh-config/interface.js.map +1 -0
- package/dist/modules/ssh-agent/index.js +8 -0
- package/dist/modules/ssh-agent/index.js.map +1 -0
- package/dist/modules/ssh-agent/session/impl.js +265 -0
- package/dist/modules/ssh-agent/session/impl.js.map +1 -0
- package/dist/modules/ssh-agent/session/interface-rpc.js +20 -0
- package/dist/modules/ssh-agent/session/interface-rpc.js.map +1 -0
- package/dist/modules/ssh-agent/session/interface.js +32 -0
- package/dist/modules/ssh-agent/session/interface.js.map +1 -0
- package/dist/modules/ssh-agent/session/passkey-prf-page.js +204 -0
- package/dist/modules/ssh-agent/session/passkey-prf-page.js.map +1 -0
- package/dist/modules/ssh-agent/session/prf-flow.js +103 -0
- package/dist/modules/ssh-agent/session/prf-flow.js.map +1 -0
- package/dist/modules/ssh-agent/ssh-agent/impl.js +117 -0
- package/dist/modules/ssh-agent/ssh-agent/impl.js.map +1 -0
- package/dist/modules/ssh-agent/ssh-agent/interface.js +2 -0
- package/dist/modules/ssh-agent/ssh-agent/interface.js.map +1 -0
- package/dist/scripts/build.js +410 -0
- package/dist/scripts/build.js.map +1 -0
- package/dist/scripts/check-npm-version.js +8 -0
- package/dist/scripts/check-npm-version.js.map +1 -0
- package/dist/scripts/kill-existing-agent.js +21 -0
- package/dist/scripts/kill-existing-agent.js.map +1 -0
- package/dist/scripts/publish.js +46 -0
- package/dist/scripts/publish.js.map +1 -0
- package/package.json +52 -11
- package/src/commands/git/hosts.ts +20 -0
- package/src/commands/git/index.ts +9 -0
- package/src/commands/git/setup.ts +182 -0
- package/src/commands/index.ts +15 -0
- package/src/commands/npm/index.ts +8 -0
- package/src/commands/npm/setup/git-remote.ts +78 -0
- package/src/commands/npm/setup/index.ts +189 -0
- package/src/commands/npm/setup/npm-auth.ts +58 -0
- package/src/commands/npm/setup/package-json-validate.ts +68 -0
- package/src/commands/npm/setup/package-registry.ts +24 -0
- package/src/commands/npm/setup/publish-workflow.yml +35 -0
- package/src/commands/npm/setup/setup.test.ts +117 -0
- package/src/commands/npm/setup/trusted-publishing.ts +106 -0
- package/src/commands/npm/setup/workflow.ts +20 -0
- package/src/commands/ssh/get-sk-credential.ts +110 -0
- package/src/commands/ssh/index.ts +8 -0
- package/src/commands/upgrade.ts +220 -0
- package/src/common/command.ts +16 -0
- package/src/common/constants.ts +26 -0
- package/src/common/effective-modules-extensions.ts +24 -0
- package/src/common/log.ts +3 -0
- package/src/common/render-caller-tree.ts +22 -0
- package/src/entrypoints/ozy-signing-agent.ts +48 -0
- package/src/entrypoints/ozy-ssh-keygen.ts +10 -0
- package/src/entrypoints/ozy-virtual-security-key.ts +21 -0
- package/src/entrypoints/ozy.ts +2 -0
- package/src/index.ts +1 -0
- package/src/internal/thing.ts +3 -0
- package/src/modules/cli/agent-client/impl.ts +152 -0
- package/src/modules/cli/agent-client/interface.ts +17 -0
- package/src/modules/cli/git/impl.ts +133 -0
- package/src/modules/cli/git/interface.ts +23 -0
- package/src/modules/cli/git/submodule/fix.ts +298 -0
- package/src/modules/cli/git/submodule/git-state.ts +217 -0
- package/src/modules/cli/git/submodule/gitmodules.ts +126 -0
- package/src/modules/cli/git/submodule/printer.ts +256 -0
- package/src/modules/cli/git/submodule/resolve-git-dir.ts +14 -0
- package/src/modules/cli/github/impl.ts +169 -0
- package/src/modules/cli/github/interface.ts +29 -0
- package/src/modules/cli/index.ts +16 -0
- package/src/modules/common/crypto/impl.ts +173 -0
- package/src/modules/common/crypto/interface.ts +20 -0
- package/src/modules/common/index.ts +19 -0
- package/src/modules/common/kep-map-store/impl.ts +47 -0
- package/src/modules/common/kep-map-store/interface.ts +9 -0
- package/src/modules/common/os-platform/caller-info.ts +152 -0
- package/src/modules/common/os-platform/impl.ts +91 -0
- package/src/modules/common/os-platform/interface.ts +16 -0
- package/src/modules/common/os-platform/virtual-hid/index.ts +12 -0
- package/src/modules/common/os-platform/virtual-hid/linux.ts +7 -0
- package/src/modules/common/os-platform/virtual-hid/mac.ts +150 -0
- package/src/modules/common/os-platform/virtual-hid/windows.ts +7 -0
- package/src/modules/common/ssh-config/impl.ts +130 -0
- package/src/modules/common/ssh-config/interface.ts +33 -0
- package/src/modules/ssh-agent/index.ts +13 -0
- package/src/modules/ssh-agent/session/agent-notes.md +210 -0
- package/src/modules/ssh-agent/session/design.md +2 -0
- package/src/modules/ssh-agent/session/html-refactor.md +23 -0
- package/src/modules/ssh-agent/session/impl.ts +328 -0
- package/src/modules/ssh-agent/session/interface-rpc.ts +24 -0
- package/src/modules/ssh-agent/session/interface.ts +62 -0
- package/src/modules/ssh-agent/session/passkey-prf-page.ts +224 -0
- package/src/modules/ssh-agent/session/prf-flow.ts +152 -0
- package/src/modules/ssh-agent/ssh-agent/impl.ts +136 -0
- package/src/modules/ssh-agent/ssh-agent/interface.ts +9 -0
- package/src/scripts/build.ts +504 -0
- package/src/scripts/check-npm-version.ts +7 -0
- package/src/scripts/command-launcher.js.ejs +168 -0
- package/src/scripts/kill-existing-agent.ts +22 -0
- package/src/scripts/publish.ts +47 -0
- package/src/scripts/windows-bin-shims.md +1104 -0
- package/multi-call-binary.exe +0 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { interfaces } from "effective-modules";
|
|
2
|
+
import type { IOSPlatform } from "./os-platform/interface";
|
|
3
|
+
import type { IKeyMapStore } from "./kep-map-store/interface";
|
|
4
|
+
import type { ICrypto } from "./crypto/interface";
|
|
5
|
+
import type { ISSHConfig } from "./ssh-config/interface";
|
|
6
|
+
|
|
7
|
+
export enum CommonModules {
|
|
8
|
+
OSPlatform = "OSPlatform",
|
|
9
|
+
KeyMapStore = "KeyMapStore",
|
|
10
|
+
Crypto = "Crypto",
|
|
11
|
+
SSHConfig = "SSHConfig",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const commonModules = interfaces<CommonModules, {
|
|
15
|
+
OSPlatform: IOSPlatform;
|
|
16
|
+
KeyMapStore: IKeyMapStore;
|
|
17
|
+
Crypto: ICrypto;
|
|
18
|
+
SSHConfig: ISSHConfig;
|
|
19
|
+
}>(CommonModules);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Effect, Option, pipe } from "effect";
|
|
2
|
+
import { effunct, implementing, type EffectGen } from "effective-modules";
|
|
3
|
+
import { commonModules } from "..";
|
|
4
|
+
import { type IKeyMapStore } from "./interface";
|
|
5
|
+
import { SSHPubkey, CredentialId } from "@/modules/common/crypto/impl";
|
|
6
|
+
import { CRED_ID_COMMENT_PREFIX, FUTURE_TOOL_NAME } from "@/common/constants";
|
|
7
|
+
|
|
8
|
+
export class KeyMapStoreImpl extends implementing(commonModules.KeyMapStore).uses(commonModules.SSHConfig) implements IKeyMapStore {
|
|
9
|
+
private *listToolPubkeyFiles(): EffectGen<{name: string, content: string}[], string> {
|
|
10
|
+
const files = yield* effunct(this.dependencies.SSHConfig.listPubkeyFiles)();
|
|
11
|
+
return files.filter(f => f.name.startsWith(`${FUTURE_TOOL_NAME}-`));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
*listPubkeys(): EffectGen<SSHPubkey[], string> {
|
|
15
|
+
const files = yield* effunct(this.listToolPubkeyFiles)();
|
|
16
|
+
return files.map(({ content }) => {
|
|
17
|
+
const pubkeyAuth = content.split(' ').slice(0, 2).join(' ');
|
|
18
|
+
return SSHPubkey.fromAuthorizedKey(pubkeyAuth);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
*getCredentialByPubkey(pubkey: SSHPubkey): EffectGen<Option.Option<string>, string> {
|
|
23
|
+
const files = yield* effunct(this.listToolPubkeyFiles)();
|
|
24
|
+
const match = files.find(({ content }) => {
|
|
25
|
+
const pubkeyAuth = content.split(' ').slice(0, 2).join(' ');
|
|
26
|
+
return pubkeyAuth === pubkey.authorizedKey;
|
|
27
|
+
});
|
|
28
|
+
if (!match) return Option.none();
|
|
29
|
+
const parts = match.content.split(' ');
|
|
30
|
+
const comment = parts[2];
|
|
31
|
+
if (!comment || !comment.startsWith(CRED_ID_COMMENT_PREFIX))
|
|
32
|
+
return yield* Effect.fail(`InvalidPubkeyFileError: file for pubkey ${pubkey.authorizedKey} has no valid credentialId comment`);
|
|
33
|
+
return Option.some(comment.slice(CRED_ID_COMMENT_PREFIX.length));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
*addKey(pubkey: SSHPubkey, credentialId: string): EffectGen<void, string> {
|
|
37
|
+
const name = new CredentialId(credentialId).humanReadableName;
|
|
38
|
+
yield* pipe(
|
|
39
|
+
effunct(this.dependencies.SSHConfig.writePubkey)({
|
|
40
|
+
name,
|
|
41
|
+
comment: Option.some(`${CRED_ID_COMMENT_PREFIX}${credentialId}`),
|
|
42
|
+
pubkey,
|
|
43
|
+
}),
|
|
44
|
+
Effect.asVoid
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
import type { EffectGen } from "effective-modules";
|
|
3
|
+
import type { SSHPubkey } from "@/modules/common/crypto/impl";
|
|
4
|
+
|
|
5
|
+
export interface IKeyMapStore {
|
|
6
|
+
listPubkeys(): EffectGen<SSHPubkey[], string>;
|
|
7
|
+
getCredentialByPubkey(pubkey: SSHPubkey): EffectGen<Option.Option<string>, string>;
|
|
8
|
+
addKey(pubkey: SSHPubkey, credentialId: string): EffectGen<void, string>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { readFileSync, readlinkSync } from "node:fs";
|
|
4
|
+
import { Option } from "effect";
|
|
5
|
+
import type { CallerProcess } from "./interface";
|
|
6
|
+
|
|
7
|
+
const isMacOS = process.platform === 'darwin';
|
|
8
|
+
|
|
9
|
+
// ---- FFI: getsockopt ----
|
|
10
|
+
|
|
11
|
+
const SOL_LOCAL = 0; // macOS: SOL_LOCAL (sys/un.h)
|
|
12
|
+
const LOCAL_PEERPID = 2; // macOS: LOCAL_PEERPID (sys/un.h)
|
|
13
|
+
const SOL_SOCKET_LINUX = 1; // Linux: SOL_SOCKET
|
|
14
|
+
const SO_PEERCRED_LINUX = 17; // Linux: SO_PEERCRED
|
|
15
|
+
|
|
16
|
+
type GetsockoptFn = (fd: number, level: number, optname: number, optval: Uint8Array, optlen: Uint32Array) => number;
|
|
17
|
+
let _getsockopt: GetsockoptFn | undefined;
|
|
18
|
+
|
|
19
|
+
function getsockoptSymbol(): GetsockoptFn {
|
|
20
|
+
if (!_getsockopt) {
|
|
21
|
+
const libName = isMacOS ? 'libSystem.B.dylib' : 'libc.so.6';
|
|
22
|
+
const lib = dlopen(libName, {
|
|
23
|
+
getsockopt: {
|
|
24
|
+
args: [FFIType.int, FFIType.int, FFIType.int, FFIType.ptr, FFIType.ptr],
|
|
25
|
+
returns: FFIType.int,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
_getsockopt = lib.symbols['getsockopt'] as unknown as GetsockoptFn;
|
|
29
|
+
}
|
|
30
|
+
return _getsockopt;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getPeerPidFromFd(fd: number): number {
|
|
34
|
+
const getsockopt = getsockoptSymbol();
|
|
35
|
+
if (isMacOS) {
|
|
36
|
+
const pidBuf = new Uint8Array(4);
|
|
37
|
+
const lenBuf = new Uint32Array([4]);
|
|
38
|
+
const ret = getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, pidBuf, lenBuf);
|
|
39
|
+
if (ret !== 0) throw new Error(`getsockopt(LOCAL_PEERPID) failed with ret=${ret}`);
|
|
40
|
+
return new DataView(pidBuf.buffer).getInt32(0, true);
|
|
41
|
+
} else {
|
|
42
|
+
// Linux: struct ucred { pid_t pid; uid_t uid; gid_t gid; } = 12 bytes
|
|
43
|
+
const credBuf = new Uint8Array(12);
|
|
44
|
+
const lenBuf = new Uint32Array([12]);
|
|
45
|
+
const ret = getsockopt(fd, SOL_SOCKET_LINUX, SO_PEERCRED_LINUX, credBuf, lenBuf);
|
|
46
|
+
if (ret !== 0) throw new Error(`getsockopt(SO_PEERCRED) failed with ret=${ret}`);
|
|
47
|
+
return new DataView(credBuf.buffer).getInt32(0, true);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Per-PID info resolution ----
|
|
52
|
+
|
|
53
|
+
interface ProcessInfo {
|
|
54
|
+
ppid: number;
|
|
55
|
+
command: string;
|
|
56
|
+
cwd: Option.Option<string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getProcessInfoMacOS(pid: number): ProcessInfo {
|
|
60
|
+
let psOut: string;
|
|
61
|
+
try {
|
|
62
|
+
psOut = execSync(`ps -p ${pid} -o ppid= -o args=`, { encoding: 'utf-8' }).trim();
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`PID ${pid} exited before caller info could be read`);
|
|
65
|
+
}
|
|
66
|
+
const firstSpace = psOut.indexOf(' ');
|
|
67
|
+
if (firstSpace < 0) throw new Error(`Unexpected ps output for PID ${pid}: "${psOut}"`);
|
|
68
|
+
const ppid = parseInt(psOut.slice(0, firstSpace).trim(), 10);
|
|
69
|
+
const command = psOut.slice(firstSpace + 1).trim();
|
|
70
|
+
|
|
71
|
+
// lsof exits non-zero on macOS when it hits permission errors on other fds even if it
|
|
72
|
+
// successfully located the cwd entry, so we check output regardless of exit code.
|
|
73
|
+
const lsofResult = Bun.spawnSync(['lsof', '-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
|
|
74
|
+
const lsofOut = lsofResult.stdout.toString().trim();
|
|
75
|
+
const cwdLine = lsofOut.split('\n').find(l => l.startsWith('n'));
|
|
76
|
+
const cwd = cwdLine ? Option.some(cwdLine.slice(1)) : Option.none();
|
|
77
|
+
|
|
78
|
+
return { ppid, command, cwd };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getProcessInfoLinux(pid: number): ProcessInfo {
|
|
82
|
+
const rawCmdline = readFileSync(`/proc/${pid}/cmdline`);
|
|
83
|
+
const command = rawCmdline.toString().replace(/\0+$/, '').split('\0').join(' ');
|
|
84
|
+
if (!command) throw new Error(`Empty cmdline for PID ${pid}`);
|
|
85
|
+
|
|
86
|
+
let cwd: Option.Option<string>;
|
|
87
|
+
try {
|
|
88
|
+
cwd = Option.some(readlinkSync(`/proc/${pid}/cwd`));
|
|
89
|
+
} catch {
|
|
90
|
+
cwd = Option.none();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const status = readFileSync(`/proc/${pid}/status`, 'utf-8');
|
|
94
|
+
const ppidMatch = status.match(/^PPid:\s+(\d+)/m);
|
|
95
|
+
if (!ppidMatch) throw new Error(`No PPid in /proc/${pid}/status`);
|
|
96
|
+
const ppid = parseInt(ppidMatch[1], 10);
|
|
97
|
+
|
|
98
|
+
return { ppid, command, cwd };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getProcessInfoWindows(pid: number): ProcessInfo {
|
|
102
|
+
const infoOut = execSync(
|
|
103
|
+
`powershell -NoProfile -Command "` +
|
|
104
|
+
`$p = Get-WmiObject Win32_Process -Filter 'ProcessId=${pid}'; ` +
|
|
105
|
+
`if ($p) { Write-Output \\\"$($p.ParentProcessId)|$($p.CommandLine)\\\" }"`,
|
|
106
|
+
{ encoding: 'utf-8' }
|
|
107
|
+
).trim();
|
|
108
|
+
if (!infoOut) throw new Error(`WMI returned no result for PID ${pid}`);
|
|
109
|
+
const pipeIdx = infoOut.indexOf('|');
|
|
110
|
+
if (pipeIdx < 0) throw new Error(`Unexpected WMI output for PID ${pid}: "${infoOut}"`);
|
|
111
|
+
const ppid = parseInt(infoOut.slice(0, pipeIdx), 10);
|
|
112
|
+
const command = infoOut.slice(pipeIdx + 1);
|
|
113
|
+
|
|
114
|
+
return { ppid, command, cwd: Option.none() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Chain walking ----
|
|
118
|
+
|
|
119
|
+
function walkChain(initialPid: number, getInfo: (pid: number) => ProcessInfo): CallerProcess[] {
|
|
120
|
+
const chain: CallerProcess[] = [];
|
|
121
|
+
let pid = initialPid;
|
|
122
|
+
const visited = new Set<number>();
|
|
123
|
+
let isFirst = true;
|
|
124
|
+
while (pid > 0 && !visited.has(pid)) {
|
|
125
|
+
visited.add(pid);
|
|
126
|
+
let info: ProcessInfo;
|
|
127
|
+
try {
|
|
128
|
+
info = getInfo(pid);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (isFirst) throw e;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
chain.push({ pid, command: info.command, directory: info.cwd });
|
|
134
|
+
pid = info.ppid;
|
|
135
|
+
isFirst = false;
|
|
136
|
+
}
|
|
137
|
+
// Reverse so chain[0] = oldest ancestor, chain[N-1] = direct caller.
|
|
138
|
+
// This matches the display logic in passkey-prf-page.ts which skips the common
|
|
139
|
+
// ancestor prefix to show the relevant tail (git, ssh, etc.).
|
|
140
|
+
return chain.reverse();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- Public API ----
|
|
144
|
+
|
|
145
|
+
export function resolveCallerChainUnix(fd: number): CallerProcess[] {
|
|
146
|
+
const pid = getPeerPidFromFd(fd);
|
|
147
|
+
return walkChain(pid, isMacOS ? getProcessInfoMacOS : getProcessInfoLinux);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resolveCallerChainWindows(remotePid: number): CallerProcess[] {
|
|
151
|
+
return walkChain(remotePid, getProcessInfoWindows);
|
|
152
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
3
|
+
import * as net from "node:net";
|
|
4
|
+
import { effunct, implementing, type EffectGen } from "effective-modules";
|
|
5
|
+
import type { IOSPlatform, CallerProcess } from "./interface";
|
|
6
|
+
import { resolveCallerChainUnix, resolveCallerChainWindows } from "./caller-info";
|
|
7
|
+
import { commonModules } from "@/modules/common";
|
|
8
|
+
import { registerVirtualHID } from "./virtual-hid";
|
|
9
|
+
import { log } from "@/common/log";
|
|
10
|
+
|
|
11
|
+
const isWindows = process.platform === 'win32';
|
|
12
|
+
|
|
13
|
+
function openBrowserCommand(url: string): string[] {
|
|
14
|
+
if (process.platform === 'darwin') return ['open', url];
|
|
15
|
+
if (process.platform === 'linux') return ['xdg-open', url];
|
|
16
|
+
// `start` is a cmd.exe builtin. The empty string is the window title slot.
|
|
17
|
+
if (isWindows) ['cmd.exe', '/c', 'start', '', url];
|
|
18
|
+
// TODO: use exhaustive matching
|
|
19
|
+
throw new Error("Unknown platform");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function openBrowserWindow(url: string): Effect.Effect<void, never> {
|
|
23
|
+
return Effect.sync(() => { Bun.spawnSync(openBrowserCommand(url)); });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeRestrictedFile(path: string, content: string): Effect.Effect<void, string> {
|
|
27
|
+
return Effect.try({
|
|
28
|
+
try: () => {
|
|
29
|
+
if (isWindows) {
|
|
30
|
+
writeFileSync(path, content);
|
|
31
|
+
const result = Bun.spawnSync(['icacls', path, '/inheritance:r', '/grant:r', `${process.env['USERNAME']}:(R,W)`]);
|
|
32
|
+
if (result.exitCode !== 0)
|
|
33
|
+
throw new Error(`icacls failed with exit code ${result.exitCode}`);
|
|
34
|
+
} else {
|
|
35
|
+
writeFileSync(path, content, { mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
catch: (e) => `Failed to write restricted file ${path}: ${e instanceof Error ? e.message : String(e)}`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeConnectionHandler(onData: (data: Buffer, callerChain: CallerProcess[]) => EffectGen<Buffer>, resolveChain: (socket: net.Socket) => CallerProcess[]) {
|
|
43
|
+
return (socket: net.Socket) => {
|
|
44
|
+
let callerChain: CallerProcess[];
|
|
45
|
+
try {
|
|
46
|
+
callerChain = resolveChain(socket);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
log(`[socket-server] ERROR: Failed to get caller info — closing connection. Reason: ${e instanceof Error ? e.message : String(e)}`);
|
|
49
|
+
socket.destroy();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
socket.on('data', async (data) => {
|
|
53
|
+
try {
|
|
54
|
+
socket.write(await Effect.runPromise(effunct(onData)(data, callerChain)));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
log(`[socket-server] ERROR in onData: ${e instanceof Error ? e.message : String(e)}`);
|
|
57
|
+
socket.destroy();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class OSPlatformImpl extends implementing(commonModules.OSPlatform) implements IOSPlatform {
|
|
64
|
+
openBrowserWindow(url: string) { return openBrowserWindow(url); }
|
|
65
|
+
writeRestrictedFile(path: string, content: string) { return writeRestrictedFile(path, content); }
|
|
66
|
+
*startSocketServer(socketPath: string, onData: (data: Buffer, callerChain: CallerProcess[]) => EffectGen<Buffer>): EffectGen<void, string> {
|
|
67
|
+
return yield* Effect.try({
|
|
68
|
+
try: () => {
|
|
69
|
+
if (existsSync(socketPath)) unlinkSync(socketPath);
|
|
70
|
+
if (isWindows) {
|
|
71
|
+
const pipePath = `\\\\.\\pipe\\${socketPath.replace(/[/\\:]/g, '-')}`;
|
|
72
|
+
const server = net.createServer(makeConnectionHandler(onData, (socket) => {
|
|
73
|
+
const remotePid = (socket as any)._handle?.remotePid as number | undefined;
|
|
74
|
+
if (remotePid == null) throw new Error('socket._handle.remotePid unavailable on this Windows/Bun version');
|
|
75
|
+
return resolveCallerChainWindows(remotePid);
|
|
76
|
+
}));
|
|
77
|
+
server.listen(pipePath);
|
|
78
|
+
} else {
|
|
79
|
+
const server = net.createServer(makeConnectionHandler(onData, (socket) => {
|
|
80
|
+
const fd = (socket as any)._handle?.fd as number | undefined;
|
|
81
|
+
if (fd == null) throw new Error('socket._handle.fd unavailable — cannot resolve peer PID');
|
|
82
|
+
return resolveCallerChainUnix(fd);
|
|
83
|
+
}));
|
|
84
|
+
server.listen(socketPath);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
catch: (e) => `Failed to start socket server at ${socketPath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
registerVirtualHID(onMessage: (data: Buffer) => Effect.Effect<Buffer>) { return registerVirtualHID(onMessage); }
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Option } from "effect";
|
|
3
|
+
import type { EffectGen } from "effective-modules";
|
|
4
|
+
|
|
5
|
+
export interface CallerProcess {
|
|
6
|
+
pid: number;
|
|
7
|
+
command: string;
|
|
8
|
+
directory: Option.Option<string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface IOSPlatform {
|
|
12
|
+
openBrowserWindow(url: string): Effect.Effect<void, never>;
|
|
13
|
+
writeRestrictedFile(path: string, content: string): Effect.Effect<void, string>;
|
|
14
|
+
startSocketServer(socketPath: string, onData: (data: Buffer, callerChain: CallerProcess[]) => EffectGen<Buffer>): EffectGen<void, string>;
|
|
15
|
+
registerVirtualHID(onMessage: (data: Buffer) => Effect.Effect<Buffer>): Effect.Effect<void, string>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { registerVirtualHIDMac } from "./mac";
|
|
3
|
+
import { registerVirtualHIDLinux } from "./linux";
|
|
4
|
+
import { registerVirtualHIDWindows } from "./windows";
|
|
5
|
+
|
|
6
|
+
export function registerVirtualHID(
|
|
7
|
+
onMessage: (data: Buffer) => Effect.Effect<Buffer>
|
|
8
|
+
): Effect.Effect<void, string> {
|
|
9
|
+
if (process.platform === 'darwin') return registerVirtualHIDMac(onMessage);
|
|
10
|
+
if (process.platform === 'win32') return registerVirtualHIDWindows(onMessage);
|
|
11
|
+
return registerVirtualHIDLinux(onMessage);
|
|
12
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Effect, Option, pipe } from "effect";
|
|
2
|
+
import { JSCallback, dlopen, FFIType, ptr, toArrayBuffer } from "bun:ffi";
|
|
3
|
+
import type { Pointer } from "bun:ffi";
|
|
4
|
+
|
|
5
|
+
// FIDO Alliance usage page (0xF1D0), 64-byte in/out reports, no report IDs
|
|
6
|
+
const FIDO2_HID_DESCRIPTOR = new Uint8Array([
|
|
7
|
+
0x06, 0xD0, 0xF1, // Usage Page (FIDO Alliance 0xF1D0, little-endian)
|
|
8
|
+
0x09, 0x01, // Usage (U2F HID Authenticator Device)
|
|
9
|
+
0xA1, 0x01, // Collection (Application)
|
|
10
|
+
0x09, 0x20, // Usage (Input report data)
|
|
11
|
+
0x15, 0x00, // Logical Minimum (0)
|
|
12
|
+
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
|
13
|
+
0x75, 0x08, // Report Size (8 bits)
|
|
14
|
+
0x95, 0x40, // Report Count (64)
|
|
15
|
+
0x81, 0x02, // Input (Data, Variable, Absolute)
|
|
16
|
+
0x09, 0x21, // Usage (Output report data)
|
|
17
|
+
0x15, 0x00, // Logical Minimum (0)
|
|
18
|
+
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
|
19
|
+
0x75, 0x08, // Report Size (8 bits)
|
|
20
|
+
0x95, 0x40, // Report Count (64)
|
|
21
|
+
0x91, 0x02, // Output (Data, Variable, Absolute)
|
|
22
|
+
0xC0, // End Collection
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const kIOHIDReportTypeOutput = 1;
|
|
26
|
+
const kIOReturnSuccess = 0;
|
|
27
|
+
const kCFStringEncodingUTF8 = 0x08000100;
|
|
28
|
+
const kCFPropertyListImmutable = 0;
|
|
29
|
+
|
|
30
|
+
// Kept at module scope to prevent GC while the virtual device is running
|
|
31
|
+
let _vhidCallback: JSCallback | null = null;
|
|
32
|
+
_vhidCallback;
|
|
33
|
+
|
|
34
|
+
const fromPtr = (label: string, p: Pointer | null): Effect.Effect<Pointer, string> =>
|
|
35
|
+
pipe(
|
|
36
|
+
Option.fromNullishOr(p),
|
|
37
|
+
Effect.fromOption,
|
|
38
|
+
Effect.mapError(() => `${label} returned NULL`),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Build the device properties as an XML plist. CFPropertyListCreateWithData parses this
|
|
42
|
+
// into a fully-initialized CFDictionary (with correct hash/equal callbacks), avoiding
|
|
43
|
+
// the need to pass raw CFDictionaryKeyCallBacks structs via FFI.
|
|
44
|
+
function buildDevicePropertiesPlist(): Uint8Array {
|
|
45
|
+
const descriptorB64 = Buffer.from(FIDO2_HID_DESCRIPTOR).toString('base64');
|
|
46
|
+
const xml = [
|
|
47
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
48
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
49
|
+
'<plist version="1.0">',
|
|
50
|
+
'<dict>',
|
|
51
|
+
' <key>ReportDescriptor</key>',
|
|
52
|
+
` <data>${descriptorB64}</data>`,
|
|
53
|
+
' <key>VendorID</key>',
|
|
54
|
+
' <integer>4617</integer>', // 0x1209 - pid.codes open-source VID
|
|
55
|
+
' <key>ProductID</key>',
|
|
56
|
+
' <integer>1</integer>',
|
|
57
|
+
' <key>VersionNumber</key>',
|
|
58
|
+
' <integer>256</integer>', // 0x0100
|
|
59
|
+
' <key>Product</key>',
|
|
60
|
+
' <string>Ozy Virtual FIDO2 Key</string>',
|
|
61
|
+
'</dict>',
|
|
62
|
+
'</plist>',
|
|
63
|
+
].join('\n');
|
|
64
|
+
return new TextEncoder().encode(xml);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function registerVirtualHIDMac(
|
|
68
|
+
onMessage: (data: Buffer) => Effect.Effect<Buffer>
|
|
69
|
+
): Effect.Effect<void, string> {
|
|
70
|
+
return Effect.gen(function* () {
|
|
71
|
+
console.log('[vhid] loading frameworks');
|
|
72
|
+
|
|
73
|
+
const IOKit = yield* Effect.try({
|
|
74
|
+
try: () => dlopen('/System/Library/Frameworks/IOKit.framework/IOKit', {
|
|
75
|
+
IOHIDUserDeviceCreate: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
|
|
76
|
+
IOHIDUserDeviceRegisterSetReportCallback: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.void },
|
|
77
|
+
IOHIDUserDeviceScheduleWithRunLoop: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.void },
|
|
78
|
+
IOHIDUserDeviceHandleReport: { args: [FFIType.ptr, FFIType.ptr, FFIType.i64], returns: FFIType.i32 },
|
|
79
|
+
}),
|
|
80
|
+
catch: (e) => `Failed to load IOKit: ${e}`,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const CF = yield* Effect.try({
|
|
84
|
+
try: () => dlopen('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation', {
|
|
85
|
+
CFRunLoopGetCurrent: { args: [], returns: FFIType.ptr },
|
|
86
|
+
CFRunLoopRunInMode: { args: [FFIType.ptr, FFIType.f64, FFIType.bool], returns: FFIType.i32 },
|
|
87
|
+
CFStringCreateWithCString: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.ptr },
|
|
88
|
+
CFDataCreate: { args: [FFIType.ptr, FFIType.ptr, FFIType.i64], returns: FFIType.ptr },
|
|
89
|
+
CFPropertyListCreateWithData: { args: [FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
|
|
90
|
+
CFRelease: { args: [FFIType.ptr], returns: FFIType.void },
|
|
91
|
+
}),
|
|
92
|
+
catch: (e) => `Failed to load CoreFoundation: ${e}`,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
console.log('[vhid] frameworks loaded');
|
|
96
|
+
|
|
97
|
+
const encoder = new TextEncoder();
|
|
98
|
+
|
|
99
|
+
const makeCFString = (s: string): Effect.Effect<Pointer, string> =>
|
|
100
|
+
// TextEncoder gives a Uint8Array with its own ArrayBuffer (byteOffset=0, pool-safe)
|
|
101
|
+
fromPtr(`CFStringCreateWithCString("${s}")`,
|
|
102
|
+
CF.symbols.CFStringCreateWithCString(null, ptr(encoder.encode(s + '\0')), kCFStringEncodingUTF8));
|
|
103
|
+
|
|
104
|
+
const makeCFData = (data: Uint8Array): Effect.Effect<Pointer, string> =>
|
|
105
|
+
fromPtr('CFDataCreate', CF.symbols.CFDataCreate(null, ptr(data), BigInt(data.length)));
|
|
106
|
+
|
|
107
|
+
console.log('[vhid] building device properties plist');
|
|
108
|
+
const plistBytes = buildDevicePropertiesPlist();
|
|
109
|
+
const plistData = yield* makeCFData(plistBytes);
|
|
110
|
+
|
|
111
|
+
const dict = yield* fromPtr('CFPropertyListCreateWithData',
|
|
112
|
+
CF.symbols.CFPropertyListCreateWithData(null, plistData, BigInt(kCFPropertyListImmutable), null, null));
|
|
113
|
+
CF.symbols.CFRelease(plistData);
|
|
114
|
+
console.log('[vhid] device properties dict =', dict);
|
|
115
|
+
|
|
116
|
+
console.log('[vhid] creating IOHIDUserDevice');
|
|
117
|
+
const device = yield* fromPtr('IOHIDUserDeviceCreate', IOKit.symbols.IOHIDUserDeviceCreate(null, dict));
|
|
118
|
+
CF.symbols.CFRelease(dict);
|
|
119
|
+
console.log('[vhid] IOHIDUserDeviceCreate =', device);
|
|
120
|
+
|
|
121
|
+
const callback = yield* Effect.try({
|
|
122
|
+
try: () => new JSCallback(
|
|
123
|
+
(_refcon: number, _result: number, _sender: number, type: number, _reportID: number, report: number, reportLength: number) => {
|
|
124
|
+
if (type !== kIOHIDReportTypeOutput) return kIOReturnSuccess;
|
|
125
|
+
const incoming = Buffer.from(toArrayBuffer(report as unknown as Pointer, 0, reportLength));
|
|
126
|
+
console.log(`[vhid] received ${incoming.length} bytes: ${incoming.toString('hex')}`);
|
|
127
|
+
Effect.runPromise(onMessage(incoming)).then(response => {
|
|
128
|
+
const responseBuf = new Uint8Array(64);
|
|
129
|
+
response.copy(Buffer.from(responseBuf.buffer), 0, 0, Math.min(response.length, 64));
|
|
130
|
+
IOKit.symbols.IOHIDUserDeviceHandleReport(device, ptr(responseBuf), BigInt(responseBuf.length));
|
|
131
|
+
}).catch(() => {});
|
|
132
|
+
return kIOReturnSuccess;
|
|
133
|
+
},
|
|
134
|
+
{ returns: 'i32', args: ['ptr', 'i32', 'ptr', 'i32', 'u32', 'ptr', 'i64'] },
|
|
135
|
+
),
|
|
136
|
+
catch: (e) => `Failed to create JSCallback: ${e}`,
|
|
137
|
+
});
|
|
138
|
+
_vhidCallback = callback;
|
|
139
|
+
|
|
140
|
+
IOKit.symbols.IOHIDUserDeviceRegisterSetReportCallback(device, callback.ptr, null);
|
|
141
|
+
|
|
142
|
+
const runLoop = CF.symbols.CFRunLoopGetCurrent();
|
|
143
|
+
const runLoopMode = yield* makeCFString('kCFRunLoopDefaultMode');
|
|
144
|
+
IOKit.symbols.IOHIDUserDeviceScheduleWithRunLoop(device, runLoop, runLoopMode);
|
|
145
|
+
|
|
146
|
+
console.log('[vhid] virtual FIDO2 device active, pumping run loop');
|
|
147
|
+
// Pump CFRunLoop from Bun's libuv event loop without blocking
|
|
148
|
+
setInterval(() => CF.symbols.CFRunLoopRunInMode(runLoopMode, 0.0, false), 10);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import { Effect, Option, Schema } from "effect";
|
|
5
|
+
import SSHConfig, { LineType } from "ssh-config";
|
|
6
|
+
import expandTilde from "expand-tilde";
|
|
7
|
+
import { implementing, type EffectGen } from "effective-modules";
|
|
8
|
+
import { commonModules } from "@/modules/common";
|
|
9
|
+
|
|
10
|
+
import { SSHConfigSection, type ISSHConfig, type SSHConfigHosts, type WriteHostInput, type WritePubkeyInput } from "./interface";
|
|
11
|
+
|
|
12
|
+
const SSH_CONFIG_PATH = "~/.ssh/config";
|
|
13
|
+
const SSH_DIR = "~/.ssh";
|
|
14
|
+
|
|
15
|
+
export class SSHConfigImpl extends implementing(commonModules.SSHConfig) implements ISSHConfig {
|
|
16
|
+
private *loadSSHConfigRaw(): EffectGen<SSHConfig, string> {
|
|
17
|
+
const configPath = expandTilde(SSH_CONFIG_PATH);
|
|
18
|
+
const exists = yield* Effect.promise(() => fs.exists(configPath));
|
|
19
|
+
const text = exists
|
|
20
|
+
? yield* Effect.tryPromise({
|
|
21
|
+
try: () => fs.readFile(configPath, "utf8"),
|
|
22
|
+
catch: (e) => `Failed to read ssh config at ${configPath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
23
|
+
})
|
|
24
|
+
: "";
|
|
25
|
+
return yield* Effect.try({
|
|
26
|
+
try: () => SSHConfig.parse(text),
|
|
27
|
+
catch: (e) => `ParseError: unable to parse ssh config at ${configPath}\n${e instanceof Error ? e.message : String(e)}`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
*getSSHConfig(): EffectGen<SSHConfigHosts, string> {
|
|
32
|
+
const configPath = expandTilde(SSH_CONFIG_PATH);
|
|
33
|
+
const exists = yield* Effect.promise(() => fs.exists(configPath));
|
|
34
|
+
if (!exists)
|
|
35
|
+
return yield* Effect.fail(`SSHConfigFileMissingError: No file found at ${configPath}`);
|
|
36
|
+
|
|
37
|
+
const parsed = yield* this.loadSSHConfigRaw();
|
|
38
|
+
|
|
39
|
+
const hosts: SSHConfigHosts = {};
|
|
40
|
+
for (const line of parsed) {
|
|
41
|
+
if (line.type === LineType.COMMENT) continue;
|
|
42
|
+
const { param, value: host } = line;
|
|
43
|
+
if (param !== "Host")
|
|
44
|
+
return yield* Effect.fail(`EntryNotAHostKeyValuePairError: unexpected top-level entry '${param}=${JSON.stringify(host)}'`);
|
|
45
|
+
if (typeof host !== "string")
|
|
46
|
+
return yield* Effect.fail(`EntryNotAHostKeyValuePairError: Host value is not a string: ${JSON.stringify(host)}`);
|
|
47
|
+
if (!("config" in line))
|
|
48
|
+
return yield* Effect.fail(`EntryNotAHostKeyValuePairError: non-section Host entry '${host}'`);
|
|
49
|
+
if (host in hosts)
|
|
50
|
+
return yield* Effect.fail(`DuplicateHostError: duplicate Host '${host}' in ${configPath}`);
|
|
51
|
+
|
|
52
|
+
const kv: Record<string, string> = {};
|
|
53
|
+
for (const inner of line.config) {
|
|
54
|
+
if (inner.type === LineType.COMMENT) continue;
|
|
55
|
+
if (typeof inner.value !== "string")
|
|
56
|
+
return yield* Effect.fail(`MalformedError: Host '${host}' key '${inner.param}' has non-string value`);
|
|
57
|
+
kv[inner.param] = inner.value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const section = yield* Schema.decodeUnknownEffect(SSHConfigSection)(kv).pipe(
|
|
61
|
+
Effect.mapError(e => `MalformedError: malformed section at Host '${host}': ${String(e)}`)
|
|
62
|
+
);
|
|
63
|
+
hosts[host] = section;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return hosts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
*writeHost({ section }: WriteHostInput): EffectGen<void, string> {
|
|
70
|
+
const configPath = expandTilde(SSH_CONFIG_PATH);
|
|
71
|
+
const cfg = yield* this.loadSSHConfigRaw();
|
|
72
|
+
const newEntries = Object.entries(section)
|
|
73
|
+
.filter(([, v]) => v !== undefined)
|
|
74
|
+
.map(([k, v]) => ({ type: LineType.DIRECTIVE, before: ' ', param: k, separator: ' ', value: v as string, after: '\n' }));
|
|
75
|
+
|
|
76
|
+
const existingIdx = cfg.findIndex((e: any) => e.param === "Host" && e.value === section.HostName);
|
|
77
|
+
if (existingIdx >= 0) {
|
|
78
|
+
(cfg[existingIdx] as any).config = newEntries;
|
|
79
|
+
} else {
|
|
80
|
+
const record: Record<string, string> = { Host: section.HostName };
|
|
81
|
+
for (const [k, v] of Object.entries(section)) {
|
|
82
|
+
if (v !== undefined) record[k] = v;
|
|
83
|
+
}
|
|
84
|
+
cfg.append(record);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
yield* Effect.try({
|
|
88
|
+
try: () => writeFileSync(configPath, SSHConfig.stringify(cfg), { mode: 0o600 }),
|
|
89
|
+
catch: (e) => `Failed to write ssh config: ${e instanceof Error ? e.message : String(e)}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
*listPubkeyFiles(): EffectGen<{name: string, content: string}[], string> {
|
|
94
|
+
const sshDir = expandTilde(SSH_DIR);
|
|
95
|
+
const entries = yield* Effect.tryPromise({
|
|
96
|
+
try: () => fs.readdir(sshDir),
|
|
97
|
+
catch: (e) => `Failed to read ${sshDir}: ${e instanceof Error ? e.message : String(e)}`,
|
|
98
|
+
});
|
|
99
|
+
const pubkeyFiles = entries.filter(f => f.endsWith('.pub'));
|
|
100
|
+
const results: {name: string, content: string}[] = [];
|
|
101
|
+
for (const file of pubkeyFiles) {
|
|
102
|
+
const content = yield* Effect.tryPromise({
|
|
103
|
+
try: () => fs.readFile(join(sshDir, file), 'utf8'),
|
|
104
|
+
catch: (e) => `Failed to read ${file}: ${e instanceof Error ? e.message : String(e)}`,
|
|
105
|
+
});
|
|
106
|
+
results.push({ name: file.slice(0, -'.pub'.length), content: content.trim() });
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getPubkeyPath(name: string): string {
|
|
112
|
+
return `${expandTilde(SSH_DIR)}/${name}.pub`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
*writePubkey({ name, comment, pubkey }: WritePubkeyInput): EffectGen<{pubkeyPath: string}, string> {
|
|
116
|
+
const sshDir = expandTilde(SSH_DIR);
|
|
117
|
+
const pubkeyPath = `${sshDir}/${name}.pub`;
|
|
118
|
+
const content = Option.match(comment, {
|
|
119
|
+
onNone: () => pubkey.authorizedKey,
|
|
120
|
+
onSome: (c) => `${pubkey.authorizedKey} ${c}`,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
yield* Effect.try({
|
|
124
|
+
try: () => writeFileSync(pubkeyPath, content + "\n", { mode: 0o600 }),
|
|
125
|
+
catch: (e) => `Failed to write pubkey to ${pubkeyPath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return {pubkeyPath};
|
|
129
|
+
}
|
|
130
|
+
}
|