create-ironclaws 1.0.0
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/README.md +101 -0
- package/bin/create.js +394 -0
- package/package.json +33 -0
- package/template/.env.example +38 -0
- package/template/CLAUDE.md +104 -0
- package/template/agent-credentials.yaml +33 -0
- package/template/agents.yaml +22 -0
- package/template/container/Dockerfile +70 -0
- package/template/container/Dockerfile.argus +34 -0
- package/template/container/agent-runner/package-lock.json +1524 -0
- package/template/container/agent-runner/package.json +23 -0
- package/template/container/agent-runner/src/index.ts +630 -0
- package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
- package/template/container/agent-runner/tsconfig.json +15 -0
- package/template/container/build-argus.sh +25 -0
- package/template/container/build.sh +23 -0
- package/template/container/skills/agent-browser/SKILL.md +159 -0
- package/template/container/skills/agent-status/SKILL.md +69 -0
- package/template/container/skills/capabilities/SKILL.md +100 -0
- package/template/container/skills/edit-agent/SKILL.md +93 -0
- package/template/container/skills/slack-formatting/SKILL.md +92 -0
- package/template/container/skills/status/SKILL.md +104 -0
- package/template/container/tools/elastic_query.py +161 -0
- package/template/container/tools/gdrive_tool.py +185 -0
- package/template/container/tools/jira_tool.py +433 -0
- package/template/container/tools/slack_history_tool.py +144 -0
- package/template/container/tools/youtube_tool.py +174 -0
- package/template/docker-compose.yml +54 -0
- package/template/docs/how-it-works.md +496 -0
- package/template/eslint.config.js +32 -0
- package/template/groups/forge/CLAUDE.md +107 -0
- package/template/package-lock.json +5278 -0
- package/template/package.json +52 -0
- package/template/scripts/github-app-token.py +58 -0
- package/template/scripts/register-expense-agent.sh +121 -0
- package/template/scripts/run-migrations.ts +105 -0
- package/template/scripts/setup-onecli-secrets.sh +252 -0
- package/template/setup-agents.sh +142 -0
- package/template/src/channels/index.ts +13 -0
- package/template/src/channels/registry.test.ts +42 -0
- package/template/src/channels/registry.ts +28 -0
- package/template/src/channels/slack.test.ts +859 -0
- package/template/src/channels/slack.ts +373 -0
- package/template/src/claw-skill.test.ts +45 -0
- package/template/src/config.ts +94 -0
- package/template/src/container-runner.test.ts +221 -0
- package/template/src/container-runner.ts +1029 -0
- package/template/src/container-runtime.test.ts +149 -0
- package/template/src/container-runtime.ts +124 -0
- package/template/src/db-migration.test.ts +67 -0
- package/template/src/db.test.ts +484 -0
- package/template/src/db.ts +837 -0
- package/template/src/env.ts +42 -0
- package/template/src/formatting.test.ts +294 -0
- package/template/src/github-token.ts +48 -0
- package/template/src/google-token.ts +75 -0
- package/template/src/group-folder.test.ts +43 -0
- package/template/src/group-folder.ts +44 -0
- package/template/src/group-queue.test.ts +484 -0
- package/template/src/group-queue.ts +363 -0
- package/template/src/http-server.ts +343 -0
- package/template/src/index.ts +960 -0
- package/template/src/ipc-auth.test.ts +679 -0
- package/template/src/ipc.ts +548 -0
- package/template/src/logger.ts +16 -0
- package/template/src/mount-security.ts +421 -0
- package/template/src/network-policy.ts +119 -0
- package/template/src/remote-control.test.ts +397 -0
- package/template/src/remote-control.ts +224 -0
- package/template/src/router.ts +52 -0
- package/template/src/routing.test.ts +170 -0
- package/template/src/sender-allowlist.test.ts +216 -0
- package/template/src/sender-allowlist.ts +128 -0
- package/template/src/task-scheduler.test.ts +129 -0
- package/template/src/task-scheduler.ts +290 -0
- package/template/src/timezone.test.ts +73 -0
- package/template/src/timezone.ts +37 -0
- package/template/src/types.ts +114 -0
- package/template/src/worktree.ts +206 -0
- package/template/tsconfig.json +20 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount Security Module for NanoClaw
|
|
3
|
+
*
|
|
4
|
+
* Validates additional mounts against an allowlist stored OUTSIDE the project root.
|
|
5
|
+
* This prevents container agents from modifying security configuration.
|
|
6
|
+
*
|
|
7
|
+
* Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import pino from 'pino';
|
|
13
|
+
|
|
14
|
+
import { MOUNT_ALLOWLIST_PATH } from './config.js';
|
|
15
|
+
import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
|
|
16
|
+
|
|
17
|
+
const logger = pino({
|
|
18
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
19
|
+
transport: { target: 'pino-pretty', options: { colorize: true } },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Cache the allowlist in memory - only reloads on process restart
|
|
23
|
+
let cachedAllowlist: MountAllowlist | null = null;
|
|
24
|
+
let allowlistLoadError: string | null = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default blocked patterns - paths that should never be mounted
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_BLOCKED_PATTERNS = [
|
|
30
|
+
'.ssh',
|
|
31
|
+
'.gnupg',
|
|
32
|
+
'.gpg',
|
|
33
|
+
'.aws',
|
|
34
|
+
'.azure',
|
|
35
|
+
'.gcloud',
|
|
36
|
+
'.kube',
|
|
37
|
+
'.docker',
|
|
38
|
+
'credentials',
|
|
39
|
+
'.env',
|
|
40
|
+
'.netrc',
|
|
41
|
+
'.npmrc',
|
|
42
|
+
'.pypirc',
|
|
43
|
+
'id_rsa',
|
|
44
|
+
'id_ed25519',
|
|
45
|
+
'private_key',
|
|
46
|
+
'.secret',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Load the mount allowlist from the external config location.
|
|
51
|
+
* Returns null if the file doesn't exist or is invalid.
|
|
52
|
+
* Result is cached in memory for the lifetime of the process.
|
|
53
|
+
*/
|
|
54
|
+
export function loadMountAllowlist(): MountAllowlist | null {
|
|
55
|
+
if (cachedAllowlist !== null) {
|
|
56
|
+
return cachedAllowlist;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (allowlistLoadError !== null) {
|
|
60
|
+
// Already tried and failed, don't spam logs
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
|
66
|
+
allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`;
|
|
67
|
+
logger.warn(
|
|
68
|
+
{ path: MOUNT_ALLOWLIST_PATH },
|
|
69
|
+
'Mount allowlist not found - additional mounts will be BLOCKED. ' +
|
|
70
|
+
'Create the file to enable additional mounts.',
|
|
71
|
+
);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
|
|
76
|
+
const allowlist = JSON.parse(content) as MountAllowlist;
|
|
77
|
+
|
|
78
|
+
// Validate structure
|
|
79
|
+
if (!Array.isArray(allowlist.allowedRoots)) {
|
|
80
|
+
throw new Error('allowedRoots must be an array');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!Array.isArray(allowlist.blockedPatterns)) {
|
|
84
|
+
throw new Error('blockedPatterns must be an array');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof allowlist.nonMainReadOnly !== 'boolean') {
|
|
88
|
+
throw new Error('nonMainReadOnly must be a boolean');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Merge with default blocked patterns
|
|
92
|
+
const mergedBlockedPatterns = [
|
|
93
|
+
...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]),
|
|
94
|
+
];
|
|
95
|
+
allowlist.blockedPatterns = mergedBlockedPatterns;
|
|
96
|
+
|
|
97
|
+
cachedAllowlist = allowlist;
|
|
98
|
+
logger.info(
|
|
99
|
+
{
|
|
100
|
+
path: MOUNT_ALLOWLIST_PATH,
|
|
101
|
+
allowedRoots: allowlist.allowedRoots.length,
|
|
102
|
+
blockedPatterns: allowlist.blockedPatterns.length,
|
|
103
|
+
},
|
|
104
|
+
'Mount allowlist loaded successfully',
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return cachedAllowlist;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
allowlistLoadError = err instanceof Error ? err.message : String(err);
|
|
110
|
+
logger.error(
|
|
111
|
+
{
|
|
112
|
+
path: MOUNT_ALLOWLIST_PATH,
|
|
113
|
+
error: allowlistLoadError,
|
|
114
|
+
},
|
|
115
|
+
'Failed to load mount allowlist - additional mounts will be BLOCKED',
|
|
116
|
+
);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Expand ~ to home directory and resolve to absolute path
|
|
123
|
+
*/
|
|
124
|
+
function expandPath(p: string): string {
|
|
125
|
+
const homeDir = process.env.HOME || os.homedir();
|
|
126
|
+
if (p.startsWith('~/')) {
|
|
127
|
+
return path.join(homeDir, p.slice(2));
|
|
128
|
+
}
|
|
129
|
+
if (p === '~') {
|
|
130
|
+
return homeDir;
|
|
131
|
+
}
|
|
132
|
+
return path.resolve(p);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the real path, resolving symlinks.
|
|
137
|
+
* Returns null if the path doesn't exist.
|
|
138
|
+
*/
|
|
139
|
+
function getRealPath(p: string): string | null {
|
|
140
|
+
try {
|
|
141
|
+
return fs.realpathSync(p);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a path matches any blocked pattern
|
|
149
|
+
*/
|
|
150
|
+
function matchesBlockedPattern(
|
|
151
|
+
realPath: string,
|
|
152
|
+
blockedPatterns: string[],
|
|
153
|
+
): string | null {
|
|
154
|
+
const pathParts = realPath.split(path.sep);
|
|
155
|
+
|
|
156
|
+
for (const pattern of blockedPatterns) {
|
|
157
|
+
// Check if any path component matches the pattern
|
|
158
|
+
for (const part of pathParts) {
|
|
159
|
+
if (part === pattern || part.includes(pattern)) {
|
|
160
|
+
return pattern;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Also check if the full path contains the pattern
|
|
165
|
+
if (realPath.includes(pattern)) {
|
|
166
|
+
return pattern;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if a real path is under an allowed root
|
|
175
|
+
*/
|
|
176
|
+
function findAllowedRoot(
|
|
177
|
+
realPath: string,
|
|
178
|
+
allowedRoots: AllowedRoot[],
|
|
179
|
+
): AllowedRoot | null {
|
|
180
|
+
for (const root of allowedRoots) {
|
|
181
|
+
const expandedRoot = expandPath(root.path);
|
|
182
|
+
const realRoot = getRealPath(expandedRoot);
|
|
183
|
+
|
|
184
|
+
if (realRoot === null) {
|
|
185
|
+
// Allowed root doesn't exist, skip it
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check if realPath is under realRoot
|
|
190
|
+
const relative = path.relative(realRoot, realPath);
|
|
191
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
192
|
+
return root;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Validate the container path to prevent escaping /workspace/extra/
|
|
201
|
+
*/
|
|
202
|
+
function isValidContainerPath(containerPath: string): boolean {
|
|
203
|
+
// Must not contain .. to prevent path traversal
|
|
204
|
+
if (containerPath.includes('..')) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Must not be absolute (it will be prefixed with /workspace/extra/)
|
|
209
|
+
if (containerPath.startsWith('/')) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Must not be empty
|
|
214
|
+
if (!containerPath || containerPath.trim() === '') {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface MountValidationResult {
|
|
222
|
+
allowed: boolean;
|
|
223
|
+
reason: string;
|
|
224
|
+
realHostPath?: string;
|
|
225
|
+
resolvedContainerPath?: string;
|
|
226
|
+
effectiveReadonly?: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Validate a single additional mount against the allowlist.
|
|
231
|
+
* Returns validation result with reason.
|
|
232
|
+
*/
|
|
233
|
+
export function validateMount(
|
|
234
|
+
mount: AdditionalMount,
|
|
235
|
+
isMain: boolean,
|
|
236
|
+
): MountValidationResult {
|
|
237
|
+
const allowlist = loadMountAllowlist();
|
|
238
|
+
|
|
239
|
+
// If no allowlist, block all additional mounts
|
|
240
|
+
if (allowlist === null) {
|
|
241
|
+
return {
|
|
242
|
+
allowed: false,
|
|
243
|
+
reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Derive containerPath from hostPath basename if not specified
|
|
248
|
+
const containerPath = mount.containerPath || path.basename(mount.hostPath);
|
|
249
|
+
|
|
250
|
+
// Validate container path (cheap check)
|
|
251
|
+
if (!isValidContainerPath(containerPath)) {
|
|
252
|
+
return {
|
|
253
|
+
allowed: false,
|
|
254
|
+
reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Expand and resolve the host path
|
|
259
|
+
const expandedPath = expandPath(mount.hostPath);
|
|
260
|
+
const realPath = getRealPath(expandedPath);
|
|
261
|
+
|
|
262
|
+
if (realPath === null) {
|
|
263
|
+
return {
|
|
264
|
+
allowed: false,
|
|
265
|
+
reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Check against blocked patterns
|
|
270
|
+
const blockedMatch = matchesBlockedPattern(
|
|
271
|
+
realPath,
|
|
272
|
+
allowlist.blockedPatterns,
|
|
273
|
+
);
|
|
274
|
+
if (blockedMatch !== null) {
|
|
275
|
+
return {
|
|
276
|
+
allowed: false,
|
|
277
|
+
reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Check if under an allowed root
|
|
282
|
+
const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots);
|
|
283
|
+
if (allowedRoot === null) {
|
|
284
|
+
return {
|
|
285
|
+
allowed: false,
|
|
286
|
+
reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
|
|
287
|
+
.map((r) => expandPath(r.path))
|
|
288
|
+
.join(', ')}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Determine effective readonly status
|
|
293
|
+
const requestedReadWrite = mount.readonly === false;
|
|
294
|
+
let effectiveReadonly = true; // Default to readonly
|
|
295
|
+
|
|
296
|
+
if (requestedReadWrite) {
|
|
297
|
+
if (!isMain && allowlist.nonMainReadOnly) {
|
|
298
|
+
// Non-main groups forced to read-only
|
|
299
|
+
effectiveReadonly = true;
|
|
300
|
+
logger.info(
|
|
301
|
+
{
|
|
302
|
+
mount: mount.hostPath,
|
|
303
|
+
},
|
|
304
|
+
'Mount forced to read-only for non-main group',
|
|
305
|
+
);
|
|
306
|
+
} else if (!allowedRoot.allowReadWrite) {
|
|
307
|
+
// Root doesn't allow read-write
|
|
308
|
+
effectiveReadonly = true;
|
|
309
|
+
logger.info(
|
|
310
|
+
{
|
|
311
|
+
mount: mount.hostPath,
|
|
312
|
+
root: allowedRoot.path,
|
|
313
|
+
},
|
|
314
|
+
'Mount forced to read-only - root does not allow read-write',
|
|
315
|
+
);
|
|
316
|
+
} else {
|
|
317
|
+
// Read-write allowed
|
|
318
|
+
effectiveReadonly = false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
allowed: true,
|
|
324
|
+
reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
|
|
325
|
+
realHostPath: realPath,
|
|
326
|
+
resolvedContainerPath: containerPath,
|
|
327
|
+
effectiveReadonly,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Validate all additional mounts for a group.
|
|
333
|
+
* Returns array of validated mounts (only those that passed validation).
|
|
334
|
+
* Logs warnings for rejected mounts.
|
|
335
|
+
*/
|
|
336
|
+
export interface ValidatedMount {
|
|
337
|
+
hostPath: string;
|
|
338
|
+
containerPath: string;
|
|
339
|
+
readonly: boolean;
|
|
340
|
+
worktree?: boolean; // Passed through so container-runner can resolve worktrees
|
|
341
|
+
originalHostPath?: string; // Original repo path (before worktree resolution)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function validateAdditionalMounts(
|
|
345
|
+
mounts: AdditionalMount[],
|
|
346
|
+
groupName: string,
|
|
347
|
+
isMain: boolean,
|
|
348
|
+
): ValidatedMount[] {
|
|
349
|
+
const validatedMounts: ValidatedMount[] = [];
|
|
350
|
+
|
|
351
|
+
for (const mount of mounts) {
|
|
352
|
+
const result = validateMount(mount, isMain);
|
|
353
|
+
|
|
354
|
+
if (result.allowed) {
|
|
355
|
+
validatedMounts.push({
|
|
356
|
+
hostPath: result.realHostPath!,
|
|
357
|
+
containerPath: `/workspace/extra/${result.resolvedContainerPath}`,
|
|
358
|
+
readonly: result.effectiveReadonly!,
|
|
359
|
+
worktree: mount.worktree,
|
|
360
|
+
originalHostPath: mount.worktree ? result.realHostPath! : undefined,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
logger.debug(
|
|
364
|
+
{
|
|
365
|
+
group: groupName,
|
|
366
|
+
hostPath: result.realHostPath,
|
|
367
|
+
containerPath: result.resolvedContainerPath,
|
|
368
|
+
readonly: result.effectiveReadonly,
|
|
369
|
+
reason: result.reason,
|
|
370
|
+
},
|
|
371
|
+
'Mount validated successfully',
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
logger.warn(
|
|
375
|
+
{
|
|
376
|
+
group: groupName,
|
|
377
|
+
requestedPath: mount.hostPath,
|
|
378
|
+
containerPath: mount.containerPath,
|
|
379
|
+
reason: result.reason,
|
|
380
|
+
},
|
|
381
|
+
'Additional mount REJECTED',
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return validatedMounts;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Generate a template allowlist file for users to customize
|
|
391
|
+
*/
|
|
392
|
+
export function generateAllowlistTemplate(): string {
|
|
393
|
+
const template: MountAllowlist = {
|
|
394
|
+
allowedRoots: [
|
|
395
|
+
{
|
|
396
|
+
path: '~/projects',
|
|
397
|
+
allowReadWrite: true,
|
|
398
|
+
description: 'Development projects',
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
path: '~/repos',
|
|
402
|
+
allowReadWrite: true,
|
|
403
|
+
description: 'Git repositories',
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
path: '~/Documents/work',
|
|
407
|
+
allowReadWrite: false,
|
|
408
|
+
description: 'Work documents (read-only)',
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
blockedPatterns: [
|
|
412
|
+
// Additional patterns beyond defaults
|
|
413
|
+
'password',
|
|
414
|
+
'secret',
|
|
415
|
+
'token',
|
|
416
|
+
],
|
|
417
|
+
nonMainReadOnly: true,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return JSON.stringify(template, null, 2);
|
|
421
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network policy enforcement for NanoClaw containers.
|
|
3
|
+
*
|
|
4
|
+
* Enforces per-container outbound rules via iptables in the DOCKER-USER chain:
|
|
5
|
+
* - ACCEPT DNS (port 53 UDP/TCP)
|
|
6
|
+
* - ACCEPT connections to OneCLI proxy (port 10255, post-DNAT)
|
|
7
|
+
* - REJECT everything else outbound (icmp-port-unreachable, works for all protocols)
|
|
8
|
+
*
|
|
9
|
+
* Rules are tagged with the container name for precise per-container cleanup.
|
|
10
|
+
*/
|
|
11
|
+
import { execSync } from 'child_process';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { logger } from './logger.js';
|
|
14
|
+
|
|
15
|
+
// iptables enforcement is Linux-only. On macOS/Windows (local dev), containers
|
|
16
|
+
// run without network enforcement — acceptable for development, not for production.
|
|
17
|
+
const IPTABLES_SUPPORTED = os.platform() === 'linux';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the OneCLI proxy container IP dynamically.
|
|
21
|
+
* The proxy runs as a Docker container — its IP changes if it restarts.
|
|
22
|
+
* We look it up from the DNAT rule so we can ACCEPT post-DNAT traffic.
|
|
23
|
+
*
|
|
24
|
+
* Throws if the IP cannot be resolved — callers must not proceed without it.
|
|
25
|
+
*/
|
|
26
|
+
function getOneCLIProxyIp(): string {
|
|
27
|
+
try {
|
|
28
|
+
// Read the NAT DNAT rule for port 10255 — the target IP is the OneCLI container
|
|
29
|
+
const natOutput = execSync(
|
|
30
|
+
`sudo iptables -t nat -L DOCKER -n 2>/dev/null | grep 'dpt:10255'`,
|
|
31
|
+
{ encoding: 'utf-8', timeout: 5000 },
|
|
32
|
+
).trim();
|
|
33
|
+
const match = natOutput.match(/to:(\d+\.\d+\.\d+\.\d+):/);
|
|
34
|
+
if (match) return match[1];
|
|
35
|
+
} catch { /* fall through to next method */ }
|
|
36
|
+
|
|
37
|
+
// Fallback: inspect the onecli container directly
|
|
38
|
+
try {
|
|
39
|
+
const ip = execSync(
|
|
40
|
+
`docker inspect onecli --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null`,
|
|
41
|
+
{ encoding: 'utf-8', timeout: 5000 },
|
|
42
|
+
).trim();
|
|
43
|
+
// Returns space-separated IPs if multiple networks — take the last non-empty one
|
|
44
|
+
const ips = ip.split(/\s+/).filter(Boolean);
|
|
45
|
+
if (ips.length > 0) return ips[ips.length - 1];
|
|
46
|
+
} catch { /* fall through */ }
|
|
47
|
+
|
|
48
|
+
throw new Error('Cannot resolve OneCLI proxy IP — is the onecli container running?');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the container IP (post-spawn, with retry).
|
|
53
|
+
* Returns null if the container doesn't have an IP after 10 retries (~3s).
|
|
54
|
+
*/
|
|
55
|
+
export function getContainerIp(containerName: string): string | null {
|
|
56
|
+
for (let i = 0; i < 10; i++) {
|
|
57
|
+
try {
|
|
58
|
+
const ip = execSync(
|
|
59
|
+
`docker inspect ${containerName} --format '{{(index .NetworkSettings.Networks "bridge").IPAddress}}' 2>/dev/null`,
|
|
60
|
+
{ encoding: 'utf-8', timeout: 5000 },
|
|
61
|
+
).trim();
|
|
62
|
+
if (ip && ip !== '<no value>') return ip;
|
|
63
|
+
} catch { /* not ready yet */ }
|
|
64
|
+
try { execSync('sleep 0.3', { stdio: 'pipe' }); } catch { /* ok */ }
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply per-container iptables rules.
|
|
71
|
+
*
|
|
72
|
+
* Must be called synchronously before writing to the container's stdin.
|
|
73
|
+
* Throws on any failure — the caller must kill the container if this throws.
|
|
74
|
+
*/
|
|
75
|
+
export function applyContainerIptables(containerName: string, containerIp: string): void {
|
|
76
|
+
if (!IPTABLES_SUPPORTED) {
|
|
77
|
+
logger.warn({ containerName }, 'iptables not supported on this platform — network enforcement skipped (dev mode only)');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const onecliIp = getOneCLIProxyIp(); // throws if not resolvable
|
|
81
|
+
|
|
82
|
+
const tag = containerName;
|
|
83
|
+
// DNS (UDP + TCP)
|
|
84
|
+
execSync(`sudo iptables -I DOCKER-USER 1 -s ${containerIp} -p udp --dport 53 -j ACCEPT -m comment --comment "${tag}"`, { stdio: 'pipe', timeout: 5000 });
|
|
85
|
+
execSync(`sudo iptables -I DOCKER-USER 1 -s ${containerIp} -p tcp --dport 53 -j ACCEPT -m comment --comment "${tag}"`, { stdio: 'pipe', timeout: 5000 });
|
|
86
|
+
// OneCLI proxy (post-DNAT destination)
|
|
87
|
+
execSync(`sudo iptables -I DOCKER-USER 1 -s ${containerIp} -d ${onecliIp} -p tcp --dport 10255 -j ACCEPT -m comment --comment "${tag}"`, { stdio: 'pipe', timeout: 5000 });
|
|
88
|
+
// Reject everything else outbound — REJECT (not DROP) so connections fail
|
|
89
|
+
// immediately rather than hanging until TCP timeout.
|
|
90
|
+
// icmp-port-unreachable works for all protocols (tcp-reset is TCP-only).
|
|
91
|
+
execSync(`sudo iptables -A DOCKER-USER -s ${containerIp} -j REJECT --reject-with icmp-port-unreachable -m comment --comment "${tag}"`, { stdio: 'pipe', timeout: 5000 });
|
|
92
|
+
|
|
93
|
+
logger.info({ containerName, containerIp, onecliIp }, 'iptables rules applied');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Remove all iptables rules tagged with this container name.
|
|
98
|
+
* Called when the container exits. Safe to call multiple times.
|
|
99
|
+
*/
|
|
100
|
+
export function cleanupContainerIptables(containerName: string): void {
|
|
101
|
+
if (!IPTABLES_SUPPORTED) return;
|
|
102
|
+
try {
|
|
103
|
+
const rules = execSync(
|
|
104
|
+
`sudo iptables -S DOCKER-USER 2>/dev/null | grep '${containerName}'`,
|
|
105
|
+
{ encoding: 'utf-8', timeout: 5000 },
|
|
106
|
+
).trim();
|
|
107
|
+
|
|
108
|
+
if (!rules) return;
|
|
109
|
+
|
|
110
|
+
for (const rule of rules.split('\n')) {
|
|
111
|
+
const deleteCmd = rule.replace(/^-[AI]\s+DOCKER-USER\s+/, '-D DOCKER-USER ');
|
|
112
|
+
try {
|
|
113
|
+
execSync(`sudo iptables ${deleteCmd}`, { stdio: 'pipe', timeout: 5000 });
|
|
114
|
+
} catch { /* rule may already be gone */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
logger.info({ containerName }, 'iptables rules cleaned up');
|
|
118
|
+
} catch { /* no rules to clean up */ }
|
|
119
|
+
}
|