@eighty4/c2 0.0.2-9 → 0.0.2
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/CHANGELOG.md +13 -2
- package/README.md +15 -6
- package/lib/attachments.spec.ts +62 -4
- package/lib/attachments.ts +22 -16
- package/lib/build.spec.ts +41 -44
- package/lib/build.ts +7 -42
- package/lib/c2.api.ts +1 -1
- package/lib/c2.bin.ts +18 -9
- package/lib/cli.spec.ts +55 -9
- package/lib/cli.ts +27 -6
- package/lib/expression.spec.ts +10 -2
- package/lib/expression.ts +3 -5
- package/lib/http.spec.ts +57 -0
- package/lib/http.ts +110 -0
- package/lib_js/attachments.js +16 -13
- package/lib_js/build.js +4 -33
- package/lib_js/c2.api.js +1 -1
- package/lib_js/c2.bin.js +17 -8
- package/lib_js/cli.js +24 -6
- package/lib_js/expression.js +3 -3
- package/lib_js/http.js +94 -0
- package/lib_types/attachments.d.ts +2 -1
- package/lib_types/attachments.d.ts.map +1 -1
- package/lib_types/build.d.ts.map +1 -1
- package/lib_types/c2.api.d.ts +1 -1
- package/lib_types/c2.api.d.ts.map +1 -1
- package/lib_types/cli.d.ts +1 -1
- package/lib_types/cli.d.ts.map +1 -1
- package/lib_types/http.d.ts +18 -0
- package/lib_types/http.d.ts.map +1 -0
- package/package.json +13 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
- ???
|
|
6
|
+
|
|
7
|
+
## [v0.0.2] - 2025-06-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Serve user data over http with `c2 DIR --http PORT`
|
|
12
|
+
- Encode user data in base 64 with `c2 DIR --base64`
|
|
3
13
|
- Evaluate env() and file() expressions in user data
|
|
4
14
|
- Merge multiple user data into a MIME multipart message
|
|
5
15
|
|
|
6
|
-
[Unreleased]: https://github.com/eighty4/c2/
|
|
16
|
+
[Unreleased]: https://github.com/eighty4/c2/compare/v0.0.2...HEAD
|
|
17
|
+
[v0.0.2]: https://github.com/eighty4/c2/releases/tag/v0.0.2
|
package/README.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Blow up your `cloud-init` developer workflows!
|
|
4
4
|
|
|
5
|
+
See the [examples dir](https://github.com/eighty4/c2/tree/main/examples) for
|
|
6
|
+
examples running on your local machine or deploying to cloud providers.
|
|
7
|
+
|
|
5
8
|
## Getting started
|
|
6
9
|
|
|
7
10
|
```shell
|
|
@@ -9,7 +12,8 @@ npm i -g @eighty4/c2
|
|
|
9
12
|
c2 -h
|
|
10
13
|
```
|
|
11
14
|
|
|
12
|
-
(tests use `bun:test` so [install Bun](https://bun.sh/docs/installation)
|
|
15
|
+
(tests use `bun:test` so [install Bun](https://bun.sh/docs/installation)
|
|
16
|
+
for contributing!)
|
|
13
17
|
|
|
14
18
|
## Using the CLI program
|
|
15
19
|
|
|
@@ -30,7 +34,8 @@ const userData: string = await buildUserData('./cloud_init_dir')
|
|
|
30
34
|
|
|
31
35
|
## Cloud Config data dir
|
|
32
36
|
|
|
33
|
-
Multiple user data are ordered by filenames and `01_` numbered prefixes help
|
|
37
|
+
Multiple user data are ordered by filenames and `01_` numbered prefixes help
|
|
38
|
+
declare execution order.
|
|
34
39
|
|
|
35
40
|
```
|
|
36
41
|
ls ./cloud_init_dir
|
|
@@ -39,11 +44,13 @@ ls ./cloud_init_dir
|
|
|
39
44
|
03_services.sh
|
|
40
45
|
```
|
|
41
46
|
|
|
42
|
-
Shell scripts are supported as `x-shellscript` and YAML is included as
|
|
47
|
+
Shell scripts are supported as `x-shellscript` and YAML is included as
|
|
48
|
+
`cloud-config` MIME types.
|
|
43
49
|
|
|
44
50
|
## Evaluating expressions in user data
|
|
45
51
|
|
|
46
|
-
Scripts and YAML support two template functions that can be used in
|
|
52
|
+
Scripts and YAML support two template functions that can be used in
|
|
53
|
+
expressions.
|
|
47
54
|
|
|
48
55
|
### env()
|
|
49
56
|
|
|
@@ -57,7 +64,8 @@ ENV_VAR="${{ env('LOCAL_ENV_VAR') }}"
|
|
|
57
64
|
|
|
58
65
|
### file()
|
|
59
66
|
|
|
60
|
-
Looks up a file from your local filesystem and replaces the expression with
|
|
67
|
+
Looks up a file from your local filesystem and replaces the expression with
|
|
68
|
+
its content.
|
|
61
69
|
|
|
62
70
|
```yaml
|
|
63
71
|
#cloud-config
|
|
@@ -72,6 +80,7 @@ Relative, absolute and `~/` paths are supported.
|
|
|
72
80
|
|
|
73
81
|
## Contributing
|
|
74
82
|
|
|
75
|
-
I use `c2` for initializing Debian cloud instances and locally test with QEMU
|
|
83
|
+
I use `c2` for initializing Debian cloud instances and locally test with QEMU
|
|
84
|
+
and Ubuntu.
|
|
76
85
|
|
|
77
86
|
Feedback on your use cases and worfklows is greatly appreciated!
|
package/lib/attachments.spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { collectAttachments } from '#c2/attachments
|
|
4
|
-
import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing
|
|
3
|
+
import { collectAttachments } from '#c2/attachments'
|
|
4
|
+
import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing'
|
|
5
5
|
|
|
6
6
|
let tmpDir: string
|
|
7
7
|
|
|
@@ -9,18 +9,53 @@ beforeEach(async () => (tmpDir = await makeTempDir()))
|
|
|
9
9
|
|
|
10
10
|
afterEach(async () => await removeDir(tmpDir))
|
|
11
11
|
|
|
12
|
+
test('collect throws error for unknown filetype', async () => {
|
|
13
|
+
await makeFile('init-cloud', 'whoopie', tmpDir)
|
|
14
|
+
await expect(() => collectAttachments(tmpDir)).toThrow(
|
|
15
|
+
'init-cloud is an unsupported file type',
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
|
|
12
19
|
test('collect cloud config yml', async () => {
|
|
13
|
-
await makeFile('init-cloud.yml', '
|
|
20
|
+
await makeFile('init-cloud.yml', '#cloud-config\nwhoopie', tmpDir)
|
|
14
21
|
expect(await collectAttachments(tmpDir)).toStrictEqual([
|
|
15
22
|
{
|
|
16
23
|
path: join(tmpDir, 'init-cloud.yml'),
|
|
17
|
-
content: '
|
|
24
|
+
content: '#cloud-config\nwhoopie',
|
|
18
25
|
filename: 'init-cloud.yml',
|
|
26
|
+
source: '#cloud-config\nwhoopie',
|
|
19
27
|
type: 'cloud-config',
|
|
20
28
|
},
|
|
21
29
|
])
|
|
22
30
|
})
|
|
23
31
|
|
|
32
|
+
test('collect cloud config yaml', async () => {
|
|
33
|
+
await makeFile('init-cloud.yaml', '#cloud-config\nwhoopie', tmpDir)
|
|
34
|
+
expect(await collectAttachments(tmpDir)).toStrictEqual([
|
|
35
|
+
{
|
|
36
|
+
path: join(tmpDir, 'init-cloud.yaml'),
|
|
37
|
+
content: '#cloud-config\nwhoopie',
|
|
38
|
+
filename: 'init-cloud.yaml',
|
|
39
|
+
source: '#cloud-config\nwhoopie',
|
|
40
|
+
type: 'cloud-config',
|
|
41
|
+
},
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('collect throws error when yml does not have #cloud-config comment', async () => {
|
|
46
|
+
await makeFile('init-cloud.yml', 'whoopie', tmpDir)
|
|
47
|
+
await expect(() => collectAttachments(tmpDir)).toThrow(
|
|
48
|
+
'YAML cloud config must start with a #cloud-config comment',
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('collect throws error when yaml does not have #cloud-config comment', async () => {
|
|
53
|
+
await makeFile('init-cloud.yaml', 'whoopie', tmpDir)
|
|
54
|
+
await expect(() => collectAttachments(tmpDir)).toThrow(
|
|
55
|
+
'YAML cloud config must start with a #cloud-config comment',
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
24
59
|
test('collect shell script', async () => {
|
|
25
60
|
await makeFile('init-cloud.sh', 'whoopie', tmpDir)
|
|
26
61
|
expect(await collectAttachments(tmpDir)).toStrictEqual([
|
|
@@ -28,9 +63,30 @@ test('collect shell script', async () => {
|
|
|
28
63
|
path: join(tmpDir, 'init-cloud.sh'),
|
|
29
64
|
content: 'whoopie',
|
|
30
65
|
filename: 'init-cloud.sh',
|
|
66
|
+
source: 'whoopie',
|
|
67
|
+
type: 'x-shellscript',
|
|
68
|
+
},
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('evals template expressions', async () => {
|
|
73
|
+
const resourceTmpDir = await makeTempDir()
|
|
74
|
+
await makeFile('whoopie', 'whoopie', resourceTmpDir)
|
|
75
|
+
await makeFile(
|
|
76
|
+
'init-cloud.sh',
|
|
77
|
+
`\${{ file('${resourceTmpDir}/whoopie') }}`,
|
|
78
|
+
tmpDir,
|
|
79
|
+
)
|
|
80
|
+
expect(await collectAttachments(tmpDir)).toStrictEqual([
|
|
81
|
+
{
|
|
82
|
+
path: tmpDir + '/init-cloud.sh',
|
|
83
|
+
content: 'whoopie',
|
|
84
|
+
filename: 'init-cloud.sh',
|
|
85
|
+
source: `\${{ file('${resourceTmpDir}/whoopie') }}`,
|
|
31
86
|
type: 'x-shellscript',
|
|
32
87
|
},
|
|
33
88
|
])
|
|
89
|
+
await removeDir(resourceTmpDir)
|
|
34
90
|
})
|
|
35
91
|
|
|
36
92
|
test('sorts attachments by filename', async () => {
|
|
@@ -41,12 +97,14 @@ test('sorts attachments by filename', async () => {
|
|
|
41
97
|
path: join(tmpDir, '01-init-cloud.sh'),
|
|
42
98
|
content: 'whoopie',
|
|
43
99
|
filename: '01-init-cloud.sh',
|
|
100
|
+
source: 'whoopie',
|
|
44
101
|
type: 'x-shellscript',
|
|
45
102
|
},
|
|
46
103
|
{
|
|
47
104
|
path: join(tmpDir, '02-init-cloud.sh'),
|
|
48
105
|
content: 'whoopie',
|
|
49
106
|
filename: '02-init-cloud.sh',
|
|
107
|
+
source: 'whoopie',
|
|
50
108
|
type: 'x-shellscript',
|
|
51
109
|
},
|
|
52
110
|
])
|
package/lib/attachments.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { evalTemplateExpressions } from '#c2/expression'
|
|
2
|
+
import { readDirListing, readToString } from '#c2/fs'
|
|
2
3
|
|
|
3
4
|
export type AttachmentType = 'cloud-config' | 'x-shellscript'
|
|
4
5
|
|
|
@@ -6,20 +7,24 @@ export interface Attachment {
|
|
|
6
7
|
path: string
|
|
7
8
|
content: string
|
|
8
9
|
filename: string
|
|
10
|
+
source: string
|
|
9
11
|
type: AttachmentType
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export async function collectAttachments(
|
|
13
15
|
dir: string,
|
|
14
16
|
): Promise<Array<Attachment>> {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
const filenames = await readDirListing(dir)
|
|
18
|
+
const attachments = await Promise.all(
|
|
19
|
+
filenames.map(async filename => {
|
|
20
|
+
const path = `${dir}/${filename}`
|
|
21
|
+
const source = await readToString(path)
|
|
22
|
+
const type = resolveAttachmentType(filename, source)
|
|
23
|
+
const content = await evalTemplateExpressions(source)
|
|
24
|
+
return { content, filename, path, type, source }
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
return attachments.sort(compareAttachmentFilenames)
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
function compareAttachmentFilenames(
|
|
@@ -33,17 +38,18 @@ function compareAttachmentFilenames(
|
|
|
33
38
|
|
|
34
39
|
export function resolveAttachmentType(
|
|
35
40
|
filename: string,
|
|
36
|
-
|
|
41
|
+
source: string,
|
|
37
42
|
): AttachmentType {
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
if (filename.endsWith('.yml') || filename.endsWith('.yaml')) {
|
|
44
|
+
if (!source.trim().startsWith('#cloud-config')) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'YAML cloud config must start with a #cloud-config comment',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
43
49
|
return 'cloud-config'
|
|
44
50
|
} else if (filename.endsWith('.sh')) {
|
|
45
51
|
return 'x-shellscript'
|
|
46
52
|
} else {
|
|
47
|
-
throw new Error(
|
|
53
|
+
throw new Error(`${filename} is an unsupported file type`)
|
|
48
54
|
}
|
|
49
55
|
}
|
package/lib/build.spec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
-
import { buildUserData } from '#c2/build
|
|
3
|
-
import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing
|
|
2
|
+
import { buildUserData } from '#c2/build'
|
|
3
|
+
import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing'
|
|
4
4
|
|
|
5
5
|
let tmpDir: string
|
|
6
6
|
|
|
@@ -11,9 +11,9 @@ beforeEach(async () => {
|
|
|
11
11
|
afterEach(async () => removeDir(tmpDir))
|
|
12
12
|
|
|
13
13
|
test('build user data of single file', async () => {
|
|
14
|
-
const initCloudYml = '
|
|
14
|
+
const initCloudYml = '#cloud-config\nwhoopie'
|
|
15
15
|
await makeFile('init-cloud.yml', initCloudYml, tmpDir)
|
|
16
|
-
expect(await buildUserData(tmpDir)).toStrictEqual(
|
|
16
|
+
expect(await buildUserData(tmpDir)).toStrictEqual(initCloudYml)
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
test('build user data of single file with template expression', async () => {
|
|
@@ -21,38 +21,37 @@ test('build user data of single file with template expression', async () => {
|
|
|
21
21
|
await makeFile('whoopie', 'whoopie', whoopie)
|
|
22
22
|
await makeFile(
|
|
23
23
|
'init-cloud.yml',
|
|
24
|
-
|
|
24
|
+
`#cloud-config\n\${{ file('${whoopie}/whoopie')}}`,
|
|
25
25
|
tmpDir,
|
|
26
26
|
)
|
|
27
|
-
expect(await buildUserData(tmpDir)).toStrictEqual('
|
|
27
|
+
expect(await buildUserData(tmpDir)).toStrictEqual('#cloud-config\nwhoopie')
|
|
28
28
|
await removeDir(whoopie)
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
test('build user data multipart message', async () => {
|
|
32
|
-
await makeFile('1-init-cloud.yml', '
|
|
32
|
+
await makeFile('1-init-cloud.yml', '#cloud-config\nwhoopie', tmpDir)
|
|
33
33
|
await makeFile('2-init-cloud.sh', 'cushion', tmpDir)
|
|
34
34
|
const boundary = Bun.randomUUIDv7()
|
|
35
35
|
expect(
|
|
36
36
|
await buildUserData(tmpDir, { attachmentBoundary: boundary }),
|
|
37
37
|
).toStrictEqual(
|
|
38
|
-
`Content-Type: multipart/mixed; boundary=${boundary}
|
|
39
|
-
MIME-Version: 1.0
|
|
40
|
-
Number-Attachments: 2
|
|
41
|
-
--${boundary}
|
|
42
|
-
Content-Type: text/cloud-config; charset="us-ascii"
|
|
43
|
-
|
|
44
|
-
Content-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
whoopie
|
|
48
|
-
--${boundary}
|
|
49
|
-
Content-Type: text/x-shellscript; charset="us-ascii"
|
|
50
|
-
|
|
51
|
-
Content-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
--${boundary}
|
|
38
|
+
`Content-Type: multipart/mixed; boundary=${boundary}\r
|
|
39
|
+
MIME-Version: 1.0\r
|
|
40
|
+
Number-Attachments: 2\r
|
|
41
|
+
--${boundary}\r
|
|
42
|
+
Content-Type: text/cloud-config; charset="us-ascii"\r
|
|
43
|
+
Content-Transfer-Encoding: 7bit\r
|
|
44
|
+
Content-Disposition: attachment; filename="1-init-cloud.yml"\r
|
|
45
|
+
\r
|
|
46
|
+
#cloud-config
|
|
47
|
+
whoopie\r
|
|
48
|
+
--${boundary}\r
|
|
49
|
+
Content-Type: text/x-shellscript; charset="us-ascii"\r
|
|
50
|
+
Content-Transfer-Encoding: 7bit\r
|
|
51
|
+
Content-Disposition: attachment; filename="2-init-cloud.sh"\r
|
|
52
|
+
\r
|
|
53
|
+
cushion\r
|
|
54
|
+
--${boundary}\r
|
|
56
55
|
`,
|
|
57
56
|
)
|
|
58
57
|
})
|
|
@@ -62,7 +61,7 @@ test('build user data multipart with template expression', async () => {
|
|
|
62
61
|
await makeFile('whoopie', 'whoopie', whoopie)
|
|
63
62
|
await makeFile(
|
|
64
63
|
'1-init-cloud.yml',
|
|
65
|
-
|
|
64
|
+
`#cloud-config\n\${{ file('${whoopie}/whoopie')}}`,
|
|
66
65
|
tmpDir,
|
|
67
66
|
)
|
|
68
67
|
await makeFile('2-init-cloud.sh', 'cushion', tmpDir)
|
|
@@ -70,24 +69,22 @@ test('build user data multipart with template expression', async () => {
|
|
|
70
69
|
expect(
|
|
71
70
|
await buildUserData(tmpDir, { attachmentBoundary: boundary }),
|
|
72
71
|
).toStrictEqual(
|
|
73
|
-
`Content-Type: multipart/mixed; boundary=${boundary}
|
|
74
|
-
MIME-Version: 1.0
|
|
75
|
-
Number-Attachments: 2
|
|
76
|
-
--${boundary}
|
|
77
|
-
Content-Type: text/cloud-config; charset="us-ascii"
|
|
78
|
-
|
|
79
|
-
Content-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
Content-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
cushion
|
|
90
|
-
--${boundary}
|
|
72
|
+
`Content-Type: multipart/mixed; boundary=${boundary}\r
|
|
73
|
+
MIME-Version: 1.0\r
|
|
74
|
+
Number-Attachments: 2\r
|
|
75
|
+
--${boundary}\r
|
|
76
|
+
Content-Type: text/cloud-config; charset="us-ascii"\r
|
|
77
|
+
Content-Transfer-Encoding: 7bit\r
|
|
78
|
+
Content-Disposition: attachment; filename="1-init-cloud.yml"\r
|
|
79
|
+
\r
|
|
80
|
+
#cloud-config\nwhoopie\r
|
|
81
|
+
--${boundary}\r
|
|
82
|
+
Content-Type: text/x-shellscript; charset="us-ascii"\r
|
|
83
|
+
Content-Transfer-Encoding: 7bit\r
|
|
84
|
+
Content-Disposition: attachment; filename="2-init-cloud.sh"\r
|
|
85
|
+
\r
|
|
86
|
+
cushion\r
|
|
87
|
+
--${boundary}\r
|
|
91
88
|
`,
|
|
92
89
|
)
|
|
93
90
|
})
|
package/lib/build.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { readToString } from '#c2/fs.ts'
|
|
1
|
+
import { collectAttachments } from '#c2/attachments'
|
|
2
|
+
import { MultipartMessage } from '#c2/http'
|
|
4
3
|
|
|
5
4
|
export type BuildUserDataOpts = {
|
|
6
5
|
attachmentBoundary?: string
|
|
@@ -15,45 +14,11 @@ export async function buildUserData(
|
|
|
15
14
|
case 0:
|
|
16
15
|
throw new Error(`nothing found in dir ${userDataDir}`)
|
|
17
16
|
case 1:
|
|
18
|
-
return
|
|
19
|
-
await readToString(attachments[0].path),
|
|
20
|
-
)
|
|
17
|
+
return attachments[0].content
|
|
21
18
|
default:
|
|
22
|
-
return
|
|
19
|
+
return new MultipartMessage(
|
|
20
|
+
attachments,
|
|
21
|
+
opts?.attachmentBoundary,
|
|
22
|
+
).toHTTP()
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
function createBoundary(): string {
|
|
27
|
-
return new Date().toISOString()
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function buildMultipartUserData(
|
|
31
|
-
attachments: Array<Attachment>,
|
|
32
|
-
boundary: string = createBoundary(),
|
|
33
|
-
): Promise<string> {
|
|
34
|
-
let result = `Content-Type: multipart/mixed; boundary=${boundary}
|
|
35
|
-
MIME-Version: 1.0
|
|
36
|
-
Number-Attachments: ${attachments.length}
|
|
37
|
-
--${boundary}
|
|
38
|
-
`
|
|
39
|
-
|
|
40
|
-
for (const attachment of attachments) {
|
|
41
|
-
let content: string
|
|
42
|
-
try {
|
|
43
|
-
content = await evalTemplateExpressions(attachment.content)
|
|
44
|
-
} catch (e: any) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`error templating ${attachment.filename}: ${e.message}`,
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
result += `Content-Type: text/${attachment.type}; charset="us-ascii"
|
|
50
|
-
MIME-Version: 1.0
|
|
51
|
-
Content-Transfer-Encoding: 7bit
|
|
52
|
-
Content-Disposition: attachment; filename="${attachment.filename}"
|
|
53
|
-
|
|
54
|
-
${content}
|
|
55
|
-
--${boundary}
|
|
56
|
-
`
|
|
57
|
-
}
|
|
58
|
-
return result
|
|
59
|
-
}
|
package/lib/c2.api.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { type BuildUserDataOpts, buildUserData } from '#c2/build
|
|
1
|
+
export { type BuildUserDataOpts, buildUserData } from '#c2/build'
|
package/lib/c2.bin.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { buildUserData } from '#c2/build
|
|
4
|
-
import { parseArgs, type ParsedArgs } from '#c2/cli
|
|
5
|
-
import { doesDirExist } from '#c2/fs
|
|
3
|
+
import { buildUserData } from '#c2/build'
|
|
4
|
+
import { parseArgs, type ParsedArgs } from '#c2/cli'
|
|
5
|
+
import { doesDirExist } from '#c2/fs'
|
|
6
|
+
import { startUserDataHttp } from '#c2/http'
|
|
6
7
|
|
|
7
8
|
let args: ParsedArgs | undefined
|
|
8
9
|
try {
|
|
@@ -21,17 +22,25 @@ if (!args || args.help) {
|
|
|
21
22
|
)
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
if (args.httpPort) {
|
|
25
|
-
errorExit('--http PORT is not yet implemented')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
25
|
if (!(await doesDirExist(args.userDataDir))) {
|
|
29
26
|
errorExit(`${args.userDataDir} directory does not exist`)
|
|
30
27
|
}
|
|
31
28
|
|
|
29
|
+
let work: () => Promise<void>
|
|
30
|
+
|
|
31
|
+
if (typeof args.httpPort !== 'undefined') {
|
|
32
|
+
work = async () => {
|
|
33
|
+
startUserDataHttp(args.httpPort!, args.userDataDir)
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
work = async () => {
|
|
37
|
+
const userData = await buildUserData(args.userDataDir)
|
|
38
|
+
console.log(args.base64 ? btoa(userData) : userData)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
try {
|
|
33
|
-
|
|
34
|
-
console.log(args.base64 ? btoa(userData) : userData)
|
|
43
|
+
await work()
|
|
35
44
|
} catch (e: any) {
|
|
36
45
|
errorExit(e.message)
|
|
37
46
|
}
|
package/lib/cli.spec.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { expect, test } from 'bun:test'
|
|
2
|
-
import { parseArgs } from '#c2/cli
|
|
2
|
+
import { parseArgs } from '#c2/cli'
|
|
3
3
|
|
|
4
|
-
test('parseArgs', () => {
|
|
4
|
+
test('parseArgs with ts entrypoint', () => {
|
|
5
5
|
expect(
|
|
6
6
|
parseArgs([
|
|
7
7
|
'/Users/who/.bun/bin/bun',
|
|
8
|
-
'/Users/who/user-data/c2.ts',
|
|
8
|
+
'/Users/who/user-data/lib/c2.bin.ts',
|
|
9
9
|
'user_data_dir',
|
|
10
10
|
]),
|
|
11
11
|
).toStrictEqual({
|
|
@@ -14,9 +14,44 @@ test('parseArgs', () => {
|
|
|
14
14
|
})
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
+
test('parseArgs with executable entrypoint', () => {
|
|
18
|
+
expect(
|
|
19
|
+
parseArgs([
|
|
20
|
+
'/Users/who/.nvm/versions/node/v23.7.0/bin/node',
|
|
21
|
+
'/Users/who/.nvm/versions/node/v23.7.0/bin/c2',
|
|
22
|
+
'user_data_dir',
|
|
23
|
+
]),
|
|
24
|
+
).toStrictEqual({
|
|
25
|
+
base64: false,
|
|
26
|
+
userDataDir: 'user_data_dir',
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('parseArgs with js entrypoint', () => {
|
|
31
|
+
expect(
|
|
32
|
+
parseArgs([
|
|
33
|
+
'/Users/who/.nvm/versions/node/v23.7.0/bin/node',
|
|
34
|
+
'/Users/who/user-data/lib_js/c2.bin.js',
|
|
35
|
+
'user_data_dir',
|
|
36
|
+
]),
|
|
37
|
+
).toStrictEqual({
|
|
38
|
+
base64: false,
|
|
39
|
+
userDataDir: 'user_data_dir',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('parseArgs with unexepected argv', () => {
|
|
44
|
+
expect(() => parseArgs(['tippity', 'doo', 'da'])).toThrow(
|
|
45
|
+
'unexpected program installation\nplease report at https://github.com/eighty4/c2/issues/new and include:\n\n[\n \"tippity\",\n \"doo\",\n \"da\"\n]',
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
17
49
|
test('parseArgs errors without USER_DATA_DIR', () => {
|
|
18
50
|
expect(() =>
|
|
19
|
-
parseArgs([
|
|
51
|
+
parseArgs([
|
|
52
|
+
'/Users/who/.bun/bin/bun',
|
|
53
|
+
'/Users/who/user-data/lib/c2.bin.ts',
|
|
54
|
+
]),
|
|
20
55
|
).toThrow()
|
|
21
56
|
})
|
|
22
57
|
|
|
@@ -24,18 +59,18 @@ test('parseArgs errors with extra USER_DATA_DIR', () => {
|
|
|
24
59
|
expect(() =>
|
|
25
60
|
parseArgs([
|
|
26
61
|
'/Users/who/.bun/bin/bun',
|
|
27
|
-
'/Users/who/user-data/c2.ts',
|
|
62
|
+
'/Users/who/user-data/lib/c2.bin.ts',
|
|
28
63
|
'user_data_dir',
|
|
29
64
|
'some_other_arg',
|
|
30
65
|
]),
|
|
31
|
-
).toThrow(
|
|
66
|
+
).toThrow()
|
|
32
67
|
})
|
|
33
68
|
|
|
34
69
|
test('parseArgs with --base64', () => {
|
|
35
70
|
expect(
|
|
36
71
|
parseArgs([
|
|
37
72
|
'/Users/who/.bun/bin/bun',
|
|
38
|
-
'/Users/who/user-data/c2.ts',
|
|
73
|
+
'/Users/who/user-data/lib/c2.bin.ts',
|
|
39
74
|
'--base64',
|
|
40
75
|
'user_data_dir',
|
|
41
76
|
]),
|
|
@@ -49,7 +84,7 @@ test('parseArgs with --http PORT', () => {
|
|
|
49
84
|
expect(
|
|
50
85
|
parseArgs([
|
|
51
86
|
'/Users/who/.bun/bin/bun',
|
|
52
|
-
'/Users/who/user-data/c2.ts',
|
|
87
|
+
'/Users/who/user-data/lib/c2.bin.ts',
|
|
53
88
|
'--http',
|
|
54
89
|
'6666',
|
|
55
90
|
'user_data_dir',
|
|
@@ -61,10 +96,21 @@ test('parseArgs with --http bunk', () => {
|
|
|
61
96
|
expect(() =>
|
|
62
97
|
parseArgs([
|
|
63
98
|
'/Users/who/.bun/bin/bun',
|
|
64
|
-
'/Users/who/user-data/c2.ts',
|
|
99
|
+
'/Users/who/user-data/lib/c2.bin.ts',
|
|
65
100
|
'--http',
|
|
66
101
|
'bunk',
|
|
67
102
|
'user_data_dir',
|
|
68
103
|
]),
|
|
69
104
|
).toThrow('--http bunk is not a valid http port')
|
|
70
105
|
})
|
|
106
|
+
|
|
107
|
+
test('parseArgs with --http without PORT', () => {
|
|
108
|
+
expect(() =>
|
|
109
|
+
parseArgs([
|
|
110
|
+
'/Users/who/.bun/bin/bun',
|
|
111
|
+
'/Users/who/user-data/lib/c2.bin.ts',
|
|
112
|
+
'user_data_dir',
|
|
113
|
+
'--http',
|
|
114
|
+
]),
|
|
115
|
+
).toThrow('--http did not include a PORT')
|
|
116
|
+
})
|
package/lib/cli.ts
CHANGED
|
@@ -7,17 +7,35 @@ export type ParsedArgs =
|
|
|
7
7
|
userDataDir: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const SCRIPT_SUFFIXES = Object.freeze([
|
|
11
|
+
'/c2',
|
|
12
|
+
'/lib_js/c2.bin.js',
|
|
13
|
+
'/lib/c2.bin.ts',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
export function parseArgs(input?: Array<string>): ParsedArgs {
|
|
17
|
+
if (!input) {
|
|
18
|
+
input = process.argv
|
|
19
|
+
}
|
|
20
|
+
let parsing: Array<string> = [...input]
|
|
21
|
+
let shifted: string | undefined
|
|
22
|
+
let found_cli: boolean = false
|
|
23
|
+
while ((shifted = parsing.shift())) {
|
|
24
|
+
if (SCRIPT_SUFFIXES.some(suffix => shifted!.endsWith(suffix))) {
|
|
25
|
+
found_cli = true
|
|
26
|
+
break
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!found_cli) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`unexpected program installation\nplease report at https://github.com/eighty4/c2/issues/new and include:\n\n${JSON.stringify(input, null, 4)}`,
|
|
32
|
+
)
|
|
13
33
|
}
|
|
14
|
-
args = [...args]
|
|
15
|
-
while (!args.shift()!.endsWith('/c2.ts')) {}
|
|
16
34
|
let base64 = false
|
|
17
35
|
let httpPort: number | undefined
|
|
18
36
|
let userData: Array<string> = []
|
|
19
37
|
let expectHttpPort = false
|
|
20
|
-
for (const arg of
|
|
38
|
+
for (const arg of parsing) {
|
|
21
39
|
if (expectHttpPort) {
|
|
22
40
|
expectHttpPort = false
|
|
23
41
|
httpPort = parseInt(arg, 10)
|
|
@@ -34,6 +52,9 @@ export function parseArgs(args?: Array<string>): ParsedArgs {
|
|
|
34
52
|
userData.push(arg)
|
|
35
53
|
}
|
|
36
54
|
}
|
|
55
|
+
if (expectHttpPort) {
|
|
56
|
+
throw new Error('--http did not include a PORT')
|
|
57
|
+
}
|
|
37
58
|
switch (userData.length) {
|
|
38
59
|
case 1:
|
|
39
60
|
const userDataDir = userData[0]
|
package/lib/expression.spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { evalTemplateExpressions } from '
|
|
4
|
-
import { makeFile, makeTempDir, removeDir } from '
|
|
3
|
+
import { evalTemplateExpressions } from './expression.ts'
|
|
4
|
+
import { makeFile, makeTempDir, removeDir } from './fs.testing.ts'
|
|
5
5
|
|
|
6
6
|
let files: Array<string> = []
|
|
7
7
|
let tmpDir: string
|
|
@@ -32,6 +32,14 @@ test('env() not found', async () => {
|
|
|
32
32
|
)
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
+
test('env() name not valid', async () => {
|
|
36
|
+
;['2_NOT_VALID', 'NOT=VALID', 'NOT@VALID'].forEach(envVar =>
|
|
37
|
+
expect(() =>
|
|
38
|
+
evalTemplateExpressions(`\${{ env('${envVar}') }}`),
|
|
39
|
+
).toThrow(`env var \`${envVar}\` is not a valid name`),
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
35
43
|
test('file() not found', async () => {
|
|
36
44
|
const path = join(await makeTempDir(), 'whoopie')
|
|
37
45
|
expect(() => evalTemplateExpressions(`\${{ file('${path}') }}`)).toThrow(
|
package/lib/expression.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import MagicString from 'magic-string'
|
|
2
|
-
import { readToString } from '#c2/fs
|
|
2
|
+
import { readToString } from '#c2/fs'
|
|
3
3
|
|
|
4
4
|
type TemplateExpression = {
|
|
5
5
|
index: number
|
|
@@ -38,10 +38,8 @@ async function evaluate(expression: string): Promise<string> {
|
|
|
38
38
|
let match: RegExpMatchArray | null
|
|
39
39
|
if ((match = expression.match(/env\(\s*'(.*)'\s*\)/)) != null) {
|
|
40
40
|
const envVarKey = match[1]
|
|
41
|
-
if (
|
|
42
|
-
throw new Error(
|
|
43
|
-
`env var expression \`${envVarKey}\` is not valid syntax`,
|
|
44
|
-
)
|
|
41
|
+
if (!/^[A-Z_][A-Z_\d]+$/.test(envVarKey)) {
|
|
42
|
+
throw new Error(`env var \`${envVarKey}\` is not a valid name`)
|
|
45
43
|
}
|
|
46
44
|
const envVarValue = process.env[envVarKey]
|
|
47
45
|
if (!envVarValue) {
|
package/lib/http.spec.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { MultipartMessage, MultipartAttachment } from '#c2/http'
|
|
3
|
+
|
|
4
|
+
let message: MultipartMessage
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
message = new MultipartMessage(
|
|
8
|
+
[
|
|
9
|
+
{
|
|
10
|
+
path: 'upgrades',
|
|
11
|
+
content: '#cloud-config\nupgrades',
|
|
12
|
+
filename: 'upgrades.yml',
|
|
13
|
+
source: '#cloud-config\nupgrades',
|
|
14
|
+
type: 'cloud-config',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
path: 'security',
|
|
18
|
+
content: 'security',
|
|
19
|
+
filename: 'security.sh',
|
|
20
|
+
source: 'security',
|
|
21
|
+
type: 'x-shellscript',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
'BOUNDARY',
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('multipart message creates cloud-init payload', () => {
|
|
29
|
+
expect(message.toHTTP()).toStrictEqual(
|
|
30
|
+
`Content-Type: multipart/mixed; boundary=BOUNDARY\r
|
|
31
|
+
MIME-Version: 1.0\r
|
|
32
|
+
Number-Attachments: 2\r
|
|
33
|
+
--BOUNDARY\r
|
|
34
|
+
Content-Type: text/cloud-config; charset="us-ascii"\r
|
|
35
|
+
Content-Transfer-Encoding: 7bit\r
|
|
36
|
+
Content-Disposition: attachment; filename="upgrades.yml"\r
|
|
37
|
+
\r
|
|
38
|
+
#cloud-config
|
|
39
|
+
upgrades\r
|
|
40
|
+
--BOUNDARY\r
|
|
41
|
+
Content-Type: text/x-shellscript; charset="us-ascii"\r
|
|
42
|
+
Content-Transfer-Encoding: 7bit\r
|
|
43
|
+
Content-Disposition: attachment; filename="security.sh"\r
|
|
44
|
+
\r
|
|
45
|
+
security\r
|
|
46
|
+
--BOUNDARY\r
|
|
47
|
+
`,
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('multipart message creates HTTP headers', () => {
|
|
52
|
+
expect(message.headers).toStrictEqual({
|
|
53
|
+
'Content-Type': 'multipart/mixed; boundary=BOUNDARY',
|
|
54
|
+
'MIME-Version': '1.0',
|
|
55
|
+
'Number-Attachments': '2',
|
|
56
|
+
})
|
|
57
|
+
})
|
package/lib/http.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { createServer, type ServerResponse } from 'node:http'
|
|
3
|
+
import { type Attachment, collectAttachments } from '#c2/attachments'
|
|
4
|
+
|
|
5
|
+
export function startUserDataHttp(port: number, userDataDir: string) {
|
|
6
|
+
const server = createServer((req, res) => {
|
|
7
|
+
console.log(req.method, req.url)
|
|
8
|
+
if (req.method !== 'GET') {
|
|
9
|
+
res.writeHead(405)
|
|
10
|
+
res.end()
|
|
11
|
+
} else if (req.url === '/user-data') {
|
|
12
|
+
sendUserData(res, userDataDir).then()
|
|
13
|
+
} else {
|
|
14
|
+
res.writeHead(
|
|
15
|
+
req.url === '/meta-data' || req.url === '/network-config'
|
|
16
|
+
? 200
|
|
17
|
+
: 404,
|
|
18
|
+
)
|
|
19
|
+
res.end()
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
server.listen(port, () => {
|
|
23
|
+
console.log(userDataDir, `up @ http://localhost:${port}/user-data`)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function sendUserData(res: ServerResponse, userDataDir: string) {
|
|
28
|
+
try {
|
|
29
|
+
const message = new MultipartMessage(
|
|
30
|
+
await collectAttachments(userDataDir),
|
|
31
|
+
)
|
|
32
|
+
res.writeHead(200)
|
|
33
|
+
res.write(message.toHTTP())
|
|
34
|
+
} catch (e: any) {
|
|
35
|
+
console.error(500, '/user-data', e.message)
|
|
36
|
+
res.writeHead(500)
|
|
37
|
+
} finally {
|
|
38
|
+
res.end()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class MultipartMessage {
|
|
43
|
+
static createBoundary(): string {
|
|
44
|
+
return randomUUID()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
readonly #attachments: Array<MultipartAttachment>
|
|
48
|
+
readonly #boundary: string
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
attachments: Array<Attachment>,
|
|
52
|
+
boundary: string = MultipartMessage.createBoundary(),
|
|
53
|
+
) {
|
|
54
|
+
this.#attachments = attachments.map(a => new MultipartAttachment(a))
|
|
55
|
+
this.#boundary = boundary
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get attachments(): Array<MultipartAttachment> {
|
|
59
|
+
return this.#attachments
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get boundary(): string {
|
|
63
|
+
return this.#boundary
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get headers(): Record<string, string> {
|
|
67
|
+
return {
|
|
68
|
+
'Content-Type': `multipart/mixed; boundary=${this.#boundary}`,
|
|
69
|
+
'MIME-Version': '1.0',
|
|
70
|
+
'Number-Attachments': `${this.#attachments.length}`,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
toHTTP(): string {
|
|
75
|
+
return [
|
|
76
|
+
`Content-Type: multipart/mixed; boundary=${this.#boundary}`,
|
|
77
|
+
'MIME-Version: 1.0',
|
|
78
|
+
`Number-Attachments: ${this.#attachments.length}`,
|
|
79
|
+
this.responseBody(),
|
|
80
|
+
].join('\r\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
responseBody(): string {
|
|
84
|
+
return [
|
|
85
|
+
'--' + this.#boundary,
|
|
86
|
+
this.#attachments
|
|
87
|
+
.map(a => a.toHTTP())
|
|
88
|
+
.join('\r\n--' + this.#boundary + '\r\n'),
|
|
89
|
+
'--' + this.#boundary + '\r\n',
|
|
90
|
+
].join('\r\n')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class MultipartAttachment {
|
|
95
|
+
#attachment: Attachment
|
|
96
|
+
|
|
97
|
+
constructor(attachment: Attachment) {
|
|
98
|
+
this.#attachment = attachment
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
toHTTP(): string {
|
|
102
|
+
return [
|
|
103
|
+
`Content-Type: text/${this.#attachment.type}; charset="us-ascii"`,
|
|
104
|
+
'Content-Transfer-Encoding: 7bit',
|
|
105
|
+
`Content-Disposition: attachment; filename="${this.#attachment.filename}"`,
|
|
106
|
+
'',
|
|
107
|
+
this.#attachment.content,
|
|
108
|
+
].join('\r\n')
|
|
109
|
+
}
|
|
110
|
+
}
|
package/lib_js/attachments.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { evalTemplateExpressions } from '#c2/expression';
|
|
2
|
+
import { readDirListing, readToString } from '#c2/fs';
|
|
2
3
|
export async function collectAttachments(dir) {
|
|
3
|
-
const
|
|
4
|
-
|
|
4
|
+
const filenames = await readDirListing(dir);
|
|
5
|
+
const attachments = await Promise.all(filenames.map(async (filename) => {
|
|
5
6
|
const path = `${dir}/${filename}`;
|
|
6
|
-
const
|
|
7
|
-
const type = resolveAttachmentType(filename,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const source = await readToString(path);
|
|
8
|
+
const type = resolveAttachmentType(filename, source);
|
|
9
|
+
const content = await evalTemplateExpressions(source);
|
|
10
|
+
return { content, filename, path, type, source };
|
|
11
|
+
}));
|
|
12
|
+
return attachments.sort(compareAttachmentFilenames);
|
|
11
13
|
}
|
|
12
14
|
function compareAttachmentFilenames(a1, a2) {
|
|
13
15
|
if (a1.filename === a2.filename)
|
|
@@ -16,16 +18,17 @@ function compareAttachmentFilenames(a1, a2) {
|
|
|
16
18
|
return 1;
|
|
17
19
|
return -1;
|
|
18
20
|
}
|
|
19
|
-
export function resolveAttachmentType(filename,
|
|
20
|
-
if (filename.endsWith('.yml') ||
|
|
21
|
-
(
|
|
22
|
-
|
|
21
|
+
export function resolveAttachmentType(filename, source) {
|
|
22
|
+
if (filename.endsWith('.yml') || filename.endsWith('.yaml')) {
|
|
23
|
+
if (!source.trim().startsWith('#cloud-config')) {
|
|
24
|
+
throw new Error('YAML cloud config must start with a #cloud-config comment');
|
|
25
|
+
}
|
|
23
26
|
return 'cloud-config';
|
|
24
27
|
}
|
|
25
28
|
else if (filename.endsWith('.sh')) {
|
|
26
29
|
return 'x-shellscript';
|
|
27
30
|
}
|
|
28
31
|
else {
|
|
29
|
-
throw new Error(
|
|
32
|
+
throw new Error(`${filename} is an unsupported file type`);
|
|
30
33
|
}
|
|
31
34
|
}
|
package/lib_js/build.js
CHANGED
|
@@ -1,42 +1,13 @@
|
|
|
1
|
-
import { collectAttachments } from '#c2/attachments
|
|
2
|
-
import {
|
|
3
|
-
import { readToString } from '#c2/fs.ts';
|
|
1
|
+
import { collectAttachments } from '#c2/attachments';
|
|
2
|
+
import { MultipartMessage } from '#c2/http';
|
|
4
3
|
export async function buildUserData(userDataDir, opts) {
|
|
5
4
|
const attachments = await collectAttachments(userDataDir);
|
|
6
5
|
switch (attachments.length) {
|
|
7
6
|
case 0:
|
|
8
7
|
throw new Error(`nothing found in dir ${userDataDir}`);
|
|
9
8
|
case 1:
|
|
10
|
-
return
|
|
9
|
+
return attachments[0].content;
|
|
11
10
|
default:
|
|
12
|
-
return
|
|
11
|
+
return new MultipartMessage(attachments, opts?.attachmentBoundary).toHTTP();
|
|
13
12
|
}
|
|
14
13
|
}
|
|
15
|
-
function createBoundary() {
|
|
16
|
-
return new Date().toISOString();
|
|
17
|
-
}
|
|
18
|
-
async function buildMultipartUserData(attachments, boundary = createBoundary()) {
|
|
19
|
-
let result = `Content-Type: multipart/mixed; boundary=${boundary}
|
|
20
|
-
MIME-Version: 1.0
|
|
21
|
-
Number-Attachments: ${attachments.length}
|
|
22
|
-
--${boundary}
|
|
23
|
-
`;
|
|
24
|
-
for (const attachment of attachments) {
|
|
25
|
-
let content;
|
|
26
|
-
try {
|
|
27
|
-
content = await evalTemplateExpressions(attachment.content);
|
|
28
|
-
}
|
|
29
|
-
catch (e) {
|
|
30
|
-
throw new Error(`error templating ${attachment.filename}: ${e.message}`);
|
|
31
|
-
}
|
|
32
|
-
result += `Content-Type: text/${attachment.type}; charset="us-ascii"
|
|
33
|
-
MIME-Version: 1.0
|
|
34
|
-
Content-Transfer-Encoding: 7bit
|
|
35
|
-
Content-Disposition: attachment; filename="${attachment.filename}"
|
|
36
|
-
|
|
37
|
-
${content}
|
|
38
|
-
--${boundary}
|
|
39
|
-
`;
|
|
40
|
-
}
|
|
41
|
-
return result;
|
|
42
|
-
}
|
package/lib_js/c2.api.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { buildUserData } from '#c2/build
|
|
1
|
+
export { buildUserData } from '#c2/build';
|
package/lib_js/c2.bin.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { buildUserData } from '#c2/build
|
|
3
|
-
import { parseArgs } from '#c2/cli
|
|
4
|
-
import { doesDirExist } from '#c2/fs
|
|
2
|
+
import { buildUserData } from '#c2/build';
|
|
3
|
+
import { parseArgs } from '#c2/cli';
|
|
4
|
+
import { doesDirExist } from '#c2/fs';
|
|
5
|
+
import { startUserDataHttp } from '#c2/http';
|
|
5
6
|
let args;
|
|
6
7
|
try {
|
|
7
8
|
args = parseArgs();
|
|
@@ -16,15 +17,23 @@ if (!args || args.help) {
|
|
|
16
17
|
const required = (s) => `\u001b[1m${s}\u001b[0m`;
|
|
17
18
|
errorExit(`c2 ${optional('[[--base64] | [--http PORT]]')} ${required('USER_DATA_DIR')}`);
|
|
18
19
|
}
|
|
19
|
-
if (args.httpPort) {
|
|
20
|
-
errorExit('--http PORT is not yet implemented');
|
|
21
|
-
}
|
|
22
20
|
if (!(await doesDirExist(args.userDataDir))) {
|
|
23
21
|
errorExit(`${args.userDataDir} directory does not exist`);
|
|
24
22
|
}
|
|
23
|
+
let work;
|
|
24
|
+
if (typeof args.httpPort !== 'undefined') {
|
|
25
|
+
work = async () => {
|
|
26
|
+
startUserDataHttp(args.httpPort, args.userDataDir);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
work = async () => {
|
|
31
|
+
const userData = await buildUserData(args.userDataDir);
|
|
32
|
+
console.log(args.base64 ? btoa(userData) : userData);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
25
35
|
try {
|
|
26
|
-
|
|
27
|
-
console.log(args.base64 ? btoa(userData) : userData);
|
|
36
|
+
await work();
|
|
28
37
|
}
|
|
29
38
|
catch (e) {
|
|
30
39
|
errorExit(e.message);
|
package/lib_js/cli.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
const SCRIPT_SUFFIXES = Object.freeze([
|
|
2
|
+
'/c2',
|
|
3
|
+
'/lib_js/c2.bin.js',
|
|
4
|
+
'/lib/c2.bin.ts',
|
|
5
|
+
]);
|
|
6
|
+
export function parseArgs(input) {
|
|
7
|
+
if (!input) {
|
|
8
|
+
input = process.argv;
|
|
9
|
+
}
|
|
10
|
+
let parsing = [...input];
|
|
11
|
+
let shifted;
|
|
12
|
+
let found_cli = false;
|
|
13
|
+
while ((shifted = parsing.shift())) {
|
|
14
|
+
if (SCRIPT_SUFFIXES.some(suffix => shifted.endsWith(suffix))) {
|
|
15
|
+
found_cli = true;
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (!found_cli) {
|
|
20
|
+
throw new Error(`unexpected program installation\nplease report at https://github.com/eighty4/c2/issues/new and include:\n\n${JSON.stringify(input, null, 4)}`);
|
|
4
21
|
}
|
|
5
|
-
args = [...args];
|
|
6
|
-
while (!args.shift().endsWith('/c2.ts')) { }
|
|
7
22
|
let base64 = false;
|
|
8
23
|
let httpPort;
|
|
9
24
|
let userData = [];
|
|
10
25
|
let expectHttpPort = false;
|
|
11
|
-
for (const arg of
|
|
26
|
+
for (const arg of parsing) {
|
|
12
27
|
if (expectHttpPort) {
|
|
13
28
|
expectHttpPort = false;
|
|
14
29
|
httpPort = parseInt(arg, 10);
|
|
@@ -29,6 +44,9 @@ export function parseArgs(args) {
|
|
|
29
44
|
userData.push(arg);
|
|
30
45
|
}
|
|
31
46
|
}
|
|
47
|
+
if (expectHttpPort) {
|
|
48
|
+
throw new Error('--http did not include a PORT');
|
|
49
|
+
}
|
|
32
50
|
switch (userData.length) {
|
|
33
51
|
case 1:
|
|
34
52
|
const userDataDir = userData[0];
|
package/lib_js/expression.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import MagicString from 'magic-string';
|
|
2
|
-
import { readToString } from '#c2/fs
|
|
2
|
+
import { readToString } from '#c2/fs';
|
|
3
3
|
export async function evalTemplateExpressions(content) {
|
|
4
4
|
const regex = new RegExp(/\${{\s*(.*)\s*}}/g);
|
|
5
5
|
let match;
|
|
@@ -24,8 +24,8 @@ async function evaluate(expression) {
|
|
|
24
24
|
let match;
|
|
25
25
|
if ((match = expression.match(/env\(\s*'(.*)'\s*\)/)) != null) {
|
|
26
26
|
const envVarKey = match[1];
|
|
27
|
-
if (
|
|
28
|
-
throw new Error(`env var
|
|
27
|
+
if (!/^[A-Z_][A-Z_\d]+$/.test(envVarKey)) {
|
|
28
|
+
throw new Error(`env var \`${envVarKey}\` is not a valid name`);
|
|
29
29
|
}
|
|
30
30
|
const envVarValue = process.env[envVarKey];
|
|
31
31
|
if (!envVarValue) {
|
package/lib_js/http.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { collectAttachments } from '#c2/attachments';
|
|
4
|
+
export function startUserDataHttp(port, userDataDir) {
|
|
5
|
+
const server = createServer((req, res) => {
|
|
6
|
+
console.log(req.method, req.url);
|
|
7
|
+
if (req.method !== 'GET') {
|
|
8
|
+
res.writeHead(405);
|
|
9
|
+
res.end();
|
|
10
|
+
}
|
|
11
|
+
else if (req.url === '/user-data') {
|
|
12
|
+
sendUserData(res, userDataDir).then();
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
res.writeHead(req.url === '/meta-data' || req.url === '/network-config'
|
|
16
|
+
? 200
|
|
17
|
+
: 404);
|
|
18
|
+
res.end();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
server.listen(port, () => {
|
|
22
|
+
console.log(userDataDir, `up @ http://localhost:${port}/user-data`);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async function sendUserData(res, userDataDir) {
|
|
26
|
+
try {
|
|
27
|
+
const message = new MultipartMessage(await collectAttachments(userDataDir));
|
|
28
|
+
res.writeHead(200);
|
|
29
|
+
res.write(message.toHTTP());
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
console.error(500, '/user-data', e.message);
|
|
33
|
+
res.writeHead(500);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
res.end();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class MultipartMessage {
|
|
40
|
+
static createBoundary() {
|
|
41
|
+
return randomUUID();
|
|
42
|
+
}
|
|
43
|
+
#attachments;
|
|
44
|
+
#boundary;
|
|
45
|
+
constructor(attachments, boundary = MultipartMessage.createBoundary()) {
|
|
46
|
+
this.#attachments = attachments.map(a => new MultipartAttachment(a));
|
|
47
|
+
this.#boundary = boundary;
|
|
48
|
+
}
|
|
49
|
+
get attachments() {
|
|
50
|
+
return this.#attachments;
|
|
51
|
+
}
|
|
52
|
+
get boundary() {
|
|
53
|
+
return this.#boundary;
|
|
54
|
+
}
|
|
55
|
+
get headers() {
|
|
56
|
+
return {
|
|
57
|
+
'Content-Type': `multipart/mixed; boundary=${this.#boundary}`,
|
|
58
|
+
'MIME-Version': '1.0',
|
|
59
|
+
'Number-Attachments': `${this.#attachments.length}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
toHTTP() {
|
|
63
|
+
return [
|
|
64
|
+
`Content-Type: multipart/mixed; boundary=${this.#boundary}`,
|
|
65
|
+
'MIME-Version: 1.0',
|
|
66
|
+
`Number-Attachments: ${this.#attachments.length}`,
|
|
67
|
+
this.responseBody(),
|
|
68
|
+
].join('\r\n');
|
|
69
|
+
}
|
|
70
|
+
responseBody() {
|
|
71
|
+
return [
|
|
72
|
+
'--' + this.#boundary,
|
|
73
|
+
this.#attachments
|
|
74
|
+
.map(a => a.toHTTP())
|
|
75
|
+
.join('\r\n--' + this.#boundary + '\r\n'),
|
|
76
|
+
'--' + this.#boundary + '\r\n',
|
|
77
|
+
].join('\r\n');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export class MultipartAttachment {
|
|
81
|
+
#attachment;
|
|
82
|
+
constructor(attachment) {
|
|
83
|
+
this.#attachment = attachment;
|
|
84
|
+
}
|
|
85
|
+
toHTTP() {
|
|
86
|
+
return [
|
|
87
|
+
`Content-Type: text/${this.#attachment.type}; charset="us-ascii"`,
|
|
88
|
+
'Content-Transfer-Encoding: 7bit',
|
|
89
|
+
`Content-Disposition: attachment; filename="${this.#attachment.filename}"`,
|
|
90
|
+
'',
|
|
91
|
+
this.#attachment.content,
|
|
92
|
+
].join('\r\n');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -3,8 +3,9 @@ export interface Attachment {
|
|
|
3
3
|
path: string;
|
|
4
4
|
content: string;
|
|
5
5
|
filename: string;
|
|
6
|
+
source: string;
|
|
6
7
|
type: AttachmentType;
|
|
7
8
|
}
|
|
8
9
|
export declare function collectAttachments(dir: string): Promise<Array<Attachment>>;
|
|
9
|
-
export declare function resolveAttachmentType(filename: string,
|
|
10
|
+
export declare function resolveAttachmentType(filename: string, source: string): AttachmentType;
|
|
10
11
|
//# sourceMappingURL=attachments.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../lib/attachments.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../lib/attachments.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,cAAc,GAAG,cAAc,GAAG,eAAe,CAAA;AAE7D,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,cAAc,CAAA;CACvB;AAED,wBAAsB,kBAAkB,CACpC,GAAG,EAAE,MAAM,GACZ,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAY5B;AAWD,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACf,cAAc,CAahB"}
|
package/lib_types/build.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../lib/build.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../lib/build.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,iBAAiB,GAAG;IAC5B,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC9B,CAAA;AAED,wBAAsB,aAAa,CAC/B,WAAW,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE,iBAAiB,GACzB,OAAO,CAAC,MAAM,CAAC,CAajB"}
|
package/lib_types/c2.api.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { type BuildUserDataOpts, buildUserData } from '#c2/build
|
|
1
|
+
export { type BuildUserDataOpts, buildUserData } from '#c2/build';
|
|
2
2
|
//# sourceMappingURL=c2.api.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"c2.api.d.ts","sourceRoot":"","sources":["../lib/c2.api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,iBAAiB,EAAE,aAAa,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"c2.api.d.ts","sourceRoot":"","sources":["../lib/c2.api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAA"}
|
package/lib_types/cli.d.ts
CHANGED
package/lib_types/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../lib/cli.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAChB;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GACd;IACI,IAAI,CAAC,EAAE,KAAK,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACtB,CAAA;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../lib/cli.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAChB;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GACd;IACI,IAAI,CAAC,EAAE,KAAK,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACtB,CAAA;AAQP,wBAAgB,SAAS,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,UAAU,CAqD3D"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Attachment } from '#c2/attachments';
|
|
2
|
+
export declare function startUserDataHttp(port: number, userDataDir: string): void;
|
|
3
|
+
export declare class MultipartMessage {
|
|
4
|
+
#private;
|
|
5
|
+
static createBoundary(): string;
|
|
6
|
+
constructor(attachments: Array<Attachment>, boundary?: string);
|
|
7
|
+
get attachments(): Array<MultipartAttachment>;
|
|
8
|
+
get boundary(): string;
|
|
9
|
+
get headers(): Record<string, string>;
|
|
10
|
+
toHTTP(): string;
|
|
11
|
+
responseBody(): string;
|
|
12
|
+
}
|
|
13
|
+
export declare class MultipartAttachment {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(attachment: Attachment);
|
|
16
|
+
toHTTP(): string;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../lib/http.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAsB,MAAM,iBAAiB,CAAA;AAErE,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,QAoBlE;AAiBD,qBAAa,gBAAgB;;IACzB,MAAM,CAAC,cAAc,IAAI,MAAM;gBAQ3B,WAAW,EAAE,KAAK,CAAC,UAAU,CAAC,EAC9B,QAAQ,GAAE,MAA0C;IAMxD,IAAI,WAAW,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAE5C;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,IAAI,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMpC;IAED,MAAM,IAAI,MAAM;IAShB,YAAY,IAAI,MAAM;CASzB;AAED,qBAAa,mBAAmB;;gBAGhB,UAAU,EAAE,UAAU;IAIlC,MAAM,IAAI,MAAM;CASnB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eighty4/c2",
|
|
3
|
-
"version": "0.0.2
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"author": "Adam McKee <adam.be.g84d@gmail.com>",
|
|
5
5
|
"repository": "https://github.com/eighty4/c2",
|
|
6
6
|
"homepage": "https://github.com/eighty4/c2",
|
|
@@ -22,15 +22,24 @@
|
|
|
22
22
|
"bin": {
|
|
23
23
|
"c2": "./lib_js/c2.bin.js"
|
|
24
24
|
},
|
|
25
|
-
"
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"bun": "./lib/c2.api.ts",
|
|
28
|
+
"node": "./lib_js/c2.api.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
26
31
|
"imports": {
|
|
27
|
-
"#c2
|
|
28
|
-
|
|
32
|
+
"#c2/*": {
|
|
33
|
+
"bun": "./lib/*.ts",
|
|
34
|
+
"node": "./lib_js/*.js"
|
|
35
|
+
}
|
|
29
36
|
},
|
|
30
37
|
"types": "./lib_types",
|
|
31
38
|
"scripts": {
|
|
32
39
|
"build": "tsc",
|
|
40
|
+
"fmt": "prettier --write .",
|
|
33
41
|
"fmtcheck": "prettier --check .",
|
|
42
|
+
"test": "bun test",
|
|
34
43
|
"typecheck": "tsc --noEmit"
|
|
35
44
|
},
|
|
36
45
|
"dependencies": {
|