@hahnfeld/msrelay-provider 0.1.1 → 0.1.4

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
@@ -45,48 +45,24 @@ This plugin provides a tunnel — it makes a local port reachable via a stable H
45
45
  openacp plugin install @hahnfeld/msrelay-provider
46
46
  ```
47
47
 
48
- The install wizard prompts for:
48
+ The install wizard walks you through the full Azure setup — showing the `az` CLI commands for each step and prompting for the values. You'll need the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) installed.
49
49
 
50
- 1. Azure Relay namespace (e.g., `myrelay.servicebus.windows.net`)
51
- 2. Hybrid Connection name (e.g., `bot-endpoint`)
52
- 3. SAS policy name and key (with `Listen` permission)
53
- 4. Local port to forward to (3978 for Bot Framework, 21420 for OpenACP API server, or any custom port)
50
+ The wizard covers:
54
51
 
55
- ## Azure Setup
52
+ 1. Creating a Relay namespace
53
+ 2. Creating a Hybrid Connection (with client authorization disabled)
54
+ 3. Creating a listen-only SAS policy
55
+ 4. Retrieving the SAS key
56
+ 5. Choosing a local port to forward to
56
57
 
57
- Before installing the plugin, provision these Azure resources:
58
+ On reinstall, existing values are pre-filled so you can press Enter to keep them. The SAS key is masked and can be kept without re-entering.
58
59
 
59
- ```bash
60
- # 1. Create Relay namespace
61
- az relay namespace create \
62
- --resource-group <rg> --name <namespace> --location westus2
63
-
64
- # 2. Create Hybrid Connection
65
- az relay hyco create \
66
- --resource-group <rg> --namespace-name <namespace> --name my-connection
67
-
68
- # 3. Disable client authorization (required if callers can't send SAS headers —
69
- # e.g., Bot Framework, external webhooks, third-party services)
70
- az relay hyco update \
71
- --resource-group <rg> --namespace-name <namespace> \
72
- --name my-connection --requires-client-authorization false
73
-
74
- # 4. Create listen-only SAS policy (least privilege)
75
- az relay hyco authorization-rule create \
76
- --resource-group <rg> --namespace-name <namespace> \
77
- --hybrid-connection-name my-connection \
78
- --name ListenOnly --rights Listen
79
-
80
- # 5. Retrieve the key
81
- az relay hyco authorization-rule keys list \
82
- --resource-group <rg> --namespace-name <namespace> \
83
- --hybrid-connection-name my-connection --name ListenOnly
84
- ```
60
+ After setup, the wizard tests connectivity by opening a real WebSocket to Azure Relay.
85
61
 
86
- The resulting endpoint URL is:
62
+ The resulting public endpoint URL is:
87
63
 
88
64
  ```
89
- https://<namespace>.servicebus.windows.net/my-connection
65
+ https://<namespace>.servicebus.windows.net/<connection>
90
66
  ```
91
67
 
92
68
  Point your external callers (Bot Framework messaging endpoint, webhook URLs, etc.) at this URL.
@@ -113,13 +89,13 @@ openacp plugin configure @hahnfeld/msrelay-provider
113
89
  | Command | Description |
114
90
  |---------|-------------|
115
91
  | `/relay` | Show connection status, uptime, request/error counts |
116
- | `/relay auth` | Validate SAS key by generating a test token |
92
+ | `/relay auth` | Test connectivity by connecting to Azure Relay |
117
93
 
118
94
  ## Development
119
95
 
120
96
  ```bash
121
97
  pnpm install
122
- pnpm test # Run tests (28 tests)
98
+ pnpm test # Run tests (42 tests)
123
99
  pnpm build # Compile TypeScript
124
100
  pnpm dev # Watch mode
125
101
  pnpm lint # Type-check without emitting
package/dist/plugin.js CHANGED
@@ -10,20 +10,41 @@ function isValidNamespace(v) {
10
10
  function isValidConnectionName(v) {
11
11
  return /^[a-zA-Z0-9._-]+$/.test(v);
12
12
  }
13
- /** Test SAS key by generating a token. No network call — just validates the key format. */
14
- function testSasToken(namespace, connectionName, keyName, keyValue) {
15
- try {
16
- const hycoHttps = require("hyco-https");
17
- const uri = hycoHttps.createRelayListenUri(namespace, connectionName);
18
- const token = hycoHttps.createRelayToken(uri, keyName, keyValue);
19
- if (token && typeof token === "string") {
20
- return { ok: true };
13
+ /** Test connectivity by opening a real WebSocket to Azure Relay. */
14
+ function testRelayConnection(namespace, connectionName, keyName, keyValue, timeoutMs = 10_000) {
15
+ return new Promise((resolve) => {
16
+ try {
17
+ const hycoHttps = require("hyco-https");
18
+ const uri = hycoHttps.createRelayListenUri(namespace, connectionName);
19
+ const server = hycoHttps.createRelayedServer({ server: uri, token: () => hycoHttps.createRelayToken(uri, keyName, keyValue) }, () => { });
20
+ let settled = false;
21
+ const settle = (result) => {
22
+ if (!settled) {
23
+ settled = true;
24
+ try {
25
+ server.close();
26
+ }
27
+ catch { /* ignore */ }
28
+ resolve(result);
29
+ }
30
+ };
31
+ const timer = setTimeout(() => {
32
+ settle({ ok: false, error: "Connection timed out — check namespace, connection name, and network access" });
33
+ }, timeoutMs);
34
+ server.on("listening", () => {
35
+ clearTimeout(timer);
36
+ settle({ ok: true });
37
+ });
38
+ server.on("error", (err) => {
39
+ clearTimeout(timer);
40
+ settle({ ok: false, error: `Connection failed: ${err.message}` });
41
+ });
42
+ server.listen();
43
+ }
44
+ catch (err) {
45
+ resolve({ ok: false, error: `Setup failed: ${err.message}` });
21
46
  }
22
- return { ok: false, error: "Token generation returned empty result" };
23
- }
24
- catch (err) {
25
- return { ok: false, error: `Token generation failed: ${err.message}` };
26
- }
47
+ });
27
48
  }
28
49
  /** Format elapsed time as human-readable duration. */
29
50
  function formatUptime(startedAt) {
@@ -64,7 +85,7 @@ export function createRelayPlugin() {
64
85
  let provider = null;
65
86
  return {
66
87
  name: "@hahnfeld/msrelay-provider",
67
- version: "0.1.1",
88
+ version: "0.1.4",
68
89
  description: "Azure Relay Hybrid Connections tunnel provider — private HTTP tunneling via Azure backbone",
69
90
  essential: false,
70
91
  permissions: [
@@ -77,30 +98,54 @@ export function createRelayPlugin() {
77
98
  // ─── Interactive Install Wizard ──────────────────────────────────────
78
99
  async install(ctx) {
79
100
  const { terminal, settings } = ctx;
80
- terminal.note("This plugin uses Azure Relay Hybrid Connections to expose local ports\n" +
81
- "through Azure's private backbone (ExpressRoute). No public internet exposure.\n" +
82
- "\n" +
83
- "Prerequisites:\n" +
84
- " 1. Azure Relay namespace (e.g., myrelay.servicebus.windows.net)\n" +
85
- " 2. Hybrid Connection with client authorization DISABLED\n" +
86
- " 3. SAS key with Listen permission\n" +
101
+ // Load existing config for pre-filling on reinstall
102
+ const existing = await settings.getAll();
103
+ terminal.note("This wizard walks you through setting up Azure Relay Hybrid Connections.\n" +
104
+ "It exposes local ports through Azure's private backbone — no public\n" +
105
+ "internet exposure, no inbound ports.\n" +
87
106
  "\n" +
88
- "See SPEC.md Section 12 for Azure CLI setup commands.", "Azure Relay Setup");
107
+ "You'll need the Azure CLI installed (az). Each step shows the command\n" +
108
+ "to run. If you've already provisioned these resources, just enter the\n" +
109
+ "existing values when prompted.", "Azure Relay Setup");
89
110
  // ── Step 1: Relay Namespace ──
90
- const namespace = await terminal.text({
91
- message: "Azure Relay namespace (e.g., myrelay.servicebus.windows.net):",
111
+ terminal.note("Create a Relay namespace (if you don't have one):\n" +
112
+ "\n" +
113
+ " az relay namespace create \\\n" +
114
+ " --resource-group <resource-group> \\\n" +
115
+ " --name <choose-a-name> \\\n" +
116
+ " --location westus2\n" +
117
+ "\n" +
118
+ "The namespace becomes <name>.servicebus.windows.net", "Step 1: Create Relay Namespace");
119
+ const namespaceInput = await terminal.text({
120
+ message: "Azure Relay namespace (e.g., myrelay or myrelay.servicebus.windows.net):",
121
+ defaultValue: existing.relayNamespace ?? undefined,
92
122
  validate: (v) => {
93
- const trimmed = v.trim();
94
- if (!trimmed)
123
+ if (!v.trim())
95
124
  return "Namespace is required";
96
- if (!isValidNamespace(trimmed))
97
- return "Must be <name>.servicebus.windows.net";
98
125
  return undefined;
99
126
  },
100
127
  });
101
- // ── Step 2: Hybrid Connection Name ──
128
+ const ns = namespaceInput.trim().endsWith(".servicebus.windows.net")
129
+ ? namespaceInput.trim()
130
+ : `${namespaceInput.trim()}.servicebus.windows.net`;
131
+ if (!isValidNamespace(ns)) {
132
+ terminal.log.warning(`"${ns}" doesn't look like a valid namespace — continuing anyway`);
133
+ }
134
+ const nsName = ns.replace(".servicebus.windows.net", "");
135
+ // ── Step 2: Hybrid Connection ──
136
+ terminal.note("Create a Hybrid Connection with client authorization disabled:\n" +
137
+ "\n" +
138
+ ` az relay hyco create \\\n` +
139
+ ` --resource-group <resource-group> \\\n` +
140
+ ` --namespace-name ${nsName} \\\n` +
141
+ ` --name <choose-a-name> \\\n` +
142
+ ` --requires-client-authorization false\n` +
143
+ "\n" +
144
+ "IMPORTANT: --requires-client-authorization cannot be changed after\n" +
145
+ "creation. If you need to change it, delete and recreate the connection.", "Step 2: Create Hybrid Connection");
102
146
  const connectionName = await terminal.text({
103
147
  message: "Hybrid Connection name (e.g., bot-endpoint):",
148
+ defaultValue: existing.hybridConnectionName ?? undefined,
104
149
  validate: (v) => {
105
150
  const trimmed = v.trim();
106
151
  if (!trimmed)
@@ -110,38 +155,72 @@ export function createRelayPlugin() {
110
155
  return undefined;
111
156
  },
112
157
  });
113
- // ── Step 3: SAS Key Name ──
158
+ const hc = connectionName.trim();
159
+ // ── Step 3: SAS Policy ──
160
+ terminal.note("Create a listen-only SAS authorization rule:\n" +
161
+ "\n" +
162
+ ` az relay hyco authorization-rule create \\\n` +
163
+ ` --resource-group <resource-group> \\\n` +
164
+ ` --namespace-name ${nsName} \\\n` +
165
+ ` --hybrid-connection-name ${hc} \\\n` +
166
+ ` --name ListenOnly --rights Listen\n` +
167
+ "\n" +
168
+ "Use a listen-only policy — this plugin only needs Listen permission.\n" +
169
+ "Do not use RootManageSharedAccessKey.", "Step 3: Create SAS Policy");
114
170
  const keyName = await terminal.text({
115
171
  message: "SAS policy name:",
116
- defaultValue: "ListenOnly",
172
+ defaultValue: existing.sasKeyName ?? "ListenOnly",
117
173
  validate: (v) => {
118
174
  if (!v.trim())
119
- return "Key name is required";
175
+ return "Policy name is required";
120
176
  return undefined;
121
177
  },
122
178
  });
123
- // ── Step 4: SAS Key Value ──
124
- const keyValue = await terminal.text({
125
- message: "SAS key value:",
179
+ const sasName = keyName.trim();
180
+ // ── Step 4: SAS Key ──
181
+ terminal.note("Get the SAS key with this command:\n" +
182
+ "\n" +
183
+ ` az relay hyco authorization-rule keys list \\\n` +
184
+ ` --resource-group <resource-group> \\\n` +
185
+ ` --namespace-name ${nsName} \\\n` +
186
+ ` --hybrid-connection-name ${hc} \\\n` +
187
+ ` --name ${sasName} \\\n` +
188
+ ` --query primaryKey -o tsv\n` +
189
+ "\n" +
190
+ "Copy the output — that's your SAS key value.", "Step 4: Get SAS Key");
191
+ const hasExistingKey = !!(existing.sasKeyValue);
192
+ let keyValue = await terminal.password({
193
+ message: hasExistingKey
194
+ ? "SAS key value — press Enter to keep current:"
195
+ : "SAS key value (paste the primaryKey):",
126
196
  validate: (v) => {
127
- if (!v.trim())
197
+ if (!(v ?? "").trim() && !hasExistingKey)
128
198
  return "Key value is required";
129
199
  return undefined;
130
200
  },
131
201
  });
132
- // ── Step 5: Port ──
133
- terminal.log.info("");
202
+ if (!(keyValue ?? "").trim() && hasExistingKey) {
203
+ keyValue = existing.sasKeyValue;
204
+ }
205
+ // ── Step 6: Port ──
134
206
  terminal.note("This tunnel can point to any local port. Common choices:\n" +
135
207
  "\n" +
136
208
  " 3978 — Teams adapter (Microsoft Bot Framework default)\n" +
137
209
  " 21420 — OpenACP API server\n" +
138
210
  "\n" +
139
- "You can change this later with: openacp plugin configure @hahnfeld/msrelay-provider", "Port Selection");
211
+ "You can change this later with:\n" +
212
+ " openacp plugin configure @hahnfeld/msrelay-provider", "Step 5: Port Selection");
213
+ const existingPort = existing.port;
140
214
  const envPort = process.env.PORT ? Number(process.env.PORT) : null;
141
215
  const portChoice = await terminal.select({
142
216
  message: "Which port should the tunnel expose?",
143
217
  options: [
144
- ...(envPort ? [{ value: String(envPort), label: `${envPort} (from PORT env var)`, hint: "Current environment" }] : []),
218
+ ...(existingPort && existingPort !== 3978 && existingPort !== 21420
219
+ ? [{ value: String(existingPort), label: `${existingPort} (current config)`, hint: "Previously configured" }]
220
+ : []),
221
+ ...(envPort && envPort !== existingPort
222
+ ? [{ value: String(envPort), label: `${envPort} (from PORT env var)`, hint: "Current environment" }]
223
+ : []),
145
224
  { value: "3978", label: "3978", hint: "Teams adapter (Bot Framework default)" },
146
225
  { value: "21420", label: "21420", hint: "OpenACP API server" },
147
226
  { value: "custom", label: "Other port" },
@@ -151,6 +230,7 @@ export function createRelayPlugin() {
151
230
  if (portChoice === "custom") {
152
231
  const portStr = await terminal.text({
153
232
  message: "Local port to expose:",
233
+ defaultValue: existingPort ? String(existingPort) : undefined,
154
234
  validate: (v) => {
155
235
  const trimmed = v.trim();
156
236
  if (!trimmed)
@@ -166,39 +246,42 @@ export function createRelayPlugin() {
166
246
  else {
167
247
  port = Number(portChoice);
168
248
  }
169
- // ── Step 6: Connectivity Test (optional) ──
249
+ // ── Step 7: Connectivity Test (optional) ──
170
250
  const wantTest = await terminal.confirm({
171
- message: "Test connectivity now? (Attempts to connect to the Relay for 10s)",
172
- initialValue: false,
251
+ message: "Test connectivity now? (connects to Azure Relay for up to 10s)",
252
+ initialValue: true,
173
253
  });
174
254
  if (wantTest) {
175
255
  const spin = terminal.spinner();
176
- spin.start("Testing SAS token generation...");
177
- const tokenResult = testSasToken(namespace.trim(), connectionName.trim(), keyName.trim(), keyValue.trim());
178
- if (tokenResult.ok) {
179
- spin.stop("SAS token generated successfully");
256
+ spin.start("Connecting to Azure Relay...");
257
+ const result = await testRelayConnection(ns, hc, sasName, keyValue.trim());
258
+ if (result.ok) {
259
+ spin.stop("Connected to Azure Relay successfully");
180
260
  }
181
261
  else {
182
- spin.fail(tokenResult.error);
262
+ spin.fail(result.error);
183
263
  terminal.log.warning("Installation will continue — you can test connectivity later with /relay auth");
184
264
  }
185
265
  }
186
- // ── Step 7: Save ──
266
+ // ── Step 8: Save ──
187
267
  await settings.setAll({
188
268
  enabled: true,
189
269
  port,
190
- relayNamespace: namespace.trim(),
191
- hybridConnectionName: connectionName.trim(),
192
- sasKeyName: keyName.trim(),
270
+ relayNamespace: ns,
271
+ hybridConnectionName: hc,
272
+ sasKeyName: sasName,
193
273
  sasKeyValue: keyValue.trim(),
194
274
  });
195
275
  terminal.log.success("Azure Relay provider configured!");
196
276
  terminal.log.info("");
197
- terminal.note(`Namespace: ${namespace.trim()}\n` +
198
- `Connection: ${connectionName.trim()}\n` +
199
- `SAS policy: ${keyName.trim()}\n` +
277
+ terminal.note(`Namespace: ${ns}\n` +
278
+ `Connection: ${hc}\n` +
279
+ `SAS policy: ${sasName}\n` +
200
280
  `Port: ${port}\n` +
201
- `Public URL: https://${namespace.trim()}/${connectionName.trim()}`, "Configuration Summary");
281
+ `Public URL: https://${ns}/${hc}\n` +
282
+ "\n" +
283
+ "Point your Bot Framework messaging endpoint (or webhook URLs) at\n" +
284
+ "the public URL above.", "Configuration Summary");
202
285
  },
203
286
  // ─── Configure (post-install changes) ────────────────────────────────
204
287
  async configure(ctx) {
@@ -270,7 +353,7 @@ export function createRelayPlugin() {
270
353
  break;
271
354
  }
272
355
  case "sasKeyValue": {
273
- const val = await terminal.text({
356
+ const val = await terminal.password({
274
357
  message: "SAS key value:",
275
358
  validate: (v) => {
276
359
  if (!v.trim())
@@ -308,10 +391,10 @@ export function createRelayPlugin() {
308
391
  }
309
392
  case "auth": {
310
393
  const spin = terminal.spinner();
311
- spin.start("Testing SAS token generation...");
312
- const result = testSasToken(current.relayNamespace, current.hybridConnectionName, current.sasKeyName, current.sasKeyValue);
394
+ spin.start("Connecting to Azure Relay...");
395
+ const result = await testRelayConnection(current.relayNamespace, current.hybridConnectionName, current.sasKeyName, current.sasKeyValue);
313
396
  if (result.ok) {
314
- spin.stop("SAS token generated successfully");
397
+ spin.stop("Connected to Azure Relay successfully");
315
398
  }
316
399
  else {
317
400
  spin.fail(result.error);
@@ -394,9 +477,9 @@ export function createRelayPlugin() {
394
477
  const raw = args.raw.trim();
395
478
  if (raw === "auth") {
396
479
  const cfg = config;
397
- const result = testSasToken(cfg.relayNamespace, cfg.hybridConnectionName, cfg.sasKeyName, cfg.sasKeyValue);
480
+ const result = await testRelayConnection(cfg.relayNamespace, cfg.hybridConnectionName, cfg.sasKeyName, cfg.sasKeyValue);
398
481
  if (result.ok) {
399
- return { type: "text", text: "SAS token generation: OK" };
482
+ return { type: "text", text: "Azure Relay connectivity: OK" };
400
483
  }
401
484
  return { type: "error", message: result.error };
402
485
  }
package/dist/provider.js CHANGED
@@ -171,19 +171,17 @@ export class AzureRelayProvider {
171
171
  onUnexpectedClose();
172
172
  }
173
173
  });
174
- server.listen((err) => {
174
+ // hyco-https listen() takes no arguments — readiness is signaled
175
+ // via the 'listening' event when the control channel WebSocket opens.
176
+ server.on("listening", () => {
175
177
  clearTimeout(timeout);
176
- if (err) {
177
- this.server = null;
178
- settle(() => reject(new Error(`Azure Relay listen failed: ${err.message}`)));
179
- return;
180
- }
181
178
  this.publicUrl = `https://${relayNamespace}/${hybridConnectionName}`;
182
179
  if (!this.metrics.startedAt) {
183
180
  this.metrics.startedAt = new Date();
184
181
  }
185
182
  settle(() => resolve(this.publicUrl));
186
183
  });
184
+ server.listen();
187
185
  });
188
186
  }
189
187
  /**
@@ -236,12 +234,18 @@ export class AzureRelayProvider {
236
234
  this.sendError(relayRes, 400, "Bad Request", `Invalid request URL: ${rawUrl}`);
237
235
  return;
238
236
  }
237
+ // Azure Relay prepends the Hybrid Connection name to the path.
238
+ // e.g., /bot-endpoint/api/messages → /api/messages
239
+ const prefix = `/${this.config.hybridConnectionName}`;
240
+ const forwardPath = rawUrl.startsWith(prefix)
241
+ ? (rawUrl.slice(prefix.length) || "/")
242
+ : rawUrl;
239
243
  const forwardHeaders = stripHopByHop(relayReq.headers);
240
244
  forwardHeaders["host"] = `localhost:${this.localPort}`;
241
245
  const localReq = httpRequest({
242
246
  hostname: "localhost",
243
247
  port: this.localPort,
244
- path: rawUrl,
248
+ path: forwardPath,
245
249
  method: relayReq.method,
246
250
  headers: forwardHeaders,
247
251
  timeout: LOCAL_FORWARD_TIMEOUT_MS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hahnfeld/msrelay-provider",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Azure Relay Hybrid Connections tunnel provider plugin for OpenACP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",