typescript-virtual-container 1.0.1 → 1.0.4
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/test-battery.yml +53 -6
- package/CHANGELOG.md +40 -0
- package/README.md +11 -29
- 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 +9 -3
- package/src/SSHMimic/commands/echo.ts +26 -0
- package/src/SSHMimic/commands/env.ts +22 -0
- package/src/SSHMimic/commands/export.ts +32 -0
- package/src/SSHMimic/commands/grep.ts +81 -0
- package/src/SSHMimic/commands/helpers.ts +26 -0
- package/src/SSHMimic/commands/index.ts +153 -1
- package/src/SSHMimic/commands/ls.ts +3 -2
- 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/set.ts +67 -0
- package/src/SSHMimic/commands/sh.ts +121 -0
- package/src/SSHMimic/commands/touch.ts +3 -2
- package/src/SSHMimic/commands/tree.ts +3 -2
- package/src/SSHMimic/commands/unset.ts +19 -0
- package/src/SSHMimic/commands/wget.ts +3 -1
- package/src/SSHMimic/executor.ts +201 -0
- package/src/SSHMimic/index.ts +25 -1
- package/src/SSHMimic/shellParser.ts +203 -0
- package/src/SSHMimic/users.ts +16 -7
- package/src/types/commands.ts +2 -0
- package/src/types/pipeline.ts +23 -0
- package/tests/helpers.test.ts +22 -0
- package/tests/users.test.ts +41 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { CommandMode, CommandResult } from "../types/commands";
|
|
2
|
+
import type { Pipeline, PipelineCommand } from "../types/pipeline";
|
|
3
|
+
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
4
|
+
import { runCommand as runSingleCommand } from "./commands";
|
|
5
|
+
import { resolvePath } from "./commands/helpers";
|
|
6
|
+
import type { VirtualUserManager } from "./users";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute a parsed pipeline, chaining commands and handling redirections.
|
|
10
|
+
* Manages stdout/stderr flow between commands and file I/O.
|
|
11
|
+
*/
|
|
12
|
+
export async function executePipeline(
|
|
13
|
+
pipeline: Pipeline,
|
|
14
|
+
authUser: string,
|
|
15
|
+
hostname: string,
|
|
16
|
+
users: VirtualUserManager,
|
|
17
|
+
mode: CommandMode,
|
|
18
|
+
cwd: string,
|
|
19
|
+
vfs: VirtualFileSystem,
|
|
20
|
+
): Promise<CommandResult> {
|
|
21
|
+
if (pipeline.commands.length === 0) {
|
|
22
|
+
return { exitCode: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (pipeline.commands.length === 1) {
|
|
26
|
+
// Single command with possible redirections
|
|
27
|
+
return executeSingleCommandWithRedirections(
|
|
28
|
+
pipeline.commands[0] as PipelineCommand,
|
|
29
|
+
authUser,
|
|
30
|
+
hostname,
|
|
31
|
+
users,
|
|
32
|
+
mode,
|
|
33
|
+
cwd,
|
|
34
|
+
vfs,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Multiple commands in a pipeline
|
|
39
|
+
return executePipelineChain(
|
|
40
|
+
pipeline.commands as PipelineCommand[],
|
|
41
|
+
authUser,
|
|
42
|
+
hostname,
|
|
43
|
+
users,
|
|
44
|
+
mode,
|
|
45
|
+
cwd,
|
|
46
|
+
vfs,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Execute a single command with input/output redirections
|
|
52
|
+
*/
|
|
53
|
+
async function executeSingleCommandWithRedirections(
|
|
54
|
+
cmd: PipelineCommand,
|
|
55
|
+
authUser: string,
|
|
56
|
+
hostname: string,
|
|
57
|
+
users: VirtualUserManager,
|
|
58
|
+
mode: CommandMode,
|
|
59
|
+
cwd: string,
|
|
60
|
+
vfs: VirtualFileSystem,
|
|
61
|
+
): Promise<CommandResult> {
|
|
62
|
+
// Prepare input if input file specified
|
|
63
|
+
let stdin: string | undefined;
|
|
64
|
+
if (cmd.inputFile) {
|
|
65
|
+
const inputPath = resolvePath(cwd, cmd.inputFile);
|
|
66
|
+
try {
|
|
67
|
+
stdin = vfs.readFile(inputPath);
|
|
68
|
+
} catch {
|
|
69
|
+
return {
|
|
70
|
+
stderr: `cat: ${cmd.inputFile}: No such file or directory`,
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build raw input for the command
|
|
77
|
+
const rawInput = [cmd.name, ...cmd.args].join(" ");
|
|
78
|
+
|
|
79
|
+
// Run the command with potential input
|
|
80
|
+
const result = await runSingleCommand(
|
|
81
|
+
rawInput,
|
|
82
|
+
authUser,
|
|
83
|
+
hostname,
|
|
84
|
+
users,
|
|
85
|
+
mode,
|
|
86
|
+
cwd,
|
|
87
|
+
vfs,
|
|
88
|
+
stdin,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Handle output redirection
|
|
92
|
+
if (cmd.outputFile) {
|
|
93
|
+
const outputPath = resolvePath(cwd, cmd.outputFile);
|
|
94
|
+
const output = result.stdout || "";
|
|
95
|
+
try {
|
|
96
|
+
if (cmd.appendOutput) {
|
|
97
|
+
try {
|
|
98
|
+
const existing = vfs.readFile(outputPath);
|
|
99
|
+
vfs.writeFile(outputPath, existing + output);
|
|
100
|
+
} catch {
|
|
101
|
+
vfs.writeFile(outputPath, output);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
vfs.writeFile(outputPath, output);
|
|
105
|
+
}
|
|
106
|
+
return { ...result, stdout: "" };
|
|
107
|
+
} catch {
|
|
108
|
+
return {
|
|
109
|
+
...result,
|
|
110
|
+
stderr: `Failed to write to ${cmd.outputFile}`,
|
|
111
|
+
exitCode: 1,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Execute a chain of commands connected by pipes
|
|
121
|
+
*/
|
|
122
|
+
async function executePipelineChain(
|
|
123
|
+
commands: PipelineCommand[],
|
|
124
|
+
authUser: string,
|
|
125
|
+
hostname: string,
|
|
126
|
+
users: VirtualUserManager,
|
|
127
|
+
mode: CommandMode,
|
|
128
|
+
cwd: string,
|
|
129
|
+
vfs: VirtualFileSystem,
|
|
130
|
+
): Promise<CommandResult> {
|
|
131
|
+
let currentOutput = "";
|
|
132
|
+
let exitCode = 0;
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < commands.length; i++) {
|
|
135
|
+
const cmd = commands[i] as PipelineCommand;
|
|
136
|
+
|
|
137
|
+
// Handle input file for first command
|
|
138
|
+
if (i === 0 && cmd.inputFile) {
|
|
139
|
+
const inputPath = resolvePath(cwd, cmd.inputFile);
|
|
140
|
+
try {
|
|
141
|
+
currentOutput = vfs.readFile(inputPath);
|
|
142
|
+
} catch {
|
|
143
|
+
return {
|
|
144
|
+
stderr: `cat: ${cmd.inputFile}: No such file or directory`,
|
|
145
|
+
exitCode: 1,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build raw input
|
|
151
|
+
const rawInput = [cmd.name, ...cmd.args].join(" ");
|
|
152
|
+
|
|
153
|
+
// Create a modified context that might accept stdin
|
|
154
|
+
// For now, we'll append input as an additional arg for commands that support it
|
|
155
|
+
const result = await runSingleCommand(
|
|
156
|
+
rawInput,
|
|
157
|
+
authUser,
|
|
158
|
+
hostname,
|
|
159
|
+
users,
|
|
160
|
+
mode,
|
|
161
|
+
cwd,
|
|
162
|
+
vfs,
|
|
163
|
+
currentOutput,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
exitCode = result.exitCode ?? 0;
|
|
167
|
+
|
|
168
|
+
// Handle output redirection (only for last command)
|
|
169
|
+
if (i === commands.length - 1 && cmd.outputFile) {
|
|
170
|
+
const outputPath = resolvePath(cwd, cmd.outputFile);
|
|
171
|
+
const output = result.stdout || "";
|
|
172
|
+
try {
|
|
173
|
+
if (cmd.appendOutput) {
|
|
174
|
+
try {
|
|
175
|
+
const existing = vfs.readFile(outputPath);
|
|
176
|
+
vfs.writeFile(outputPath, existing + output);
|
|
177
|
+
} catch {
|
|
178
|
+
vfs.writeFile(outputPath, output);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
vfs.writeFile(outputPath, output);
|
|
182
|
+
}
|
|
183
|
+
currentOutput = "";
|
|
184
|
+
} catch {
|
|
185
|
+
return {
|
|
186
|
+
stderr: `Failed to write to ${cmd.outputFile}`,
|
|
187
|
+
exitCode: 1,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// Pass output to next command
|
|
192
|
+
currentOutput = result.stdout || "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (result.stderr && exitCode !== 0) {
|
|
196
|
+
return { stderr: result.stderr, exitCode };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { stdout: currentOutput, exitCode };
|
|
201
|
+
}
|
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
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { Pipeline, PipelineCommand } from "../types/pipeline";
|
|
2
|
+
|
|
3
|
+
/** Parse a shell command line into a structured pipeline */
|
|
4
|
+
export function parseShellPipeline(rawInput: string): Pipeline {
|
|
5
|
+
const trimmed = rawInput.trim();
|
|
6
|
+
|
|
7
|
+
if (!trimmed) {
|
|
8
|
+
return { commands: [], isValid: true };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const commands: PipelineCommand[] = [];
|
|
12
|
+
const pipeTokens = tokenizePipeline(trimmed);
|
|
13
|
+
|
|
14
|
+
for (const token of pipeTokens) {
|
|
15
|
+
const cmd = parseCommandWithRedirections(token);
|
|
16
|
+
if (!cmd.isValid) {
|
|
17
|
+
return {
|
|
18
|
+
commands: [],
|
|
19
|
+
isValid: false,
|
|
20
|
+
error: cmd.error,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (cmd.command) {
|
|
24
|
+
commands.push(cmd.command);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { commands, isValid: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Tokenize input by pipes, respecting quoted strings */
|
|
32
|
+
function tokenizePipeline(input: string): string[] {
|
|
33
|
+
const tokens: string[] = [];
|
|
34
|
+
let current = "";
|
|
35
|
+
let inQuotes = false;
|
|
36
|
+
let quoteChar = "";
|
|
37
|
+
let i = 0;
|
|
38
|
+
|
|
39
|
+
while (i < input.length) {
|
|
40
|
+
const ch = input[i];
|
|
41
|
+
|
|
42
|
+
if ((ch === '"' || ch === "'") && (i === 0 || input[i - 1] !== "\\")) {
|
|
43
|
+
if (!inQuotes) {
|
|
44
|
+
inQuotes = true;
|
|
45
|
+
quoteChar = ch;
|
|
46
|
+
} else if (ch === quoteChar) {
|
|
47
|
+
inQuotes = false;
|
|
48
|
+
}
|
|
49
|
+
current += ch;
|
|
50
|
+
i++;
|
|
51
|
+
} else if (ch === "|" && !inQuotes) {
|
|
52
|
+
if (current.trim()) {
|
|
53
|
+
tokens.push(current.trim());
|
|
54
|
+
}
|
|
55
|
+
current = "";
|
|
56
|
+
i++;
|
|
57
|
+
} else {
|
|
58
|
+
current += ch;
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (current.trim()) {
|
|
64
|
+
tokens.push(current.trim());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return tokens;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ParseResult {
|
|
71
|
+
command?: PipelineCommand;
|
|
72
|
+
isValid: boolean;
|
|
73
|
+
error?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Parse a single command with its redirections (>, >>, <) */
|
|
77
|
+
function parseCommandWithRedirections(token: string): ParseResult {
|
|
78
|
+
const parts = tokenizeCommand(token);
|
|
79
|
+
|
|
80
|
+
if (parts.length === 0) {
|
|
81
|
+
return { isValid: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cmdParts: string[] = [];
|
|
85
|
+
let inputFile: string | undefined;
|
|
86
|
+
let outputFile: string | undefined;
|
|
87
|
+
let appendOutput = false;
|
|
88
|
+
|
|
89
|
+
let i = 0;
|
|
90
|
+
while (i < parts.length) {
|
|
91
|
+
const part = parts[i] as string;
|
|
92
|
+
|
|
93
|
+
if (part === "<") {
|
|
94
|
+
i++;
|
|
95
|
+
if (i >= parts.length) {
|
|
96
|
+
return {
|
|
97
|
+
isValid: false,
|
|
98
|
+
error: "Syntax error: expected filename after <",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
inputFile = parts[i];
|
|
102
|
+
i++;
|
|
103
|
+
} else if (part === ">>") {
|
|
104
|
+
i++;
|
|
105
|
+
if (i >= parts.length) {
|
|
106
|
+
return {
|
|
107
|
+
isValid: false,
|
|
108
|
+
error: "Syntax error: expected filename after >>",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
outputFile = parts[i];
|
|
112
|
+
appendOutput = true;
|
|
113
|
+
i++;
|
|
114
|
+
} else if (part === ">") {
|
|
115
|
+
i++;
|
|
116
|
+
if (i >= parts.length) {
|
|
117
|
+
return {
|
|
118
|
+
isValid: false,
|
|
119
|
+
error: "Syntax error: expected filename after >",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
outputFile = parts[i];
|
|
123
|
+
appendOutput = false;
|
|
124
|
+
i++;
|
|
125
|
+
} else {
|
|
126
|
+
cmdParts.push(part);
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (cmdParts.length === 0) {
|
|
132
|
+
return { isValid: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const name = (cmdParts[0] as string).toLowerCase();
|
|
136
|
+
const args = cmdParts.slice(1);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
command: {
|
|
140
|
+
name,
|
|
141
|
+
args,
|
|
142
|
+
inputFile,
|
|
143
|
+
outputFile,
|
|
144
|
+
appendOutput,
|
|
145
|
+
},
|
|
146
|
+
isValid: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tokenize a command, respecting quotes and handling >> vs > */
|
|
151
|
+
function tokenizeCommand(input: string): string[] {
|
|
152
|
+
const tokens: string[] = [];
|
|
153
|
+
let current = "";
|
|
154
|
+
let inQuotes = false;
|
|
155
|
+
let quoteChar = "";
|
|
156
|
+
let i = 0;
|
|
157
|
+
|
|
158
|
+
while (i < input.length) {
|
|
159
|
+
const ch = input[i];
|
|
160
|
+
const next = input[i + 1];
|
|
161
|
+
|
|
162
|
+
// Handle quotes
|
|
163
|
+
if ((ch === '"' || ch === "'") && (i === 0 || input[i - 1] !== "\\")) {
|
|
164
|
+
if (!inQuotes) {
|
|
165
|
+
inQuotes = true;
|
|
166
|
+
quoteChar = ch;
|
|
167
|
+
} else if (ch === quoteChar) {
|
|
168
|
+
inQuotes = false;
|
|
169
|
+
quoteChar = "";
|
|
170
|
+
} else {
|
|
171
|
+
current += ch;
|
|
172
|
+
}
|
|
173
|
+
i++;
|
|
174
|
+
} else if (ch === " " && !inQuotes) {
|
|
175
|
+
if (current) {
|
|
176
|
+
tokens.push(current);
|
|
177
|
+
current = "";
|
|
178
|
+
}
|
|
179
|
+
i++;
|
|
180
|
+
} else if ((ch === ">" || ch === "<") && !inQuotes) {
|
|
181
|
+
if (current) {
|
|
182
|
+
tokens.push(current);
|
|
183
|
+
current = "";
|
|
184
|
+
}
|
|
185
|
+
if (ch === ">" && next === ">") {
|
|
186
|
+
tokens.push(">>");
|
|
187
|
+
i += 2;
|
|
188
|
+
} else {
|
|
189
|
+
tokens.push(ch);
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
current += ch;
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (current) {
|
|
199
|
+
tokens.push(current);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return tokens;
|
|
203
|
+
}
|
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/types/commands.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface CommandContext {
|
|
|
76
76
|
mode: CommandMode;
|
|
77
77
|
/** Tokenized arguments excluding command name. */
|
|
78
78
|
args: string[];
|
|
79
|
+
/** Optional stdin payload (used by pipes/redirections). */
|
|
80
|
+
stdin?: string;
|
|
79
81
|
/** Current working directory for command execution. */
|
|
80
82
|
cwd: string;
|
|
81
83
|
/** Virtual filesystem instance for IO operations. */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Represents a single command in a pipeline. */
|
|
2
|
+
export interface PipelineCommand {
|
|
3
|
+
/** Command name */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Command arguments */
|
|
6
|
+
args: string[];
|
|
7
|
+
/** Input redirection file path (< file) */
|
|
8
|
+
inputFile?: string;
|
|
9
|
+
/** Output redirection file path (> file) */
|
|
10
|
+
outputFile?: string;
|
|
11
|
+
/** Append to output file (>> file) */
|
|
12
|
+
appendOutput?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Represents a parsed shell pipeline */
|
|
16
|
+
export interface Pipeline {
|
|
17
|
+
/** List of commands in the pipeline */
|
|
18
|
+
commands: PipelineCommand[];
|
|
19
|
+
/** Whether this is a valid pipeline */
|
|
20
|
+
isValid: boolean;
|
|
21
|
+
/** Error message if parsing failed */
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
@@ -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
|
+
});
|