onesecret 0.0.0-stage → 0.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/LICENSE.md +21 -0
- package/README.md +313 -2
- package/bin/help.txt +37 -0
- package/bin/index.js +14 -0
- package/package.json +106 -3
- package/src/age.js +363 -0
- package/src/bech32.js +89 -0
- package/src/cli.js +347 -0
- package/src/deploy.js +113 -0
- package/src/dotenv.js +67 -0
- package/src/error.js +12 -0
- package/src/fly.js +109 -0
- package/src/format.js +61 -0
- package/src/index.js +20 -0
- package/src/keys.js +197 -0
- package/src/regexp.js +91 -0
- package/src/secrets.js +331 -0
- package/src/sops.js +434 -0
- package/src/store.js +257 -0
- package/src/write.js +288 -0
- package/src/yaml.js +361 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { spawn, spawnSync } = require('node:child_process')
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const mri = require('mri')
|
|
8
|
+
const secrets = require('./secrets')
|
|
9
|
+
const write = require('./write')
|
|
10
|
+
const deploy = require('./deploy')
|
|
11
|
+
const store = require('./store')
|
|
12
|
+
const { isEnvScalar, toDotenv } = require('./format')
|
|
13
|
+
const yaml = require('./yaml')
|
|
14
|
+
const { withoutAgeKey } = require('./keys')
|
|
15
|
+
const { OnesecretError } = require('./error')
|
|
16
|
+
|
|
17
|
+
const HELP = fs.readFileSync(
|
|
18
|
+
path.join(__dirname, '..', 'bin', 'help.txt'),
|
|
19
|
+
'utf8'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const usage = message => {
|
|
23
|
+
throw new OnesecretError(`${message}\n\n${HELP}`, 'EUSAGE')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const readOptions = flags => ({
|
|
27
|
+
dir: flags.dir,
|
|
28
|
+
env: flags.env,
|
|
29
|
+
cwd: flags.cwd
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const writeOptions = flags => ({ dir: flags.dir, cwd: flags.cwd })
|
|
33
|
+
|
|
34
|
+
const writeLayerOf = flags => flags.env || 'default'
|
|
35
|
+
|
|
36
|
+
const relative = file => path.relative(process.cwd(), file) || file
|
|
37
|
+
|
|
38
|
+
const shellQuote = text => `'${text.replace(/'/g, "'\\''")}'`
|
|
39
|
+
|
|
40
|
+
const WAIT_FLAGS = {
|
|
41
|
+
atom: '--wait',
|
|
42
|
+
bbedit: '-w',
|
|
43
|
+
clion: '--wait',
|
|
44
|
+
code: '--wait',
|
|
45
|
+
'code-insiders': '--wait',
|
|
46
|
+
codium: '--wait',
|
|
47
|
+
cursor: '--wait',
|
|
48
|
+
gedit: '--wait',
|
|
49
|
+
goland: '--wait',
|
|
50
|
+
gvim: '-f',
|
|
51
|
+
idea: '--wait',
|
|
52
|
+
kate: '--block',
|
|
53
|
+
mate: '-w',
|
|
54
|
+
mvim: '-f',
|
|
55
|
+
nova: '--wait',
|
|
56
|
+
phpstorm: '--wait',
|
|
57
|
+
pycharm: '--wait',
|
|
58
|
+
rider: '--wait',
|
|
59
|
+
rubymine: '--wait',
|
|
60
|
+
subl: '--wait',
|
|
61
|
+
webstorm: '--wait',
|
|
62
|
+
windsurf: '--wait',
|
|
63
|
+
zed: '--wait'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const waitingEditor = editor => {
|
|
67
|
+
const [command, ...args] = editor.trim().split(/\s+/)
|
|
68
|
+
const flag = WAIT_FLAGS[path.basename(command)]
|
|
69
|
+
if (!flag || [flag, '-w', '--wait'].some(wait => args.includes(wait))) {
|
|
70
|
+
return editor
|
|
71
|
+
}
|
|
72
|
+
return `${editor} ${flag}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const openEditor = file => {
|
|
76
|
+
const editor = waitingEditor(process.env.VISUAL || process.env.EDITOR || 'vi')
|
|
77
|
+
const { status, error } = spawnSync(`${editor} ${shellQuote(file)}`, {
|
|
78
|
+
stdio: 'inherit',
|
|
79
|
+
shell: true
|
|
80
|
+
})
|
|
81
|
+
if (error || status !== 0) {
|
|
82
|
+
throw new OnesecretError(
|
|
83
|
+
`editor "${editor}" exited without saving`,
|
|
84
|
+
'EUSAGE'
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const EDIT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP']
|
|
91
|
+
|
|
92
|
+
const assertOnlyFileOfLayer = (file, kind) => {
|
|
93
|
+
const dir = path.dirname(file)
|
|
94
|
+
const layer = secrets.assertEnv(store.layerOfFile(file))
|
|
95
|
+
const existing =
|
|
96
|
+
kind === 'plain'
|
|
97
|
+
? store.findPlainFile(dir, layer)
|
|
98
|
+
: store.findLayerFile(dir, layer)
|
|
99
|
+
if (existing && existing !== file) {
|
|
100
|
+
throw new OnesecretError(
|
|
101
|
+
`${relative(existing)} already holds the ${layer} layer, edit it instead`,
|
|
102
|
+
'EUSAGE'
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const plainTarget = file => {
|
|
108
|
+
const original = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
|
109
|
+
return {
|
|
110
|
+
file,
|
|
111
|
+
original,
|
|
112
|
+
workspaceName: path.basename(file),
|
|
113
|
+
save: copy => {
|
|
114
|
+
try {
|
|
115
|
+
store.readPlain(copy)
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new OnesecretError(
|
|
118
|
+
error.reason.split(copy).join(relative(file)),
|
|
119
|
+
'EVALUE'
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
store.writeText(file, fs.readFileSync(copy, 'utf8'))
|
|
123
|
+
return `${relative(file)} saved`
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const encryptedTarget = current => {
|
|
129
|
+
write.encryptionFor(current.file, current.existing)
|
|
130
|
+
return {
|
|
131
|
+
file: current.file,
|
|
132
|
+
original: write.toEditableYaml(current.tree),
|
|
133
|
+
workspaceName: path
|
|
134
|
+
.basename(current.file)
|
|
135
|
+
.replace(/\.enc\.(ya?ml|json)$/, '.yml'),
|
|
136
|
+
save: copy =>
|
|
137
|
+
`${relative(
|
|
138
|
+
write.writeLayer(
|
|
139
|
+
current,
|
|
140
|
+
write.fromEditableYaml(fs.readFileSync(copy, 'utf8'))
|
|
141
|
+
)
|
|
142
|
+
)} encrypted`
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const editTargetOf = (flags, args) => {
|
|
147
|
+
if (args.length > 1) usage('edit takes at most one file')
|
|
148
|
+
if (args.length === 0) {
|
|
149
|
+
return encryptedTarget(
|
|
150
|
+
write.readLayer(writeOptions(flags), writeLayerOf(flags))
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (flags.env) usage('edit takes a file or --env, not both')
|
|
154
|
+
const file = path.resolve(flags.cwd || process.cwd(), args[0])
|
|
155
|
+
const kind = store.fileKindOf(file)
|
|
156
|
+
if (!kind) usage(`edit needs a .yml, .yaml, or .json file, got "${args[0]}"`)
|
|
157
|
+
assertOnlyFileOfLayer(file, kind)
|
|
158
|
+
return kind === 'plain'
|
|
159
|
+
? plainTarget(file)
|
|
160
|
+
: encryptedTarget(write.readFile(writeOptions(flags), file))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const saveErrorOf = error =>
|
|
164
|
+
error instanceof OnesecretError
|
|
165
|
+
? error.message
|
|
166
|
+
: `onesecret: cannot save, ${error.code || error.message}`
|
|
167
|
+
|
|
168
|
+
const editInWorkspace = (target, copy) => {
|
|
169
|
+
fs.writeFileSync(copy, target.original, { mode: 0o600 })
|
|
170
|
+
let failed = null
|
|
171
|
+
for (;;) {
|
|
172
|
+
const text = openEditor(copy)
|
|
173
|
+
if (text === target.original) return `${relative(target.file)} unchanged`
|
|
174
|
+
const retry = failed !== null && text === failed.text
|
|
175
|
+
if (retry && failed.invalid) {
|
|
176
|
+
throw new OnesecretError(
|
|
177
|
+
`edit aborted, ${relative(target.file)} is unchanged`,
|
|
178
|
+
'EVALUE'
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return target.save(copy)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error(
|
|
185
|
+
`${saveErrorOf(error)}\nReopening the editor, save it unchanged to ${
|
|
186
|
+
error instanceof OnesecretError ? 'abort' : 'try again'
|
|
187
|
+
}.`
|
|
188
|
+
)
|
|
189
|
+
failed = { text, invalid: error instanceof OnesecretError }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const edit = (flags, args) => {
|
|
195
|
+
const target = editTargetOf(flags, args)
|
|
196
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'onesecret-'))
|
|
197
|
+
const removeWorkspace = () =>
|
|
198
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
199
|
+
const onSignal = signal => {
|
|
200
|
+
removeWorkspace()
|
|
201
|
+
process.exit(128 + os.constants.signals[signal])
|
|
202
|
+
}
|
|
203
|
+
for (const signal of EDIT_SIGNALS) process.on(signal, onSignal)
|
|
204
|
+
try {
|
|
205
|
+
return editInWorkspace(target, path.join(workspace, target.workspaceName))
|
|
206
|
+
} finally {
|
|
207
|
+
removeWorkspace()
|
|
208
|
+
for (const signal of EDIT_SIGNALS) process.off(signal, onSignal)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const exec = (flags, command) => {
|
|
213
|
+
if (command.length === 0) usage('exec needs a command after --')
|
|
214
|
+
const env = withoutAgeKey(process.env)
|
|
215
|
+
if (flags.env) env.NODE_ENV = flags.env
|
|
216
|
+
secrets.fill(
|
|
217
|
+
{ ...readOptions(flags), processEnv: env },
|
|
218
|
+
{ allowKeyCommand: true }
|
|
219
|
+
)
|
|
220
|
+
const child = spawn(command[0], command.slice(1), { stdio: 'inherit', env })
|
|
221
|
+
for (const signal of EDIT_SIGNALS) {
|
|
222
|
+
process.on(signal, () => child.kill(signal))
|
|
223
|
+
}
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
child.on('error', error =>
|
|
226
|
+
reject(
|
|
227
|
+
new OnesecretError(
|
|
228
|
+
`cannot run ${command[0]}: ${error.message}`,
|
|
229
|
+
'EUSAGE'
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
child.on('exit', (code, signal) =>
|
|
234
|
+
resolve({ exitCode: signal ? 128 + os.constants.signals[signal] : code })
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const COMMANDS = {
|
|
240
|
+
init: flags => {
|
|
241
|
+
const result = write.initialize(writeOptions(flags))
|
|
242
|
+
if (result.status === 'existing') {
|
|
243
|
+
return `already initialized for ${result.recipients.join(
|
|
244
|
+
','
|
|
245
|
+
)} (${relative(result.defaultFile)})`
|
|
246
|
+
}
|
|
247
|
+
if (result.status === 'adopted') {
|
|
248
|
+
return `created ${relative(
|
|
249
|
+
result.defaultFile
|
|
250
|
+
)} for ${result.recipients.join(',')} (from ${result.source})`
|
|
251
|
+
}
|
|
252
|
+
return [
|
|
253
|
+
`public key ${result.recipients[0]}`,
|
|
254
|
+
`private key ${result.identity}`,
|
|
255
|
+
`secrets ${relative(result.defaultFile)}`,
|
|
256
|
+
'',
|
|
257
|
+
'The private key is shown only now and is not saved anywhere. Keep it in your',
|
|
258
|
+
'password manager and as SOPS_AGE_KEY in CI. To use it in this shell:',
|
|
259
|
+
'',
|
|
260
|
+
` export SOPS_AGE_KEY=${result.identity}`
|
|
261
|
+
].join('\n')
|
|
262
|
+
},
|
|
263
|
+
set: (flags, args) => {
|
|
264
|
+
if (args.length === 0) usage('set needs at least one NAME=value')
|
|
265
|
+
return `${relative(
|
|
266
|
+
write.set(writeOptions(flags), writeLayerOf(flags), args)
|
|
267
|
+
)} encrypted`
|
|
268
|
+
},
|
|
269
|
+
unset: (flags, args) => {
|
|
270
|
+
if (args.length === 0) usage('unset needs at least one NAME')
|
|
271
|
+
return `${relative(
|
|
272
|
+
write.unset(writeOptions(flags), writeLayerOf(flags), args)
|
|
273
|
+
)} encrypted`
|
|
274
|
+
},
|
|
275
|
+
edit,
|
|
276
|
+
rekey: flags =>
|
|
277
|
+
`${relative(
|
|
278
|
+
write.rekey(writeOptions(flags), writeLayerOf(flags))
|
|
279
|
+
)} encrypted with a new data key`,
|
|
280
|
+
ls: flags => secrets.names(readOptions(flags)).join('\n'),
|
|
281
|
+
get: (flags, args) => {
|
|
282
|
+
if (args.length !== 1) usage('get needs exactly one NAME')
|
|
283
|
+
const values = secrets.get(readOptions(flags), args[0])
|
|
284
|
+
if (!Object.hasOwn(values, args[0])) {
|
|
285
|
+
throw new OnesecretError(`"${args[0]}" is not set in any layer`, 'EVALUE')
|
|
286
|
+
}
|
|
287
|
+
const value = values[args[0]]
|
|
288
|
+
if (value === null) return ''
|
|
289
|
+
return isEnvScalar(value) ? String(value) : yaml.dump(value).trimEnd()
|
|
290
|
+
},
|
|
291
|
+
dotenv: flags =>
|
|
292
|
+
toDotenv(
|
|
293
|
+
secrets.loadSecrets(secrets.readLayers(readOptions(flags)))
|
|
294
|
+
).trimEnd(),
|
|
295
|
+
exec: (flags, args) => exec(flags, args),
|
|
296
|
+
kubernetes: flags => {
|
|
297
|
+
const { checksum, output } = deploy.kubernetes({
|
|
298
|
+
...readOptions(flags),
|
|
299
|
+
name: flags.name,
|
|
300
|
+
namespace: flags.namespace,
|
|
301
|
+
context: flags.context
|
|
302
|
+
})
|
|
303
|
+
if (output) console.error(output)
|
|
304
|
+
return checksum
|
|
305
|
+
},
|
|
306
|
+
fly: flags => {
|
|
307
|
+
const { checksum, output } = deploy.fly({
|
|
308
|
+
...readOptions(flags),
|
|
309
|
+
app: flags.app,
|
|
310
|
+
stage: flags.stage
|
|
311
|
+
})
|
|
312
|
+
if (output) console.error(output)
|
|
313
|
+
return checksum
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const parse = argv => {
|
|
318
|
+
const separator = argv.indexOf('--')
|
|
319
|
+
const own = separator === -1 ? argv : argv.slice(0, separator)
|
|
320
|
+
const rest = separator === -1 ? [] : argv.slice(separator + 1)
|
|
321
|
+
const {
|
|
322
|
+
_: [command, ...args],
|
|
323
|
+
...flags
|
|
324
|
+
} = mri(own, {
|
|
325
|
+
boolean: ['help', 'version', 'stage'],
|
|
326
|
+
string: ['dir', 'env', 'cwd', 'name', 'namespace', 'context', 'app'],
|
|
327
|
+
alias: { h: 'help', v: 'version', e: 'env', n: 'namespace' },
|
|
328
|
+
default: { stage: true }
|
|
329
|
+
})
|
|
330
|
+
return {
|
|
331
|
+
command,
|
|
332
|
+
args: command === 'exec' ? rest : [...args, ...rest],
|
|
333
|
+
flags
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const run = async argv => {
|
|
338
|
+
const { command, args, flags } = parse(argv)
|
|
339
|
+
if (flags.version) return { stdout: require('../package.json').version }
|
|
340
|
+
if (flags.help || !command) return { stdout: HELP.trimEnd() }
|
|
341
|
+
const handler = COMMANDS[command]
|
|
342
|
+
if (!handler) usage(`unknown command "${command}"`)
|
|
343
|
+
const result = await handler(flags, args)
|
|
344
|
+
return typeof result === 'object' ? result : { stdout: result }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = { run }
|
package/src/deploy.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process')
|
|
4
|
+
const yaml = require('js-yaml')
|
|
5
|
+
const { loadSecrets, readLayers } = require('./secrets')
|
|
6
|
+
const { withoutAgeKey } = require('./keys')
|
|
7
|
+
const { checksum, toEnv } = require('./format')
|
|
8
|
+
const flyEncoding = require('./fly')
|
|
9
|
+
const { OnesecretError } = require('./error')
|
|
10
|
+
|
|
11
|
+
const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by'
|
|
12
|
+
|
|
13
|
+
const loadForDeploy = (target, options) => {
|
|
14
|
+
if (!options.env) {
|
|
15
|
+
throw new OnesecretError(
|
|
16
|
+
`${target} needs an explicit env, such as "production"`,
|
|
17
|
+
'EVALUE'
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
const layered = readLayers(options)
|
|
21
|
+
const envLayer = layered.layers.find(({ name }) => name === layered.env)
|
|
22
|
+
if (!envLayer.plainFile && !envLayer.encryptedFile) {
|
|
23
|
+
throw new OnesecretError(
|
|
24
|
+
`${target} found no "${layered.env}" layer in ${layered.dir}, pass --env default to deploy the default layer alone`,
|
|
25
|
+
'EVALUE'
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
return loadSecrets(layered)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const run = (binary, args, input) => {
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync(binary, args, {
|
|
34
|
+
input,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
env: withoutAgeKey(process.env),
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
38
|
+
})
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const output = [error.stdout, error.stderr].filter(Boolean).join('\n')
|
|
41
|
+
const redacted = output.replace(/=.*/g, '=***').trim()
|
|
42
|
+
const reason =
|
|
43
|
+
error.code === 'ENOENT'
|
|
44
|
+
? `${binary} is not installed`
|
|
45
|
+
: redacted || error.message
|
|
46
|
+
throw new OnesecretError(
|
|
47
|
+
`${binary} ${args.slice(0, 3).join(' ')} failed: ${reason}`,
|
|
48
|
+
'EDEPLOY'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const kubernetesManifest = ({ name, namespace, secrets }) =>
|
|
54
|
+
yaml.dump(
|
|
55
|
+
{
|
|
56
|
+
apiVersion: 'v1',
|
|
57
|
+
kind: 'Secret',
|
|
58
|
+
metadata: {
|
|
59
|
+
name,
|
|
60
|
+
...(namespace && { namespace }),
|
|
61
|
+
labels: { [MANAGED_BY_LABEL]: 'onesecret' }
|
|
62
|
+
},
|
|
63
|
+
type: 'Opaque',
|
|
64
|
+
data: Object.fromEntries(
|
|
65
|
+
Object.entries(toEnv(secrets)).map(([key, value]) => [
|
|
66
|
+
key,
|
|
67
|
+
Buffer.from(value).toString('base64')
|
|
68
|
+
])
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
{ lineWidth: -1 }
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const kubernetes = ({
|
|
75
|
+
name,
|
|
76
|
+
namespace,
|
|
77
|
+
context,
|
|
78
|
+
kubectl = 'kubectl',
|
|
79
|
+
args = [],
|
|
80
|
+
...options
|
|
81
|
+
}) => {
|
|
82
|
+
if (!name) {
|
|
83
|
+
throw new OnesecretError('kubernetes needs the Secret name', 'EVALUE')
|
|
84
|
+
}
|
|
85
|
+
const secrets = loadForDeploy('kubernetes', options)
|
|
86
|
+
const contextArgs = context ? ['--context', context] : []
|
|
87
|
+
const output = run(
|
|
88
|
+
kubectl,
|
|
89
|
+
[...contextArgs, ...args, 'apply', '-f', '-'],
|
|
90
|
+
kubernetesManifest({ name, namespace, secrets })
|
|
91
|
+
)
|
|
92
|
+
return { checksum: checksum(secrets), output: output.trim() }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const flyImport = secrets => flyEncoding.encode(toEnv(secrets))
|
|
96
|
+
|
|
97
|
+
const fly = ({ app, stage = true, flyctl = 'fly', ...options }) => {
|
|
98
|
+
if (!app) throw new OnesecretError('fly needs the app name', 'EVALUE')
|
|
99
|
+
const secrets = loadForDeploy('fly', options)
|
|
100
|
+
const stageArgs = stage ? ['--stage'] : []
|
|
101
|
+
const output = run(
|
|
102
|
+
flyctl,
|
|
103
|
+
['secrets', 'import', ...stageArgs, '--app', app],
|
|
104
|
+
flyImport(secrets)
|
|
105
|
+
)
|
|
106
|
+
const summary = output
|
|
107
|
+
.split('\n')
|
|
108
|
+
.filter(line => line.trim() && !line.includes('='))
|
|
109
|
+
.join('\n')
|
|
110
|
+
return { checksum: checksum(secrets), output: summary }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { fly, flyImport, kubernetes, kubernetesManifest }
|
package/src/dotenv.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { parseEnv } = require('node:util')
|
|
4
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
5
|
+
const { OnesecretError } = require('./error')
|
|
6
|
+
|
|
7
|
+
const DOTENV_LINE =
|
|
8
|
+
/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm
|
|
9
|
+
const QUOTES = ["'", '`', '"']
|
|
10
|
+
const NEIGHBOR = 'ONESECRET_NEIGHBOR'
|
|
11
|
+
|
|
12
|
+
const parseDotenvPackage = source => {
|
|
13
|
+
const secrets = {}
|
|
14
|
+
const lines = source.replace(/\r\n?/gm, '\n')
|
|
15
|
+
for (const match of lines.matchAll(DOTENV_LINE)) {
|
|
16
|
+
const quote = (match[2] || '').trim()[0]
|
|
17
|
+
let value = (match[2] || '').trim().replace(/^(['"`])([\s\S]*)\1$/gm, '$2')
|
|
18
|
+
if (quote === '"') value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r')
|
|
19
|
+
secrets[match[1]] = value
|
|
20
|
+
}
|
|
21
|
+
return secrets
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PARSERS = [source => ({ ...parseEnv(source) }), parseDotenvPackage]
|
|
25
|
+
|
|
26
|
+
const readsBackAs = (source, expected) =>
|
|
27
|
+
PARSERS.every(parse => isDeepStrictEqual(parse(source), expected))
|
|
28
|
+
|
|
29
|
+
const survivesNeighbors = (line, name, value) =>
|
|
30
|
+
QUOTES.every(quote =>
|
|
31
|
+
readsBackAs(`${line}${NEIGHBOR}=${quote}#${quote}\n`, {
|
|
32
|
+
[name]: value,
|
|
33
|
+
[NEIGHBOR]: '#'
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const encodeLine = (name, value) => {
|
|
38
|
+
const line = [value, ...QUOTES.map(quote => quote + value + quote)]
|
|
39
|
+
.map(encoded => `${name}=${encoded}\n`)
|
|
40
|
+
.find(
|
|
41
|
+
line =>
|
|
42
|
+
readsBackAs(line, { [name]: value }) &&
|
|
43
|
+
survivesNeighbors(line, name, value)
|
|
44
|
+
)
|
|
45
|
+
if (!line) {
|
|
46
|
+
throw new OnesecretError(
|
|
47
|
+
`"${name}" cannot be written as dotenv without changing its value`,
|
|
48
|
+
'EVALUE'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return line
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const encode = secrets => {
|
|
55
|
+
const body = Object.entries(secrets)
|
|
56
|
+
.map(([name, value]) => encodeLine(name, value))
|
|
57
|
+
.join('')
|
|
58
|
+
if (!readsBackAs(body, secrets)) {
|
|
59
|
+
throw new OnesecretError(
|
|
60
|
+
'the dotenv output does not read back to the same secrets',
|
|
61
|
+
'EVALUE'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
return body
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { encode, parseDotenvPackage }
|
package/src/error.js
ADDED
package/src/fly.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
4
|
+
const { OnesecretError } = require('./error')
|
|
5
|
+
|
|
6
|
+
const TRIPLE_QUOTE = '"""'
|
|
7
|
+
const SCANNER_MAX_LINE_BYTES = 64 * 1024 - 1
|
|
8
|
+
|
|
9
|
+
const scanLines = text => {
|
|
10
|
+
const lines = text.split('\n')
|
|
11
|
+
if (lines[lines.length - 1] === '') lines.pop()
|
|
12
|
+
return lines.map(line => line.replace(/\r$/, ''))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const countQuotes = text => text.split('"').length - 1
|
|
16
|
+
|
|
17
|
+
const parseSinglelineValue = line => {
|
|
18
|
+
const separator = line.indexOf('=')
|
|
19
|
+
let value = line.slice(separator + 1).replace(/^ +/, '')
|
|
20
|
+
const hash = value.indexOf('#')
|
|
21
|
+
if (hash !== -1 && countQuotes(value.slice(0, hash)) % 2 === 0) { value = value.slice(0, hash).replace(/ +$/, '') }
|
|
22
|
+
return { key: line.slice(0, separator).trim(), value }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const parse = text => {
|
|
26
|
+
const secrets = {}
|
|
27
|
+
let multiline = null
|
|
28
|
+
for (const line of scanLines(text)) {
|
|
29
|
+
if (multiline) {
|
|
30
|
+
if (line.endsWith(TRIPLE_QUOTE)) {
|
|
31
|
+
secrets[multiline.key] =
|
|
32
|
+
multiline.value + line.slice(0, -TRIPLE_QUOTE.length)
|
|
33
|
+
multiline = null
|
|
34
|
+
} else {
|
|
35
|
+
multiline.value += `${line}\n`
|
|
36
|
+
}
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (line.startsWith('#') || line.trim() === '') continue
|
|
40
|
+
if (!line.includes('=')) throw new Error(`"${line}" is not NAME=VALUE`)
|
|
41
|
+
const { key, value } = parseSinglelineValue(line)
|
|
42
|
+
if (
|
|
43
|
+
value.startsWith(TRIPLE_QUOTE) &&
|
|
44
|
+
value.endsWith(TRIPLE_QUOTE) &&
|
|
45
|
+
value.length >= 6
|
|
46
|
+
) {
|
|
47
|
+
secrets[key] = value.slice(3, -3)
|
|
48
|
+
} else if (value.startsWith(TRIPLE_QUOTE)) {
|
|
49
|
+
multiline = { key, value: `${value.slice(3)}\n` }
|
|
50
|
+
} else if (
|
|
51
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
52
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
53
|
+
) {
|
|
54
|
+
if (value.length < 2) throw new Error('flyctl panics on a lone quote')
|
|
55
|
+
secrets[key] = value.slice(1, -1)
|
|
56
|
+
} else {
|
|
57
|
+
secrets[key] = value
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return secrets
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const candidates = value => [
|
|
64
|
+
value,
|
|
65
|
+
`${TRIPLE_QUOTE}${value}${TRIPLE_QUOTE}`,
|
|
66
|
+
`"${value}"`,
|
|
67
|
+
`'${value}'`
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
const readsBackAs = (text, expected) => {
|
|
71
|
+
try {
|
|
72
|
+
return isDeepStrictEqual(parse(text), expected)
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isWithinScanner = text =>
|
|
79
|
+
scanLines(text).every(
|
|
80
|
+
line => Buffer.byteLength(line) <= SCANNER_MAX_LINE_BYTES
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const encodeLine = (name, value) => {
|
|
84
|
+
const line = candidates(value)
|
|
85
|
+
.map(encoded => `${name}=${encoded}\n`)
|
|
86
|
+
.find(line => isWithinScanner(line) && readsBackAs(line, { [name]: value }))
|
|
87
|
+
if (!line) {
|
|
88
|
+
throw new OnesecretError(
|
|
89
|
+
`"${name}" cannot be imported into fly without changing its value`,
|
|
90
|
+
'EVALUE'
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
return line
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const encode = secrets => {
|
|
97
|
+
const body = Object.entries(secrets)
|
|
98
|
+
.map(([name, value]) => encodeLine(name, value))
|
|
99
|
+
.join('')
|
|
100
|
+
if (!readsBackAs(body, secrets)) {
|
|
101
|
+
throw new OnesecretError(
|
|
102
|
+
'the fly import does not read back to the same secrets',
|
|
103
|
+
'EVALUE'
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
return body
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { encode, parse }
|
package/src/format.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto')
|
|
4
|
+
const dotenv = require('./dotenv')
|
|
5
|
+
const { OnesecretError } = require('./error')
|
|
6
|
+
|
|
7
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
8
|
+
|
|
9
|
+
const isEnvName = name => ENV_NAME.test(name) && name !== '__proto__'
|
|
10
|
+
|
|
11
|
+
const assertEnvName = name => {
|
|
12
|
+
if (!isEnvName(name)) {
|
|
13
|
+
throw new OnesecretError(
|
|
14
|
+
`"${name}" is not a valid environment variable name`,
|
|
15
|
+
'EVALUE'
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isEnvScalar = value =>
|
|
21
|
+
['string', 'number', 'boolean', 'bigint'].includes(typeof value)
|
|
22
|
+
|
|
23
|
+
const toEnvValue = (name, value) => {
|
|
24
|
+
assertEnvName(name)
|
|
25
|
+
if (value === null || value === undefined) return ''
|
|
26
|
+
if (isEnvScalar(value)) return String(value)
|
|
27
|
+
throw new OnesecretError(
|
|
28
|
+
`"${name}" must be a string, number, or boolean to become an environment variable`,
|
|
29
|
+
'EVALUE'
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const toEnv = secrets =>
|
|
34
|
+
Object.fromEntries(
|
|
35
|
+
Object.entries(secrets).map(([name, value]) => [
|
|
36
|
+
name,
|
|
37
|
+
toEnvValue(name, value)
|
|
38
|
+
])
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const toDotenv = secrets => dotenv.encode(toEnv(secrets))
|
|
42
|
+
|
|
43
|
+
const checksum = secrets => {
|
|
44
|
+
const entries = Object.entries(toEnv(secrets)).sort(([a], [b]) =>
|
|
45
|
+
a < b ? -1 : a > b ? 1 : 0
|
|
46
|
+
)
|
|
47
|
+
return crypto
|
|
48
|
+
.createHash('sha256')
|
|
49
|
+
.update(JSON.stringify(entries))
|
|
50
|
+
.digest('hex')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
assertEnvName,
|
|
55
|
+
checksum,
|
|
56
|
+
isEnvName,
|
|
57
|
+
isEnvScalar,
|
|
58
|
+
toDotenv,
|
|
59
|
+
toEnv,
|
|
60
|
+
toEnvValue
|
|
61
|
+
}
|