myapi-vault-cli 0.1.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 +73 -0
- package/bin/myapi.js +53 -0
- package/lib/asc.js +33 -0
- package/lib/cf.js +37 -0
- package/lib/config.js +29 -0
- package/lib/http.js +40 -0
- package/lib/login.js +119 -0
- package/lib/vault.js +47 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# myapi-vault-cli
|
|
2
|
+
|
|
3
|
+
One-command access to your self-hosted **MyApi** credential vault from any machine. Browser login, ASC-signed requests (bypass per-device approval), and simple `get`/`put` of secrets — no manual env vars.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g myapi-vault-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node ≥ 18. For per-user Cloudflare Access SSO, install [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (optional; a shared service token also works).
|
|
12
|
+
|
|
13
|
+
## Login
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
myapi login
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`login` is **100% browser by default** — no secrets, no env vars:
|
|
20
|
+
|
|
21
|
+
1. **Cloudflare Access** — opens browser SSO via `cloudflared` (install once: `brew install cloudflared` / [downloads](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)). Use `--cf-token` for a shared service token instead.
|
|
22
|
+
2. **App token** — a device-flow code you approve in the dashboard (no paste). Use `--paste` to supply an existing token instead.
|
|
23
|
+
3. **ASC key** — generates + registers an Ed25519 identity so future requests skip per-device approval (approve it once under Devices).
|
|
24
|
+
|
|
25
|
+
Config is cached at `~/.config/myapi/config.json` (mode 600). Approve the ASC device once in your MyApi dashboard when prompted.
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
myapi whoami # verify identity
|
|
31
|
+
myapi list # list stored entries
|
|
32
|
+
myapi put "OpenAI" "sk-..." https://api.openai.com openai
|
|
33
|
+
myapi get openai # reveal by label/service
|
|
34
|
+
myapi reveal <id> # reveal by id
|
|
35
|
+
myapi del <id> # delete
|
|
36
|
+
myapi logout # clear local config
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Reading a secret in scripts
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
KEY=$(myapi get openai | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Flags
|
|
46
|
+
|
|
47
|
+
| Flag | Effect |
|
|
48
|
+
|------|--------|
|
|
49
|
+
| `--cf-token` | Use a Cloudflare Access **service token** instead of `cloudflared` browser SSO. |
|
|
50
|
+
| `--paste` | Paste an existing MyApi token instead of the browser device flow. |
|
|
51
|
+
|
|
52
|
+
With `--cf-token`, set the service-token credentials via env (recommended over the prompt):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export MYAPI_CF_CLIENT_ID='<...>.access'
|
|
56
|
+
export MYAPI_CF_CLIENT_SECRET='<...>'
|
|
57
|
+
myapi login --cf-token
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Env
|
|
61
|
+
|
|
62
|
+
- `MYAPI_CONFIG_DIR` — override `~/.config/myapi`.
|
|
63
|
+
- `MYAPI_CF_CLIENT_ID` / `MYAPI_CF_CLIENT_SECRET` — Cloudflare Access service token (for `--cf-token`).
|
|
64
|
+
|
|
65
|
+
## Security
|
|
66
|
+
|
|
67
|
+
- Your token + ASC key live only in `~/.config/myapi/` (0600). Keep to trusted machines.
|
|
68
|
+
- The MyApi vault is full-access per token; each engineer should use their **own** token (revoke individually).
|
|
69
|
+
- No secrets are printed to logs.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
package/bin/myapi.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// myapi — CLI for the self-hosted MyApi credential vault.
|
|
3
|
+
const config = require('../lib/config');
|
|
4
|
+
const vault = require('../lib/vault');
|
|
5
|
+
const { login } = require('../lib/login');
|
|
6
|
+
|
|
7
|
+
const HELP = `myapi — cliente de tu bóveda MyApi
|
|
8
|
+
|
|
9
|
+
Uso:
|
|
10
|
+
myapi login [--paste] [--cf-token] Inicia sesión (100% navegador por defecto)
|
|
11
|
+
myapi whoami Verifica identidad
|
|
12
|
+
myapi list Lista tus claves guardadas
|
|
13
|
+
myapi get <label|service> Revela una clave por nombre/servicio
|
|
14
|
+
myapi reveal <id> Revela una clave por id
|
|
15
|
+
myapi put <name> <secret> <url> [svc] Guarda una clave
|
|
16
|
+
myapi del <id> Borra una clave
|
|
17
|
+
myapi logout Borra la config local
|
|
18
|
+
|
|
19
|
+
Login (por defecto: navegador vía cloudflared + aprobación en el dashboard):
|
|
20
|
+
--cf-token Usa un CF service-token (env/prompt) en vez del SSO de cloudflared
|
|
21
|
+
--paste Pega un token existente en vez del device flow
|
|
22
|
+
|
|
23
|
+
Env: MYAPI_CONFIG_DIR sobreescribe ~/.config/myapi`;
|
|
24
|
+
|
|
25
|
+
function out(x) { console.log(typeof x === 'string' ? x : JSON.stringify(x, null, 2)); }
|
|
26
|
+
function die(m) { console.error(`error: ${m}`); process.exit(1); }
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
30
|
+
const flags = new Set(rest.filter((a) => a.startsWith('--')));
|
|
31
|
+
const args = rest.filter((a) => !a.startsWith('--'));
|
|
32
|
+
|
|
33
|
+
if (!cmd || cmd === 'help' || flags.has('--help')) return out(HELP);
|
|
34
|
+
if (cmd === 'login') {
|
|
35
|
+
return login({ paste: flags.has('--paste'), cf: flags.has('--cf-token') ? 'service-token' : undefined });
|
|
36
|
+
}
|
|
37
|
+
if (cmd === 'logout') { config.clear(); return out('✓ sesión borrada'); }
|
|
38
|
+
|
|
39
|
+
const cfg = config.load();
|
|
40
|
+
if (!cfg.token) die('no has iniciado sesión — corre: myapi login');
|
|
41
|
+
|
|
42
|
+
switch (cmd) {
|
|
43
|
+
case 'whoami': return out(await vault.whoami(cfg));
|
|
44
|
+
case 'list': return out(await vault.list(cfg));
|
|
45
|
+
case 'get': if (!args[0]) die('uso: myapi get <label|service>'); return out(await vault.get(cfg, args[0]));
|
|
46
|
+
case 'reveal': if (!args[0]) die('uso: myapi reveal <id>'); return out(await vault.reveal(cfg, args[0]));
|
|
47
|
+
case 'put': if (args.length < 3) die('uso: myapi put <name> <secret> <url> [service]'); return out(await vault.put(cfg, args[0], args[1], args[2], args[3]));
|
|
48
|
+
case 'del': if (!args[0]) die('uso: myapi del <id>'); return out(await vault.del(cfg, args[0]));
|
|
49
|
+
default: die(`comando desconocido: ${cmd}\n\n${HELP}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch((e) => die(e.message));
|
package/lib/asc.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ASC (Agentic Secure Connection): Ed25519 identity that lets signed requests
|
|
2
|
+
// bypass MyApi's per-device approval. Server verifies sign("<ts>:<tokenId>").
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
// Generate a keypair in the exact shape MyApi expects: a PKCS8 PEM private key
|
|
7
|
+
// and the raw 32-byte Ed25519 public key, base64-encoded.
|
|
8
|
+
function generateKeypair() {
|
|
9
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
10
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
11
|
+
const spkiDer = publicKey.export({ type: 'spki', format: 'der' });
|
|
12
|
+
const pubKeyB64 = spkiDer.subarray(spkiDer.length - 32).toString('base64');
|
|
13
|
+
return { privateKeyPem, pubKeyB64 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Build the X-Agent-* headers for this instant. Returns {} if no usable key,
|
|
17
|
+
// so the same request path works before ASC is set up (falls back to approval).
|
|
18
|
+
function ascHeaders(asc) {
|
|
19
|
+
if (!asc || !asc.privateKeyPem || !asc.tokenId) return {};
|
|
20
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
21
|
+
const sig = crypto.sign(null, Buffer.from(`${ts}:${asc.tokenId}`), crypto.createPrivateKey(asc.privateKeyPem));
|
|
22
|
+
return {
|
|
23
|
+
'X-Agent-PublicKey': asc.pubKeyB64,
|
|
24
|
+
'X-Agent-Timestamp': ts,
|
|
25
|
+
'X-Agent-Signature': sig.toString('base64'),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultLabel() {
|
|
30
|
+
return `myapi-cli @ ${os.hostname()}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { generateKeypair, ascHeaders, defaultLabel };
|
package/lib/cf.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Cloudflare Access layer. MyApi sits behind CF Access, so every request must
|
|
2
|
+
// pass the edge. Two modes:
|
|
3
|
+
// - 'cloudflared' : per-user browser SSO; a short-lived JWT via the cloudflared CLI
|
|
4
|
+
// - 'service-token' : static shared Client-Id/Secret headers (admin fallback)
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
function cfHeaders(cfg) {
|
|
8
|
+
const cf = cfg.cf || {};
|
|
9
|
+
if (cf.mode === 'service-token') {
|
|
10
|
+
return {
|
|
11
|
+
'CF-Access-Client-Id': cf.clientId || '',
|
|
12
|
+
'CF-Access-Client-Secret': cf.clientSecret || '',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (cf.mode === 'cloudflared') {
|
|
16
|
+
try {
|
|
17
|
+
const jwt = execFileSync('cloudflared', ['access', 'token', `-app=${cfg.base}`], { encoding: 'utf8' }).trim();
|
|
18
|
+
if (!jwt) throw new Error('empty token');
|
|
19
|
+
return { 'cf-access-token': jwt };
|
|
20
|
+
} catch (e) {
|
|
21
|
+
throw new Error(`cloudflared token failed — run \`cloudflared access login ${cfg.base}\` or \`myapi login\` again. (${String(e.message).split('\n')[0]})`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One-time interactive browser SSO for the Access app.
|
|
28
|
+
function cloudflaredLogin(base) {
|
|
29
|
+
execFileSync('cloudflared', ['access', 'login', base], { stdio: 'inherit' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasCloudflared() {
|
|
33
|
+
try { execFileSync('cloudflared', ['--version'], { stdio: 'ignore' }); return true; }
|
|
34
|
+
catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { cfHeaders, cloudflaredLogin, hasCloudflared };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Local config for the myapi CLI. Stored at ~/.config/myapi/config.json (0600).
|
|
2
|
+
// Holds: base URL, app token, ASC keypair, and the Cloudflare Access mode.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const DIR = process.env.MYAPI_CONFIG_DIR || path.join(os.homedir(), '.config', 'myapi');
|
|
8
|
+
const FILE = path.join(DIR, 'config.json');
|
|
9
|
+
const DEFAULT_BASE = 'https://myapi.boxlab.cloud';
|
|
10
|
+
|
|
11
|
+
function load() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(FILE, 'utf8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function save(cfg) {
|
|
20
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
21
|
+
fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
22
|
+
return FILE;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clear() {
|
|
26
|
+
try { fs.unlinkSync(FILE); } catch { /* already gone */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { load, save, clear, FILE, DIR, DEFAULT_BASE };
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Single authenticated request path for every MyApi call.
|
|
2
|
+
// Layers all three auth headers: Cloudflare Access + Bearer token + ASC signature.
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const { cfHeaders } = require('./cf');
|
|
6
|
+
const { ascHeaders } = require('./asc');
|
|
7
|
+
|
|
8
|
+
function request(cfg, method, path, bodyObj) {
|
|
9
|
+
const url = new URL(cfg.base + path);
|
|
10
|
+
const headers = { Accept: 'application/json', ...cfHeaders(cfg), ...ascHeaders(cfg.asc) };
|
|
11
|
+
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
|
|
12
|
+
|
|
13
|
+
let payload;
|
|
14
|
+
if (bodyObj !== undefined) {
|
|
15
|
+
payload = JSON.stringify(bodyObj);
|
|
16
|
+
headers['Content-Type'] = 'application/json';
|
|
17
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const lib = url.protocol === 'http:' ? http : https;
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const req = lib.request(
|
|
23
|
+
{ hostname: url.hostname, port: url.port || undefined, path: url.pathname + url.search, method, headers },
|
|
24
|
+
(res) => {
|
|
25
|
+
let data = '';
|
|
26
|
+
res.on('data', (c) => { data += c; });
|
|
27
|
+
res.on('end', () => {
|
|
28
|
+
let body;
|
|
29
|
+
try { body = data ? JSON.parse(data) : {}; } catch { body = { raw: data }; }
|
|
30
|
+
resolve({ status: res.statusCode, body });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
req.on('error', reject);
|
|
35
|
+
if (payload) req.write(payload);
|
|
36
|
+
req.end();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { request };
|
package/lib/login.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Login flows:
|
|
2
|
+
// v1 (paste) : paste an existing MyApi token once; CLI auto-registers ASC.
|
|
3
|
+
// v2 (device flow) : browser approval, no paste; needs the MyApi /device/authorize patch.
|
|
4
|
+
// Both end by registering a fresh ASC key so future requests skip device approval.
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { request } = require('./http');
|
|
8
|
+
const config = require('./config');
|
|
9
|
+
const asc = require('./asc');
|
|
10
|
+
const cf = require('./cf');
|
|
11
|
+
|
|
12
|
+
function prompt(question, hidden = false) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
15
|
+
rl.question(question, (answer) => {
|
|
16
|
+
if (hidden) process.stdout.write('\n');
|
|
17
|
+
rl.close();
|
|
18
|
+
resolve(answer.trim());
|
|
19
|
+
});
|
|
20
|
+
if (hidden) rl._writeToOutput = () => {}; // suppress echo of the secret
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Generate + register a fresh ASC keypair against the current token.
|
|
25
|
+
// asc/register is device-approval-exempt and returns our own token_id, so this
|
|
26
|
+
// works on a brand-new machine with no prior approval.
|
|
27
|
+
async function registerAsc(cfg) {
|
|
28
|
+
const kp = asc.generateKeypair();
|
|
29
|
+
const reg = await request({ ...cfg, asc: null }, 'POST', '/api/v1/agentic/asc/register', {
|
|
30
|
+
public_key: kp.pubKeyB64,
|
|
31
|
+
label: asc.defaultLabel(),
|
|
32
|
+
});
|
|
33
|
+
if (reg.status >= 300 && reg.status !== 202) {
|
|
34
|
+
throw new Error(`asc/register failed (HTTP ${reg.status}): ${JSON.stringify(reg.body).slice(0, 200)}`);
|
|
35
|
+
}
|
|
36
|
+
const tokenId = reg.body.token_id;
|
|
37
|
+
if (!tokenId) throw new Error('asc/register did not return token_id — MyApi server patch missing');
|
|
38
|
+
const fingerprint = reg.body.key_fingerprint || null;
|
|
39
|
+
const alreadyApproved = reg.body.status === 'already_approved';
|
|
40
|
+
return { ...kp, tokenId, fingerprint, alreadyApproved };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// v2: OAuth 2.0 device grant. Requires MyApi to expose /device/authorize publicly.
|
|
44
|
+
async function deviceFlow(cfg) {
|
|
45
|
+
const authz = await request(cfg, 'POST', '/api/v1/agentic/device/authorize', { label: asc.defaultLabel() });
|
|
46
|
+
if (authz.status >= 300) {
|
|
47
|
+
throw new Error(`device flow unavailable (HTTP ${authz.status}) — needs MyApi v2 patch. Use paste login instead.`);
|
|
48
|
+
}
|
|
49
|
+
const a = authz.body;
|
|
50
|
+
const uri = a.verification_uri_complete || a.verification_uri;
|
|
51
|
+
const interval = (a.interval || 5) * 1000;
|
|
52
|
+
const deadline = Date.now() + (a.expires_in || 900) * 1000;
|
|
53
|
+
console.log(`\n Abre en tu navegador: ${uri}`);
|
|
54
|
+
console.log(` Código: ${a.user_code}\n Esperando aprobación…`);
|
|
55
|
+
|
|
56
|
+
while (Date.now() < deadline) {
|
|
57
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
58
|
+
const tok = await request(cfg, 'POST', '/api/v1/agentic/device/token', { device_code: a.device_code });
|
|
59
|
+
const token = tok.body.access_token || tok.body.token || (tok.body.data && tok.body.data.token);
|
|
60
|
+
if (tok.status === 200 && token) return token;
|
|
61
|
+
const err = tok.body && tok.body.error;
|
|
62
|
+
if (err && !/pending|slow_down/i.test(err)) throw new Error(`device/token: ${err}`);
|
|
63
|
+
}
|
|
64
|
+
throw new Error('device flow timed out');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function login(opts = {}) {
|
|
68
|
+
const cfg = config.load();
|
|
69
|
+
cfg.base = opts.base || cfg.base || config.DEFAULT_BASE;
|
|
70
|
+
|
|
71
|
+
// 1) Cloudflare Access — default is browser SSO via cloudflared (no shared secret).
|
|
72
|
+
// --cf-token switches to a static service token (env or prompt).
|
|
73
|
+
if (opts.cf === 'service-token') {
|
|
74
|
+
console.log('→ Cloudflare Access (service token)');
|
|
75
|
+
let clientId = process.env.MYAPI_CF_CLIENT_ID;
|
|
76
|
+
let clientSecret = process.env.MYAPI_CF_CLIENT_SECRET;
|
|
77
|
+
if (!clientId) clientId = await prompt(' CF-Access-Client-Id: ');
|
|
78
|
+
if (!clientSecret) clientSecret = await prompt(' CF-Access-Client-Secret: ', true);
|
|
79
|
+
if (!clientId || !clientSecret) throw new Error('faltan CF-Access-Client-Id/Secret');
|
|
80
|
+
cfg.cf = { mode: 'service-token', clientId: clientId.trim(), clientSecret: clientSecret.trim() };
|
|
81
|
+
} else {
|
|
82
|
+
if (!cf.hasCloudflared()) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
'Necesitas cloudflared para el login por navegador. Instálalo:\n' +
|
|
85
|
+
' macOS: brew install cloudflared\n' +
|
|
86
|
+
' Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n' +
|
|
87
|
+
' (o usa un token de servicio: myapi login --cf-token)'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
console.log(`→ Cloudflare Access: abriendo tu navegador para iniciar sesión (${cfg.base}) …`);
|
|
91
|
+
cf.cloudflaredLogin(cfg.base);
|
|
92
|
+
cfg.cf = { mode: 'cloudflared' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2) App token — default is the browser device flow; --paste uses an existing token.
|
|
96
|
+
if (opts.paste) {
|
|
97
|
+
cfg.token = await prompt(' Pega tu token de MyApi: ', true);
|
|
98
|
+
} else {
|
|
99
|
+
console.log('→ Aprobación por navegador (device flow)…');
|
|
100
|
+
cfg.token = await deviceFlow(cfg);
|
|
101
|
+
}
|
|
102
|
+
if (!cfg.token) throw new Error('token vacío');
|
|
103
|
+
|
|
104
|
+
// 3) Auto-ASC (bypass device approval from any machine)
|
|
105
|
+
console.log('→ Registrando llave ASC…');
|
|
106
|
+
cfg.asc = await registerAsc(cfg);
|
|
107
|
+
|
|
108
|
+
const file = config.save(cfg);
|
|
109
|
+
console.log(`\n✓ Sesión guardada en ${file}`);
|
|
110
|
+
if (cfg.asc.alreadyApproved) {
|
|
111
|
+
console.log(' Llave ASC ya aprobada — listo.');
|
|
112
|
+
} else if (cfg.asc.fingerprint) {
|
|
113
|
+
console.log(` Aprueba esta llave 1 vez en el dashboard (${cfg.base}/dashboard → Devices/Approvals):`);
|
|
114
|
+
console.log(` ${cfg.asc.fingerprint}`);
|
|
115
|
+
}
|
|
116
|
+
console.log(' Prueba: myapi whoami');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { login, registerAsc, deviceFlow };
|
package/lib/vault.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Vault operations. Every endpoint here is master/full-scope only on the server.
|
|
2
|
+
const { request } = require('./http');
|
|
3
|
+
|
|
4
|
+
function ok(r, what) {
|
|
5
|
+
if (r.status >= 200 && r.status < 300) return r.body;
|
|
6
|
+
const msg = (r.body && (r.body.error || r.body.message)) || JSON.stringify(r.body);
|
|
7
|
+
throw new Error(`${what} failed (HTTP ${r.status}): ${msg}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function whoami(cfg) {
|
|
11
|
+
const b = ok(await request(cfg, 'GET', '/api/v1/gateway/context'), 'whoami');
|
|
12
|
+
return b.data || b;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function list(cfg) {
|
|
16
|
+
const b = ok(await request(cfg, 'GET', '/api/v1/vault/tokens'), 'list');
|
|
17
|
+
return b.data || b.tokens || b;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function put(cfg, name, secret, url, service) {
|
|
21
|
+
const body = { name, token: secret, websiteUrl: url, ...(service ? { service } : {}) };
|
|
22
|
+
const b = ok(await request(cfg, 'POST', '/api/v1/vault/tokens', body), 'put');
|
|
23
|
+
return b.data || b;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function reveal(cfg, id) {
|
|
27
|
+
const b = ok(await request(cfg, 'GET', `/api/v1/vault/tokens/${encodeURIComponent(id)}/reveal`), 'reveal');
|
|
28
|
+
return b.data || b;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function del(cfg, id) {
|
|
32
|
+
const b = ok(await request(cfg, 'DELETE', `/api/v1/vault/tokens/${encodeURIComponent(id)}`), 'del');
|
|
33
|
+
return b.data || b;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Convenience: find one entry by label/name/service, then reveal its secret.
|
|
37
|
+
async function get(cfg, needle) {
|
|
38
|
+
const items = await list(cfg);
|
|
39
|
+
const n = String(needle).toLowerCase();
|
|
40
|
+
const match = (Array.isArray(items) ? items : []).find(
|
|
41
|
+
(t) => [t.label, t.name, t.service].some((v) => String(v || '').toLowerCase() === n)
|
|
42
|
+
);
|
|
43
|
+
if (!match) throw new Error(`no vault entry matching "${needle}"`);
|
|
44
|
+
return reveal(cfg, match.id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { whoami, list, put, reveal, del, get };
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "myapi-vault-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for the self-hosted MyApi credential vault: browser login, ASC-signed access, connect from any machine.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"myapi": "bin/myapi.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"lib",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"myapi",
|
|
19
|
+
"vault",
|
|
20
|
+
"credentials",
|
|
21
|
+
"secrets",
|
|
22
|
+
"cli",
|
|
23
|
+
"asc",
|
|
24
|
+
"cloudflare-access"
|
|
25
|
+
],
|
|
26
|
+
"author": "emiliovt3",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/emiliovt3/myapi-vault-cli.git"
|
|
31
|
+
}
|
|
32
|
+
}
|