akv-manager 0.0.5 → 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 CHANGED
@@ -34,6 +34,27 @@ If you're not signed in, the UI tells you to run `az login` and press Refresh.
34
34
  **edit** values (current value pre-filled), and **batch** enable/disable/set-expiry.
35
35
  - **Expiry** — scan every readable vault for secrets/certs that are expired or near
36
36
  expiry, plotted on a time horizon.
37
+ - **Clusters** — read-only AKS monitoring: browse namespaces and pods, read **pod
38
+ logs** and **configmaps**, all as your own `az` identity. Talks to the Kubernetes
39
+ API directly (no `kubectl`/`kubelogin` needed) via an AAD token for the managed
40
+ AAD server app. Private API servers need you on the right network (e.g. corporate
41
+ VPN); the UI flags it when a cluster is unreachable.
42
+
43
+ ### Configuring AKS clusters
44
+
45
+ Clusters you have ARM *Reader* on are auto-discovered. If you only have **data-plane**
46
+ (Kubernetes-RBAC) access — common — list them yourself via either variable:
47
+
48
+ ```bash
49
+ # inline JSON …
50
+ AZBO_AKS_CLUSTERS='[{"name":"my-aks","resourceGroup":"my-rg","subscription":"My Sub","env":"Dev","label":"My cluster"}]' npx akv-manager
51
+
52
+ # … or a file
53
+ AZBO_AKS_CLUSTERS_FILE=./aks-clusters.json npx akv-manager
54
+ ```
55
+
56
+ Each entry needs `name`, `resourceGroup`, `subscription` (name or id); `env` and
57
+ `label` are optional. The console never bundles any cluster list — it's yours to provide.
37
58
 
38
59
  ## Configuration (all optional, via environment variables)
39
60
 
@@ -44,6 +65,9 @@ If you're not signed in, the UI tells you to run `az login` and press Refresh.
44
65
  | `AZBO_NO_OPEN` | — | Set to anything to skip auto-opening the browser. |
45
66
  | `AZBO_TITLE` | `Keyvault Console` | Custom title shown in the sidebar. |
46
67
  | `AZBO_ENV_RULES` | — | JSON `[["pattern","Label"], …]` mapping subscription-name patterns to environment labels. |
68
+ | `AZBO_PROTECTED_TAGS` | governance set | Comma-separated tag names that are read-only in the UI (`*` = prefix wildcard). Set empty to disable. |
69
+ | `AZBO_AKS_CLUSTERS` | — | JSON array of `{name,resourceGroup,subscription,env?,label?}` clusters to monitor. |
70
+ | `AZBO_AKS_CLUSTERS_FILE` | — | Path to a JSON file with the same cluster array. |
47
71
  | `AZ_PATH` | `az` | Path to the Azure CLI binary. |
48
72
  | `AZ_TIMEOUT` | `120000` | Per-`az`-call timeout (ms). |
49
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akv-manager",
3
- "version": "0.0.5",
3
+ "version": "0.1.0",
4
4
  "description": "Local web console to browse and manage Azure Key Vaults — secrets, keys, certificates, expiry — using your own az login. No credentials are stored.",
5
5
  "type": "commonjs",
6
6
  "main": "server.js",
@@ -30,7 +30,8 @@
30
30
  "cli"
31
31
  ],
32
32
  "dependencies": {
33
- "express": "^4.19.2"
33
+ "express": "^4.19.2",
34
+ "ws": "^8.21.0"
34
35
  },
35
36
  "engines": {
36
37
  "node": ">=18"
package/public/index.html CHANGED
@@ -32,6 +32,10 @@
32
32
  };
33
33
  </script>
34
34
  <link rel="stylesheet" href="/styles.css">
35
+ <!-- xterm.js: in-browser terminal for the AKS pod shell -->
36
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
37
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
38
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
35
39
  </head>
36
40
  <body>
37
41
  <div class="grid min-h-screen grid-cols-[236px_1fr] max-[900px]:grid-cols-1">
@@ -65,6 +69,9 @@
65
69
  <button data-view="expiry">
66
70
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg>
67
71
  Expiry</button>
72
+ <button data-view="clusters">
73
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z"/><path d="M12 7l4 2.2v4.6L12 16l-4-2.2V9.2L12 7z"/></svg>
74
+ Clusters</button>
68
75
  </nav>
69
76
 
70
77
  <div class="navlabel" id="pinLabel">Pinned</div>
@@ -213,6 +220,7 @@ async function boot(){
213
220
  renderSidebar();
214
221
  router();
215
222
  kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
223
+ loadClustersCache(); // so ⌘K can jump to clusters without visiting the section first
216
224
  }
217
225
 
218
226
  async function loadSubs(){
@@ -237,25 +245,30 @@ const META = {
237
245
  dashboard:['Operations','Dashboard','Secrets and certificates that need attention, your pinned vaults, and access health.'],
238
246
  vaults:['Inventory','Key Vaults','Every readable vault across your subscriptions. Filter by environment and tags; open one to manage it.'],
239
247
  expiry:['Lifecycle','Expiring soon','Secrets and certificates that are expired or approaching expiry across every vault you can read.'],
248
+ clusters:['Kubernetes','Clusters','Read-only monitoring of your AKS clusters — browse pods, read logs and configmaps. Configured locally; runs as your az identity.'],
240
249
  settings:['Account','Account & subscriptions','Your Azure identity and the subscription the console is pointed at.'],
241
250
  };
242
251
  let mountedVault = null;
243
252
  function setHead(view){
244
- const onVault = view==='vault';
245
- $('#headblock').style.display = onVault ? 'none' : '';
246
- $('#search').classList.toggle('hide', onVault);
247
- if(!onVault && META[view]){ $('#eyebrow').textContent=META[view][0]; $('#title').textContent=META[view][1]; $('#subtitle').textContent=META[view][2]; }
248
- document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active', b.dataset.view===view));
253
+ const detail = view==='vault' || view==='cluster'; // detail pages carry their own breadcrumb
254
+ $('#headblock').style.display = detail ? 'none' : '';
255
+ $('#search').classList.toggle('hide', view!=='vaults' && view!=='dashboard' && view!=='expiry' && view!=='settings');
256
+ if(!detail && META[view]){ $('#eyebrow').textContent=META[view][0]; $('#title').textContent=META[view][1]; $('#subtitle').textContent=META[view][2]; }
257
+ const navActive = view==='vault'?'vaults' : view==='cluster'?'clusters' : view;
258
+ document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active', b.dataset.view===navActive));
249
259
  }
250
260
  function router(){
251
261
  if(!STATE.account) return;
252
262
  const parts = parseHash();
253
263
  const route = parts[0]||'dashboard';
254
264
  STATE.route = route;
265
+ if(route!=='cluster') teardownTerm(); // close any open pod shell when leaving Clusters
255
266
  if(route!=='vault') mountedVault=null;
256
267
  if(route==='vaults'){ setHead('vaults'); viewVaults(); }
257
268
  else if(route==='vault'){ setHead('vault'); viewVault(parts[1], parts[2]||'secrets'); }
258
269
  else if(route==='expiry'){ setHead('expiry'); viewExpiry(); }
270
+ else if(route==='clusters'){ setHead('clusters'); viewClusters(); }
271
+ else if(route==='cluster'){ setHead('cluster'); viewCluster(parts[1], parts[2], parts[3]||'pods'); }
259
272
  else if(route==='settings'){ setHead('settings'); viewSettings(); }
260
273
  else { setHead('dashboard'); viewDashboard(); }
261
274
  renderSidebar();
@@ -433,7 +446,7 @@ function viewVault(name, kind){
433
446
  const v = findVault(name) || (name?{name, subscriptionId:'', resourceGroup:'', uri:'', enableRbac:'', tags:{}}:null);
434
447
  if(!v){ $('#route').innerHTML=`<div class="panel"><div class="empty">No vault specified. <a href="#/vaults">Back to Key Vaults</a></div></div>`; return; }
435
448
  STATE.drawerVault=v; STATE.kind=kind;
436
- if(mountedVault!==name){
449
+ if(mountedVault!==name || !document.getElementById('vbody')){
437
450
  mountedVault=name;
438
451
  DRW={vault:name, kind, items:[], filter:'', sel:new Set()};
439
452
  const tabs = KIND_TABS.map(([k,l])=>`<button data-kind="${k}" class="${k===kind?'active':''}" onclick="goKind('${esc(name)}','${k}')">${l}</button>`).join('');
@@ -954,6 +967,357 @@ function card(k,v,sub,mod){
954
967
  return `<div class="card ${mod?'card--'+mod:''}"><div class="k">${esc(k)}</div><div class="v">${v} <small>${esc(sub)}</small></div></div>`;
955
968
  }
956
969
 
970
+ // ---------- CLUSTERS (AKS — read-only: pods, logs, configmaps) ----------
971
+ let CL = { all:null, name:'', cluster:null, ns:'', kind:'pods', items:[], namespaces:[], logPod:null, filter:'', tier:'all', cm:{} };
972
+ let mountedCluster = null;
973
+ function age(iso){ if(!iso) return ''; const s=(Date.now()-Date.parse(iso))/1000;
974
+ if(s<3600) return Math.max(1,Math.floor(s/60))+'m'; if(s<86400) return Math.floor(s/3600)+'h'; return Math.floor(s/86400)+'d'; }
975
+ function clErr(r){
976
+ if(r.network) return `<div class="empty">🚫 Can't reach this cluster's API server — it's private. Connect to the corporate VPN and retry.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div></div>`;
977
+ if(r.needLogin) return `<div class="empty">Not signed in — run <code>az login</code>, then Refresh.</div>`;
978
+ if(r.forbidden) return `<div class="empty">🔒 Your identity has no Kubernetes-RBAC permission for this on this cluster.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div></div>`;
979
+ return `<div class="empty">${esc(r.error||'failed')}</div>`;
980
+ }
981
+ async function loadClustersCache(){ const r=await api('/api/aks/clusters'); if(r.ok) CL.all=r.data.clusters||[]; return CL.all; }
982
+
983
+ async function viewClusters(){
984
+ $('#route').innerHTML = `<div class="panel"><div class="empty"><span class="spin"></span> loading clusters…</div></div>`;
985
+ const r = await api('/api/aks/clusters');
986
+ if(STATE.route!=='clusters') return;
987
+ if(!r.ok){ $('#route').innerHTML = `<div class="panel">${clErr(r)}</div>`; return; }
988
+ CL.all = r.data.clusters||[];
989
+ if(!CL.all.length){
990
+ $('#route').innerHTML = `<div class="panel"><div class="empty" style="padding:42px;line-height:1.8">
991
+ No AKS clusters configured or visible to you.<br>
992
+ <span class="faint mono" style="font-size:12px">Set <b>AZBO_AKS_CLUSTERS</b> (JSON) or <b>AZBO_AKS_CLUSTERS_FILE</b> to a list of<br>
993
+ <code>{ name, resourceGroup, subscription, env, label }</code> and restart.</span></div></div>`;
994
+ return;
995
+ }
996
+ $('#route').innerHTML = `
997
+ <div class="panel"><div class="scroll"><table>
998
+ <thead><tr><th>Cluster</th><th>Env</th><th>Version</th><th>Network</th><th>Source</th><th class="right">Actions</th></tr></thead>
999
+ <tbody>${CL.all.map(c=>`<tr>
1000
+ <td class="mono"><b>${esc(c.label||c.name)}</b>${c.label&&c.label!==c.name?`<br><span class="faint" style="font-size:11px">${esc(c.name)}</span>`:''}</td>
1001
+ <td>${c.env?`<span class="tag ${envClass(c.env)}">${esc(c.env)}</span>`:'<span class="faint">—</span>'}</td>
1002
+ <td class="mono muted">${esc(c.version||'—')}</td>
1003
+ <td>${c.private?'<span class="pill policy">private · VPN</span>':c.private===false?'<span class="pill rbac">public</span>':'<span class="faint mono">?</span>'}</td>
1004
+ <td><span class="pill">${c.configured?'configured':'discovered'}</span></td>
1005
+ <td class="right"><button class="iconbtn" onclick="openCluster('${esc(c.name)}')">Open</button></td>
1006
+ </tr>`).join('')}</tbody>
1007
+ </table></div></div>
1008
+ <div class="tablenote">${CL.all.length} cluster${CL.all.length!==1?'s':''} · ${r.data.configured} configured locally</div>`;
1009
+ }
1010
+ function openCluster(name){ navigate('cluster/'+encodeURIComponent(name)); }
1011
+
1012
+ function viewCluster(name){
1013
+ CL.name = name;
1014
+ if(mountedCluster!==name || !document.getElementById('clBody')){
1015
+ mountedCluster=name; CL.ns=''; CL.items=[]; CL.namespaces=[]; CL.logPod=null; CL.cluster=null; CL.kind='pods';
1016
+ $('#route').innerHTML = `
1017
+ <div class="vhead">
1018
+ <div class="crumb"><a href="#/clusters">Clusters</a><span class="sep">/</span>
1019
+ <span class="here" id="clName">${esc(name)}</span> <span id="clEnv"></span></div>
1020
+ <div class="vmeta" id="clMeta"><span class="faint">read-only · pods · logs · configmaps</span></div>
1021
+ </div>
1022
+ <div class="cmctl">
1023
+ <div class="seg" id="clKind"><button data-k="pods" class="active">Pods</button><button data-k="configmaps">ConfigMaps</button></div>
1024
+ <button class="btn ghost" id="clRefresh">↻ Refresh</button>
1025
+ </div>
1026
+ <div id="clBody"><div class="empty"><span class="spin"></span> loading…</div></div>`;
1027
+ $('#clKind').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.kind=b.dataset.k;
1028
+ $('#clKind').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); loadClusterContent(); });
1029
+ $('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(); };
1030
+ enrichClusterMeta(name);
1031
+ loadClusterContent();
1032
+ } else {
1033
+ loadClusterContent();
1034
+ }
1035
+ }
1036
+ async function enrichClusterMeta(name){
1037
+ const list = CL.all || await loadClustersCache();
1038
+ const c = (list||[]).find(x=>x.name===name); if(!c) return;
1039
+ CL.cluster=c;
1040
+ if(STATE.route!=='cluster' || CL.name!==name) return;
1041
+ if($('#clName')) $('#clName').textContent = c.label||c.name;
1042
+ if($('#clEnv')&&c.env) $('#clEnv').innerHTML = `<span class="tag ${envClass(c.env)}">${esc(c.env)}</span>`;
1043
+ if($('#clMeta')) $('#clMeta').innerHTML = `<span class="mono">${esc(c.name)}</span><span class="sep">·</span>`+
1044
+ `<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
1045
+ }
1046
+ function loadClusterContent(){
1047
+ CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
1048
+ $('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
1049
+ if(CL.kind==='pods') loadPods(); else loadConfigMaps();
1050
+ }
1051
+ function clFilterBar(placeholder, extra){
1052
+ return `<div class="cmctl" style="margin-bottom:10px">
1053
+ <input class="search" id="clFilter" placeholder="${placeholder}" value="${esc(CL.filter)}" autocomplete="off" style="min-width:300px">
1054
+ ${extra||''}
1055
+ <span class="faint mono" id="clCount"></span></div>
1056
+ <div id="clList"></div>`;
1057
+ }
1058
+ const TIER_RE = {
1059
+ cache:/(^|-)cache(\d+)?(-|$)|-cache$/,
1060
+ fe:/frontend|-fe-|-fe\b|-web\b|-ui\b|-spa\b/,
1061
+ be:/backend|-be-|-be\b|-api\b|-svc\b|-worker\b|-jobs?\b|-bgjobs?\b/,
1062
+ };
1063
+ function podTier(nm){ nm=(nm||'').toLowerCase(); if(TIER_RE.cache.test(nm))return'cache'; if(TIER_RE.fe.test(nm))return'fe'; if(TIER_RE.be.test(nm))return'be'; return'other'; }
1064
+ async function loadPods(){
1065
+ const name=CL.name;
1066
+ const r = await api(`/api/aks/${name}/pods`);
1067
+ if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
1068
+ if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
1069
+ CL.items=r.data||[];
1070
+ const tier = `<div class="seg" id="clTier">
1071
+ <button data-t="all" class="${CL.tier==='all'?'active':''}">All</button>
1072
+ <button data-t="be" class="${CL.tier==='be'?'active':''}">Backend</button>
1073
+ <button data-t="fe" class="${CL.tier==='fe'?'active':''}">Frontend</button>
1074
+ <button data-t="cache" class="${CL.tier==='cache'?'active':''}">Cache</button></div>`;
1075
+ $('#clBody').innerHTML = clFilterBar('filter pods by name or namespace…', tier);
1076
+ $('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderPodRows(); };
1077
+ $('#clTier').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.tier=b.dataset.t;
1078
+ $('#clTier').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); renderPodRows(); });
1079
+ renderPodRows();
1080
+ }
1081
+ function podMatches(p){
1082
+ if(CL.tier!=='all' && podTier(p.name)!==CL.tier) return false;
1083
+ const q=CL.filter.toLowerCase();
1084
+ const nm=p.name.toLowerCase();
1085
+ return !q || nm.includes(q) || (p.namespace||'').toLowerCase().includes(q);
1086
+ }
1087
+ function renderPodRows(){
1088
+ const list=CL.items.filter(podMatches);
1089
+ $('#clCount').textContent = CL.items.length ? `${list.length}${list.length!==CL.items.length?' / '+CL.items.length:''} pod${CL.items.length!==1?'s':''}` : '';
1090
+ if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
1091
+ if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
1092
+ $('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
1093
+ <thead><tr><th>Pod</th><th>Namespace</th><th>Build</th><th>Ready</th><th>Status</th><th class="right">Restarts</th><th>Age</th><th class="right">Actions</th></tr></thead>
1094
+ <tbody>${list.map(p=>`<tr>
1095
+ <td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
1096
+ <td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
1097
+ <td class="mono"><span class="ell" style="max-width:140px" title="${esc(p.build||'')}">${esc(p.build||'—')}</span></td>
1098
+ <td class="mono">${esc(p.ready||'')}</td>
1099
+ <td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
1100
+ <td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
1101
+ <td class="mono muted">${age(p.created)}</td>
1102
+ <td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
1103
+ <button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
1104
+ <button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
1105
+ <button class="iconbtn" onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">Vaults</button></td>
1106
+ </tr>`).join('')}</tbody></table></div></div>`;
1107
+ }
1108
+ async function openPodDetail(ns,pod){
1109
+ CL.logPod=null; CL.termPod=null; teardownTerm();
1110
+ $('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
1111
+ <span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span></div>
1112
+ <div id="pdOut"><div class="empty"><span class="spin"></span> loading pod details…</div></div>`;
1113
+ const r=await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/detail`);
1114
+ if(STATE.route!=='cluster') return; const out=$('#pdOut'); if(!out) return;
1115
+ if(!r.ok){ out.innerHTML=clErr(r); return; }
1116
+ const d=r.data; const labels=Object.entries(d.labels||{});
1117
+ const labelChips = labels.length
1118
+ ? `<div class="tagchips">${labels.map(([k,v])=>`<span class="tagchip"><b>${esc(k)}</b>${v?' '+esc(v):''}</span>`).join('')}</div>`
1119
+ : '<span class="faint mono">no labels</span>';
1120
+ const cont=(d.containers||[]).map(c=>{
1121
+ const rows=(c.env||[]).map(e=>{
1122
+ const val = e.value!=null ? esc(e.value)
1123
+ : e.from ? `<span class="faint">← ${esc(e.from.kind)} ${esc(e.from.name||'')}${e.from.key?'.'+esc(e.from.key):''}</span>` : '';
1124
+ return `<tr><td class="mono"><b>${esc(e.name)}</b></td><td class="mono" style="word-break:break-all">${val}</td></tr>`;
1125
+ }).join('');
1126
+ const efRows=(c.envFrom||[]).map(ef=>`<tr><td class="mono faint">∗ all keys</td><td class="mono faint">← ${esc(ef.kind)} ${esc(ef.name||'')}${ef.prefix?` (prefix ${esc(ef.prefix)})`:''}${ef.kind==='secret'?' · values not shown':''}</td></tr>`).join('');
1127
+ const body = rows||efRows
1128
+ ? `<div class="panel" style="margin-top:6px"><div class="scroll" style="max-height:none"><table>
1129
+ <thead><tr><th>Env var</th><th>Value / source</th></tr></thead><tbody>${rows}${efRows}</tbody></table></div></div>`
1130
+ : '<div class="faint mono" style="margin-top:6px">no env vars declared</div>';
1131
+ return `<div style="margin:16px 0">
1132
+ <div class="kvline" style="padding:4px 0"><span class="nm mono"><b>${esc(c.name)}</b>${c.init?' <span class="pill">init</span>':''}</span>
1133
+ <span class="meta">${c.tag?`<span class="tagchip">build ${esc(c.tag)}</span>`:''}</span></div>
1134
+ <div class="cmval">${esc(c.image||'')}</div>${body}</div>`;
1135
+ }).join('');
1136
+ out.innerHTML = `<div class="secthead">Labels (tags)</div>${labelChips}
1137
+ <div class="secthead" style="margin-top:18px">Containers · image · env vars</div>${cont}`;
1138
+ }
1139
+ function showLogs(ns,pod){
1140
+ const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
1141
+ CL.logNs=ns; CL.logPod=pod;
1142
+ const conts = p.containers||[];
1143
+ const contSel = conts.length>1
1144
+ ? `<select class="facetsel" id="logC">${conts.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select>`
1145
+ : conts.length ? `<input type="hidden" id="logC" value="${esc(conts[0])}">` : '';
1146
+ $('#clBody').innerHTML = `
1147
+ <div class="cmctl">
1148
+ <button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
1149
+ <span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span>
1150
+ ${contSel}
1151
+ <select class="facetsel" id="logTail"><option value="200">200 lines</option><option value="1000" selected>1000 lines</option><option value="5000">5000 lines</option><option value="20000">20000 lines</option></select>
1152
+ <label class="selall"><input type="checkbox" id="logTs"> timestamps</label>
1153
+ <label class="selall"><input type="checkbox" id="logPrev"> previous (crash)</label>
1154
+ <button class="btn sm" id="logGo">Load</button>
1155
+ <span class="newbtns"><button class="iconbtn" id="logCopy">⧉ Copy</button><button class="iconbtn" id="logDl">↓ Download</button></span>
1156
+ </div>
1157
+ <div class="logbox" id="logOut"><div class="empty"><span class="spin"></span></div></div>`;
1158
+ $('#logGo').onclick=fetchLogs;
1159
+ $('#logTail').onchange=fetchLogs; $('#logC') && ($('#logC').onchange=fetchLogs);
1160
+ $('#logCopy').onclick=()=>{ const t=$('#logOut').dataset.raw||''; t?copy(t):toast('Nothing to copy'); };
1161
+ $('#logDl').onclick=downloadLog;
1162
+ fetchLogs();
1163
+ }
1164
+ async function fetchLogs(){
1165
+ const out=$('#logOut'); if(!out) return;
1166
+ out.innerHTML='<div class="empty"><span class="spin"></span> fetching logs…</div>';
1167
+ const cEl=$('#logC'); const container=cEl?cEl.value:'';
1168
+ const qs=new URLSearchParams({tailLines:$('#logTail').value});
1169
+ if(container) qs.set('container',container);
1170
+ if($('#logTs').checked) qs.set('timestamps','true');
1171
+ if($('#logPrev').checked) qs.set('previous','true');
1172
+ const pod=CL.logPod;
1173
+ const r=await api(`/api/aks/${CL.name}/${CL.logNs}/pods/${pod}/log?${qs.toString()}`);
1174
+ if(STATE.route!=='cluster' || CL.logPod!==pod) return;
1175
+ if(!r.ok){ out.innerHTML=clErr(r); return; }
1176
+ const text=r.data.log||'';
1177
+ out.dataset.raw=text;
1178
+ out.textContent = text || '(empty log)'; // textContent: safe against log content
1179
+ }
1180
+ function downloadLog(){
1181
+ const text=$('#logOut').dataset.raw||''; if(!text){ toast('Nothing to download'); return; }
1182
+ const env=(CL.cluster&&CL.cluster.env)?CL.cluster.env+'-':'';
1183
+ const blob=new Blob([text],{type:'text/plain'}); const url=URL.createObjectURL(blob);
1184
+ const a=document.createElement('a'); a.href=url; a.download=`log-${env}${CL.logNs}-${CL.logPod}.txt`; a.click(); URL.revokeObjectURL(url);
1185
+ }
1186
+ async function loadConfigMaps(){
1187
+ const name=CL.name;
1188
+ const r=await api(`/api/aks/${name}/configmaps`);
1189
+ if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='configmaps'||CL.logPod||CL.termPod) return;
1190
+ if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
1191
+ CL.items=r.data||[];
1192
+ $('#clBody').innerHTML = clFilterBar('filter configmaps by name or namespace…');
1193
+ $('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderCmRows(); };
1194
+ renderCmRows();
1195
+ }
1196
+ function renderCmRows(){
1197
+ const q=CL.filter.toLowerCase();
1198
+ const list=CL.items.filter(m=>!q||m.name.toLowerCase().includes(q)||(m.namespace||'').toLowerCase().includes(q));
1199
+ $('#clCount').textContent = CL.items.length ? `${list.length}${list.length!==CL.items.length?' / '+CL.items.length:''} configmap${CL.items.length!==1?'s':''}` : '';
1200
+ if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No configmaps visible on this cluster.</div>`; return; }
1201
+ if(!list.length){ $('#clList').innerHTML=`<div class="empty">No configmaps match “${esc(CL.filter)}”.</div>`; return; }
1202
+ $('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
1203
+ <thead><tr><th>ConfigMap</th><th>Namespace</th><th>Keys</th><th class="right">Actions</th></tr></thead>
1204
+ <tbody>${list.map(m=>`<tr>
1205
+ <td class="mono"><b><span class="ell" title="${esc(m.name)}">${esc(m.name)}</span></b></td>
1206
+ <td class="mono muted"><span class="ell ns" title="${esc(m.namespace||'')}">${esc(m.namespace||'')}</span></td>
1207
+ <td class="faint mono ell" style="font-size:11px;max-width:360px" title="${esc((m.keys||[]).join(', '))}">${(m.keys||[]).slice(0,6).map(esc).join(', ')}${m.keys.length>6?` +${m.keys.length-6}`:''}</td>
1208
+ <td class="right"><button class="iconbtn" onclick="viewCm('${esc(m.namespace)}','${esc(m.name)}')">View</button>
1209
+ <button class="iconbtn" onclick="copy('kubectl describe configmap ${esc(m.name)} -n ${esc(m.namespace)}')">⧉</button></td>
1210
+ </tr><tr><td colspan="4" style="padding:0;border:0"><div class="hide" id="cm-${esc(m.namespace)}__${esc(m.name)}"></div></td></tr>`).join('')}</tbody>
1211
+ </table></div></div>`;
1212
+ }
1213
+ async function viewCm(ns,name){
1214
+ const box=document.getElementById('cm-'+ns+'__'+name); if(!box) return;
1215
+ if(!box.classList.contains('hide')){ box.classList.add('hide'); box.innerHTML=''; return; }
1216
+ box.classList.remove('hide'); box.innerHTML='<div class="empty"><span class="spin"></span></div>';
1217
+ const r=await api(`/api/aks/${CL.name}/${ns}/configmaps/${name}`);
1218
+ if(!r.ok){ box.innerHTML=clErr(r); return; }
1219
+ const data=r.data.data||{}; const keys=Object.keys(data); const ckey=ns+'/'+name; CL.cm[ckey]=data;
1220
+ box.innerHTML = `<div style="padding:8px 0 12px">${keys.length? keys.map(k=>`
1221
+ <div class="cmkey"><div class="kvline" style="padding:5px 0"><span class="nm mono"><b>${esc(k)}</b></span>
1222
+ <span class="meta"><button class="iconbtn" onclick='copyCm(${JSON.stringify(ckey)},${JSON.stringify(k)})'>⧉ copy</button></span></div>
1223
+ <pre class="cmval"></pre></div>`).join('') : '<div class="empty">empty configmap</div>'}</div>`;
1224
+ const pres=box.querySelectorAll('pre.cmval'); keys.forEach((k,i)=>{ if(pres[i]) pres[i].textContent=data[k]; });
1225
+ }
1226
+ function copyCm(ckey,k){ const d=CL.cm[ckey]||{}; copy(d[k]!=null?String(d[k]):''); }
1227
+
1228
+ // ---------- which Key Vaults does a pod use ----------
1229
+ async function openPodVaults(ns,pod){
1230
+ CL.logPod=null; CL.termPod=null; teardownTerm();
1231
+ $('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
1232
+ <span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span><span class="faint mono">Key Vault references</span></div>
1233
+ <div id="pkvOut"><div class="empty"><span class="spin"></span> inspecting pod env, configmaps and CSI SecretProviderClasses…</div></div>`;
1234
+ const r = await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/keyvaults`);
1235
+ if(STATE.route!=='cluster') return;
1236
+ const out=$('#pkvOut'); if(!out) return;
1237
+ if(!r.ok){ out.innerHTML=clErr(r); return; }
1238
+ const vaults=r.data.vaults||[];
1239
+ const spcs=r.data.spcs||[];
1240
+ const eso=r.data.externalSecrets||[];
1241
+ const unreadable=spcs.filter(s=>!s.readable);
1242
+ const esoUnresolved=eso.filter(e=>!e.resolvable);
1243
+ const spcNote = spcs.length
1244
+ ? `<div style="margin-top:10px">Mounts CSI SecretProviderClass(es): <span class="tagchips" style="display:inline-flex">${spcs.map(s=>`<span class="tagchip">${esc(s.name)}${s.readable?'':' 🔒'}</span>`).join('')}</span>${unreadable.length?`<div class="hint" style="margin-top:6px">🔒 couldn't read ${unreadable.length} SecretProviderClass(es) to resolve the Key Vault — needs read on <code>secretproviderclasses</code>.</div>`:''}</div>`
1245
+ : '';
1246
+ const esoNote = esoUnresolved.length
1247
+ ? `<div style="margin-top:10px">Config synced by <b>External Secrets Operator</b> from a Key Vault: <span class="tagchips" style="display:inline-flex">${esoUnresolved.map(e=>`<span class="tagchip">${esc(e.externalSecret)} 🔒</span>`).join('')}</span>
1248
+ <div class="hint" style="margin-top:6px">🔒 the source vault is defined in the ExternalSecret's SecretStore — ${esc(esoUnresolved[0].reason||'not readable')}.</div></div>`
1249
+ : '';
1250
+ if(!vaults.length){
1251
+ out.innerHTML=`<div class="empty">No Key Vault could be resolved for this pod.
1252
+ <div class="hint" style="margin-top:8px">Checked CSI SecretProviderClasses, External Secrets, container env, and ${r.data.configmapsScanned.length} configmap(s).</div>
1253
+ ${spcNote}${esoNote}</div>`;
1254
+ return;
1255
+ }
1256
+ out.innerHTML = `<div class="panel"><div class="scroll"><table>
1257
+ <thead><tr><th>Key Vault</th><th>Referenced via</th><th class="right">Actions</th></tr></thead>
1258
+ <tbody>${vaults.map(v=>{ const known=findVault(v.name);
1259
+ return `<tr><td class="mono"><b>${esc(v.name)}</b></td>
1260
+ <td><div class="tagchips">${v.sources.map(s=>`<span class="tagchip">${esc(s)}</span>`).join('')}</div></td>
1261
+ <td class="right">${known
1262
+ ? `<button class="iconbtn" onclick="openVault('${esc(known.name)}')">Open vault →</button>`
1263
+ : `<span class="faint mono" title="not in your visible inventory">not visible</span>`}</td></tr>`;
1264
+ }).join('')}</tbody></table></div></div>
1265
+ <div class="tablenote">${vaults.length} vault${vaults.length!==1?'s':''} · scanned ${r.data.configmapsScanned.length} configmap(s), ${spcs.length} SecretProviderClass(es), ${eso.length} ExternalSecret(s)</div>
1266
+ ${unreadable.length?`<div class="tablenote">${spcNote}</div>`:''}
1267
+ ${esoUnresolved.length?`<div class="tablenote">${esoNote}</div>`:''}`;
1268
+ }
1269
+
1270
+ // ---------- in-pod terminal (xterm.js over a WS exec bridge) ----------
1271
+ let TERM=null, TERMWS=null, TERMRO=null;
1272
+ function teardownTerm(){
1273
+ try{ TERMRO&&TERMRO.disconnect(); }catch{} TERMRO=null;
1274
+ try{ TERMWS&&TERMWS.close(); }catch{} TERMWS=null;
1275
+ try{ TERM&&TERM.dispose(); }catch{} TERM=null;
1276
+ }
1277
+ function openTerminal(ns,pod){
1278
+ const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
1279
+ CL.logPod=null; CL.termNs=ns; CL.termPod=pod;
1280
+ const conts=p.containers||[];
1281
+ const contSel = conts.length>1
1282
+ ? `<select class="facetsel" id="tmC">${conts.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select>`
1283
+ : conts.length ? `<input type="hidden" id="tmC" value="${esc(conts[0])}">` : '';
1284
+ $('#clBody').innerHTML = `
1285
+ <div class="cmctl">
1286
+ <button class="iconbtn" onclick="closeTerminal()">← Pods</button>
1287
+ <span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span>
1288
+ ${contSel}
1289
+ <span class="faint mono" id="tmStatus">connecting…</span>
1290
+ <span class="newbtns"><button class="iconbtn" id="tmReconnect">↻ Reconnect</button></span>
1291
+ </div>
1292
+ <div id="termHost" class="termhost"></div>`;
1293
+ $('#tmReconnect').onclick=()=>startTerm();
1294
+ const cSel=$('#tmC'); if(cSel && cSel.tagName==='SELECT') cSel.onchange=()=>startTerm();
1295
+ startTerm();
1296
+ }
1297
+ function closeTerminal(){ teardownTerm(); CL.termPod=null; loadClusterContent(); }
1298
+ function tmStatus(txt, cls){ const el=$('#tmStatus'); if(!el)return; el.textContent=txt; el.className='mono '+(cls||'faint'); }
1299
+ function startTerm(){
1300
+ if(typeof Terminal==='undefined' || typeof FitAddon==='undefined'){ tmStatus('terminal library failed to load (offline?)','dead'); return; }
1301
+ teardownTerm();
1302
+ const host=$('#termHost'); if(!host) return; host.innerHTML='';
1303
+ TERM=new Terminal({ fontFamily:'"IBM Plex Mono", ui-monospace, monospace', fontSize:12.5, cursorBlink:true,
1304
+ theme:{ background:'#070A11', foreground:'#CDE0F5', cursor:'#5B9DFF' } });
1305
+ const fit=new FitAddon.FitAddon(); TERM.loadAddon(fit); TERM.open(host);
1306
+ try{ fit.fit(); }catch{}
1307
+ const cont=$('#tmC')?$('#tmC').value:'';
1308
+ const proto=location.protocol==='https:'?'wss':'ws';
1309
+ const qs=new URLSearchParams({cluster:CL.name, ns:CL.termNs, pod:CL.termPod}); if(cont) qs.set('container',cont);
1310
+ const ws=new WebSocket(`${proto}://${location.host}/ws/aks/exec?${qs.toString()}`);
1311
+ ws.binaryType='arraybuffer'; TERMWS=ws;
1312
+ const sendResize=()=>{ try{ fit.fit(); if(ws.readyState===1) ws.send(JSON.stringify({type:'resize',cols:TERM.cols,rows:TERM.rows})); }catch{} };
1313
+ ws.onopen=()=>{ tmStatus('connected','live'); sendResize(); TERM.focus(); };
1314
+ ws.onmessage=ev=>{ if(typeof ev.data==='string') TERM.write(ev.data); else TERM.write(new Uint8Array(ev.data)); };
1315
+ ws.onclose=()=>tmStatus('disconnected','dead');
1316
+ ws.onerror=()=>tmStatus('connection error','dead');
1317
+ TERM.onData(d=>{ if(ws.readyState===1) ws.send(new TextEncoder().encode(d)); });
1318
+ TERMRO=new ResizeObserver(()=>sendResize()); TERMRO.observe(host);
1319
+ }
1320
+
957
1321
  // ---------- command palette ----------
958
1322
  let CMDK = { open:false, items:[], cur:0 };
959
1323
  function cmdkActions(){
@@ -961,6 +1325,7 @@ function cmdkActions(){
961
1325
  {type:'view', label:'Dashboard', hint:'go', act:()=>navigate('dashboard')},
962
1326
  {type:'view', label:'Key Vaults', hint:'go', act:()=>navigate('vaults')},
963
1327
  {type:'view', label:'Expiry', hint:'go', act:()=>navigate('expiry')},
1328
+ {type:'view', label:'Clusters', hint:'go', act:()=>navigate('clusters')},
964
1329
  {type:'view', label:'Account & subscriptions', hint:'go', act:()=>navigate('settings')},
965
1330
  {type:'action', label:'Refresh inventory', hint:'run', act:()=>{ doRefresh(); }},
966
1331
  ];
@@ -974,16 +1339,22 @@ function renderCmdk(q){
974
1339
  .filter(v=>!q || v.name.toLowerCase().includes(q) || (v.service||'').toLowerCase().includes(q) || Object.values(v.tags||{}).join(' ').toLowerCase().includes(q))
975
1340
  .slice(0,40)
976
1341
  .map(v=>({type:'vault', label:v.name, sub:v.env, env:v.env, act:()=>openVault(v.name)}));
977
- CMDK.items = [...acts, ...vaults]; CMDK.cur=0;
1342
+ const clusters = (CL.all||[])
1343
+ .filter(c=>!q || (c.label||c.name).toLowerCase().includes(q) || c.name.toLowerCase().includes(q))
1344
+ .slice(0,20)
1345
+ .map(c=>({type:'cluster', label:c.label||c.name, sub:c.env, act:()=>openCluster(c.name)}));
1346
+ CMDK.items = [...acts, ...clusters, ...vaults]; CMDK.cur=0;
978
1347
  const list=$('#cmdkList');
979
1348
  if(!CMDK.items.length){ list.innerHTML='<div class="cmdk-empty">No matches.</div>'; return; }
980
1349
  const vIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>';
1350
+ const cIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z"/></svg>';
981
1351
  const gIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M5 6h14M5 18h14"/></svg>';
1352
+ const secName={vault:'Vaults',cluster:'Clusters',view:'Go to',action:'Actions'};
982
1353
  let html=''; let lastType='';
983
1354
  CMDK.items.forEach((it,idx)=>{
984
- if(it.type!==lastType){ html+=`<div class="cmdk-sec">${it.type==='vault'?'Vaults':it.type==='view'?'Go to':'Actions'}</div>`; lastType=it.type; }
1355
+ if(it.type!==lastType){ html+=`<div class="cmdk-sec">${secName[it.type]||''}</div>`; lastType=it.type; }
985
1356
  html+=`<div class="cmdk-it ${idx===CMDK.cur?'cur':''}" data-i="${idx}">
986
- ${it.type==='vault'?vIco:gIco}
1357
+ ${it.type==='vault'?vIco:it.type==='cluster'?cIco:gIco}
987
1358
  <span class="lbl">${esc(it.label)}${it.sub?`<small>${esc(it.sub)}</small>`:''}</span>
988
1359
  <span class="hint">${it.hint?esc(it.hint):'open'}</span></div>`;
989
1360
  });
package/public/styles.css CHANGED
@@ -358,6 +358,29 @@
358
358
  .stick{position:sticky;top:0;z-index:5}
359
359
  .itemwrap{margin-top:8px}
360
360
 
361
+ /* ---------- clusters (AKS) ---------- */
362
+ .cmctl{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px}
363
+ .cmctl .seg{padding:3px}
364
+ .logbox{background:#070A11;border:1px solid var(--line);border-radius:var(--r-sm);padding:13px 15px;
365
+ font-family:var(--mono);font-size:11.5px;line-height:1.55;color:#CDE0F5;white-space:pre;
366
+ overflow:auto;max-height:calc(100vh - 300px);tab-size:2}
367
+ .phase{font-family:var(--mono);font-size:10px;letter-spacing:.05em;padding:2px 8px;border-radius:5px;border:1px solid var(--line);color:var(--muted)}
368
+ .phase.Running{color:#7FE0C8;border-color:rgba(70,214,180,.3)}
369
+ .phase.Pending{color:#F4C977;border-color:rgba(242,181,61,.3)}
370
+ .phase.Failed,.phase.Error,.phase.CrashLoopBackOff{color:#FF8893;border-color:rgba(255,92,108,.32)}
371
+ .phase.Succeeded,.phase.Completed{color:var(--muted)}
372
+ .cmval{background:#070A11;border:1px solid var(--line);border-radius:6px;padding:9px 11px;margin:0 0 4px;
373
+ font-family:var(--mono);font-size:11.5px;color:#A9D2FF;white-space:pre-wrap;word-break:break-word;
374
+ overflow:auto;max-height:320px}
375
+ .cmkey{padding:4px 16px 10px}
376
+ .cmkey .nm{font-size:12.5px}
377
+ .ell{display:inline-block;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}
378
+ .ell.ns{max-width:200px}
379
+ .termhost{height:calc(100vh - 290px);min-height:340px;background:#070A11;border:1px solid var(--line);
380
+ border-radius:var(--r-sm);padding:8px 4px 8px 10px;overflow:hidden}
381
+ .termhost .xterm{height:100%}
382
+ #tmStatus.live{color:var(--ok)} #tmStatus.dead{color:var(--alert)}
383
+
361
384
  @media (max-width:900px){
362
385
  /* .app grid + main padding handled by Tailwind max-[900px]: utilities */
363
386
  aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}
package/server.js CHANGED
@@ -63,9 +63,10 @@ function az(args, { json = true } = {}) {
63
63
 
64
64
  function send(res, p) {
65
65
  p.then((data) => res.json({ ok: true, data }))
66
- .catch((err) => res.status(err.needLogin ? 401 : err.forbidden ? 403 : 500).json({
66
+ .catch((err) => res.status(err.status || (err.needLogin ? 401 : err.forbidden ? 403 : err.notFound ? 404 : 500)).json({
67
67
  ok: false, error: err.stderr || err.message,
68
68
  needLogin: !!err.needLogin, forbidden: !!err.forbidden, firewall: !!err.firewall,
69
+ network: !!err.network, notFound: !!err.notFound,
69
70
  }));
70
71
  }
71
72
 
@@ -600,6 +601,468 @@ app.post('/api/batch', async (req, res) => {
600
601
  res.json({ ok: true, results });
601
602
  });
602
603
 
604
+ // ---- API: AKS clusters (read-only monitoring: pods, logs, configmaps) -------
605
+ // Data plane goes straight to the Kubernetes API server over HTTPS, authenticated
606
+ // with an AAD token for the well-known managed-AAD server app — the same token
607
+ // `kubelogin` (azurecli mode) injects, so no kubelogin/kubectl binary is needed.
608
+
609
+ const https = require('https');
610
+ const AKS_AAD_SERVER_APP = '6dae42f8-4368-4678-94ff-3960e28e3630'; // Microsoft public constant
611
+ const getAksToken = () => getToken(AKS_AAD_SERVER_APP);
612
+ const K8S_NAME = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/; // DNS-1123-ish (namespaces, pods, cms)
613
+ const isK8sName = (v) => typeof v === 'string' && K8S_NAME.test(v);
614
+
615
+ // Operator-supplied cluster list — NEVER baked in. JSON via AZBO_AKS_CLUSTERS, or a
616
+ // path via AZBO_AKS_CLUSTERS_FILE. Each: { name, resourceGroup, subscription, env?, label? }.
617
+ const AKS_CLUSTERS = (() => {
618
+ let raw = process.env.AZBO_AKS_CLUSTERS;
619
+ if (!raw && process.env.AZBO_AKS_CLUSTERS_FILE) {
620
+ try { raw = require('fs').readFileSync(process.env.AZBO_AKS_CLUSTERS_FILE, 'utf8'); }
621
+ catch (e) { console.warn(' AZBO_AKS_CLUSTERS_FILE could not be read:', e.message); }
622
+ }
623
+ if (!raw) return [];
624
+ try {
625
+ const arr = JSON.parse(raw);
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 []; }
630
+ })();
631
+
632
+ async function resolveSubId(nameOrId) {
633
+ if (/^[0-9a-fA-F-]{36}$/.test(nameOrId)) return nameOrId;
634
+ const subs = await listSubscriptions();
635
+ const s = subs.find((x) => x.displayName === nameOrId || x.subscriptionId === nameOrId);
636
+ return s ? s.subscriptionId : nameOrId;
637
+ }
638
+
639
+ // Cache each cluster's API server URL + CA (from listClusterUserCredential) for 30 min.
640
+ const aksCredCache = new Map();
641
+ async function aksCredential(c) {
642
+ const key = `${c.subscription}/${c.resourceGroup}/${c.name}`;
643
+ const hit = aksCredCache.get(key);
644
+ if (hit && Date.now() - hit.at < 30 * 60 * 1000) return hit;
645
+ const subId = await resolveSubId(c.subscription);
646
+ const url = `/subscriptions/${subId}/resourceGroups/${c.resourceGroup}` +
647
+ `/providers/Microsoft.ContainerService/managedClusters/${c.name}` +
648
+ `/listClusterUserCredential?api-version=2024-05-01`;
649
+ const out = await armFetch(url, { method: 'POST' });
650
+ const kc = out.kubeconfigs && out.kubeconfigs[0];
651
+ if (!kc || !kc.value) throw new Error('No kubeconfig returned for this cluster');
652
+ const yaml = Buffer.from(kc.value, 'base64').toString('utf8');
653
+ const server = (yaml.match(/server:\s*(\S+)/) || [])[1];
654
+ const caB64 = (yaml.match(/certificate-authority-data:\s*(\S+)/) || [])[1];
655
+ if (!server) throw new Error('Could not parse API server URL from kubeconfig');
656
+ const cred = { server: server.replace(/\/+$/, ''),
657
+ ca: caB64 ? Buffer.from(caB64, 'base64').toString('utf8') : undefined, at: Date.now() };
658
+ aksCredCache.set(key, cred);
659
+ return cred;
660
+ }
661
+
662
+ function k8sMsg(body) { try { return JSON.parse(body).message; } catch { return null; } }
663
+
664
+ // The "build" of a pod = its container image tag (e.g. registry/app:11.5.0387 → 11.5.0387).
665
+ // Digest-pinned images (@sha256:…) have no human tag; callers fall back to a version label.
666
+ function imageTag(img) {
667
+ if (!img || img.includes('@')) return '';
668
+ const repoTag = img.slice(img.lastIndexOf('/') + 1);
669
+ const c = repoTag.indexOf(':');
670
+ return c >= 0 ? repoTag.slice(c + 1) : '';
671
+ }
672
+
673
+ // One GET against a cluster's Kubernetes API. raw=true returns text (for pod logs).
674
+ function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
675
+ return new Promise((resolve, reject) => {
676
+ getAksToken().then((token) => {
677
+ const u = new URL(cred.server + pathQuery);
678
+ const req = https.request({
679
+ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
680
+ // '*/*' for raw so the log subresource returns text/plain without tripping content negotiation
681
+ headers: { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' },
682
+ ca: cred.ca, timeout,
683
+ }, (res) => {
684
+ let data = ''; res.setEncoding('utf8');
685
+ res.on('data', (c) => { data += c; });
686
+ res.on('end', () => {
687
+ const s = res.statusCode;
688
+ if (s === 401) { tokenCache.delete(AKS_AAD_SERVER_APP); return reject(Object.assign(new Error('Token rejected — run az login'), { needLogin: true })); }
689
+ if (s === 403) return reject(Object.assign(new Error(k8sMsg(data) || 'Forbidden'), { forbidden: true }));
690
+ if (s === 404) return reject(Object.assign(new Error(k8sMsg(data) || 'Not found'), { notFound: true }));
691
+ if (s >= 400) return reject(new Error(k8sMsg(data) || `Kubernetes returned ${s}`));
692
+ try { resolve(raw ? data : (data ? JSON.parse(data) : null)); }
693
+ catch (e) { reject(new Error('Failed to parse Kubernetes response: ' + e.message)); }
694
+ });
695
+ });
696
+ req.on('error', (e) => {
697
+ const code = e.code || '';
698
+ const network = /ENOTFOUND|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|ECONNRESET|UNABLE_TO_VERIFY|SELF_SIGNED/.test(code + e.message);
699
+ reject(Object.assign(new Error(network
700
+ ? 'Cannot reach the cluster API server — it is private. Connect to the corporate VPN and retry.'
701
+ : e.message), { network }));
702
+ });
703
+ req.on('timeout', () => req.destroy(Object.assign(new Error('Cluster API request timed out — is the VPN connected?'), { network: true })));
704
+ req.end();
705
+ }).catch(reject);
706
+ });
707
+ }
708
+
709
+ // Discover clusters you have ARM read on (best-effort) and merge with the configured list.
710
+ const AKS_QUERY =
711
+ "resources | where type =~ 'microsoft.containerservice/managedclusters' " +
712
+ "| project name, resourceGroup, subscriptionId, " +
713
+ "ver=tostring(properties.kubernetesVersion), " +
714
+ "priv=tostring(properties.apiServerAccessProfile.enablePrivateCluster) | order by name asc";
715
+ async function discoverClusters() {
716
+ try {
717
+ const subIds = (await listSubscriptions()).map((s) => s.subscriptionId);
718
+ 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 } },
720
+ });
721
+ return (out.data || []).map((c) => ({ name: c.name, resourceGroup: c.resourceGroup,
722
+ subscription: c.subscriptionId, version: c.ver, private: c.priv === 'true', configured: false }));
723
+ } catch { return []; }
724
+ }
725
+
726
+ function clusterById(list, id) { return list.find((c) => c.name === id); }
727
+ async function aksList() {
728
+ const discovered = await discoverClusters();
729
+ const byKey = new Map();
730
+ for (const c of discovered) byKey.set(c.name.toLowerCase(), c);
731
+ for (const c of AKS_CLUSTERS) { // configured entries win + carry their label/env
732
+ const prev = byKey.get(c.name.toLowerCase()) || {};
733
+ byKey.set(c.name.toLowerCase(), { ...prev, ...c });
734
+ }
735
+ return [...byKey.values()].sort((a, b) => (a.label || a.name).localeCompare(b.label || b.name));
736
+ }
737
+
738
+ app.get('/api/aks/clusters', (req, res) =>
739
+ send(res, aksList().then((clusters) => ({ clusters, configured: AKS_CLUSTERS.length }))));
740
+
741
+ // Resolve a :cluster route param to a known cluster, or throw a typed 400/404.
742
+ async function getClusterChecked(name) {
743
+ if (!isValidName(name)) throw Object.assign(new Error('Invalid cluster name'), { status: 400 });
744
+ const c = clusterById(await aksList(), name);
745
+ if (!c) throw Object.assign(new Error('Cluster is not in your configured/visible list'), { status: 404, notFound: true });
746
+ return c;
747
+ }
748
+
749
+ app.get('/api/aks/:cluster/namespaces', (req, res) =>
750
+ send(res, (async () => {
751
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
752
+ const out = await k8sGet(cred, '/api/v1/namespaces?limit=500');
753
+ return (out.items || []).map((n) => ({ name: n.metadata.name, status: n.status && n.status.phase }));
754
+ })()));
755
+
756
+ // Cluster-wide pod list (all namespaces) — each row carries its namespace.
757
+ app.get('/api/aks/:cluster/pods', (req, res) =>
758
+ send(res, (async () => {
759
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
760
+ const out = await k8sGet(cred, '/api/v1/pods?limit=5000');
761
+ return (out.items || []).map((p) => {
762
+ const cs = (p.status && p.status.containerStatuses) || [];
763
+ const spec = (p.spec && p.spec.containers) || [];
764
+ const labels = p.metadata.labels || {};
765
+ const build = imageTag((spec[0] || {}).image) || labels['app.kubernetes.io/version'] || labels.version || labels['tags.datadoghq.com/version'] || '';
766
+ return { name: p.metadata.name, namespace: p.metadata.namespace, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
767
+ build,
768
+ restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
769
+ ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
770
+ containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
771
+ });
772
+ })()));
773
+
774
+ // Pod detail: labels (tags), images+tags, and declared env vars (names + inline values +
775
+ // secret/configmap references). Secret VALUES are never read — only the declarations.
776
+ app.get('/api/aks/:cluster/:ns/pods/:pod/detail', (req, res) => {
777
+ const { ns, pod } = req.params;
778
+ if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
779
+ send(res, (async () => {
780
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
781
+ const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
782
+ const md = p.metadata || {}, spec = p.spec || {};
783
+ const inits = spec.initContainers || [];
784
+ const descFrom = (vf) => {
785
+ if (!vf) return null;
786
+ if (vf.secretKeyRef) return { kind: 'secret', name: vf.secretKeyRef.name, key: vf.secretKeyRef.key };
787
+ if (vf.configMapKeyRef) return { kind: 'configMap', name: vf.configMapKeyRef.name, key: vf.configMapKeyRef.key };
788
+ if (vf.fieldRef) return { kind: 'field', key: vf.fieldRef.fieldPath };
789
+ if (vf.resourceFieldRef) return { kind: 'resource', key: vf.resourceFieldRef.resource };
790
+ return { kind: '?' };
791
+ };
792
+ const containers = [...(spec.containers || []), ...inits].map((c) => ({
793
+ name: c.name, init: inits.includes(c), image: c.image, tag: imageTag(c.image),
794
+ env: (c.env || []).map((e) => ({ name: e.name, value: e.value != null ? e.value : undefined, from: descFrom(e.valueFrom) })),
795
+ envFrom: (c.envFrom || []).map((ef) => ({ kind: ef.secretRef ? 'secret' : ef.configMapRef ? 'configMap' : '?', name: (ef.secretRef || ef.configMapRef || {}).name, prefix: ef.prefix || '' })),
796
+ }));
797
+ return { name: md.name, namespace: md.namespace, node: spec.nodeName, labels: md.labels || {}, annotations: md.annotations || {}, containers };
798
+ })());
799
+ });
800
+
801
+ // Cluster-wide configmap list (all namespaces).
802
+ app.get('/api/aks/:cluster/configmaps', (req, res) =>
803
+ send(res, (async () => {
804
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
805
+ const out = await k8sGet(cred, '/api/v1/configmaps?limit=5000');
806
+ return (out.items || []).map((m) => ({ name: m.metadata.name, namespace: m.metadata.namespace,
807
+ keys: Object.keys(m.data || {}), created: m.metadata.creationTimestamp }));
808
+ })()));
809
+
810
+ app.get('/api/aks/:cluster/:ns/pods', (req, res) => {
811
+ if (!isK8sName(req.params.ns)) return res.status(400).json({ ok: false, error: 'Invalid namespace' });
812
+ send(res, (async () => {
813
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
814
+ const out = await k8sGet(cred, `/api/v1/namespaces/${req.params.ns}/pods?limit=1000`);
815
+ return (out.items || []).map((p) => {
816
+ const cs = (p.status && p.status.containerStatuses) || [];
817
+ const spec = (p.spec && p.spec.containers) || [];
818
+ return { name: p.metadata.name, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
819
+ restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
820
+ ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
821
+ containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
822
+ });
823
+ })());
824
+ });
825
+
826
+ app.get('/api/aks/:cluster/:ns/pods/:pod/log', (req, res) => {
827
+ const { ns, pod } = req.params;
828
+ if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
829
+ const q = new URLSearchParams();
830
+ const tail = Math.min(20000, Math.max(1, Number(req.query.tailLines) || 1000));
831
+ q.set('tailLines', String(tail));
832
+ if (req.query.timestamps === 'true') q.set('timestamps', 'true');
833
+ if (req.query.previous === 'true') q.set('previous', 'true');
834
+ if (req.query.container && isK8sName(req.query.container)) q.set('container', req.query.container);
835
+ send(res, (async () => {
836
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
837
+ const text = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}/log?${q.toString()}`, { raw: true });
838
+ return { log: text, lines: text ? text.split('\n').length : 0 };
839
+ })());
840
+ });
841
+
842
+ app.get('/api/aks/:cluster/:ns/configmaps', (req, res) => {
843
+ if (!isK8sName(req.params.ns)) return res.status(400).json({ ok: false, error: 'Invalid namespace' });
844
+ send(res, (async () => {
845
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
846
+ const out = await k8sGet(cred, `/api/v1/namespaces/${req.params.ns}/configmaps?limit=500`);
847
+ return (out.items || []).map((m) => ({ name: m.metadata.name, keys: Object.keys(m.data || {}),
848
+ created: m.metadata.creationTimestamp }));
849
+ })());
850
+ });
851
+
852
+ app.get('/api/aks/:cluster/:ns/configmaps/:cm', (req, res) => {
853
+ const { ns, cm } = req.params;
854
+ if (!isK8sName(ns) || !isK8sName(cm)) return res.status(400).json({ ok: false, error: 'Invalid namespace/configmap' });
855
+ send(res, (async () => {
856
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
857
+ const out = await k8sGet(cred, `/api/v1/namespaces/${ns}/configmaps/${cm}`);
858
+ return { name: out.metadata.name, data: out.data || {} };
859
+ })());
860
+ });
861
+
862
+ // Which Key Vaults does a pod reference? Three signals, best-effort:
863
+ // 1) CSI Secrets Store — pod volume → SecretProviderClass → keyvaultName (exact, declarative)
864
+ // 2) container env values containing a *.vault.azure.net URL
865
+ // 3) configmaps the pod consumes (envFrom / valueFrom / volume) whose values contain one
866
+ const VAULT_URL_RE = /\b([a-z0-9][a-z0-9-]{1,22}[a-z0-9])\.vault\.azure\.(?:net|cn|usgovcloudapi\.net)\b/gi;
867
+ function scanVaultRefs(text, set, source, srcMap) {
868
+ if (typeof text !== 'string') return;
869
+ let m; VAULT_URL_RE.lastIndex = 0;
870
+ while ((m = VAULT_URL_RE.exec(text))) { const n = m[1].toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add(source); }
871
+ }
872
+ async function fetchSpc(cred, ns, name) {
873
+ for (const v of ['v1', 'v1alpha1']) {
874
+ try { return await k8sGet(cred, `/apis/secrets-store.csi.x-k8s.io/${v}/namespaces/${ns}/secretproviderclasses/${name}`); }
875
+ catch (e) { if (e.notFound) continue; throw e; }
876
+ }
877
+ return null;
878
+ }
879
+
880
+ app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
881
+ const { ns, pod } = req.params;
882
+ if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
883
+ send(res, (async () => {
884
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
885
+ const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
886
+ const spec = p.spec || {};
887
+ const containers = [...(spec.containers || []), ...(spec.initContainers || []), ...(spec.ephemeralContainers || [])];
888
+ const set = new Set(), srcMap = {};
889
+ // 2) inline env values
890
+ for (const c of containers) for (const e of (c.env || [])) scanVaultRefs(e.value, set, 'env', srcMap);
891
+ // configmaps the pod directly references (envFrom / valueFrom / configMap & projected volumes)
892
+ const refNames = new Set();
893
+ for (const c of containers) {
894
+ for (const ef of (c.envFrom || [])) if (ef.configMapRef && ef.configMapRef.name) refNames.add(ef.configMapRef.name);
895
+ for (const e of (c.env || [])) if (e.valueFrom && e.valueFrom.configMapKeyRef) refNames.add(e.valueFrom.configMapKeyRef.name);
896
+ }
897
+ for (const v of (spec.volumes || [])) {
898
+ if (v.configMap && v.configMap.name) refNames.add(v.configMap.name);
899
+ for (const s of ((v.projected && v.projected.sources) || [])) if (s.configMap && s.configMap.name) refNames.add(s.configMap.name);
900
+ }
901
+ // 3) List the namespace's configmaps (data included) and scan the ones that are either
902
+ // referenced by the pod, or name-related to it (the "<app>-appsettings" convention).
903
+ const podLc = pod.toLowerCase();
904
+ const cmBase = (n) => n.toLowerCase().replace(/-(appsettings|configmap|config|settings|secrets?|env|vars|values)$/, '');
905
+ const scanned = [];
906
+ try {
907
+ const list = await k8sGet(cred, `/api/v1/namespaces/${ns}/configmaps?limit=500`);
908
+ for (const m of (list.items || [])) {
909
+ const nm = m.metadata.name, base = cmBase(nm);
910
+ const related = refNames.has(nm) || podLc === base || podLc.startsWith(base + '-') || podLc.startsWith(nm.toLowerCase() + '-');
911
+ if (!related) continue;
912
+ scanned.push(nm);
913
+ for (const val of Object.values(m.data || {})) scanVaultRefs(val, set, 'configmap:' + nm, srcMap);
914
+ }
915
+ } catch { /* can't list configmaps — skip that signal */ }
916
+ // 1) CSI SecretProviderClasses referenced by the pod's volumes
917
+ const spcNames = new Set();
918
+ for (const v of (spec.volumes || [])) {
919
+ const drv = (v.csi && v.csi.driver) || '';
920
+ if (/secrets-store\.csi/.test(drv) && v.csi.volumeAttributes && v.csi.volumeAttributes.secretProviderClass)
921
+ spcNames.add(v.csi.volumeAttributes.secretProviderClass);
922
+ }
923
+ const spcs = [];
924
+ for (const spc of spcNames) {
925
+ try {
926
+ const o = await fetchSpc(cred, ns, spc);
927
+ if (!o) { spcs.push({ name: spc, readable: false }); continue; }
928
+ const params = (o.spec && o.spec.parameters) || {};
929
+ const kv = params.keyvaultName || params.keyvaultname || params.keyVaultName;
930
+ if (kv) { const n = String(kv).toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add('csi:' + spc); }
931
+ for (const val of Object.values(params)) scanVaultRefs(String(val), set, 'csi:' + spc, srcMap); // objects YAML etc.
932
+ spcs.push({ name: spc, readable: true, vault: kv ? String(kv).toLowerCase() : null });
933
+ } catch (e) {
934
+ // The CRD is often unreadable, but a SecretProviderClass is commonly named after its
935
+ // vault — if that name matches a vault you can see, surface it (no false positives).
936
+ let inferred = null;
937
+ try { const mv = await findVaultMeta(spc); if (mv) { const n = mv.name.toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add('csi:' + spc + ' (by name)'); inferred = mv.name; } } catch { /* ignore */ }
938
+ spcs.push({ name: spc, readable: false, forbidden: !!e.forbidden, inferredVault: inferred });
939
+ }
940
+ }
941
+ // 4) Secrets the pod consumes that are synced from a Key Vault by External Secrets
942
+ // Operator. We read only secret METADATA (never values) to find the owning
943
+ // ExternalSecret, then resolve its SecretStore's azurekv vaultUrl when readable.
944
+ const secretNames = new Set();
945
+ for (const c of containers) {
946
+ for (const ef of (c.envFrom || [])) if (ef.secretRef && ef.secretRef.name) secretNames.add(ef.secretRef.name);
947
+ for (const e of (c.env || [])) if (e.valueFrom && e.valueFrom.secretKeyRef) secretNames.add(e.valueFrom.secretKeyRef.name);
948
+ }
949
+ for (const v of (spec.volumes || [])) {
950
+ if (v.secret && v.secret.secretName) secretNames.add(v.secret.secretName);
951
+ for (const s of ((v.projected && v.projected.sources) || [])) if (s.secret && s.secret.name) secretNames.add(s.secret.name);
952
+ }
953
+ const esNames = new Set();
954
+ for (const sn of secretNames) {
955
+ try {
956
+ const s = await k8sGet(cred, `/api/v1/namespaces/${ns}/secrets/${sn}`); // metadata only is used below
957
+ const md = s.metadata || {};
958
+ const owner = (md.ownerReferences || []).find((o) => o.kind === 'ExternalSecret');
959
+ const eso = owner || (md.labels && md.labels['reconcile.external-secrets.io/managed'] === 'true') ||
960
+ Object.keys(md.annotations || {}).some((a) => a.startsWith('reconcile.external-secrets.io/'));
961
+ if (eso) esNames.add(owner ? owner.name : sn);
962
+ } catch { /* can't read secret metadata — skip */ }
963
+ }
964
+ const externalSecrets = [];
965
+ const getCRD = async (path) => { for (const ver of ['v1', 'v1beta1']) { try { return await k8sGet(cred, path.replace('{v}', ver)); } catch (e) { if (e.notFound) continue; throw e; } } return null; };
966
+ for (const esn of esNames) {
967
+ const entry = { externalSecret: esn, vault: null, resolvable: false, reason: null };
968
+ try {
969
+ const es = await getCRD(`/apis/external-secrets.io/{v}/namespaces/${ns}/externalsecrets/${esn}`);
970
+ const ref = (es && es.spec && es.spec.secretStoreRef) || {};
971
+ const storePath = ref.kind === 'ClusterSecretStore'
972
+ ? `/apis/external-secrets.io/{v}/clustersecretstores/${ref.name}`
973
+ : `/apis/external-secrets.io/{v}/namespaces/${ns}/secretstores/${ref.name}`;
974
+ const store = ref.name ? await getCRD(storePath) : null;
975
+ const akv = store && store.spec && store.spec.provider && store.spec.provider.azurekv;
976
+ const url = akv && (akv.vaultUrl || akv.vaultUri);
977
+ const m = url && /([a-z0-9][a-z0-9-]{1,22}[a-z0-9])\.vault\.azure\./i.exec(url);
978
+ if (m) { const n = m[1].toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add('eso:' + esn); entry.vault = n; entry.resolvable = true; }
979
+ else entry.reason = 'store has no azurekv vaultUrl';
980
+ } catch (e) { entry.reason = e.forbidden ? 'needs read on externalsecrets / secretstores' : (e.message || 'unreadable'); }
981
+ externalSecrets.push(entry);
982
+ }
983
+ const vaults = [...set].sort().map((name) => ({ name, sources: [...(srcMap[name] || [])] }));
984
+ return { vaults, configmapsScanned: scanned, spcs, externalSecrets };
985
+ })());
986
+ });
987
+
988
+ // Interactive shell INTO a pod, in the browser. The browser opens a WebSocket to us;
989
+ // we open an upstream WebSocket to the Kubernetes `exec` endpoint (CA + AAD bearer
990
+ // token) and bridge the channel-framed streams. No kubectl needed. The API server
991
+ // enforces Kubernetes RBAC (pods/exec) — this grants nothing the identity lacks.
992
+ const { WebSocketServer, WebSocket } = require('ws');
993
+ const execWss = new WebSocketServer({ noServer: true });
994
+
995
+ function attachExecWss(server) {
996
+ server.on('upgrade', (req, socket, head) => {
997
+ let url; try { url = new URL(req.url, 'http://localhost'); } catch { socket.destroy(); return; }
998
+ if (url.pathname !== '/ws/aks/exec') { socket.destroy(); return; }
999
+ execWss.handleUpgrade(req, socket, head, (client) => bridgeExec(client, url.searchParams));
1000
+ });
1001
+ }
1002
+
1003
+ async function bridgeExec(client, params) {
1004
+ const clusterName = params.get('cluster'), ns = params.get('ns'), pod = params.get('pod');
1005
+ const container = params.get('container') || null;
1006
+ const tell = (m) => { try { if (client.readyState === 1) client.send(m); } catch { /* ignore */ } };
1007
+ if (!isValidName(clusterName || '') || !isK8sName(ns || '') || !isK8sName(pod || '') || (container && !isK8sName(container))) {
1008
+ tell('\r\n\x1b[31mInvalid cluster/namespace/pod.\x1b[0m\r\n'); client.close(); return;
1009
+ }
1010
+ let cred, token;
1011
+ try {
1012
+ const c = clusterById(await aksList(), clusterName);
1013
+ if (!c) throw new Error('Cluster is not in your configured/visible list');
1014
+ cred = await aksCredential(c);
1015
+ token = await getAksToken();
1016
+ } catch (e) {
1017
+ tell('\r\n\x1b[31m' + (e.network ? 'Cannot reach the cluster — connect to the VPN.' : (e.message || 'auth failed')) + '\x1b[0m\r\n');
1018
+ client.close(); return;
1019
+ }
1020
+ const q = new URLSearchParams();
1021
+ q.set('stdin', 'true'); q.set('stdout', 'true'); q.set('stderr', 'true'); q.set('tty', 'true');
1022
+ if (container) q.set('container', container);
1023
+ // Prefer bash, fall back to ash/sh — one exec, the shell picks what exists.
1024
+ for (const part of ['/bin/sh', '-c', 'clear; (bash || ash || sh)']) q.append('command', part);
1025
+ const wsUrl = cred.server.replace(/^https:/, 'wss:') + `/api/v1/namespaces/${ns}/pods/${pod}/exec?` + q.toString();
1026
+
1027
+ const upstream = new WebSocket(wsUrl, ['v4.channel.k8s.io', 'channel.k8s.io'], {
1028
+ headers: { Authorization: `Bearer ${token}` }, ca: cred.ca,
1029
+ });
1030
+ let handled = false; // so the generic 'error' doesn't double-report a decoded HTTP status
1031
+
1032
+ // The exec handshake is plain HTTP until it upgrades; decode a non-101 status precisely.
1033
+ upstream.on('unexpected-response', (reqU, resU) => {
1034
+ handled = true;
1035
+ let body = ''; resU.on('data', (d) => { body += d; }); resU.on('end', () => {
1036
+ let msg; try { msg = JSON.parse(body).message; } catch { /* ignore */ }
1037
+ const s = resU.statusCode;
1038
+ const friendly = s === 401 ? 'Not authorized — token rejected. Run az login (and check the cluster is managed-AAD).'
1039
+ : s === 403 ? 'Forbidden — your identity lacks Kubernetes "pods/exec" permission in this namespace.'
1040
+ : s === 404 ? 'Pod, namespace or container not found.'
1041
+ : (msg || `Kubernetes returned ${s}`);
1042
+ tell('\r\n\x1b[31m' + friendly + '\x1b[0m\r\n'); try { client.close(); } catch { /* ignore */ }
1043
+ });
1044
+ });
1045
+
1046
+ upstream.on('open', () => tell(`\x1b[32m● connected to ${ns}/${pod}${container ? ' [' + container + ']' : ''}\x1b[0m\r\n`));
1047
+ upstream.on('message', (data) => {
1048
+ if (!data || !data.length) return;
1049
+ const ch = data[0], payload = data.subarray(1); // channel: 1=stdout 2=stderr 3=error/status
1050
+ if (ch === 1 || ch === 2) { if (client.readyState === 1) client.send(payload); }
1051
+ else if (ch === 3) { try { const j = JSON.parse(payload.toString()); if (j.status === 'Failure' && j.message) tell('\r\n\x1b[31m' + j.message + '\x1b[0m\r\n'); } catch { /* ignore */ } }
1052
+ });
1053
+ upstream.on('close', () => { tell('\r\n\x1b[33m● session closed\x1b[0m\r\n'); try { client.close(); } catch { /* ignore */ } });
1054
+ upstream.on('error', (e) => { if (handled) return; tell('\r\n\x1b[31m' + (e.message || 'connection error — is the VPN connected?') + '\x1b[0m\r\n'); try { client.close(); } catch { /* ignore */ } });
1055
+
1056
+ client.on('message', (msg, isBinary) => {
1057
+ if (upstream.readyState !== 1) return;
1058
+ if (isBinary) { upstream.send(Buffer.concat([Buffer.from([0]), msg])); return; } // channel 0 = stdin
1059
+ try { const j = JSON.parse(msg.toString()); // text = control (resize)
1060
+ if (j.type === 'resize') upstream.send(Buffer.concat([Buffer.from([4]), Buffer.from(JSON.stringify({ Width: j.cols, Height: j.rows }))]));
1061
+ } catch { /* ignore */ }
1062
+ });
1063
+ client.on('close', () => { try { upstream.close(); } catch { /* ignore */ } });
1064
+ }
1065
+
603
1066
  // ---- fallback --------------------------------------------------------------
604
1067
 
605
1068
  app.get('/api/health', (req, res) => res.json({ ok: true, az: AZ }));
@@ -613,7 +1076,7 @@ const ENV_RULES = (() => {
613
1076
  const PKG_VERSION = (() => { try { return require('./package.json').version; } catch { return null; } })();
614
1077
  app.get('/api/config', (req, res) =>
615
1078
  res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null,
616
- version: PKG_VERSION, protectedTags: PROTECTED_TAGS } }));
1079
+ version: PKG_VERSION, protectedTags: PROTECTED_TAGS, aksConfigured: AKS_CLUSTERS.length } }));
617
1080
 
618
1081
  function openBrowser(url) {
619
1082
  if (process.env.AZBO_NO_OPEN) return;
@@ -630,6 +1093,7 @@ function start(port, attemptsLeft = 12) {
630
1093
  console.log(` Stop with Ctrl-C.\n`);
631
1094
  openBrowser(url);
632
1095
  });
1096
+ attachExecWss(server); // browser ⇆ Kubernetes exec bridge for the in-pod terminal
633
1097
  server.on('error', (e) => {
634
1098
  if (e.code === 'EADDRINUSE' && attemptsLeft > 0) {
635
1099
  console.log(` Port ${port} in use, trying ${port + 1}…`);