akv-manager 0.1.0 → 0.2.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 +5 -0
- package/package.json +5 -2
- package/public/index.html +612 -54
- package/public/styles.css +15 -0
- package/server.js +510 -68
package/server.js
CHANGED
|
@@ -88,23 +88,43 @@ const ARM_RES = 'https://management.azure.com';
|
|
|
88
88
|
// `az account get-access-token` is the ONLY remaining az call on the hot path —
|
|
89
89
|
// it bridges the existing az login (MFA / conditional access) into a bearer token;
|
|
90
90
|
// everything else below is plain fetch against Azure REST.
|
|
91
|
-
const tokenCache = new Map(); // resource -> { token, exp, pending }
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
const tokenCache = new Map(); // `${tenant}|${resource}` -> { token, exp, pending }
|
|
92
|
+
let selectedSub = null; // active subscription in single-account mode; scopes tokens to its user+tenant
|
|
93
|
+
let aggregateMode = false; // "All accounts": fan out across every logged-in tenant and merge results
|
|
94
|
+
const subTenant = new Map(); // subscriptionId -> tenantId, populated by azAccountList (for token keying)
|
|
95
|
+
|
|
96
|
+
// Cache/mint an access token for `resource`, scoped to a subscription's tenant. `sub`
|
|
97
|
+
// picks the tenant (falls back to selectedSub, else the CLI default); tokens are keyed
|
|
98
|
+
// by tenant so subscriptions sharing a tenant reuse one token (fewer slow `az` calls).
|
|
99
|
+
async function getToken(resource, sub) {
|
|
100
|
+
const scope = sub || selectedSub; // subscription id, or null → CLI default
|
|
101
|
+
const tenant = scope ? (subTenant.get(scope) || scope) : 'default';
|
|
102
|
+
const key = `${tenant}|${resource}`;
|
|
103
|
+
const c = tokenCache.get(key);
|
|
95
104
|
if (c && c.token && Date.now() < c.exp) return c.token;
|
|
96
105
|
if (c && c.pending) return c.pending;
|
|
97
106
|
const pending = (async () => {
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
// Scope the token to a subscription so it carries that subscription's user + tenant.
|
|
108
|
+
// This is how switching account — and "All accounts" mode, which mints one token per
|
|
109
|
+
// tenant — works without mutating the CLI's global default (no `az account set`).
|
|
110
|
+
const args = ['account', 'get-access-token', '--resource', resource];
|
|
111
|
+
if (scope) args.push('--subscription', scope);
|
|
112
|
+
const out = await az(args);
|
|
113
|
+
tokenCache.set(key, { token: out.accessToken, exp: Date.now() + 45 * 60 * 1000, pending: null });
|
|
100
114
|
return out.accessToken;
|
|
101
115
|
})();
|
|
102
|
-
tokenCache.set(
|
|
116
|
+
tokenCache.set(key, { ...(c || {}), pending });
|
|
103
117
|
try { return await pending; }
|
|
104
|
-
catch (e) { tokenCache.delete(
|
|
118
|
+
catch (e) { tokenCache.delete(key); throw e; }
|
|
119
|
+
}
|
|
120
|
+
// Drop a cached token (on 401) for the tenant behind a given subscription.
|
|
121
|
+
function dropToken(resource, sub) {
|
|
122
|
+
const scope = sub || selectedSub;
|
|
123
|
+
const tenant = scope ? (subTenant.get(scope) || scope) : 'default';
|
|
124
|
+
tokenCache.delete(`${tenant}|${resource}`);
|
|
105
125
|
}
|
|
106
|
-
const getVaultToken = () => getToken(VAULT_RES);
|
|
107
|
-
const getArmToken = () => getToken(ARM_RES);
|
|
126
|
+
const getVaultToken = (sub) => getToken(VAULT_RES, sub);
|
|
127
|
+
const getArmToken = (sub) => getToken(ARM_RES, sub);
|
|
108
128
|
|
|
109
129
|
function decodeJwt(t) {
|
|
110
130
|
try {
|
|
@@ -114,8 +134,8 @@ function decodeJwt(t) {
|
|
|
114
134
|
}
|
|
115
135
|
|
|
116
136
|
// Azure Resource Manager REST call (management plane).
|
|
117
|
-
async function armFetch(pathOrUrl, { method = 'GET', body, timeout = 40000 } = {}) {
|
|
118
|
-
const token = await getArmToken();
|
|
137
|
+
async function armFetch(pathOrUrl, { method = 'GET', body, timeout = 40000, sub } = {}) {
|
|
138
|
+
const token = await getArmToken(sub);
|
|
119
139
|
const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${ARM_RES}${pathOrUrl}`;
|
|
120
140
|
const ctrl = new AbortController();
|
|
121
141
|
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
@@ -130,7 +150,7 @@ async function armFetch(pathOrUrl, { method = 'GET', body, timeout = 40000 } = {
|
|
|
130
150
|
} catch (e) {
|
|
131
151
|
throw Object.assign(new Error(e.name === 'AbortError' ? 'ARM request timed out' : e.message), { timedOut: e.name === 'AbortError' });
|
|
132
152
|
} finally { clearTimeout(timer); }
|
|
133
|
-
if (resp.status === 401) {
|
|
153
|
+
if (resp.status === 401) { dropToken(ARM_RES, sub); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
|
|
134
154
|
if (resp.status === 403) throw Object.assign(new Error('Forbidden'), { forbidden: true });
|
|
135
155
|
if (!resp.ok) throw new Error(`ARM ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
|
136
156
|
return resp.json();
|
|
@@ -145,7 +165,48 @@ async function listSubscriptions(force) {
|
|
|
145
165
|
subsCache = { at: Date.now(), data };
|
|
146
166
|
return data;
|
|
147
167
|
}
|
|
148
|
-
|
|
168
|
+
|
|
169
|
+
// All identities currently logged into the Azure CLI (every `az login`), across all
|
|
170
|
+
// tenants — the tenant-independent master list behind the account switcher. Unlike
|
|
171
|
+
// listSubscriptions (ARM, single tenant), this reads local CLI state, so it still
|
|
172
|
+
// works when the *active* account's token has expired.
|
|
173
|
+
let accountsCache = { at: 0, data: null };
|
|
174
|
+
const ACCOUNTS_TTL = 30 * 1000;
|
|
175
|
+
async function azAccountList(force) {
|
|
176
|
+
if (!force && accountsCache.data && Date.now() - accountsCache.at < ACCOUNTS_TTL) return accountsCache.data;
|
|
177
|
+
const out = await az(['account', 'list']);
|
|
178
|
+
const data = (Array.isArray(out) ? out : []).map((a) => ({
|
|
179
|
+
id: a.id, name: a.name, state: a.state, tenantId: a.tenantId, isDefault: !!a.isDefault,
|
|
180
|
+
user: (a.user && a.user.name) || 'unknown', type: (a.user && a.user.type) || 'user',
|
|
181
|
+
}));
|
|
182
|
+
accountsCache = { at: Date.now(), data };
|
|
183
|
+
for (const a of data) subTenant.set(a.id, a.tenantId); // keep the sub→tenant map warm for token keying
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
// The account the console is pointed at: the selected sub, else the CLI default, else the first.
|
|
187
|
+
function pickActiveAccount(accounts) {
|
|
188
|
+
return accounts.find((a) => a.id === selectedSub) || accounts.find((a) => a.isDefault) || accounts[0] || null;
|
|
189
|
+
}
|
|
190
|
+
// Distinct tenants across the logged-in accounts, each with its subscription ids and a
|
|
191
|
+
// representative sub used to mint that tenant's token. Drives "All accounts" fan-out.
|
|
192
|
+
function tenantsOf(accounts) {
|
|
193
|
+
const byTenant = new Map();
|
|
194
|
+
for (const a of accounts) {
|
|
195
|
+
if (!byTenant.has(a.tenantId)) byTenant.set(a.tenantId, { tenantId: a.tenantId, repSub: a.id, subIds: [], user: a.user });
|
|
196
|
+
byTenant.get(a.tenantId).subIds.push(a.id);
|
|
197
|
+
}
|
|
198
|
+
return [...byTenant.values()];
|
|
199
|
+
}
|
|
200
|
+
// Everything that depends on *which* identity is active. Cleared when the account or
|
|
201
|
+
// aggregate mode changes so the next calls re-auth and re-inventory as the new scope.
|
|
202
|
+
function resetIdentityCaches() {
|
|
203
|
+
tokenCache.clear();
|
|
204
|
+
subsCache = { at: 0, data: null };
|
|
205
|
+
vaultCache = { at: 0, data: null };
|
|
206
|
+
aksDiscoverCache = { at: 0, data: null };
|
|
207
|
+
listCache.clear();
|
|
208
|
+
aksCredCache.clear();
|
|
209
|
+
}
|
|
149
210
|
|
|
150
211
|
// Tell apart a real permission denial from a Key Vault firewall / network block.
|
|
151
212
|
async function forbiddenError(resp) {
|
|
@@ -155,8 +216,16 @@ async function forbiddenError(resp) {
|
|
|
155
216
|
return Object.assign(new Error(msg), { forbidden: true, firewall });
|
|
156
217
|
}
|
|
157
218
|
|
|
158
|
-
|
|
159
|
-
|
|
219
|
+
// Which subscription (hence tenant) a vault lives in — lets data-plane calls pick the
|
|
220
|
+
// right token in "All accounts" mode. Reads the cached inventory, so it's a cheap lookup.
|
|
221
|
+
async function vaultSub(name) {
|
|
222
|
+
try { const m = await findVaultMeta(name); return m ? m.subscriptionId : null; }
|
|
223
|
+
catch { return null; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function kvFetch(name, pathQuery, { timeout = 15000, sub } = {}) {
|
|
227
|
+
const scope = sub || await vaultSub(name);
|
|
228
|
+
const token = await getVaultToken(scope);
|
|
160
229
|
const ctrl = new AbortController();
|
|
161
230
|
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
162
231
|
let resp;
|
|
@@ -166,7 +235,7 @@ async function kvFetch(name, pathQuery, { timeout = 15000 } = {}) {
|
|
|
166
235
|
} catch (e) {
|
|
167
236
|
throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), { timedOut: e.name === 'AbortError' });
|
|
168
237
|
} finally { clearTimeout(timer); }
|
|
169
|
-
if (resp.status === 401) {
|
|
238
|
+
if (resp.status === 401) { dropToken(VAULT_RES, scope);
|
|
170
239
|
throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
|
|
171
240
|
if (resp.status === 403) throw await forbiddenError(resp);
|
|
172
241
|
if (resp.status === 404) throw Object.assign(new Error('Vault or item not found'), { notFound: true });
|
|
@@ -218,21 +287,42 @@ async function kvListCached(name, kind) {
|
|
|
218
287
|
|
|
219
288
|
// ---- API: accounts & subscriptions ----------------------------------------
|
|
220
289
|
|
|
290
|
+
// Active identity. Sourced from `az account list` (so it resolves even when the
|
|
291
|
+
// token is expired), then a liveness probe tells the UI whether re-login is needed.
|
|
221
292
|
app.get('/api/account', (req, res) =>
|
|
222
293
|
send(res, (async () => {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
if (
|
|
226
|
-
|
|
294
|
+
const accounts = await azAccountList();
|
|
295
|
+
if (!accounts.length) throw Object.assign(new Error('Azure CLI is signed out — run az login'), { needLogin: true });
|
|
296
|
+
if (aggregateMode) {
|
|
297
|
+
const users = new Set(accounts.map((a) => a.user)), tenants = new Set(accounts.map((a) => a.tenantId));
|
|
298
|
+
return {
|
|
299
|
+
aggregate: true, user: { name: `All accounts · ${users.size} user${users.size === 1 ? '' : 's'}` },
|
|
300
|
+
name: `${accounts.length} subscriptions · ${tenants.size} tenant${tenants.size === 1 ? '' : 's'}`,
|
|
301
|
+
id: '', tenantId: '', live: true, needLogin: false,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const act = pickActiveAccount(accounts);
|
|
305
|
+
if (act && !selectedSub) selectedSub = act.id;
|
|
306
|
+
let live = false, tenantId = (act && act.tenantId) || '';
|
|
307
|
+
try { const claims = decodeJwt(await getArmToken()); live = true; if (claims.tid) tenantId = claims.tid; }
|
|
308
|
+
catch { /* token expired / conditional access → surface needLogin but keep identity */ }
|
|
227
309
|
return {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
310
|
+
aggregate: false,
|
|
311
|
+
user: { name: (act && act.user) || 'signed in' },
|
|
312
|
+
name: (act && act.name) || '', id: (act && act.id) || '',
|
|
313
|
+
tenantId, live, needLogin: !live,
|
|
231
314
|
};
|
|
232
315
|
})()));
|
|
233
316
|
|
|
234
317
|
app.get('/api/subscriptions', (req, res) =>
|
|
235
318
|
send(res, (async () => {
|
|
319
|
+
// In "All accounts" mode a single ARM token only sees one tenant, so list every
|
|
320
|
+
// logged-in subscription (all tenants) from the CLI instead.
|
|
321
|
+
if (aggregateMode) {
|
|
322
|
+
return (await azAccountList(!!req.query.refresh)).map((a) => ({
|
|
323
|
+
id: a.id, name: a.name, state: a.state, tenantId: a.tenantId, isDefault: false,
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
236
326
|
const subs = await listSubscriptions(!!req.query.refresh);
|
|
237
327
|
if (!selectedSub && subs.length) selectedSub = subs[0].subscriptionId;
|
|
238
328
|
return subs.map((s) => ({
|
|
@@ -241,24 +331,64 @@ app.get('/api/subscriptions', (req, res) =>
|
|
|
241
331
|
}));
|
|
242
332
|
})()));
|
|
243
333
|
|
|
334
|
+
// Every logged-in identity, grouped by user — powers the account switcher. Needs no
|
|
335
|
+
// valid token, so it stays reachable even when the active account has expired.
|
|
336
|
+
app.get('/api/accounts', (req, res) =>
|
|
337
|
+
send(res, (async () => {
|
|
338
|
+
const accounts = await azAccountList(!!req.query.refresh);
|
|
339
|
+
const byUser = new Map();
|
|
340
|
+
for (const a of accounts) {
|
|
341
|
+
if (!byUser.has(a.user)) byUser.set(a.user, { user: a.user, type: a.type, subscriptions: [] });
|
|
342
|
+
byUser.get(a.user).subscriptions.push({
|
|
343
|
+
id: a.id, name: a.name, state: a.state, tenantId: a.tenantId,
|
|
344
|
+
cliDefault: a.isDefault, active: a.id === selectedSub,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return { activeSubscription: selectedSub, aggregate: aggregateMode, users: [...byUser.values()] };
|
|
348
|
+
})()));
|
|
349
|
+
|
|
350
|
+
// Point the console at another subscription — which may belong to a different user /
|
|
351
|
+
// tenant. Because tokens are scoped to `selectedSub`, this is exactly what "change
|
|
352
|
+
// user" does: drop the identity-scoped caches so the next calls re-auth as the new
|
|
353
|
+
// identity. Validated against `az account list` so cross-tenant subs are allowed.
|
|
244
354
|
app.post('/api/subscription/set', (req, res) => {
|
|
245
|
-
const { subscription } = req.body || {};
|
|
355
|
+
const { subscription, all } = req.body || {};
|
|
356
|
+
// "All accounts": aggregate across every logged-in tenant (one token per tenant, merged).
|
|
357
|
+
if (all) {
|
|
358
|
+
return send(res, (async () => {
|
|
359
|
+
const accounts = await azAccountList();
|
|
360
|
+
if (!accounts.length) throw Object.assign(new Error('No logged-in accounts — run az login'), { needLogin: true });
|
|
361
|
+
aggregateMode = true;
|
|
362
|
+
selectedSub = null;
|
|
363
|
+
resetIdentityCaches();
|
|
364
|
+
const users = new Set(accounts.map((a) => a.user)), tenants = new Set(accounts.map((a) => a.tenantId));
|
|
365
|
+
return { aggregate: true, user: { name: `All accounts · ${users.size} user${users.size === 1 ? '' : 's'}` },
|
|
366
|
+
name: `${accounts.length} subscriptions · ${tenants.size} tenant${tenants.size === 1 ? '' : 's'}`,
|
|
367
|
+
subscriptions: accounts.length, tenants: tenants.size, live: true, needLogin: false };
|
|
368
|
+
})());
|
|
369
|
+
}
|
|
246
370
|
if (!subscription || !SUB_RE.test(subscription))
|
|
247
371
|
return res.status(400).json({ ok: false, error: 'Invalid subscription id/name' });
|
|
248
|
-
// With everything on REST, the "active subscription" is just server state.
|
|
249
372
|
send(res, (async () => {
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
if (!
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
373
|
+
const accounts = await azAccountList();
|
|
374
|
+
const act = accounts.find((a) => a.id === subscription || a.name === subscription);
|
|
375
|
+
if (!act) throw Object.assign(new Error('Subscription not found among logged-in accounts'), { status: 404 });
|
|
376
|
+
aggregateMode = false;
|
|
377
|
+
selectedSub = act.id;
|
|
378
|
+
resetIdentityCaches(); // identity changed → drop identity-scoped caches
|
|
379
|
+
let live = false;
|
|
380
|
+
try { await getArmToken(); live = true; } catch { /* target account needs re-login */ }
|
|
381
|
+
return {
|
|
382
|
+
aggregate: false,
|
|
383
|
+
user: { name: act.user }, name: act.name, id: act.id, tenantId: act.tenantId,
|
|
384
|
+
live, needLogin: !live, loginHint: live ? null : `az login --tenant ${act.tenantId}`,
|
|
385
|
+
};
|
|
257
386
|
})());
|
|
258
387
|
});
|
|
259
388
|
|
|
260
389
|
app.post('/api/logout', (req, res) => {
|
|
261
|
-
|
|
390
|
+
selectedSub = null; aggregateMode = false;
|
|
391
|
+
resetIdentityCaches(); accountsCache = { at: 0, data: null }; subTenant.clear();
|
|
262
392
|
send(res, az(['logout'], { json: false }).then(() => ({ loggedOut: true })).catch(() => ({ loggedOut: true })));
|
|
263
393
|
});
|
|
264
394
|
|
|
@@ -272,10 +402,9 @@ const VAULT_QUERY =
|
|
|
272
402
|
"uri=tostring(properties.vaultUri), tags " +
|
|
273
403
|
"| order by name asc";
|
|
274
404
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// Resource Graph returns at most 1000 rows per page; follow $skipToken to get them all.
|
|
405
|
+
// One Resource Graph sweep over a set of subscriptions, using `sub`'s tenant token.
|
|
406
|
+
// Resource Graph returns at most 1000 rows per page; follow $skipToken to get them all.
|
|
407
|
+
async function resourceGraphVaults(subIds, sub) {
|
|
279
408
|
const data = [];
|
|
280
409
|
let skipToken;
|
|
281
410
|
for (let page = 0; page < 100; page++) { // hard stop at 100k vaults — a safety bound
|
|
@@ -284,19 +413,47 @@ async function listVaultsRaw(force) {
|
|
|
284
413
|
const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
|
|
285
414
|
method: 'POST',
|
|
286
415
|
body: { subscriptions: subIds, query: VAULT_QUERY, options },
|
|
416
|
+
sub,
|
|
287
417
|
});
|
|
288
418
|
if (out.data) data.push(...out.data);
|
|
289
419
|
skipToken = out.$skipToken;
|
|
290
420
|
if (!skipToken) break;
|
|
291
421
|
}
|
|
292
|
-
|
|
422
|
+
return data;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function listVaultsRaw(force) {
|
|
426
|
+
if (!force && vaultCache.data && Date.now() - vaultCache.at < VAULT_TTL) return vaultCache.data;
|
|
427
|
+
const data = [];
|
|
428
|
+
const warnings = []; // accounts whose slice failed (e.g. expired) — surfaced as a partial-results note
|
|
429
|
+
if (aggregateMode) {
|
|
430
|
+
// "All accounts": one Resource Graph sweep per tenant (each needs its own token),
|
|
431
|
+
// tag every vault with its owning account, and keep going if one tenant fails.
|
|
432
|
+
for (const t of tenantsOf(await azAccountList(force))) {
|
|
433
|
+
try {
|
|
434
|
+
const rows = await resourceGraphVaults(t.subIds, t.repSub);
|
|
435
|
+
for (const r of rows) { r.tenantId = t.tenantId; r.account = t.user; }
|
|
436
|
+
data.push(...rows);
|
|
437
|
+
} catch (e) {
|
|
438
|
+
warnings.push({ tenantId: t.tenantId, user: t.user, needLogin: !!e.needLogin,
|
|
439
|
+
error: (e.stderr || e.message || 'failed').slice(0, 200) });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
const subIds = (await listSubscriptions(force)).map((s) => s.subscriptionId);
|
|
444
|
+
const rows = await resourceGraphVaults(subIds, selectedSub);
|
|
445
|
+
const act = pickActiveAccount(await azAccountList().catch(() => [])) || {};
|
|
446
|
+
for (const r of rows) { r.tenantId = act.tenantId || ''; r.account = act.user || ''; }
|
|
447
|
+
data.push(...rows);
|
|
448
|
+
}
|
|
449
|
+
vaultCache = { at: Date.now(), data, warnings };
|
|
293
450
|
return data;
|
|
294
451
|
}
|
|
295
452
|
|
|
296
453
|
app.get('/api/vaults', async (req, res) => {
|
|
297
454
|
try {
|
|
298
455
|
const data = await listVaultsRaw(!!req.query.refresh);
|
|
299
|
-
res.json({ ok: true, data });
|
|
456
|
+
res.json({ ok: true, data, aggregate: aggregateMode, warnings: (vaultCache.warnings || []) });
|
|
300
457
|
} catch (err) {
|
|
301
458
|
res.status(err.needLogin ? 401 : 500)
|
|
302
459
|
.json({ ok: false, error: err.stderr || err.message, needLogin: !!err.needLogin });
|
|
@@ -328,6 +485,25 @@ function tagMatch(pattern, key) {
|
|
|
328
485
|
}
|
|
329
486
|
function isProtectedTag(key) { return PROTECTED_TAGS.some((p) => tagMatch(p, key)); }
|
|
330
487
|
|
|
488
|
+
const IPV4_RE = /^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}(?:\/(?:3[0-2]|[12]?\d))?$/;
|
|
489
|
+
function normalizeIpRule(ip) {
|
|
490
|
+
const s = String(ip || '').trim();
|
|
491
|
+
if (!IPV4_RE.test(s)) throw Object.assign(new Error('Invalid IPv4 address/range'), { status: 400 });
|
|
492
|
+
return s.includes('/') ? s : `${s}/32`;
|
|
493
|
+
}
|
|
494
|
+
function ipRuleMatches(ruleValue, targetRule) {
|
|
495
|
+
const rv = String(ruleValue || '').trim().toLowerCase();
|
|
496
|
+
const t = String(targetRule || '').trim().toLowerCase();
|
|
497
|
+
if (!rv || !t) return false;
|
|
498
|
+
if (rv === t) return true;
|
|
499
|
+
if (t.endsWith('/32') && rv === t.slice(0, -3)) return true;
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
function parseFirewallIp(msg) {
|
|
503
|
+
const m = /Client address:\s*([0-9]{1,3}(?:\.[0-9]{1,3}){3})/i.exec(String(msg || ''));
|
|
504
|
+
return m ? m[1] : null;
|
|
505
|
+
}
|
|
506
|
+
|
|
331
507
|
// Replace a vault's tag set (management plane). The body's `tags` object is the
|
|
332
508
|
// COMPLETE desired set — Key Vault's PATCH overwrites the whole tags collection.
|
|
333
509
|
// Protected/default tags are force-preserved from the current state, so they can be
|
|
@@ -356,12 +532,81 @@ app.put('/api/vaults/:name/tags', (req, res) => {
|
|
|
356
532
|
for (const [k, val] of Object.entries(current)) { if (isProtectedTag(k)) finalTags[k] = val; }
|
|
357
533
|
const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
|
|
358
534
|
`/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
|
|
359
|
-
const out = await armFetch(url, { method: 'PATCH', body: { tags: finalTags } });
|
|
535
|
+
const out = await armFetch(url, { method: 'PATCH', body: { tags: finalTags }, sub: v.subscriptionId });
|
|
360
536
|
v.tags = (out && out.tags) || finalTags; // reflect immediately in the cached inventory
|
|
361
537
|
return { tags: v.tags };
|
|
362
538
|
})());
|
|
363
539
|
});
|
|
364
540
|
|
|
541
|
+
// Allow one IPv4 (or CIDR) through a vault firewall by adding it to networkAcls.ipRules.
|
|
542
|
+
// Requires management-plane write on the vault (e.g. Contributor).
|
|
543
|
+
app.post('/api/vaults/:name/firewall/allow-ip', (req, res) => {
|
|
544
|
+
const { name } = req.params;
|
|
545
|
+
if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
|
|
546
|
+
send(res, (async () => {
|
|
547
|
+
const v = await findVaultMeta(name);
|
|
548
|
+
if (!v) throw Object.assign(new Error('Vault not found in inventory — try Refresh first'), { status: 404, notFound: true });
|
|
549
|
+
|
|
550
|
+
let ip = null;
|
|
551
|
+
try {
|
|
552
|
+
const bodyIp = req.body && req.body.ip;
|
|
553
|
+
ip = bodyIp ? String(bodyIp).trim() : null;
|
|
554
|
+
if (!ip) ip = parseFirewallIp(req.body && req.body.error);
|
|
555
|
+
ip = normalizeIpRule(ip || '');
|
|
556
|
+
} catch {
|
|
557
|
+
throw Object.assign(new Error('Could not determine a valid IP. Provide req.body.ip (IPv4)'), { status: 400 });
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
|
|
561
|
+
`/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
|
|
562
|
+
const cur = await armFetch(url, { sub: v.subscriptionId });
|
|
563
|
+
const props = cur.properties || {};
|
|
564
|
+
const acl = props.networkAcls || {};
|
|
565
|
+
const currentRules = Array.isArray(acl.ipRules) ? acl.ipRules : [];
|
|
566
|
+
const have = currentRules.some((r) => String((r && r.value) || '').toLowerCase() === ip.toLowerCase());
|
|
567
|
+
if (!have) {
|
|
568
|
+
const nextAcls = { ...acl, ipRules: [...currentRules, { value: ip }] };
|
|
569
|
+
await armFetch(url, { method: 'PATCH', body: { properties: { networkAcls: nextAcls } }, sub: v.subscriptionId });
|
|
570
|
+
}
|
|
571
|
+
return { vault: name, ip, alreadyAllowed: have };
|
|
572
|
+
})());
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Remove one IPv4 (or CIDR) from a vault firewall (networkAcls.ipRules).
|
|
576
|
+
// Requires management-plane write on the vault (e.g. Contributor).
|
|
577
|
+
app.post('/api/vaults/:name/firewall/remove-ip', (req, res) => {
|
|
578
|
+
const { name } = req.params;
|
|
579
|
+
if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
|
|
580
|
+
send(res, (async () => {
|
|
581
|
+
const v = await findVaultMeta(name);
|
|
582
|
+
if (!v) throw Object.assign(new Error('Vault not found in inventory — try Refresh first'), { status: 404, notFound: true });
|
|
583
|
+
|
|
584
|
+
let ip;
|
|
585
|
+
try {
|
|
586
|
+
const bodyIp = req.body && req.body.ip;
|
|
587
|
+
ip = bodyIp ? String(bodyIp).trim() : null;
|
|
588
|
+
if (!ip) ip = parseFirewallIp(req.body && req.body.error);
|
|
589
|
+
ip = normalizeIpRule(ip || '');
|
|
590
|
+
} catch {
|
|
591
|
+
throw Object.assign(new Error('Could not determine a valid IP. Provide req.body.ip (IPv4)'), { status: 400 });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
|
|
595
|
+
`/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
|
|
596
|
+
const cur = await armFetch(url, { sub: v.subscriptionId });
|
|
597
|
+
const props = cur.properties || {};
|
|
598
|
+
const acl = props.networkAcls || {};
|
|
599
|
+
const currentRules = Array.isArray(acl.ipRules) ? acl.ipRules : [];
|
|
600
|
+
const nextRules = currentRules.filter((r) => !ipRuleMatches((r && r.value) || '', ip));
|
|
601
|
+
const removed = nextRules.length !== currentRules.length;
|
|
602
|
+
if (removed) {
|
|
603
|
+
const nextAcls = { ...acl, ipRules: nextRules };
|
|
604
|
+
await armFetch(url, { method: 'PATCH', body: { properties: { networkAcls: nextAcls } }, sub: v.subscriptionId });
|
|
605
|
+
}
|
|
606
|
+
return { vault: name, ip, removed };
|
|
607
|
+
})());
|
|
608
|
+
});
|
|
609
|
+
|
|
365
610
|
// ---- API: expiring secrets & certificates (scans every readable vault) -----
|
|
366
611
|
|
|
367
612
|
/** Run async worker over items with bounded concurrency. */
|
|
@@ -493,7 +738,8 @@ function buildAttrs(src = {}) {
|
|
|
493
738
|
}
|
|
494
739
|
|
|
495
740
|
async function kvWrite(vaultName, method, pathQuery, body) {
|
|
496
|
-
const
|
|
741
|
+
const scope = await vaultSub(vaultName);
|
|
742
|
+
const token = await getVaultToken(scope);
|
|
497
743
|
const ctrl = new AbortController();
|
|
498
744
|
const timer = setTimeout(() => ctrl.abort(), 20000);
|
|
499
745
|
let resp;
|
|
@@ -505,7 +751,7 @@ async function kvWrite(vaultName, method, pathQuery, body) {
|
|
|
505
751
|
} catch (e) {
|
|
506
752
|
throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), {});
|
|
507
753
|
} finally { clearTimeout(timer); }
|
|
508
|
-
if (resp.status === 401) {
|
|
754
|
+
if (resp.status === 401) { dropToken(VAULT_RES, scope); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
|
|
509
755
|
if (resp.status === 403) throw await forbiddenError(resp);
|
|
510
756
|
if (resp.status === 404) throw Object.assign(new Error('Vault or item not found'), { notFound: true });
|
|
511
757
|
if (!resp.ok) {
|
|
@@ -608,12 +854,23 @@ app.post('/api/batch', async (req, res) => {
|
|
|
608
854
|
|
|
609
855
|
const https = require('https');
|
|
610
856
|
const AKS_AAD_SERVER_APP = '6dae42f8-4368-4678-94ff-3960e28e3630'; // Microsoft public constant
|
|
611
|
-
const getAksToken = () => getToken(AKS_AAD_SERVER_APP);
|
|
857
|
+
const getAksToken = (sub) => getToken(AKS_AAD_SERVER_APP, sub);
|
|
612
858
|
const K8S_NAME = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/; // DNS-1123-ish (namespaces, pods, cms)
|
|
613
859
|
const isK8sName = (v) => typeof v === 'string' && K8S_NAME.test(v);
|
|
614
860
|
|
|
615
|
-
//
|
|
616
|
-
//
|
|
861
|
+
// Cluster list — NEVER baked in. Two sources, merged: (1) the operator's file/env
|
|
862
|
+
// (AZBO_AKS_CLUSTERS / AZBO_AKS_CLUSTERS_FILE), and (2) a list the user pastes in the
|
|
863
|
+
// UI, persisted in their browser's localStorage and registered with this local server
|
|
864
|
+
// for the session. Each entry: { name, resourceGroup, subscription, env?, label? }.
|
|
865
|
+
const RG_RE = /^[\w.()-]{1,90}$/;
|
|
866
|
+
function clusterValid(c) {
|
|
867
|
+
return c && isValidName(c.name) && RG_RE.test(c.resourceGroup || '') && SUB_RE.test(c.subscription || '');
|
|
868
|
+
}
|
|
869
|
+
function normalizeClusters(arr, source) {
|
|
870
|
+
return (Array.isArray(arr) ? arr : []).filter((c) => c && c.name && c.resourceGroup && c.subscription)
|
|
871
|
+
.map((c) => ({ name: String(c.name), resourceGroup: String(c.resourceGroup), subscription: String(c.subscription),
|
|
872
|
+
env: c.env ? String(c.env) : '', label: c.label ? String(c.label) : String(c.name), configured: true, source }));
|
|
873
|
+
}
|
|
617
874
|
const AKS_CLUSTERS = (() => {
|
|
618
875
|
let raw = process.env.AZBO_AKS_CLUSTERS;
|
|
619
876
|
if (!raw && process.env.AZBO_AKS_CLUSTERS_FILE) {
|
|
@@ -621,13 +878,10 @@ const AKS_CLUSTERS = (() => {
|
|
|
621
878
|
catch (e) { console.warn(' AZBO_AKS_CLUSTERS_FILE could not be read:', e.message); }
|
|
622
879
|
}
|
|
623
880
|
if (!raw) return [];
|
|
624
|
-
try {
|
|
625
|
-
|
|
626
|
-
return (Array.isArray(arr) ? arr : []).filter((c) => c && c.name && c.resourceGroup && c.subscription)
|
|
627
|
-
.map((c) => ({ name: c.name, resourceGroup: c.resourceGroup, subscription: c.subscription,
|
|
628
|
-
env: c.env || '', label: c.label || c.name, configured: true }));
|
|
629
|
-
} catch { console.warn(' AZBO_AKS_CLUSTERS is not valid JSON — ignoring.'); return []; }
|
|
881
|
+
try { return normalizeClusters(JSON.parse(raw), 'file'); }
|
|
882
|
+
catch { console.warn(' AZBO_AKS_CLUSTERS is not valid JSON — ignoring.'); return []; }
|
|
630
883
|
})();
|
|
884
|
+
let SESSION_CLUSTERS = []; // set by the client via POST /api/aks/clusters (from its localStorage)
|
|
631
885
|
|
|
632
886
|
async function resolveSubId(nameOrId) {
|
|
633
887
|
if (/^[0-9a-fA-F-]{36}$/.test(nameOrId)) return nameOrId;
|
|
@@ -646,14 +900,16 @@ async function aksCredential(c) {
|
|
|
646
900
|
const url = `/subscriptions/${subId}/resourceGroups/${c.resourceGroup}` +
|
|
647
901
|
`/providers/Microsoft.ContainerService/managedClusters/${c.name}` +
|
|
648
902
|
`/listClusterUserCredential?api-version=2024-05-01`;
|
|
649
|
-
const out = await armFetch(url, { method: 'POST' });
|
|
903
|
+
const out = await armFetch(url, { method: 'POST', sub: subId });
|
|
650
904
|
const kc = out.kubeconfigs && out.kubeconfigs[0];
|
|
651
905
|
if (!kc || !kc.value) throw new Error('No kubeconfig returned for this cluster');
|
|
652
906
|
const yaml = Buffer.from(kc.value, 'base64').toString('utf8');
|
|
653
907
|
const server = (yaml.match(/server:\s*(\S+)/) || [])[1];
|
|
654
908
|
const caB64 = (yaml.match(/certificate-authority-data:\s*(\S+)/) || [])[1];
|
|
655
909
|
if (!server) throw new Error('Could not parse API server URL from kubeconfig');
|
|
656
|
-
|
|
910
|
+
// Carry the cluster's subscription on the cred so k8sGet mints the right tenant's
|
|
911
|
+
// AKS token — this is what makes cross-tenant clusters work in "All accounts" mode.
|
|
912
|
+
const cred = { server: server.replace(/\/+$/, ''), sub: subId,
|
|
657
913
|
ca: caB64 ? Buffer.from(caB64, 'base64').toString('utf8') : undefined, at: Date.now() };
|
|
658
914
|
aksCredCache.set(key, cred);
|
|
659
915
|
return cred;
|
|
@@ -673,7 +929,7 @@ function imageTag(img) {
|
|
|
673
929
|
// One GET against a cluster's Kubernetes API. raw=true returns text (for pod logs).
|
|
674
930
|
function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
|
|
675
931
|
return new Promise((resolve, reject) => {
|
|
676
|
-
getAksToken().then((token) => {
|
|
932
|
+
getAksToken(cred.sub).then((token) => {
|
|
677
933
|
const u = new URL(cred.server + pathQuery);
|
|
678
934
|
const req = https.request({
|
|
679
935
|
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
|
|
@@ -685,7 +941,7 @@ function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
|
|
|
685
941
|
res.on('data', (c) => { data += c; });
|
|
686
942
|
res.on('end', () => {
|
|
687
943
|
const s = res.statusCode;
|
|
688
|
-
if (s === 401) {
|
|
944
|
+
if (s === 401) { dropToken(AKS_AAD_SERVER_APP, cred.sub); return reject(Object.assign(new Error('Token rejected — run az login'), { needLogin: true })); }
|
|
689
945
|
if (s === 403) return reject(Object.assign(new Error(k8sMsg(data) || 'Forbidden'), { forbidden: true }));
|
|
690
946
|
if (s === 404) return reject(Object.assign(new Error(k8sMsg(data) || 'Not found'), { notFound: true }));
|
|
691
947
|
if (s >= 400) return reject(new Error(k8sMsg(data) || `Kubernetes returned ${s}`));
|
|
@@ -712,23 +968,40 @@ const AKS_QUERY =
|
|
|
712
968
|
"| project name, resourceGroup, subscriptionId, " +
|
|
713
969
|
"ver=tostring(properties.kubernetesVersion), " +
|
|
714
970
|
"priv=tostring(properties.apiServerAccessProfile.enablePrivateCluster) | order by name asc";
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
971
|
+
// Cached so getClusterChecked (called on every pod/log/configmap request) doesn't
|
|
972
|
+
// re-run a Resource Graph query each time. Force-refreshed via ?refresh=1.
|
|
973
|
+
let aksDiscoverCache = { at: 0, data: null };
|
|
974
|
+
const AKS_DISCOVER_TTL = 60 * 1000;
|
|
975
|
+
async function discoverClusters(force) {
|
|
976
|
+
if (!force && aksDiscoverCache.data && Date.now() - aksDiscoverCache.at < AKS_DISCOVER_TTL) return aksDiscoverCache.data;
|
|
977
|
+
const query = async (subIds, sub) => {
|
|
718
978
|
const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
|
|
719
|
-
method: 'POST', body: { subscriptions: subIds, query: AKS_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } },
|
|
979
|
+
method: 'POST', body: { subscriptions: subIds, query: AKS_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } }, sub,
|
|
720
980
|
});
|
|
721
981
|
return (out.data || []).map((c) => ({ name: c.name, resourceGroup: c.resourceGroup,
|
|
722
982
|
subscription: c.subscriptionId, version: c.ver, private: c.priv === 'true', configured: false }));
|
|
723
|
-
}
|
|
983
|
+
};
|
|
984
|
+
try {
|
|
985
|
+
const data = [];
|
|
986
|
+
if (aggregateMode) {
|
|
987
|
+
// one sweep per tenant; skip tenants that fail (e.g. expired) so the rest still list
|
|
988
|
+
for (const t of tenantsOf(await azAccountList(force))) {
|
|
989
|
+
try { data.push(...await query(t.subIds, t.repSub)); } catch { /* skip this tenant */ }
|
|
990
|
+
}
|
|
991
|
+
} else {
|
|
992
|
+
data.push(...await query((await listSubscriptions()).map((s) => s.subscriptionId), selectedSub));
|
|
993
|
+
}
|
|
994
|
+
aksDiscoverCache = { at: Date.now(), data };
|
|
995
|
+
return data;
|
|
996
|
+
} catch { return aksDiscoverCache.data || []; } // on error, reuse last good result
|
|
724
997
|
}
|
|
725
998
|
|
|
726
999
|
function clusterById(list, id) { return list.find((c) => c.name === id); }
|
|
727
|
-
async function aksList() {
|
|
728
|
-
const discovered = await discoverClusters();
|
|
1000
|
+
async function aksList(force) {
|
|
1001
|
+
const discovered = await discoverClusters(force);
|
|
729
1002
|
const byKey = new Map();
|
|
730
1003
|
for (const c of discovered) byKey.set(c.name.toLowerCase(), c);
|
|
731
|
-
for (const c of AKS_CLUSTERS) {
|
|
1004
|
+
for (const c of [...AKS_CLUSTERS, ...SESSION_CLUSTERS]) { // configured (file then session) win + carry label/env
|
|
732
1005
|
const prev = byKey.get(c.name.toLowerCase()) || {};
|
|
733
1006
|
byKey.set(c.name.toLowerCase(), { ...prev, ...c });
|
|
734
1007
|
}
|
|
@@ -736,7 +1009,23 @@ async function aksList() {
|
|
|
736
1009
|
}
|
|
737
1010
|
|
|
738
1011
|
app.get('/api/aks/clusters', (req, res) =>
|
|
739
|
-
send(res, aksList().then((clusters) => ({ clusters,
|
|
1012
|
+
send(res, aksList(!!req.query.refresh).then((clusters) => ({ clusters,
|
|
1013
|
+
configured: AKS_CLUSTERS.length + SESSION_CLUSTERS.length, file: AKS_CLUSTERS.length, session: SESSION_CLUSTERS.length }))));
|
|
1014
|
+
|
|
1015
|
+
// Register the user's pasted cluster list for this session (from their localStorage).
|
|
1016
|
+
// Replaces the previous session list; validated; localhost-only like the rest of the API.
|
|
1017
|
+
app.post('/api/aks/clusters', (req, res) => {
|
|
1018
|
+
const { clusters } = req.body || {};
|
|
1019
|
+
if (!Array.isArray(clusters)) return res.status(400).json({ ok: false, error: 'clusters must be a JSON array' });
|
|
1020
|
+
if (clusters.length > 200) return res.status(400).json({ ok: false, error: 'Too many clusters (max 200)' });
|
|
1021
|
+
for (const c of clusters) {
|
|
1022
|
+
if (!c || !c.name || !c.resourceGroup || !c.subscription) return res.status(400).json({ ok: false, error: `Each entry needs name, resourceGroup, subscription (got: ${JSON.stringify(c).slice(0, 60)})` });
|
|
1023
|
+
if (!clusterValid(c)) return res.status(400).json({ ok: false, error: `Invalid cluster entry "${c.name}" — check name / resourceGroup / subscription` });
|
|
1024
|
+
}
|
|
1025
|
+
SESSION_CLUSTERS = normalizeClusters(clusters, 'session');
|
|
1026
|
+
aksCredCache.clear(); // coordinates may have changed
|
|
1027
|
+
res.json({ ok: true, data: { count: SESSION_CLUSTERS.length } });
|
|
1028
|
+
});
|
|
740
1029
|
|
|
741
1030
|
// Resolve a :cluster route param to a known cluster, or throw a typed 400/404.
|
|
742
1031
|
async function getClusterChecked(name) {
|
|
@@ -764,7 +1053,7 @@ app.get('/api/aks/:cluster/pods', (req, res) =>
|
|
|
764
1053
|
const labels = p.metadata.labels || {};
|
|
765
1054
|
const build = imageTag((spec[0] || {}).image) || labels['app.kubernetes.io/version'] || labels.version || labels['tags.datadoghq.com/version'] || '';
|
|
766
1055
|
return { name: p.metadata.name, namespace: p.metadata.namespace, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
|
|
767
|
-
build,
|
|
1056
|
+
build, labels, // labels: used client-side to match Service selectors → Ingress
|
|
768
1057
|
restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
|
|
769
1058
|
ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
|
|
770
1059
|
containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
|
|
@@ -876,6 +1165,67 @@ async function fetchSpc(cred, ns, name) {
|
|
|
876
1165
|
}
|
|
877
1166
|
return null;
|
|
878
1167
|
}
|
|
1168
|
+
// ---- External Secrets helpers (shared by the per-pod trace and the cluster map) -----
|
|
1169
|
+
function parseVaultHost(url) { const m = /([a-z0-9][a-z0-9-]{1,22}[a-z0-9])\.vault\.azure\./i.exec(String(url || '')); return m ? m[1].toLowerCase() : null; }
|
|
1170
|
+
function akvVaultOf(store) { const a = store && store.spec && store.spec.provider && store.spec.provider.azurekv; return a ? parseVaultHost(a.vaultUrl || a.vaultUri) : null; }
|
|
1171
|
+
// GET the first readable API version among candidates; tolerate not-installed (404) / no-access (403).
|
|
1172
|
+
async function k8sFirst(cred, paths) {
|
|
1173
|
+
for (const p of paths) { try { return await k8sGet(cred, p); } catch (e) { if (e.notFound || e.forbidden) continue; throw e; } }
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
const ES_GROUP = 'external-secrets.io', CSI_GROUP = 'secrets-store.csi.x-k8s.io';
|
|
1177
|
+
const esPaths = (suffix) => [`/apis/${ES_GROUP}/v1/${suffix}`, `/apis/${ES_GROUP}/v1beta1/${suffix}`];
|
|
1178
|
+
const csiPaths = (suffix) => [`/apis/${CSI_GROUP}/v1/${suffix}`, `/apis/${CSI_GROUP}/v1alpha1/${suffix}`];
|
|
1179
|
+
// Resolve a secretStoreRef ({kind,name}) to its azurekv vault short-name (or null).
|
|
1180
|
+
async function storeRefVault(cred, ns, ref) {
|
|
1181
|
+
if (!ref || !ref.name) return null;
|
|
1182
|
+
const store = await k8sFirst(cred, ref.kind === 'ClusterSecretStore'
|
|
1183
|
+
? esPaths(`clustersecretstores/${ref.name}`) : esPaths(`namespaces/${ns}/secretstores/${ref.name}`));
|
|
1184
|
+
return akvVaultOf(store);
|
|
1185
|
+
}
|
|
1186
|
+
// When listing SecretStores is denied but get-by-name isn't (common on locked-down
|
|
1187
|
+
// clusters), try these conventional store names. Override/extend for your naming via
|
|
1188
|
+
// AZBO_SECRETSTORE_NAMES (comma-separated).
|
|
1189
|
+
const CONVENTIONAL_STORES = (process.env.AZBO_SECRETSTORE_NAMES || 'secretstore-app,secretstore,azure-store,azurekv,azure-kv')
|
|
1190
|
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
1191
|
+
// Vault short-names a namespace reads from, resolved directly from its External Secrets
|
|
1192
|
+
// config (SecretStores + ExternalSecrets) and CSI SecretProviderClasses. Mirrors
|
|
1193
|
+
// `kubectl get secretstore -n <ns>`, so it needs only namespaced read. Reports
|
|
1194
|
+
// `restricted` when RBAC blocks reading those CRDs (so the UI can say so, not "none").
|
|
1195
|
+
// With `deep`, also GETs conventional SecretStore names — works with get-only RBAC.
|
|
1196
|
+
async function namespaceVaults(cred, ns, { deep = false } = {}) {
|
|
1197
|
+
const set = new Set();
|
|
1198
|
+
let forbidden = false;
|
|
1199
|
+
const listItems = async (paths) => {
|
|
1200
|
+
for (const p of paths) {
|
|
1201
|
+
try { return (await k8sGet(cred, p)).items || []; }
|
|
1202
|
+
catch (e) { if (e.forbidden) { forbidden = true; return null; } if (e.notFound) continue; throw e; }
|
|
1203
|
+
}
|
|
1204
|
+
return [];
|
|
1205
|
+
};
|
|
1206
|
+
for (const it of (await listItems(esPaths(`namespaces/${ns}/secretstores`)) || [])) { const v = akvVaultOf(it); if (v) set.add(v); }
|
|
1207
|
+
for (const it of (await listItems(esPaths(`namespaces/${ns}/externalsecrets`)) || [])) {
|
|
1208
|
+
const v = await storeRefVault(cred, ns, (it.spec && it.spec.secretStoreRef) || {}); if (v) set.add(v);
|
|
1209
|
+
}
|
|
1210
|
+
for (const it of (await listItems(csiPaths(`namespaces/${ns}/secretproviderclasses`)) || [])) {
|
|
1211
|
+
const params = (it.spec && it.spec.parameters) || {};
|
|
1212
|
+
const kv = params.keyvaultName || params.keyvaultname || params.keyVaultName;
|
|
1213
|
+
if (kv) set.add(String(kv).toLowerCase());
|
|
1214
|
+
}
|
|
1215
|
+
// get-only RBAC fallback: fetch conventional SecretStore names directly by name.
|
|
1216
|
+
if (deep && !set.size) {
|
|
1217
|
+
for (const nm of CONVENTIONAL_STORES) {
|
|
1218
|
+
let store = null, stop = false;
|
|
1219
|
+
for (const p of esPaths(`namespaces/${ns}/secretstores/${nm}`)) {
|
|
1220
|
+
try { store = await k8sGet(cred, p); break; }
|
|
1221
|
+
catch (e) { if (e.forbidden) { forbidden = true; stop = true; break; } if (e.notFound) continue; throw e; }
|
|
1222
|
+
}
|
|
1223
|
+
if (stop) break;
|
|
1224
|
+
const v = akvVaultOf(store); if (v) set.add(v);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return { vaults: [...set], restricted: forbidden && set.size === 0 };
|
|
1228
|
+
}
|
|
879
1229
|
|
|
880
1230
|
app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
|
|
881
1231
|
const { ns, pod } = req.params;
|
|
@@ -980,8 +1330,100 @@ app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
|
|
|
980
1330
|
} catch (e) { entry.reason = e.forbidden ? 'needs read on externalsecrets / secretstores' : (e.message || 'unreadable'); }
|
|
981
1331
|
externalSecrets.push(entry);
|
|
982
1332
|
}
|
|
1333
|
+
// 5) Fallback that mirrors `kubectl get secretstore -n <ns>`: resolve the namespace's
|
|
1334
|
+
// own External Secrets config directly. Robust when the per-secret owner chain above
|
|
1335
|
+
// doesn't resolve (e.g. a frontend pod that never mounts the synced Secret).
|
|
1336
|
+
let nsInfo = { vaults: [], restricted: false };
|
|
1337
|
+
try { nsInfo = await namespaceVaults(cred, ns, { deep: true }); } catch { /* namespace CRDs not readable */ }
|
|
1338
|
+
for (const v of nsInfo.vaults) { set.add(v); (srcMap[v] = srcMap[v] || new Set()).add('namespace secretstore'); }
|
|
1339
|
+
|
|
983
1340
|
const vaults = [...set].sort().map((name) => ({ name, sources: [...(srcMap[name] || [])] }));
|
|
984
|
-
return { vaults, configmapsScanned: scanned, spcs, externalSecrets };
|
|
1341
|
+
return { vaults, configmapsScanned: scanned, spcs, externalSecrets, namespaceVaults: nsInfo.vaults, restricted: nsInfo.restricted };
|
|
1342
|
+
})());
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
// Namespace → Key Vault map for the pod list. Resolves each namespace directly from its
|
|
1346
|
+
// own External Secrets config + CSI classes (namespaced reads — so it works with
|
|
1347
|
+
// per-namespace RBAC, unlike a cluster-wide list). The client passes ?namespaces= (the
|
|
1348
|
+
// namespaces its pods are in) so we only probe what's on screen.
|
|
1349
|
+
app.get('/api/aks/:cluster/secret-vaults', (req, res) => {
|
|
1350
|
+
send(res, (async () => {
|
|
1351
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
1352
|
+
const nsList = [...new Set(String(req.query.namespaces || '').split(',')
|
|
1353
|
+
.map((s) => s.trim()).filter((s) => isK8sName(s)))].slice(0, 200); // bound the fan-out
|
|
1354
|
+
const byNamespace = {}, restricted = {};
|
|
1355
|
+
const results = await pool(nsList, async (ns) => [ns, await namespaceVaults(cred, ns)], 8);
|
|
1356
|
+
for (const r of results) {
|
|
1357
|
+
if (!r) continue;
|
|
1358
|
+
const [ns, info] = r;
|
|
1359
|
+
if (info.vaults.length) byNamespace[ns] = info.vaults.sort();
|
|
1360
|
+
else if (info.restricted) restricted[ns] = true;
|
|
1361
|
+
}
|
|
1362
|
+
return { byNamespace, restricted };
|
|
1363
|
+
})());
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
const ingBackendSvc = (b) => (b && ((b.service && b.service.name) || b.serviceName)) || null;
|
|
1367
|
+
const ingBackendPort = (b) => { const s = b && b.service; const p = s && s.port; return (p && (p.name || p.number)) || (b && b.servicePort) || null; };
|
|
1368
|
+
// Parse an Ingress into the config worth showing: routes (host/path/backend:port), TLS
|
|
1369
|
+
// (secret + cert-manager/ACME), class, and the common nginx annotations (HTTPS redirect,
|
|
1370
|
+
// CORS, body size). Keeps only a curated set so the map stays lean.
|
|
1371
|
+
function parseIngress(ing) {
|
|
1372
|
+
const a = (ing.metadata && ing.metadata.annotations) || {};
|
|
1373
|
+
const spec = ing.spec || {};
|
|
1374
|
+
const rules = spec.rules || [];
|
|
1375
|
+
const routes = [];
|
|
1376
|
+
for (const r of rules) for (const p of ((r.http && r.http.paths) || []))
|
|
1377
|
+
routes.push({ host: r.host || null, path: p.path || '/', pathType: p.pathType || null, service: ingBackendSvc(p.backend), port: ingBackendPort(p.backend) });
|
|
1378
|
+
const cm = (a['cert-manager.io/common-name'] || a['kubernetes.io/tls-acme'] || a['cert-manager.io/cluster-issuer'] || a['cert-manager.io/issuer'] || a['certmanager.k8s.io/acme-challenge-type']) ? {
|
|
1379
|
+
commonName: a['cert-manager.io/common-name'] || null, tlsAcme: a['kubernetes.io/tls-acme'] || null,
|
|
1380
|
+
issuer: a['cert-manager.io/cluster-issuer'] || a['cert-manager.io/issuer'] || null,
|
|
1381
|
+
challenge: a['certmanager.k8s.io/acme-challenge-type'] || a['cert-manager.io/acme-challenge-type'] || null,
|
|
1382
|
+
dnsProvider: a['certmanager.k8s.io/acme-dns01-provider'] || a['cert-manager.io/acme-dns01-provider'] || null,
|
|
1383
|
+
} : null;
|
|
1384
|
+
const cors = a['nginx.ingress.kubernetes.io/enable-cors'] === 'true' ? {
|
|
1385
|
+
origin: a['nginx.ingress.kubernetes.io/cors-allow-origin'] || null,
|
|
1386
|
+
methods: a['nginx.ingress.kubernetes.io/cors-allow-methods'] || null,
|
|
1387
|
+
headers: a['nginx.ingress.kubernetes.io/cors-allow-headers'] || null,
|
|
1388
|
+
} : null;
|
|
1389
|
+
return {
|
|
1390
|
+
name: ing.metadata && ing.metadata.name,
|
|
1391
|
+
className: spec.ingressClassName || a['kubernetes.io/ingress.class'] || null,
|
|
1392
|
+
hosts: [...new Set(rules.map((r) => r.host).filter(Boolean))],
|
|
1393
|
+
routes,
|
|
1394
|
+
tls: (spec.tls || []).map((t) => ({ hosts: t.hosts || [], secretName: t.secretName || null })),
|
|
1395
|
+
sslRedirect: a['nginx.ingress.kubernetes.io/force-ssl-redirect'] === 'true' || a['nginx.org/redirect-to-https'] === 'true',
|
|
1396
|
+
allowHttp: a['kubernetes.io/ingress.allow-http'] || null,
|
|
1397
|
+
proxyBodySize: a['nginx.ingress.kubernetes.io/proxy-body-size'] || a['nginx.org/client-max-body-size'] || null,
|
|
1398
|
+
cors, certManager: cm,
|
|
1399
|
+
backendServices: [...new Set(routes.map((r) => r.service).filter(Boolean))],
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Namespace → { Services (name+selector), parsed Ingresses } for the pod list. The client
|
|
1404
|
+
// matches each pod's labels to a Service's selector, then to the Ingress(es) routing to it
|
|
1405
|
+
// — surfacing host, TLS, class, CORS, redirect and body-size. Namespaced list reads
|
|
1406
|
+
// (Services + Ingresses), allowed here even where External Secrets are not.
|
|
1407
|
+
app.get('/api/aks/:cluster/ingress-map', (req, res) => {
|
|
1408
|
+
send(res, (async () => {
|
|
1409
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
1410
|
+
const nsList = [...new Set(String(req.query.namespaces || '').split(',')
|
|
1411
|
+
.map((s) => s.trim()).filter((s) => isK8sName(s)))].slice(0, 200);
|
|
1412
|
+
const tolerant = async (path) => { try { return ((await k8sGet(cred, path)).items) || []; } catch (e) { if (e.forbidden || e.notFound) return []; throw e; } };
|
|
1413
|
+
const results = await pool(nsList, async (ns) => {
|
|
1414
|
+
const [svcs, ings] = await Promise.all([
|
|
1415
|
+
tolerant(`/api/v1/namespaces/${ns}/services`),
|
|
1416
|
+
tolerant(`/apis/networking.k8s.io/v1/namespaces/${ns}/ingresses`),
|
|
1417
|
+
]);
|
|
1418
|
+
const services = svcs
|
|
1419
|
+
.filter((s) => s.spec && s.spec.selector && Object.keys(s.spec.selector).length)
|
|
1420
|
+
.map((s) => ({ name: s.metadata.name, selector: s.spec.selector }));
|
|
1421
|
+
const ingresses = ings.map(parseIngress).filter((i) => i.backendServices.length || i.hosts.length);
|
|
1422
|
+
return [ns, { services, ingresses }];
|
|
1423
|
+
}, 8);
|
|
1424
|
+
const byNamespace = {};
|
|
1425
|
+
for (const r of results) if (r && r[1] && (r[1].services.length || r[1].ingresses.length)) byNamespace[r[0]] = r[1];
|
|
1426
|
+
return { byNamespace };
|
|
985
1427
|
})());
|
|
986
1428
|
});
|
|
987
1429
|
|
|
@@ -1012,7 +1454,7 @@ async function bridgeExec(client, params) {
|
|
|
1012
1454
|
const c = clusterById(await aksList(), clusterName);
|
|
1013
1455
|
if (!c) throw new Error('Cluster is not in your configured/visible list');
|
|
1014
1456
|
cred = await aksCredential(c);
|
|
1015
|
-
token = await getAksToken();
|
|
1457
|
+
token = await getAksToken(cred.sub);
|
|
1016
1458
|
} catch (e) {
|
|
1017
1459
|
tell('\r\n\x1b[31m' + (e.network ? 'Cannot reach the cluster — connect to the VPN.' : (e.message || 'auth failed')) + '\x1b[0m\r\n');
|
|
1018
1460
|
client.close(); return;
|