typescript-virtual-container 0.1.0 → 1.0.3
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/.github/workflows/create-pull-request.yml +11 -9
- package/.github/workflows/test-battery.yml +31 -8
- package/CHANGELOG.md +35 -0
- package/README.md +5 -1
- package/biome.json +7 -6
- package/package.json +1 -1
- package/src/SSHMimic/commands/cat.ts +3 -2
- package/src/SSHMimic/commands/cd.ts +3 -2
- package/src/SSHMimic/commands/curl.ts +140 -9
- package/src/SSHMimic/commands/helpers.ts +49 -1
- package/src/SSHMimic/commands/ls.ts +38 -3
- package/src/SSHMimic/commands/mkdir.ts +5 -3
- package/src/SSHMimic/commands/nano.ts +3 -2
- package/src/SSHMimic/commands/rm.ts +5 -3
- package/src/SSHMimic/commands/touch.ts +3 -2
- package/src/SSHMimic/commands/tree.ts +3 -2
- package/src/SSHMimic/commands/wget.ts +115 -11
- package/src/SSHMimic/exec.ts +9 -2
- package/src/SSHMimic/index.ts +25 -1
- package/src/SSHMimic/shell.ts +11 -4
- package/src/SSHMimic/users.ts +16 -7
- package/src/index.ts +6 -4
- package/tests/helpers.test.ts +22 -0
- package/tests/users.test.ts +41 -0
- package/tsconfig.json +1 -1
|
@@ -3,11 +3,10 @@ name: Auto Create Pull Request
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
branches:
|
|
6
|
-
- 'dev'
|
|
7
6
|
|
|
8
7
|
jobs:
|
|
9
8
|
create-pull-request:
|
|
10
|
-
if: github.ref
|
|
9
|
+
if: github.ref != 'refs/heads/main'
|
|
11
10
|
runs-on: ubuntu-latest
|
|
12
11
|
steps:
|
|
13
12
|
- name: Validate pull request token
|
|
@@ -26,18 +25,21 @@ jobs:
|
|
|
26
25
|
script: |
|
|
27
26
|
const owner = context.repo.owner;
|
|
28
27
|
const repo = context.repo.repo;
|
|
29
|
-
const
|
|
28
|
+
const sourceBranch = context.ref.startsWith('refs/heads/')
|
|
29
|
+
? context.ref.slice('refs/heads/'.length)
|
|
30
|
+
: context.ref;
|
|
31
|
+
const head = `${owner}:${sourceBranch}`;
|
|
30
32
|
const base = 'main';
|
|
31
33
|
|
|
32
|
-
// Check if there are commits between main and
|
|
34
|
+
// Check if there are commits between main and the current branch
|
|
33
35
|
const { data: comparison } = await github.rest.repos.compareCommits({
|
|
34
36
|
owner,
|
|
35
37
|
repo,
|
|
36
38
|
base: 'main',
|
|
37
|
-
head:
|
|
39
|
+
head: sourceBranch,
|
|
38
40
|
}).catch(err => {
|
|
39
41
|
if (err.status === 404 && err.message.includes('No commits')) {
|
|
40
|
-
core.info('No commits between main and
|
|
42
|
+
core.info('No commits between main and ' + sourceBranch + ' - skipping PR creation');
|
|
41
43
|
return { data: { ahead_by: 0 } };
|
|
42
44
|
}
|
|
43
45
|
throw err;
|
|
@@ -64,13 +66,13 @@ jobs:
|
|
|
64
66
|
const { data: pullRequest } = await github.rest.pulls.create({
|
|
65
67
|
owner,
|
|
66
68
|
repo,
|
|
67
|
-
head:
|
|
69
|
+
head: sourceBranch,
|
|
68
70
|
base,
|
|
69
|
-
title:
|
|
71
|
+
title: `chore: auto PR from ${sourceBranch} to main`,
|
|
70
72
|
body: [
|
|
71
73
|
'This pull request was created automatically by GitHub Actions.',
|
|
72
74
|
'',
|
|
73
|
-
`- source branch:
|
|
75
|
+
`- source branch: \`${sourceBranch}\``,
|
|
74
76
|
`- target branch: \`main\``,
|
|
75
77
|
`- triggered by commit \`${context.sha}\``,
|
|
76
78
|
].join('\n'),
|
|
@@ -3,16 +3,13 @@ name: Test battery
|
|
|
3
3
|
on:
|
|
4
4
|
pull_request:
|
|
5
5
|
branches:
|
|
6
|
-
- main
|
|
7
|
-
- dev
|
|
8
6
|
|
|
9
7
|
permissions:
|
|
10
8
|
contents: read
|
|
11
9
|
|
|
12
10
|
jobs:
|
|
13
|
-
|
|
11
|
+
setup:
|
|
14
12
|
runs-on: ubuntu-latest
|
|
15
|
-
|
|
16
13
|
steps:
|
|
17
14
|
- name: Checkout repository
|
|
18
15
|
uses: actions/checkout@v4
|
|
@@ -25,12 +22,36 @@ jobs:
|
|
|
25
22
|
- name: Install dependencies
|
|
26
23
|
run: bun install
|
|
27
24
|
|
|
25
|
+
- name: Cache dependencies
|
|
26
|
+
uses: actions/cache@v3
|
|
27
|
+
with:
|
|
28
|
+
path: node_modules
|
|
29
|
+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
30
|
+
|
|
31
|
+
typecheck:
|
|
32
|
+
needs: setup
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
steps:
|
|
35
|
+
- name: Checkout repository
|
|
36
|
+
uses: actions/checkout@v4
|
|
37
|
+
|
|
38
|
+
- name: Setup Bun
|
|
39
|
+
uses: oven-sh/setup-bun@v1
|
|
40
|
+
with:
|
|
41
|
+
bun-version: latest
|
|
42
|
+
|
|
43
|
+
- name: Restore dependencies
|
|
44
|
+
uses: actions/cache@v3
|
|
45
|
+
with:
|
|
46
|
+
path: node_modules
|
|
47
|
+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
48
|
+
|
|
28
49
|
- name: Run typecheck
|
|
29
50
|
run: bun check
|
|
30
51
|
|
|
31
52
|
lint:
|
|
53
|
+
needs: setup
|
|
32
54
|
runs-on: ubuntu-latest
|
|
33
|
-
|
|
34
55
|
steps:
|
|
35
56
|
- name: Checkout repository
|
|
36
57
|
uses: actions/checkout@v4
|
|
@@ -40,8 +61,11 @@ jobs:
|
|
|
40
61
|
with:
|
|
41
62
|
bun-version: latest
|
|
42
63
|
|
|
43
|
-
- name:
|
|
44
|
-
|
|
64
|
+
- name: Restore dependencies
|
|
65
|
+
uses: actions/cache@v3
|
|
66
|
+
with:
|
|
67
|
+
path: node_modules
|
|
68
|
+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
45
69
|
|
|
46
70
|
- name: Run lint
|
|
47
71
|
run: bun lint
|
|
@@ -51,7 +75,6 @@ jobs:
|
|
|
51
75
|
- typecheck
|
|
52
76
|
- lint
|
|
53
77
|
runs-on: ubuntu-latest
|
|
54
|
-
|
|
55
78
|
steps:
|
|
56
79
|
- name: Test battery passed
|
|
57
80
|
run: echo "Typecheck, lint and tests succeeded."
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,8 @@ The format is based on Keep a Changelog.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.0.2] - 2026-04-14
|
|
10
|
+
|
|
9
11
|
### Added
|
|
10
12
|
|
|
11
13
|
- Governance and community files:
|
|
@@ -14,6 +16,39 @@ The format is based on Keep a Changelog.
|
|
|
14
16
|
- SECURITY.md
|
|
15
17
|
- CODE_OF_CONDUCT.md
|
|
16
18
|
- GitHub issue and PR templates
|
|
19
|
+
- Security hardening for virtual auth storage and shell access:
|
|
20
|
+
- Non-root commands now block access to `/virtual-env-js/.auth/**`.
|
|
21
|
+
- Auth files are persisted with restrictive modes (`0700` for `.auth`, `0600` for `htpasswd` and `sudoers`).
|
|
22
|
+
- Root password no longer falls back to a fixed default when `SSH_MIMIC_ROOT_PASSWORD` is unset; startup generates an ephemeral password instead.
|
|
23
|
+
- New environment toggle `SSH_MIMIC_AUTO_SUDO_NEW_USERS` to control whether newly created users are added to sudoers by default.
|
|
24
|
+
- README and security docs now describe the new auth hardening and configuration flags.
|
|
25
|
+
- Added tests covering `.auth` path protection and auto-sudo behavior.
|
|
26
|
+
|
|
27
|
+
## [1.0.1] - 2026-04-14
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- `ls -l` / `ls --long` support with long listing format (permissions, size, updated time).
|
|
32
|
+
- Host-command mirroring for network tools:
|
|
33
|
+
- `curl` now runs through host `curl` via `child_process`.
|
|
34
|
+
- `wget` now runs through host `wget` via `child_process`.
|
|
35
|
+
- Temporary host download flow for `wget` using `/tmp` before import into VFS.
|
|
36
|
+
- Terminal line normalization utility for command help and diagnostics rendering.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `curl` behavior is now aligned with the host binary output and exit codes.
|
|
41
|
+
- `curl -o` writes host command output to the virtual filesystem target path.
|
|
42
|
+
- `wget` writes downloaded payloads to VFS after host-side transfer, preserving command semantics.
|
|
43
|
+
- URL fetch helper now accepts host-only inputs by normalizing missing protocol to `http://`.
|
|
44
|
+
- Auto pull-request GitHub workflow now targets any non-`main` branch instead of only `dev`.
|
|
45
|
+
- Auto PR metadata now uses the dynamic source branch name in PR head/title/body.
|
|
46
|
+
- Test workflow trigger scope was generalized by removing hardcoded branch filters.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Resolved large horizontal spacing artifacts in SSH terminal output by normalizing TTY line endings (`\r\n`) in both interactive shell and exec paths.
|
|
51
|
+
- Reduced excessive whitespace in help output rendering (`curl --help`, `wget --help`) by normalizing tabs and over-padded spacing.
|
|
17
52
|
|
|
18
53
|
## [1.0.0] - 2026-04-14
|
|
19
54
|
|
package/README.md
CHANGED
|
@@ -1040,13 +1040,15 @@ The following commands are available in both SSH shell mode and via `SshClient.e
|
|
|
1040
1040
|
### Environment Variables
|
|
1041
1041
|
|
|
1042
1042
|
- **`SSH_MIMIC_HOSTNAME`**: Override server hostname at startup (default: "typescript-vm")
|
|
1043
|
-
- **`SSH_MIMIC_ROOT_PASSWORD`**: Set root password
|
|
1043
|
+
- **`SSH_MIMIC_ROOT_PASSWORD`**: Set root password. If unset, a random ephemeral password is generated at startup and logged once.
|
|
1044
|
+
- **`SSH_MIMIC_AUTO_SUDO_NEW_USERS`**: Control whether new users are added to sudoers automatically (default: enabled). Set to `0`, `false`, `no`, or `off` to disable.
|
|
1044
1045
|
|
|
1045
1046
|
**Example:**
|
|
1046
1047
|
|
|
1047
1048
|
```bash
|
|
1048
1049
|
export SSH_MIMIC_HOSTNAME=production-lab
|
|
1049
1050
|
export SSH_MIMIC_ROOT_PASSWORD=SecurePass123
|
|
1051
|
+
export SSH_MIMIC_AUTO_SUDO_NEW_USERS=false
|
|
1050
1052
|
npm run start
|
|
1051
1053
|
```
|
|
1052
1054
|
|
|
@@ -1231,6 +1233,8 @@ const ssh = new VirtualMachine({ port: 2222, hostname: "hostname" });
|
|
|
1231
1233
|
- Passwords are hashed with `scrypt` in the virtual auth store.
|
|
1232
1234
|
- Root account is always protected and cannot be deleted.
|
|
1233
1235
|
- Sudo privileges are explicit and persisted in sudoers data.
|
|
1236
|
+
- Protect the root password in production by setting `SSH_MIMIC_ROOT_PASSWORD`; otherwise startup logs a generated ephemeral password.
|
|
1237
|
+
- Disable `SSH_MIMIC_AUTO_SUDO_NEW_USERS` when you want newly created users to stay unprivileged by default.
|
|
1234
1238
|
- This project is not intended to provide kernel-level or process-level isolation.
|
|
1235
1239
|
|
|
1236
1240
|
If you discover a vulnerability, avoid public disclosure in issues and contact maintainers privately first.
|
package/biome.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"assist": {
|
|
3
|
+
"actions": {
|
|
4
|
+
"source": {
|
|
5
|
+
"organizeImports": "off"
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
},
|
|
2
9
|
"linter": {
|
|
3
10
|
"rules": {
|
|
4
11
|
"suspicious": {
|
|
@@ -10,11 +17,5 @@
|
|
|
10
17
|
"noNonNullAssertion": "off"
|
|
11
18
|
}
|
|
12
19
|
}
|
|
13
|
-
},
|
|
14
|
-
"assist": {
|
|
15
|
-
"source": {
|
|
16
|
-
"autoImport": "on",
|
|
17
|
-
"importModuleSpecifierPreference": "relative"
|
|
18
|
-
}
|
|
19
20
|
}
|
|
20
21
|
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolveReadablePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolveReadablePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const catCommand: ShellModule = {
|
|
5
5
|
name: "cat",
|
|
6
6
|
params: ["<file>"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
const fileArg = args[0];
|
|
9
9
|
if (!fileArg) {
|
|
10
10
|
return { stderr: "cat: missing file operand", exitCode: 1 };
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const target = resolveReadablePath(vfs, cwd, fileArg);
|
|
14
|
+
assertPathAccess(authUser, target, "cat");
|
|
14
15
|
return { stdout: vfs.readFile(target), exitCode: 0 };
|
|
15
16
|
},
|
|
16
17
|
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const cdCommand: ShellModule = {
|
|
5
5
|
name: "cd",
|
|
6
6
|
params: ["[path]"],
|
|
7
|
-
run: ({ vfs, cwd, args, mode }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args, mode }) => {
|
|
8
8
|
const target = resolvePath(cwd, args[0] ?? "/virtual-env-js");
|
|
9
|
+
assertPathAccess(authUser, target, "cd");
|
|
9
10
|
const stats = vfs.stat(target);
|
|
10
11
|
if (stats.type !== "directory") {
|
|
11
12
|
return { stderr: `cd: not a directory: ${target}`, exitCode: 1 };
|
|
@@ -1,27 +1,158 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
assertPathAccess,
|
|
5
|
+
normalizeTerminalOutput,
|
|
6
|
+
resolvePath,
|
|
7
|
+
} from "./helpers";
|
|
8
|
+
|
|
9
|
+
function parseCurlOutputPath(args: string[]): {
|
|
10
|
+
outputPath: string | null;
|
|
11
|
+
inputArgs: string[];
|
|
12
|
+
} {
|
|
13
|
+
const filtered: string[] = [];
|
|
14
|
+
let outputPath: string | null = null;
|
|
15
|
+
|
|
16
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
17
|
+
const arg = args[index]!;
|
|
18
|
+
|
|
19
|
+
if (arg === "-o" || arg === "--output") {
|
|
20
|
+
outputPath = args[index + 1] ?? null;
|
|
21
|
+
index += 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (arg.startsWith("-o=")) {
|
|
26
|
+
outputPath = arg.slice(3);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (arg.startsWith("--output=")) {
|
|
31
|
+
outputPath = arg.slice("--output=".length);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
filtered.push(arg);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { outputPath, inputArgs: filtered };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runHostCurl(args: string[]): Promise<{
|
|
42
|
+
stdout: string;
|
|
43
|
+
stderr: string;
|
|
44
|
+
exitCode: number;
|
|
45
|
+
}> {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let childProcess: ReturnType<typeof spawn>;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
childProcess = spawn("curl", args, {
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
resolve({
|
|
55
|
+
stdout: "",
|
|
56
|
+
stderr: `curl: ${error instanceof Error ? error.message : String(error)}`,
|
|
57
|
+
exitCode: 1,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let stdout = "";
|
|
63
|
+
let stderr = "";
|
|
64
|
+
const stdoutStream = childProcess.stdout;
|
|
65
|
+
const stderrStream = childProcess.stderr;
|
|
66
|
+
|
|
67
|
+
if (!stdoutStream || !stderrStream) {
|
|
68
|
+
resolve({
|
|
69
|
+
stdout: "",
|
|
70
|
+
stderr: "curl: failed to capture process output",
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
stdoutStream.setEncoding("utf8");
|
|
77
|
+
stderrStream.setEncoding("utf8");
|
|
78
|
+
|
|
79
|
+
stdoutStream.on("data", (chunk: string) => {
|
|
80
|
+
stdout += chunk;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
stderrStream.on("data", (chunk: string) => {
|
|
84
|
+
stderr += chunk;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
childProcess.on("error", (error) => {
|
|
88
|
+
const errorCode =
|
|
89
|
+
error instanceof Error && "code" in error
|
|
90
|
+
? String((error as NodeJS.ErrnoException).code ?? "")
|
|
91
|
+
: "";
|
|
92
|
+
resolve({
|
|
93
|
+
stdout: "",
|
|
94
|
+
stderr: `curl: ${error.message}`,
|
|
95
|
+
exitCode: errorCode === "ENOENT" ? 127 : 1,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
childProcess.on("close", (code) => {
|
|
100
|
+
resolve({
|
|
101
|
+
stdout,
|
|
102
|
+
stderr,
|
|
103
|
+
exitCode: code ?? 1,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
3
108
|
|
|
4
109
|
export const curlCommand: ShellModule = {
|
|
5
110
|
name: "curl",
|
|
6
111
|
params: ["[-o file] <url>"],
|
|
7
|
-
run: async ({ vfs, cwd, args }) => {
|
|
8
|
-
const { outputPath, inputArgs } =
|
|
112
|
+
run: async ({ authUser, vfs, cwd, args }) => {
|
|
113
|
+
const { outputPath, inputArgs } = parseCurlOutputPath(args);
|
|
9
114
|
const url = inputArgs[0];
|
|
115
|
+
const isHelpLike = inputArgs.some(
|
|
116
|
+
(arg) =>
|
|
117
|
+
arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version",
|
|
118
|
+
);
|
|
10
119
|
|
|
11
120
|
if (!url) {
|
|
12
121
|
return { stderr: "curl: missing URL", exitCode: 1 };
|
|
13
122
|
}
|
|
14
123
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
124
|
+
const passthroughArgs = outputPath ? [...inputArgs, "-o", "-"] : inputArgs;
|
|
125
|
+
const result = await runHostCurl(passthroughArgs);
|
|
126
|
+
|
|
127
|
+
if (result.exitCode !== 0) {
|
|
128
|
+
return {
|
|
129
|
+
stderr: normalizeTerminalOutput(
|
|
130
|
+
result.stderr || `curl: exited with code ${result.exitCode}`,
|
|
131
|
+
),
|
|
132
|
+
exitCode: result.exitCode,
|
|
133
|
+
};
|
|
18
134
|
}
|
|
19
135
|
|
|
20
136
|
if (outputPath) {
|
|
21
|
-
|
|
22
|
-
|
|
137
|
+
const target = resolvePath(cwd, outputPath);
|
|
138
|
+
assertPathAccess(authUser, target, "curl");
|
|
139
|
+
vfs.writeFile(target, result.stdout);
|
|
140
|
+
return {
|
|
141
|
+
stderr: result.stderr
|
|
142
|
+
? normalizeTerminalOutput(result.stderr)
|
|
143
|
+
: undefined,
|
|
144
|
+
exitCode: 0,
|
|
145
|
+
};
|
|
23
146
|
}
|
|
24
147
|
|
|
25
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
stdout: isHelpLike
|
|
150
|
+
? normalizeTerminalOutput(result.stdout)
|
|
151
|
+
: result.stdout,
|
|
152
|
+
stderr: result.stderr
|
|
153
|
+
? normalizeTerminalOutput(result.stderr)
|
|
154
|
+
: undefined,
|
|
155
|
+
exitCode: 0,
|
|
156
|
+
};
|
|
26
157
|
},
|
|
27
158
|
};
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type VirtualFileSystem from "../../VirtualFileSystem";
|
|
3
3
|
|
|
4
|
+
const PROTECTED_PREFIXES = ["/virtual-env-js/.auth"] as const;
|
|
5
|
+
|
|
6
|
+
function normalizeFetchUrl(input: string): string {
|
|
7
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
|
|
8
|
+
return input;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return `http://${input}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeTerminalOutput(text: string): string {
|
|
15
|
+
return text
|
|
16
|
+
.replace(/\r\n/g, "\n")
|
|
17
|
+
.replace(/\r/g, "\n")
|
|
18
|
+
.replace(/\t/g, " ")
|
|
19
|
+
.split("\n")
|
|
20
|
+
.map((line) =>
|
|
21
|
+
line.replace(/^[ \u00A0]{8,}/, " ").replace(/[ \u00A0]{3,}/g, " "),
|
|
22
|
+
)
|
|
23
|
+
.join("\n")
|
|
24
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
25
|
+
.trimEnd();
|
|
26
|
+
}
|
|
27
|
+
|
|
4
28
|
export function resolvePath(cwd: string, inputPath: string): string {
|
|
5
29
|
if (!inputPath || inputPath.trim() === "") {
|
|
6
30
|
return cwd;
|
|
@@ -10,6 +34,30 @@ export function resolvePath(cwd: string, inputPath: string): string {
|
|
|
10
34
|
: path.posix.normalize(path.posix.join(cwd, inputPath));
|
|
11
35
|
}
|
|
12
36
|
|
|
37
|
+
function isProtectedPath(targetPath: string): boolean {
|
|
38
|
+
const normalized = targetPath.startsWith("/")
|
|
39
|
+
? path.posix.normalize(targetPath)
|
|
40
|
+
: path.posix.normalize(`/${targetPath}`);
|
|
41
|
+
|
|
42
|
+
return PROTECTED_PREFIXES.some(
|
|
43
|
+
(prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertPathAccess(
|
|
48
|
+
authUser: string,
|
|
49
|
+
targetPath: string,
|
|
50
|
+
operation: string,
|
|
51
|
+
): void {
|
|
52
|
+
if (authUser === "root") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isProtectedPath(targetPath)) {
|
|
57
|
+
throw new Error(`${operation}: permission denied: ${targetPath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
13
61
|
export function parseOutputPath(args: string[]): {
|
|
14
62
|
outputPath: string | null;
|
|
15
63
|
inputArgs: string[];
|
|
@@ -56,7 +104,7 @@ export function stripUrlFilename(url: string): string {
|
|
|
56
104
|
export async function fetchResource(
|
|
57
105
|
url: string,
|
|
58
106
|
): Promise<{ text: string; status: number; contentType: string | null }> {
|
|
59
|
-
const response = await fetch(url);
|
|
107
|
+
const response = await fetch(normalizeFetchUrl(url));
|
|
60
108
|
const contentType = response.headers.get("content-type");
|
|
61
109
|
return {
|
|
62
110
|
text: await response.text(),
|
|
@@ -1,14 +1,49 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { joinListWithType, resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, joinListWithType, resolvePath } from "./helpers";
|
|
3
|
+
|
|
4
|
+
function formatPermissions(mode: number, isDirectory: boolean): string {
|
|
5
|
+
const fileType = isDirectory ? "d" : "-";
|
|
6
|
+
const permissionBits = [
|
|
7
|
+
[0o400, "r"],
|
|
8
|
+
[0o200, "w"],
|
|
9
|
+
[0o100, "x"],
|
|
10
|
+
[0o040, "r"],
|
|
11
|
+
[0o020, "w"],
|
|
12
|
+
[0o010, "x"],
|
|
13
|
+
[0o004, "r"],
|
|
14
|
+
[0o002, "w"],
|
|
15
|
+
[0o001, "x"],
|
|
16
|
+
] as const;
|
|
17
|
+
const permissions = permissionBits
|
|
18
|
+
.map(([bit, symbol]) => (mode & bit ? symbol : "-"))
|
|
19
|
+
.join("");
|
|
20
|
+
|
|
21
|
+
return `${fileType}${permissions}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatDate(date: Date): string {
|
|
25
|
+
return date.toISOString().replace("T", " ").slice(0, 16);
|
|
26
|
+
}
|
|
3
27
|
|
|
4
28
|
export const lsCommand: ShellModule = {
|
|
5
29
|
name: "ls",
|
|
6
30
|
params: ["[path]"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
31
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
32
|
+
const longFormat = args.includes("-l") || args.includes("--long");
|
|
8
33
|
const targetArg = args.find((arg) => !arg.startsWith("-"));
|
|
9
34
|
const target = resolvePath(cwd, targetArg ?? cwd);
|
|
35
|
+
assertPathAccess(authUser, target, "ls");
|
|
10
36
|
const items = vfs.list(target).filter((name) => !name.startsWith("."));
|
|
11
|
-
const rendered =
|
|
37
|
+
const rendered = longFormat
|
|
38
|
+
? items
|
|
39
|
+
.map((name) => {
|
|
40
|
+
const childPath = resolvePath(target, name);
|
|
41
|
+
const stat = vfs.stat(childPath);
|
|
42
|
+
const size = stat.type === "file" ? stat.size : stat.childrenCount;
|
|
43
|
+
return `${formatPermissions(stat.mode, stat.type === "directory")} 1 ${size} ${formatDate(stat.updatedAt)} ${name}${stat.type === "directory" ? "/" : ""}`;
|
|
44
|
+
})
|
|
45
|
+
.join("\n")
|
|
46
|
+
: joinListWithType(target, items, (p) => vfs.stat(p));
|
|
12
47
|
return { stdout: rendered, exitCode: 0 };
|
|
13
48
|
},
|
|
14
49
|
};
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const mkdirCommand: ShellModule = {
|
|
5
5
|
name: "mkdir",
|
|
6
6
|
params: ["<dir>"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
if (args.length === 0) {
|
|
9
9
|
return { stderr: "mkdir: missing operand", exitCode: 1 };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
for (const dir of args) {
|
|
13
|
-
|
|
13
|
+
const target = resolvePath(cwd, dir);
|
|
14
|
+
assertPathAccess(authUser, target, "mkdir");
|
|
15
|
+
vfs.mkdir(target);
|
|
14
16
|
}
|
|
15
17
|
return { exitCode: 0 };
|
|
16
18
|
},
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type { ShellModule } from "../../types/commands";
|
|
3
|
-
import { resolvePath } from "./helpers";
|
|
3
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
4
4
|
|
|
5
5
|
export const nanoCommand: ShellModule = {
|
|
6
6
|
name: "nano",
|
|
7
7
|
params: ["<file>"],
|
|
8
|
-
run: ({ vfs, cwd, args }) => {
|
|
8
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
9
9
|
const fileArg = args[0];
|
|
10
10
|
if (!fileArg) {
|
|
11
11
|
return { stderr: "nano: missing file operand", exitCode: 1 };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const targetPath = resolvePath(cwd, fileArg);
|
|
15
|
+
assertPathAccess(authUser, targetPath, "nano");
|
|
15
16
|
const initialContent = vfs.exists(targetPath)
|
|
16
17
|
? vfs.readFile(targetPath)
|
|
17
18
|
: "";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const rmCommand: ShellModule = {
|
|
5
5
|
name: "rm",
|
|
6
6
|
params: ["[-r|-rf] <path>"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
if (args.length === 0) {
|
|
9
9
|
return { stderr: "rm: missing operand", exitCode: 1 };
|
|
10
10
|
}
|
|
@@ -18,7 +18,9 @@ export const rmCommand: ShellModule = {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
for (const target of targets) {
|
|
21
|
-
|
|
21
|
+
const resolvedTarget = resolvePath(cwd, target);
|
|
22
|
+
assertPathAccess(authUser, resolvedTarget, "rm");
|
|
23
|
+
vfs.remove(resolvedTarget, { recursive });
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
return { exitCode: 0 };
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const touchCommand: ShellModule = {
|
|
5
5
|
name: "touch",
|
|
6
6
|
params: ["<file>"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
if (args.length === 0) {
|
|
9
9
|
return { stderr: "touch: missing file operand", exitCode: 1 };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
for (const file of args) {
|
|
13
13
|
const target = resolvePath(cwd, file);
|
|
14
|
+
assertPathAccess(authUser, target, "touch");
|
|
14
15
|
if (!vfs.exists(target)) {
|
|
15
16
|
vfs.writeFile(target, "");
|
|
16
17
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
-
import { resolvePath } from "./helpers";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const treeCommand: ShellModule = {
|
|
5
5
|
name: "tree",
|
|
6
6
|
params: ["[path]"],
|
|
7
|
-
run: ({ vfs, cwd, args }) => {
|
|
7
|
+
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
8
|
const target = resolvePath(cwd, args[0] ?? cwd);
|
|
9
|
+
assertPathAccess(authUser, target, "tree");
|
|
9
10
|
return { stdout: vfs.tree(target), exitCode: 0 };
|
|
10
11
|
},
|
|
11
12
|
};
|
|
@@ -1,33 +1,137 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
1
5
|
import type { ShellModule } from "../../types/commands";
|
|
2
6
|
import {
|
|
3
|
-
|
|
7
|
+
assertPathAccess,
|
|
8
|
+
normalizeTerminalOutput,
|
|
4
9
|
parseOutputPath,
|
|
5
10
|
resolvePath,
|
|
6
11
|
stripUrlFilename,
|
|
7
12
|
} from "./helpers";
|
|
8
13
|
|
|
14
|
+
function runHostWget(args: string[]): Promise<{
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
exitCode: number;
|
|
18
|
+
}> {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
let childProcess: ReturnType<typeof spawn>;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
childProcess = spawn("wget", args, {
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
});
|
|
26
|
+
} catch (error) {
|
|
27
|
+
resolve({
|
|
28
|
+
stdout: "",
|
|
29
|
+
stderr: `wget: ${error instanceof Error ? error.message : String(error)}`,
|
|
30
|
+
exitCode: 1,
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let stdout = "";
|
|
36
|
+
let stderr = "";
|
|
37
|
+
const stdoutStream = childProcess.stdout;
|
|
38
|
+
const stderrStream = childProcess.stderr;
|
|
39
|
+
|
|
40
|
+
if (!stdoutStream || !stderrStream) {
|
|
41
|
+
resolve({
|
|
42
|
+
stdout: "",
|
|
43
|
+
stderr: "wget: failed to capture process output",
|
|
44
|
+
exitCode: 1,
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stdoutStream.setEncoding("utf8");
|
|
50
|
+
stderrStream.setEncoding("utf8");
|
|
51
|
+
|
|
52
|
+
stdoutStream.on("data", (chunk: string) => {
|
|
53
|
+
stdout += chunk;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
stderrStream.on("data", (chunk: string) => {
|
|
57
|
+
stderr += chunk;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
childProcess.on("error", (error) => {
|
|
61
|
+
const errorCode =
|
|
62
|
+
error instanceof Error && "code" in error
|
|
63
|
+
? String((error as NodeJS.ErrnoException).code ?? "")
|
|
64
|
+
: "";
|
|
65
|
+
resolve({
|
|
66
|
+
stdout: "",
|
|
67
|
+
stderr: `wget: ${error.message}`,
|
|
68
|
+
exitCode: errorCode === "ENOENT" ? 127 : 1,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
childProcess.on("close", (code) => {
|
|
73
|
+
resolve({
|
|
74
|
+
stdout,
|
|
75
|
+
stderr,
|
|
76
|
+
exitCode: code ?? 1,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
9
82
|
export const wgetCommand: ShellModule = {
|
|
10
83
|
name: "wget",
|
|
11
84
|
params: ["[url]"],
|
|
12
|
-
run: async ({ vfs, cwd, args }) => {
|
|
85
|
+
run: async ({ authUser, vfs, cwd, args }) => {
|
|
13
86
|
const { outputPath, inputArgs } = parseOutputPath(args);
|
|
14
87
|
const url = inputArgs[0];
|
|
88
|
+
const isHelpLike = inputArgs.some(
|
|
89
|
+
(arg) =>
|
|
90
|
+
arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version",
|
|
91
|
+
);
|
|
15
92
|
|
|
16
93
|
if (!url) {
|
|
17
94
|
return { stderr: "wget: missing URL", exitCode: 1 };
|
|
18
95
|
}
|
|
19
96
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return {
|
|
97
|
+
if (isHelpLike) {
|
|
98
|
+
const result = await runHostWget(inputArgs);
|
|
99
|
+
return {
|
|
100
|
+
stdout: normalizeTerminalOutput(result.stdout),
|
|
101
|
+
stderr: result.stderr
|
|
102
|
+
? normalizeTerminalOutput(result.stderr)
|
|
103
|
+
: undefined,
|
|
104
|
+
exitCode: result.exitCode,
|
|
105
|
+
};
|
|
23
106
|
}
|
|
24
107
|
|
|
25
|
-
const
|
|
26
|
-
|
|
108
|
+
const tempDir = await mkdtemp(join(tmpdir(), "virtual-env-js-wget-"));
|
|
109
|
+
const tempFile = join(tempDir, "download");
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const hostArgs = [...inputArgs, "-O", tempFile];
|
|
113
|
+
const result = await runHostWget(hostArgs);
|
|
27
114
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
115
|
+
if (result.exitCode !== 0) {
|
|
116
|
+
return {
|
|
117
|
+
stderr: normalizeTerminalOutput(
|
|
118
|
+
result.stderr || `wget: exited with code ${result.exitCode}`,
|
|
119
|
+
),
|
|
120
|
+
exitCode: result.exitCode,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const content = await readFile(tempFile, "utf8");
|
|
125
|
+
const target = resolvePath(cwd, outputPath ?? stripUrlFilename(url));
|
|
126
|
+
assertPathAccess(authUser, target, "wget");
|
|
127
|
+
vfs.writeFile(target, content);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
stdout: `saved ${target}`,
|
|
131
|
+
exitCode: 0,
|
|
132
|
+
};
|
|
133
|
+
} finally {
|
|
134
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
32
136
|
},
|
|
33
137
|
};
|
package/src/SSHMimic/exec.ts
CHANGED
|
@@ -3,6 +3,13 @@ import type VirtualFileSystem from "../VirtualFileSystem";
|
|
|
3
3
|
import { runCommand } from "./commands";
|
|
4
4
|
import type { VirtualUserManager } from "./users";
|
|
5
5
|
|
|
6
|
+
function toTtyLines(text: string): string {
|
|
7
|
+
return text
|
|
8
|
+
.replace(/\r\n/g, "\n")
|
|
9
|
+
.replace(/\r/g, "\n")
|
|
10
|
+
.replace(/\n/g, "\r\n");
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export function runExec(
|
|
7
14
|
stream: ExecStream,
|
|
8
15
|
cmd: string,
|
|
@@ -23,11 +30,11 @@ export function runExec(
|
|
|
23
30
|
),
|
|
24
31
|
).then((result) => {
|
|
25
32
|
if (result.stdout) {
|
|
26
|
-
stream.write(`${result.stdout}\n`);
|
|
33
|
+
stream.write(`${toTtyLines(result.stdout)}\r\n`);
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
if (result.stderr) {
|
|
30
|
-
stream.stderr.write(`${result.stderr}\n`);
|
|
37
|
+
stream.stderr.write(`${toTtyLines(result.stderr)}\r\n`);
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
stream.exit(result.exitCode ?? 0);
|
package/src/SSHMimic/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { Server as SshServer } from "ssh2";
|
|
2
3
|
import VirtualFileSystem from "../VirtualFileSystem";
|
|
3
4
|
import { runExec } from "./exec";
|
|
@@ -5,6 +6,28 @@ import { loadOrCreateHostKey } from "./hostKey";
|
|
|
5
6
|
import { startShell } from "./shell";
|
|
6
7
|
import { VirtualUserManager } from "./users";
|
|
7
8
|
|
|
9
|
+
function resolveRootPassword(): string {
|
|
10
|
+
const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
|
|
11
|
+
if (configured && configured.trim().length > 0) {
|
|
12
|
+
return configured;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const generated = randomBytes(18).toString("base64url");
|
|
16
|
+
console.warn(
|
|
17
|
+
`[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
|
|
18
|
+
);
|
|
19
|
+
return generated;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveAutoSudoForNewUsers(): boolean {
|
|
23
|
+
const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
|
|
24
|
+
if (!configured) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return !["0", "false", "no", "off"].includes(configured.toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
/**
|
|
9
32
|
* SSH server wrapper that exposes virtual shell and exec sessions.
|
|
10
33
|
*
|
|
@@ -52,7 +75,8 @@ class SshMimic {
|
|
|
52
75
|
await this.vfs.restoreMirror();
|
|
53
76
|
this.users = new VirtualUserManager(
|
|
54
77
|
this.vfs,
|
|
55
|
-
|
|
78
|
+
resolveRootPassword(),
|
|
79
|
+
resolveAutoSudoForNewUsers(),
|
|
56
80
|
);
|
|
57
81
|
await this.users.initialize();
|
|
58
82
|
|
package/src/SSHMimic/shell.ts
CHANGED
|
@@ -33,6 +33,13 @@ interface TerminalSize {
|
|
|
33
33
|
rows: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function toTtyLines(text: string): string {
|
|
37
|
+
return text
|
|
38
|
+
.replace(/\r\n/g, "\n")
|
|
39
|
+
.replace(/\r/g, "\n")
|
|
40
|
+
.replace(/\n/g, "\r\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
export function startShell(
|
|
37
44
|
stream: ShellStream,
|
|
38
45
|
authUser: string,
|
|
@@ -198,11 +205,11 @@ export function startShell(
|
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
if (result.stdout) {
|
|
201
|
-
stream.write(`${result.stdout}\r\n`);
|
|
208
|
+
stream.write(`${toTtyLines(result.stdout)}\r\n`);
|
|
202
209
|
}
|
|
203
210
|
|
|
204
211
|
if (result.stderr) {
|
|
205
|
-
stream.write(`${result.stderr}\r\n`);
|
|
212
|
+
stream.write(`${toTtyLines(result.stderr)}\r\n`);
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
if (result.switchUser) {
|
|
@@ -671,11 +678,11 @@ export function startShell(
|
|
|
671
678
|
}
|
|
672
679
|
|
|
673
680
|
if (result.stdout) {
|
|
674
|
-
stream.write(`${result.stdout}\r\n`);
|
|
681
|
+
stream.write(`${toTtyLines(result.stdout)}\r\n`);
|
|
675
682
|
}
|
|
676
683
|
|
|
677
684
|
if (result.stderr) {
|
|
678
|
-
stream.write(`${result.stderr}\r\n`);
|
|
685
|
+
stream.write(`${toTtyLines(result.stderr)}\r\n`);
|
|
679
686
|
}
|
|
680
687
|
|
|
681
688
|
if (result.closeSession) {
|
package/src/SSHMimic/users.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface VirtualActiveSession {
|
|
|
31
31
|
export class VirtualUserManager {
|
|
32
32
|
private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
|
|
33
33
|
private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
|
|
34
|
+
private readonly authDirPath = "/virtual-env-js/.auth";
|
|
34
35
|
private readonly users = new Map<string, VirtualUserRecord>();
|
|
35
36
|
private readonly sudoers = new Set<string>();
|
|
36
37
|
private readonly activeSessions = new Map<string, VirtualActiveSession>();
|
|
@@ -45,6 +46,7 @@ export class VirtualUserManager {
|
|
|
45
46
|
constructor(
|
|
46
47
|
private readonly vfs: VirtualFileSystem,
|
|
47
48
|
private readonly defaultRootPassword: string = "root",
|
|
49
|
+
private readonly autoSudoForNewUsers: boolean = true,
|
|
48
50
|
) {}
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -54,12 +56,7 @@ export class VirtualUserManager {
|
|
|
54
56
|
this.loadFromVfs();
|
|
55
57
|
this.loadSudoersFromVfs();
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
this.users.set(
|
|
59
|
-
"root",
|
|
60
|
-
this.createRecord("root", this.defaultRootPassword),
|
|
61
|
-
);
|
|
62
|
-
}
|
|
59
|
+
this.users.set("root", this.createRecord("root", this.defaultRootPassword));
|
|
63
60
|
|
|
64
61
|
this.sudoers.add("root");
|
|
65
62
|
|
|
@@ -97,7 +94,9 @@ export class VirtualUserManager {
|
|
|
97
94
|
}
|
|
98
95
|
|
|
99
96
|
this.users.set(username, this.createRecord(username, password));
|
|
100
|
-
this.
|
|
97
|
+
if (this.autoSudoForNewUsers) {
|
|
98
|
+
this.sudoers.add(username);
|
|
99
|
+
}
|
|
101
100
|
const homePath = `/home/${username}`;
|
|
102
101
|
if (!this.vfs.exists(homePath)) {
|
|
103
102
|
this.vfs.mkdir(homePath, 0o755);
|
|
@@ -290,6 +289,10 @@ export class VirtualUserManager {
|
|
|
290
289
|
}
|
|
291
290
|
|
|
292
291
|
private async persist(): Promise<void> {
|
|
292
|
+
if (!this.vfs.exists(this.authDirPath)) {
|
|
293
|
+
this.vfs.mkdir(this.authDirPath, 0o700);
|
|
294
|
+
}
|
|
295
|
+
|
|
293
296
|
const content = Array.from(this.users.values())
|
|
294
297
|
.sort((left, right) => left.username.localeCompare(right.username))
|
|
295
298
|
.map((record) =>
|
|
@@ -300,11 +303,13 @@ export class VirtualUserManager {
|
|
|
300
303
|
this.vfs.writeFile(
|
|
301
304
|
this.usersPath,
|
|
302
305
|
content.length > 0 ? `${content}\n` : "",
|
|
306
|
+
{ mode: 0o600 },
|
|
303
307
|
);
|
|
304
308
|
const sudoersContent = Array.from(this.sudoers.values()).sort().join("\n");
|
|
305
309
|
this.vfs.writeFile(
|
|
306
310
|
this.sudoersPath,
|
|
307
311
|
sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
|
|
312
|
+
{ mode: 0o600 },
|
|
308
313
|
);
|
|
309
314
|
await this.vfs.flushMirror();
|
|
310
315
|
}
|
|
@@ -326,6 +331,10 @@ export class VirtualUserManager {
|
|
|
326
331
|
if (!username || username.trim() === "") {
|
|
327
332
|
throw new Error("invalid username");
|
|
328
333
|
}
|
|
334
|
+
|
|
335
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/i.test(username)) {
|
|
336
|
+
throw new Error("invalid username");
|
|
337
|
+
}
|
|
329
338
|
}
|
|
330
339
|
|
|
331
340
|
private validatePassword(password: string): void {
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type {
|
|
|
10
10
|
CommandResult,
|
|
11
11
|
NanoEditorSession,
|
|
12
12
|
ShellModule,
|
|
13
|
-
SudoChallenge
|
|
13
|
+
SudoChallenge,
|
|
14
14
|
} from "./types/commands";
|
|
15
15
|
export type { ExecStream, ShellStream } from "./types/streams";
|
|
16
16
|
export type {
|
|
@@ -25,10 +25,12 @@ export type {
|
|
|
25
25
|
VfsSnapshotDirectoryNode,
|
|
26
26
|
VfsSnapshotFileNode,
|
|
27
27
|
VfsSnapshotNode,
|
|
28
|
-
WriteFileOptions
|
|
28
|
+
WriteFileOptions,
|
|
29
29
|
} from "./types/vfs";
|
|
30
30
|
|
|
31
31
|
export {
|
|
32
|
-
SshClient,
|
|
32
|
+
SshClient,
|
|
33
|
+
VirtualFileSystem,
|
|
34
|
+
SshMimic as VirtualMachine,
|
|
35
|
+
VirtualUserManager,
|
|
33
36
|
};
|
|
34
|
-
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { assertPathAccess } from "../src/SSHMimic/commands/helpers";
|
|
3
|
+
|
|
4
|
+
describe("assertPathAccess", () => {
|
|
5
|
+
test("blocks non-root access to auth store", () => {
|
|
6
|
+
expect(() =>
|
|
7
|
+
assertPathAccess("alice", "/virtual-env-js/.auth/htpasswd", "cat"),
|
|
8
|
+
).toThrow("cat: permission denied: /virtual-env-js/.auth/htpasswd");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("allows root access to auth store", () => {
|
|
12
|
+
expect(() =>
|
|
13
|
+
assertPathAccess("root", "/virtual-env-js/.auth/htpasswd", "cat"),
|
|
14
|
+
).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("allows non-root access outside protected paths", () => {
|
|
18
|
+
expect(() =>
|
|
19
|
+
assertPathAccess("alice", "/home/alice/README.txt", "cat"),
|
|
20
|
+
).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { VirtualUserManager } from "../src/SSHMimic/users";
|
|
6
|
+
import VirtualFileSystem from "../src/VirtualFileSystem";
|
|
7
|
+
|
|
8
|
+
async function withTempVfs(
|
|
9
|
+
run: (vfs: VirtualFileSystem) => Promise<void>,
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
const tempDir = await mkdtemp(join(tmpdir(), "virtual-env-js-test-"));
|
|
12
|
+
try {
|
|
13
|
+
const vfs = new VirtualFileSystem(tempDir);
|
|
14
|
+
await vfs.restoreMirror();
|
|
15
|
+
await run(vfs);
|
|
16
|
+
} finally {
|
|
17
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("VirtualUserManager auto sudo", () => {
|
|
22
|
+
test("adds new users to sudoers by default", async () => {
|
|
23
|
+
await withTempVfs(async (vfs) => {
|
|
24
|
+
const users = new VirtualUserManager(vfs, "root-pass");
|
|
25
|
+
await users.initialize();
|
|
26
|
+
await users.addUser("alice", "alice-pass");
|
|
27
|
+
|
|
28
|
+
expect(users.isSudoer("alice")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("does not auto-add sudoers when disabled", async () => {
|
|
33
|
+
await withTempVfs(async (vfs) => {
|
|
34
|
+
const users = new VirtualUserManager(vfs, "root-pass", false);
|
|
35
|
+
await users.initialize();
|
|
36
|
+
await users.addUser("bob", "bob-pass");
|
|
37
|
+
|
|
38
|
+
expect(users.isSudoer("bob")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|