sap-ai-dev-toolkit 0.7.9 → 0.8.1
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 +31 -1
- package/package.json +1 -1
- package/src/launcher.mjs +18 -5
- package/src/mcp-config.mjs +2 -0
- package/src/setup.mjs +107 -19
- package/src/tls-adt-proxy.mjs +122 -0
package/README.md
CHANGED
|
@@ -272,8 +272,35 @@ The installer handles VSP provisioning automatically:
|
|
|
272
272
|
|
|
273
273
|
Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default. Use **Space** to choose destinations and **Enter** to confirm. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker.
|
|
274
274
|
|
|
275
|
-
Without `H2O_URL`, interactive setup looks for local SAP GUI system configuration on macOS and Windows (for example SAP GUI landscape XML and `saplogon.ini`). SAP GUI landscape files describe SAP GUI/DIAG connectivity, not the ADT HTTP(S) endpoint, so setup asks you to confirm the ADT URL
|
|
275
|
+
Without `H2O_URL`, interactive setup looks for local SAP GUI system configuration on macOS and Windows (for example SAP GUI landscape XML and `saplogon.ini`). SAP GUI landscape files describe SAP GUI/DIAG connectivity, not the ADT HTTP(S) endpoint or a complete ABAP client catalog, so setup asks for the SAP client or comma-separated clients to configure for each selected system (for example `100,200`), proposes an ADT URL from the SAP GUI host and instance when it can, and asks you to confirm or edit that value. Multiple entered clients create separate isolated MCP entries, one per system/client pair. When the ADT URL uses an IP address, setup automatically inspects the SAP HTTPS certificate, extracts DNS subjectAltName entries, and offers to enable secure TLS self-healing with a simple yes/no confirmation; you do not need to type certificate names. The generated MCP entry uses VS Code input prompts for the SAP user and password at server start; raw SAP credentials are not stored in `mcp.json`. On Windows, setup can instead write an experimental `SAP_AUTH_MODE=windows-sso` entry without username/password prompts; this requires the ADT endpoint to support HTTP Negotiate/SPNEGO. Runtime uses the bundled Windows Negotiate helper by default, or a trusted custom helper configured with `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER`. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, destination selection is skipped without changing MCP config.
|
|
276
276
|
|
|
277
|
+
### 🖥️ Local machine SAP GUI support
|
|
278
|
+
|
|
279
|
+
Local setup is intended for developers running the MCP server from their own workstation rather than SAP Business Application Studio.
|
|
280
|
+
|
|
281
|
+
Supported discovery and runtime behavior:
|
|
282
|
+
|
|
283
|
+
- **Discovery sources:** SAP GUI landscape XML and `saplogon.ini` style configuration on macOS and Windows. Setup de-duplicates entries, ignores incomplete DIAG records, and shows the discovered SAP systems in the same checkbox picker used for BAS destinations.
|
|
284
|
+
- **Client selection:** SAP GUI entries may contain one default client but cannot reliably list every ABAP client available through ADT. Setup asks for one or more 3-digit clients per selected system; enter comma-separated values such as `100,200` to create separate MCP servers for each client.
|
|
285
|
+
- **ADT endpoint derivation:** SAP GUI entries usually contain DIAG/router information, not the HTTP(S) ADT endpoint. Setup derives the common ADT port from the SAP instance when possible, for example instance `00` → `https://host:44300`, and asks for confirmation only when configuring the selected entry.
|
|
286
|
+
- **Credential handling:** username/password local entries use VS Code MCP input prompts (`${input:...}`) at server start. Plain SAP credentials are not written to `mcp.json`.
|
|
287
|
+
- **Windows SSO:** local Windows entries can use `SAP_AUTH_MODE=windows-sso` instead of username/password prompts when the ABAP ADT endpoint supports HTTP Negotiate/SPNEGO. The runtime starts a loopback ADT proxy and uses the bundled Windows SSPI helper unless `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER` points to a trusted custom helper. NTLM fallback is rejected.
|
|
288
|
+
- **IP-address HTTPS self-healing:** if the confirmed ADT URL is `https://<ip>:<port>` and the certificate is issued to DNS names, setup reads the certificate DNS SANs automatically and stores them as `SAP_TLS_SERVER_NAMES`. At runtime a loopback proxy dials the IP address but verifies TLS against those discovered DNS names, trying them one by one. Certificate verification stays enabled; there is no `--insecure` mode.
|
|
289
|
+
- **Private CAs:** if the SAP HTTPS certificate chains to an internal CA that is not trusted by the local Node runtime, set `SAP_TLS_CA_FILE` or `SAP_AI_DEV_TOOLKIT_TLS_CA_FILE` to a PEM CA bundle. This adds trust roots for the self-healing proxy without disabling certificate validation.
|
|
290
|
+
- **Redirect and request safety:** local loopback proxies reject cross-origin forward-form requests and rewrite same-origin SAP redirects back through the loopback listener, so VSP stays on the safe route.
|
|
291
|
+
|
|
292
|
+
Typical local setup flow:
|
|
293
|
+
|
|
294
|
+
```sh
|
|
295
|
+
sap-ai-dev --setup
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
1. Select the discovered SAP GUI system with **Space** and confirm with **Enter**.
|
|
299
|
+
2. Confirm the detected/default SAP client, or enter multiple clients such as `100,200`.
|
|
300
|
+
3. Accept the proposed ADT URL when it is correct, or edit it if your landscape exposes ADT through a different HTTP(S) host/port.
|
|
301
|
+
4. If setup discovers certificate DNS names for an IP-based HTTPS URL, accept the yes/no TLS self-healing prompt.
|
|
302
|
+
5. Choose password or Windows SSO when the prompt is available.
|
|
303
|
+
6. Start each generated MCP server from VS Code's **MCP: List Servers** command.
|
|
277
304
|
|
|
278
305
|
The same postinstall offers the bundled agents and skills for several AI coding harnesses in a checkbox picker. **GitHub Copilot** and **Claude Code** start pre-checked, so pressing **Enter** alone installs both; **Space** selects or deselects a harness, **a** toggles all seven, and confirming with none selected skips the step without changing files. When destination MCP setup succeeds, the generated managed MCP servers are also mirrored into selected harnesses that have a stable user-level JSON MCP configuration (Claude Code, Cursor, Gemini CLI, and Pi Coding Agent). GitHub Copilot continues to use the BAS/VS Code MCP configuration written during destination setup. With no interactive terminal, the assets are installed for GitHub Copilot only; set `SAP_AI_DEV_TOOLKIT_HARNESSES` to a comma-separated list of harness ids (`github-copilot`, `claude-code`, `codex`, `cursor`, `gemini-cli`, `opencode`, `pi-coding-agent`) to choose non-interactively.
|
|
279
306
|
|
|
@@ -750,6 +777,9 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
750
777
|
| `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tool subset and local workflow tools, including `LintABAP`. |
|
|
751
778
|
| `SAP_ALLOW_TRANSPORTABLE_EDITS` | Generated MCP entries set this to `true` to permit source edits in transportable packages; VSP safety checks and SAP authorizations still apply. |
|
|
752
779
|
| `SAP_AUTH_MODE=windows-sso` | Local SAP GUI entry mode for experimental Windows Integrated Authentication. Runtime starts a loopback ADT proxy and does not pass SAP username/password credentials to VSP. |
|
|
780
|
+
| `SAP_TLS_SERVER_NAMES` / `SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES` | Comma-separated local SAP GUI TLS self-healing names, normally auto-discovered during setup from the SAP HTTPS certificate when `SAP_URL` uses an IP address. Runtime tries them one by one while keeping certificate validation enabled. |
|
|
781
|
+
| `SAP_TLS_SERVER_NAME` / `SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME` | Single-name compatibility form of the local SAP GUI TLS self-healing override. Prefer the auto-discovered plural variable written by setup. |
|
|
782
|
+
| `SAP_TLS_CA_FILE` / `SAP_AI_DEV_TOOLKIT_TLS_CA_FILE` | Optional PEM CA bundle for the local TLS self-healing proxy when the SAP HTTPS certificate chains to a private CA that is not in the OS/Node trust store. Certificate verification remains enabled. |
|
|
753
783
|
| `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER` / `SAP_AI_DEV_TOOLKIT_SSPI_HELPER` | Optional trusted Windows helper executable used by the local Windows SSO proxy to convert an HTTP `WWW-Authenticate: Negotiate` challenge into a Kerberos/SPNEGO token. NTLM fallback is rejected because it is connection-oriented and unsafe for this proxy shape. When unset on Windows, the bundled PowerShell SSPI helper is used. A custom helper receives one JSON line on stdin (`url`, `challenge`, `mechanism`) and prints the base64 token on stdout. |
|
|
754
784
|
| `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for BAS discovery and probe traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
|
|
755
785
|
| `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` | Per-request timeout for calls forwarded to a VSP child (default 600000 = 10 minutes; `0` disables). A stalled request fails with a timeout error; the child is left running. |
|
package/package.json
CHANGED
package/src/launcher.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
|
11
11
|
import { runSetup } from './setup.mjs';
|
|
12
12
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
13
13
|
import { createWindowsSsoAdtProxy } from './windows-sso-adt-proxy.mjs';
|
|
14
|
+
import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
|
|
14
15
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
15
16
|
import { redactText } from './redact.mjs';
|
|
16
17
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -175,8 +176,18 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
175
176
|
const name = env.SAP_AI_DEV_TOOLKIT_DESTINATION || env.SAP_SYSTEM_ID || 'local-sap';
|
|
176
177
|
if (!env.SAP_URL) throw new Error('SAP_URL is required for local SAP GUI MCP entries. Rerun sap-ai-dev --setup and enter the ADT URL.');
|
|
177
178
|
const authMode = String(env.SAP_AUTH_MODE || '').toLowerCase();
|
|
179
|
+
const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
|
|
180
|
+
const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
|
|
181
|
+
const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
|
|
182
|
+
const tlsRoute = tlsServerNames.length
|
|
183
|
+
? await createTlsServerNameAdtProxy({ destinationUrl: env.SAP_URL, tlsServerNames, caFile: tlsCaFile || undefined })
|
|
184
|
+
: null;
|
|
185
|
+
const adtUrl = tlsRoute?.url || env.SAP_URL;
|
|
186
|
+
const closeRoute = async route => {
|
|
187
|
+
await Promise.all([route?.close?.(), tlsRoute?.close?.()].filter(Boolean).map(close => Promise.resolve().then(close).catch(() => {})));
|
|
188
|
+
};
|
|
178
189
|
if (authMode === 'windows-sso') {
|
|
179
|
-
const route = await createWindowsSsoAdtProxy({ destinationUrl:
|
|
190
|
+
const route = await createWindowsSsoAdtProxy({ destinationUrl: adtUrl, env });
|
|
180
191
|
return {
|
|
181
192
|
source: 'sap-gui-local',
|
|
182
193
|
name,
|
|
@@ -185,21 +196,23 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
185
196
|
client: env.SAP_CLIENT || '001',
|
|
186
197
|
authentication: 'WindowsSSO',
|
|
187
198
|
childEnv: {},
|
|
188
|
-
close: route
|
|
189
|
-
probe: { status: 'configured', available: true }
|
|
199
|
+
close: () => closeRoute(route),
|
|
200
|
+
probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
|
|
190
201
|
};
|
|
191
202
|
}
|
|
192
203
|
return {
|
|
193
204
|
source: 'sap-gui-local',
|
|
194
205
|
name,
|
|
195
|
-
url:
|
|
206
|
+
url: adtUrl,
|
|
207
|
+
backendUrl: tlsRoute ? env.SAP_URL : undefined,
|
|
196
208
|
client: env.SAP_CLIENT || '001',
|
|
197
209
|
authentication: 'Basic',
|
|
198
210
|
childEnv: {
|
|
199
211
|
SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
|
|
200
212
|
SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
201
213
|
},
|
|
202
|
-
|
|
214
|
+
close: tlsRoute?.close,
|
|
215
|
+
probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
|
|
203
216
|
};
|
|
204
217
|
}
|
|
205
218
|
|
package/src/mcp-config.mjs
CHANGED
|
@@ -192,6 +192,8 @@ export function buildMcpEntries(destinations, env = process.env) {
|
|
|
192
192
|
SAP_URL: String(destination.url),
|
|
193
193
|
SAP_CLIENT: String(destination.client || '001'),
|
|
194
194
|
...(destination.systemId ? { SAP_SYSTEM_ID: String(destination.systemId) } : {}),
|
|
195
|
+
...(Array.isArray(destination.tlsServerNames) && destination.tlsServerNames.length ? { SAP_TLS_SERVER_NAMES: destination.tlsServerNames.join(',') } : {}),
|
|
196
|
+
...(!Array.isArray(destination.tlsServerNames) && destination.tlsServerName ? { SAP_TLS_SERVER_NAME: String(destination.tlsServerName) } : {}),
|
|
195
197
|
...(destination.childEnv || {})
|
|
196
198
|
});
|
|
197
199
|
} else {
|
package/src/setup.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
3
|
import { platform } from 'node:os';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
4
5
|
import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
|
|
5
6
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
6
7
|
import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
7
8
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
8
9
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
10
|
+
import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
|
|
9
11
|
|
|
10
12
|
const SETUP_COMMAND = 'sap-ai-dev --setup';
|
|
11
13
|
|
|
@@ -43,6 +45,51 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
export function parseSapClientList(value, fallback = '001') {
|
|
49
|
+
const supplied = String(value ?? '').trim();
|
|
50
|
+
const raw = supplied || String(fallback || '001').trim() || '001';
|
|
51
|
+
const clients = raw.split(',').map(client => client.trim()).filter(Boolean);
|
|
52
|
+
const unique = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const client of clients) {
|
|
55
|
+
if (!/^\d{3}$/.test(client)) throw new Error('Enter one or more SAP clients as 3 digits, separated by commas, for example 100 or 100,200.');
|
|
56
|
+
if (!seen.has(client)) {
|
|
57
|
+
seen.add(client);
|
|
58
|
+
unique.push(client);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!unique.length) throw new Error('Enter at least one SAP client.');
|
|
62
|
+
return unique;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function promptSapClients(destination, { input = stdin, output = stdout } = {}) {
|
|
66
|
+
const fallback = destination.client || '001';
|
|
67
|
+
const answer = await textPrompt({
|
|
68
|
+
message: `SAP client(s) for ${destination.name}`,
|
|
69
|
+
placeholder: fallback,
|
|
70
|
+
required: false,
|
|
71
|
+
validate: value => {
|
|
72
|
+
try { parseSapClientList(value, fallback); return true; }
|
|
73
|
+
catch (error) { return error.message; }
|
|
74
|
+
}
|
|
75
|
+
}, { input, output });
|
|
76
|
+
return parseSapClientList(answer, fallback);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function localSystemDiscriminator(destination) {
|
|
80
|
+
return [destination.systemId, destination.host, destination.instance].map(value => String(value || '').trim()).filter(Boolean).join(' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function localClientDestinationName(destination, client, { multipleClients = false, duplicateSystemName = false } = {}) {
|
|
84
|
+
const suffix = [];
|
|
85
|
+
if (multipleClients || duplicateSystemName) suffix.push(client);
|
|
86
|
+
if (duplicateSystemName) {
|
|
87
|
+
const discriminator = localSystemDiscriminator(destination);
|
|
88
|
+
if (discriminator) suffix.push(discriminator);
|
|
89
|
+
}
|
|
90
|
+
return suffix.length ? `${destination.name} ${suffix.join(' ')}` : destination.name;
|
|
91
|
+
}
|
|
92
|
+
|
|
46
93
|
|
|
47
94
|
function safeProbe(probe) {
|
|
48
95
|
const result = {};
|
|
@@ -79,6 +126,8 @@ function safeLocalSapGuiDestination(destination) {
|
|
|
79
126
|
authentication: destination.authentication || 'Basic',
|
|
80
127
|
authMode: destination.authMode,
|
|
81
128
|
proxyType: 'Internet',
|
|
129
|
+
tlsServerName: destination.tlsServerName,
|
|
130
|
+
tlsServerNames: destination.tlsServerNames,
|
|
82
131
|
probe: safeProbe(destination.probe),
|
|
83
132
|
childEnv: destination.childEnv || {}
|
|
84
133
|
};
|
|
@@ -289,7 +338,7 @@ export async function runSetup({
|
|
|
289
338
|
print(output, ' Space = select/deselect · a = toggle all · Enter = confirm.');
|
|
290
339
|
print(output, ' Nothing selected removes this add-on’s MCP entries.');
|
|
291
340
|
print(output, '');
|
|
292
|
-
|
|
341
|
+
let selected = await checkboxPrompt({
|
|
293
342
|
message: colorText('🧭 Select destinations', 'cyan', output),
|
|
294
343
|
choices,
|
|
295
344
|
required: false,
|
|
@@ -298,12 +347,23 @@ export async function runSetup({
|
|
|
298
347
|
|
|
299
348
|
if (!isBas && selected.length) {
|
|
300
349
|
print(output, '');
|
|
301
|
-
print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints. Confirm the ADT URL and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
|
|
350
|
+
print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints or a complete client catalog. Confirm the SAP client(s), ADT URL, and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
|
|
351
|
+
print(output, ' Enter multiple clients as comma-separated 3-digit values, for example 100,200. Setup creates one isolated MCP server per system/client pair.');
|
|
302
352
|
if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication. Username/password remains the default.');
|
|
353
|
+
const selectedNameCounts = selected.reduce((counts, destination) => {
|
|
354
|
+
const key = String(destination.name).toLowerCase();
|
|
355
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
356
|
+
return counts;
|
|
357
|
+
}, new Map());
|
|
358
|
+
const expanded = [];
|
|
303
359
|
for (const destination of selected) {
|
|
360
|
+
const duplicateSystemName = (selectedNameCounts.get(String(destination.name).toLowerCase()) || 0) > 1;
|
|
361
|
+
const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
|
|
362
|
+
const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
|
|
363
|
+
const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
|
|
304
364
|
const fallback = defaultAdtUrl(destination);
|
|
305
365
|
const url = await textPrompt({
|
|
306
|
-
message: `ADT URL for ${
|
|
366
|
+
message: `ADT URL for ${promptLabel}`,
|
|
307
367
|
placeholder: fallback || 'https://host:44300',
|
|
308
368
|
validate: value => {
|
|
309
369
|
const candidate = value || fallback;
|
|
@@ -320,26 +380,54 @@ export async function runSetup({
|
|
|
320
380
|
required: false
|
|
321
381
|
}, { input, output });
|
|
322
382
|
destination.url = url || fallback;
|
|
323
|
-
const
|
|
383
|
+
const parsedAdtUrl = new URL(destination.url);
|
|
384
|
+
if (parsedAdtUrl.protocol === 'https:' && isIP(parsedAdtUrl.hostname)) {
|
|
385
|
+
print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
|
|
386
|
+
let discoveredNames = [];
|
|
387
|
+
try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
|
|
388
|
+
catch { discoveredNames = []; }
|
|
389
|
+
if (discoveredNames.length) {
|
|
390
|
+
const preview = discoveredNames.slice(0, 5).join(', ');
|
|
391
|
+
const answer = await textPrompt({
|
|
392
|
+
message: `Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${promptLabel}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`,
|
|
393
|
+
placeholder: 'y',
|
|
394
|
+
required: false,
|
|
395
|
+
validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
|
|
396
|
+
}, { input, output });
|
|
397
|
+
if (!/^n(?:o)?$/i.test(String(answer || '').trim())) {
|
|
398
|
+
destination.tlsServerNames = discoveredNames;
|
|
399
|
+
destination.tlsServerName = discoveredNames[0];
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
324
406
|
destination.authentication = authMode === 'windows-sso' ? 'WindowsSSO' : 'Basic';
|
|
325
407
|
destination.authMode = authMode;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
408
|
+
for (const client of clients) {
|
|
409
|
+
const name = localClientDestinationName(destination, client, { multipleClients: clients.length > 1, duplicateSystemName });
|
|
410
|
+
const localDestination = { ...destination, name, serverName: name, client };
|
|
411
|
+
if (authMode === 'windows-sso') {
|
|
412
|
+
localDestination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
|
|
413
|
+
localDestination.inputs = [];
|
|
414
|
+
} else {
|
|
415
|
+
const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
416
|
+
const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
417
|
+
localDestination.childEnv = {
|
|
418
|
+
SAP_AUTH_MODE: 'basic',
|
|
419
|
+
SAP_USER: `\${input:${userInputId}}`,
|
|
420
|
+
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
421
|
+
};
|
|
422
|
+
localDestination.inputs = [
|
|
423
|
+
{ id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
|
|
424
|
+
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
expanded.push(localDestination);
|
|
341
428
|
}
|
|
342
429
|
}
|
|
430
|
+
selected = expanded;
|
|
343
431
|
}
|
|
344
432
|
|
|
345
433
|
let sapDevelopmentServers = [];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import tls from 'node:tls';
|
|
4
|
+
import { once } from 'node:events';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { isIP } from 'node:net';
|
|
7
|
+
|
|
8
|
+
const HOP_BY_HOP = new Set([
|
|
9
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
10
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade'
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function filteredHeaders(headers) {
|
|
14
|
+
const result = { ...headers };
|
|
15
|
+
for (const name of Object.keys(result)) {
|
|
16
|
+
if (HOP_BY_HOP.has(name.toLowerCase())) delete result[name];
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function requestHeaders(headers, backend) {
|
|
22
|
+
return { ...filteredHeaders(headers), host: backend.host };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseSubjectAltName(value) {
|
|
26
|
+
return String(value || '')
|
|
27
|
+
.split(/,\s*/)
|
|
28
|
+
.map(item => item.match(/^DNS:(.+)$/i)?.[1]?.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function discoverCertificateDnsNames(destinationUrl) {
|
|
33
|
+
const backend = new URL(destinationUrl);
|
|
34
|
+
if (backend.protocol !== 'https:') return [];
|
|
35
|
+
const socket = tls.connect({
|
|
36
|
+
host: backend.hostname,
|
|
37
|
+
port: Number(backend.port || 443),
|
|
38
|
+
...(isIP(backend.hostname) ? {} : { servername: backend.hostname }),
|
|
39
|
+
rejectUnauthorized: false
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
await once(socket, 'secureConnect');
|
|
43
|
+
const certificate = socket.getPeerCertificate();
|
|
44
|
+
return [...new Set(parseSubjectAltName(certificate?.subjectaltname))];
|
|
45
|
+
} finally {
|
|
46
|
+
socket.destroy();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function responseHeaders(headers, backend, clientReq) {
|
|
51
|
+
const result = filteredHeaders(headers);
|
|
52
|
+
const location = result.location;
|
|
53
|
+
if (typeof location === 'string') {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(location, backend);
|
|
56
|
+
if (parsed.origin === backend.origin) {
|
|
57
|
+
result.location = `http://${clientReq.headers.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Starts a loopback HTTP reverse proxy for ADT HTTPS endpoints whose network
|
|
66
|
+
* address and certificate identity differ. The proxy dials the configured
|
|
67
|
+
* backend URL but verifies TLS with tlsServerName; CA validation stays enabled.
|
|
68
|
+
*/
|
|
69
|
+
export async function createTlsServerNameAdtProxy({ destinationUrl, tlsServerName, tlsServerNames, caFile } = {}) {
|
|
70
|
+
const backend = new URL(destinationUrl);
|
|
71
|
+
if (backend.protocol !== 'https:') throw new Error('TLS server-name override requires an https:// SAP URL.');
|
|
72
|
+
const serverNames = [...new Set([...(Array.isArray(tlsServerNames) ? tlsServerNames : []), ...String(tlsServerName || '').split(',')].map(value => String(value || '').trim()).filter(Boolean))];
|
|
73
|
+
if (!serverNames.length) throw new Error('TLS server-name override requires a DNS name.');
|
|
74
|
+
|
|
75
|
+
const ca = caFile ? await readFile(caFile) : undefined;
|
|
76
|
+
const server = http.createServer((clientReq, clientRes) => {
|
|
77
|
+
let target;
|
|
78
|
+
try {
|
|
79
|
+
target = new URL(clientReq.url || '/', backend);
|
|
80
|
+
if (target.origin !== backend.origin) throw new Error('cross-origin request rejected');
|
|
81
|
+
} catch (error) {
|
|
82
|
+
clientRes.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
83
|
+
clientRes.end(`ADT TLS proxy rejected request: ${error.message}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const chunks = [];
|
|
87
|
+
clientReq.on('data', chunk => chunks.push(chunk));
|
|
88
|
+
clientReq.on('end', () => {
|
|
89
|
+
const body = Buffer.concat(chunks);
|
|
90
|
+
const tryServerName = index => {
|
|
91
|
+
const request = https.request({
|
|
92
|
+
protocol: 'https:',
|
|
93
|
+
hostname: backend.hostname,
|
|
94
|
+
port: backend.port || 443,
|
|
95
|
+
method: clientReq.method,
|
|
96
|
+
path: `${target.pathname}${target.search}`,
|
|
97
|
+
headers: requestHeaders(clientReq.headers, backend),
|
|
98
|
+
servername: serverNames[index],
|
|
99
|
+
rejectUnauthorized: true,
|
|
100
|
+
...(ca ? { ca } : {})
|
|
101
|
+
}, backendRes => {
|
|
102
|
+
clientRes.writeHead(backendRes.statusCode || 502, backendRes.statusMessage, responseHeaders(backendRes.headers, backend, clientReq));
|
|
103
|
+
backendRes.pipe(clientRes);
|
|
104
|
+
});
|
|
105
|
+
request.on('error', error => {
|
|
106
|
+
if (index + 1 < serverNames.length && /certificate|hostname|altname|x509/i.test(error.message)) return tryServerName(index + 1);
|
|
107
|
+
if (!clientRes.headersSent) clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
108
|
+
clientRes.end(`ADT TLS proxy failed: ${error.message}`);
|
|
109
|
+
});
|
|
110
|
+
request.end(body);
|
|
111
|
+
};
|
|
112
|
+
tryServerName(0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
server.listen(0, '127.0.0.1');
|
|
116
|
+
await once(server, 'listening');
|
|
117
|
+
const { port } = server.address();
|
|
118
|
+
return {
|
|
119
|
+
url: `http://127.0.0.1:${port}`,
|
|
120
|
+
close: () => new Promise(resolve => server.close(() => resolve()))
|
|
121
|
+
};
|
|
122
|
+
}
|