@camstack/addon-tailscale-ingress 0.1.3 → 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.
@@ -4621,7 +4621,7 @@ function _instanceof(cls, params = {}) {
4621
4621
  return inst;
4622
4622
  }
4623
4623
  //#endregion
4624
- //#region ../types/dist/index-YnRVILXN.mjs
4624
+ //#region ../types/dist/index-Ce7RZWP4.mjs
4625
4625
  var MODEL_FORMATS = [
4626
4626
  "onnx",
4627
4627
  "coreml",
@@ -7288,14 +7288,35 @@ var StatusSchema = object({
7288
7288
  embeddedRunning: boolean()
7289
7289
  });
7290
7290
  method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
7291
+ var LinkStateSchema = _enum([
7292
+ "unlinked",
7293
+ "linked",
7294
+ "error"
7295
+ ]);
7296
+ var ExportSetupFieldSchema = object({
7297
+ label: string(),
7298
+ value: string(),
7299
+ /** Mask the value by default + render a reveal toggle (client id, secrets). */
7300
+ secret: boolean().optional()
7301
+ });
7302
+ var ExportSetupSchema = object({
7303
+ /** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
7304
+ qr: string().optional(),
7305
+ /** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
7306
+ fields: array(ExportSetupFieldSchema).readonly().optional(),
7307
+ /** Free-form operator instructions rendered above the fields. */
7308
+ note: string().optional()
7309
+ });
7291
7310
  var DeviceExportStatusSchema = object({
7292
- linkState: _enum([
7293
- "unlinked",
7294
- "linked",
7295
- "error"
7296
- ]),
7311
+ linkState: LinkStateSchema,
7297
7312
  exposedDeviceCount: number(),
7298
- error: string().optional()
7313
+ error: string().optional(),
7314
+ /**
7315
+ * Optional pairing/account info the panel renders in a generic
7316
+ * "Setup" section. Addon-agnostic — the addon id identifies the
7317
+ * export target, never an `ecosystem` key here.
7318
+ */
7319
+ setup: ExportSetupSchema.optional()
7299
7320
  });
7300
7321
  var DeviceKindSchema = string();
7301
7322
  var ExposedDeviceSchema = object({
@@ -9551,7 +9572,24 @@ var MeshStatusSchema = object({
9551
9572
  * doesn't rotate keys for the bound host. Operator-facing surface
9552
9573
  * for "your access expires on …" banners.
9553
9574
  */
9554
- keyExpiry: number().nullable()
9575
+ keyExpiry: number().nullable(),
9576
+ /**
9577
+ * When the provider runs its OWN mesh daemon (e.g. the Tailscale
9578
+ * client addon in `onboard` mode spawns a private `tailscaled`),
9579
+ * this carries the local control-socket path. Companion addons that
9580
+ * must drive the SAME daemon — chiefly `tailscale-ingress` for
9581
+ * Serve/Funnel — read it to point their CLI at the right socket
9582
+ * instead of the system default. Empty when the provider uses the
9583
+ * host's system daemon (or doesn't have the concept).
9584
+ */
9585
+ daemonSocket: string().optional(),
9586
+ /**
9587
+ * Path to the mesh CLI binary the provider downloaded for onboard
9588
+ * mode. Companion addons reuse it so they don't need a system
9589
+ * install when the operator chose a fully self-contained mesh.
9590
+ * Empty in host mode.
9591
+ */
9592
+ daemonCliPath: string().optional()
9555
9593
  });
9556
9594
  method(_void(), MeshStatusSchema), method(object({
9557
9595
  /** Provider-specific auth key. For Tailscale this is the
@@ -10233,6 +10271,21 @@ var AddonAutoUpdateSchema = ChannelWithInheritSchema;
10233
10271
  var RestartAddonResultSchema = unknown();
10234
10272
  var InstallPackageResultSchema = unknown();
10235
10273
  var ReloadPackagesResultSchema = unknown();
10274
+ var UpdateFrameworkPackageResultSchema = object({
10275
+ packageName: string(),
10276
+ fromVersion: string(),
10277
+ toVersion: string(),
10278
+ /** Ms-epoch the server scheduled its self-restart. */
10279
+ restartingAt: number()
10280
+ });
10281
+ var FrameworkPackageStatusSchema = object({
10282
+ packageName: string(),
10283
+ currentVersion: string(),
10284
+ latestVersion: string().nullable(),
10285
+ hasUpdate: boolean(),
10286
+ /** Optional manifest description for the row tooltip. */
10287
+ description: string().optional()
10288
+ });
10236
10289
  var LogStreamEntrySchema = object({
10237
10290
  timestamp: string(),
10238
10291
  level: string(),
@@ -10264,21 +10317,43 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
10264
10317
  }), method(_void(), ReloadPackagesResultSchema, {
10265
10318
  kind: "mutation",
10266
10319
  auth: "admin"
10267
- }), method(object({ query: string().optional() }), array(SearchResultSchema)), method(_void(), array(PackageUpdateSchema).readonly(), { auth: "admin" }), method(object({
10320
+ }), method(object({ query: string().optional() }), array(SearchResultSchema)), method(object({ nodeId: string().optional() }), array(PackageUpdateSchema).readonly(), { auth: "admin" }), method(object({
10268
10321
  name: string().min(1),
10269
- version: string().optional()
10322
+ version: string().optional(),
10323
+ nodeId: string().optional()
10270
10324
  }), unknown(), {
10271
10325
  kind: "mutation",
10272
10326
  auth: "admin"
10273
10327
  }), method(object({ name: string().min(1) }), object({ rolledBackTo: string().nullable() }), {
10274
10328
  kind: "mutation",
10275
10329
  auth: "admin"
10276
- }), method(_void(), unknown(), {
10330
+ }), method(object({ nodeId: string().optional() }), unknown(), {
10277
10331
  kind: "mutation",
10278
10332
  auth: "admin"
10279
10333
  }), method(object({ confirm: literal(true) }), unknown(), {
10280
10334
  kind: "mutation",
10281
10335
  auth: "admin"
10336
+ }), method(_void(), object({
10337
+ kind: _enum([
10338
+ "framework-update",
10339
+ "manual",
10340
+ "system"
10341
+ ]),
10342
+ packageName: string().optional(),
10343
+ fromVersion: string().optional(),
10344
+ toVersion: string().optional(),
10345
+ requestedBy: string().optional(),
10346
+ requestedAt: number()
10347
+ }).nullable(), { auth: "admin" }), method(_void(), array(FrameworkPackageStatusSchema).readonly(), { auth: "admin" }), method(object({ capName: string().min(1) }), array(object({
10348
+ addonId: string(),
10349
+ mode: _enum(["singleton", "collection"]),
10350
+ isActive: boolean()
10351
+ })).readonly()), method(object({
10352
+ packageName: string().min(1),
10353
+ version: string().optional()
10354
+ }), UpdateFrameworkPackageResultSchema, {
10355
+ kind: "mutation",
10356
+ auth: "admin"
10282
10357
  }), method(object({ name: string() }), array(PackageVersionInfoSchema).readonly()), method(object({ addonId: string() }), RestartAddonResultSchema, {
10283
10358
  kind: "mutation",
10284
10359
  auth: "admin"
@@ -10310,6 +10385,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
10310
10385
  EventCategory2["SystemBoot"] = "system.boot";
10311
10386
  EventCategory2["SystemAddonsReady"] = "system.addons-ready";
10312
10387
  EventCategory2["SystemRestarting"] = "system.restarting";
10388
+ EventCategory2["SystemRestartCompleted"] = "system.restart-completed";
10313
10389
  EventCategory2["SystemReadyState"] = "system.ready-state";
10314
10390
  EventCategory2["AddonStarted"] = "addon.started";
10315
10391
  EventCategory2["AddonStopped"] = "addon.stopped";
@@ -10832,6 +10908,12 @@ Object.freeze({
10832
10908
  addonId: null,
10833
10909
  access: "view"
10834
10910
  },
10911
+ "addons.getLastRestart": {
10912
+ capName: "addons",
10913
+ capScope: "system",
10914
+ addonId: null,
10915
+ access: "view"
10916
+ },
10835
10917
  "addons.getLogs": {
10836
10918
  capName: "addons",
10837
10919
  capScope: "system",
@@ -10868,6 +10950,18 @@ Object.freeze({
10868
10950
  addonId: null,
10869
10951
  access: "view"
10870
10952
  },
10953
+ "addons.listCapabilityProviders": {
10954
+ capName: "addons",
10955
+ capScope: "system",
10956
+ addonId: null,
10957
+ access: "view"
10958
+ },
10959
+ "addons.listFrameworkPackages": {
10960
+ capName: "addons",
10961
+ capScope: "system",
10962
+ addonId: null,
10963
+ access: "view"
10964
+ },
10871
10965
  "addons.listPackages": {
10872
10966
  capName: "addons",
10873
10967
  capScope: "system",
@@ -10946,6 +11040,12 @@ Object.freeze({
10946
11040
  addonId: null,
10947
11041
  access: "delete"
10948
11042
  },
11043
+ "addons.updateFrameworkPackage": {
11044
+ capName: "addons",
11045
+ capScope: "system",
11046
+ addonId: null,
11047
+ access: "create"
11048
+ },
10949
11049
  "addons.updatePackage": {
10950
11050
  capName: "addons",
10951
11051
  capScope: "system",
@@ -14053,32 +14153,98 @@ var TailscaleCliError = class extends Error {
14053
14153
  this.name = "TailscaleCliError";
14054
14154
  }
14055
14155
  };
14156
+ var NOOP_LOGGER = {
14157
+ info: () => void 0,
14158
+ warn: () => void 0,
14159
+ error: () => void 0,
14160
+ debug: () => void 0,
14161
+ child: () => NOOP_LOGGER,
14162
+ withTags: () => NOOP_LOGGER
14163
+ };
14056
14164
  var TailscaleCli = class {
14057
14165
  resolvedBin = null;
14166
+ logger;
14167
+ binPathOverride;
14168
+ socketPath;
14169
+ constructor(opts = {}) {
14170
+ if (typeof opts.info === "function" && !("logger" in opts)) {
14171
+ this.logger = opts;
14172
+ this.binPathOverride = void 0;
14173
+ this.socketPath = void 0;
14174
+ } else {
14175
+ const o = opts;
14176
+ this.logger = o.logger ?? NOOP_LOGGER;
14177
+ this.binPathOverride = o.binPath;
14178
+ this.socketPath = o.socketPath;
14179
+ }
14180
+ }
14181
+ /** Prepend `--socket=<path>` when driving an onboard daemon. */
14182
+ withSocket(args) {
14183
+ return this.socketPath ? [`--socket=${this.socketPath}`, ...args] : [...args];
14184
+ }
14058
14185
  /** Locate the `tailscale` binary once and cache the result. */
14059
14186
  async resolveBin() {
14060
14187
  if (this.resolvedBin) return this.resolvedBin;
14188
+ if (this.binPathOverride) {
14189
+ this.resolvedBin = this.binPathOverride;
14190
+ this.logger.info("CLI binary (onboard override)", { meta: {
14191
+ bin: this.binPathOverride,
14192
+ socketPath: this.socketPath
14193
+ } });
14194
+ return this.binPathOverride;
14195
+ }
14196
+ const tried = [];
14061
14197
  for (const candidate of TAILSCALE_CANDIDATES) try {
14062
14198
  await execFileP(candidate, ["version"], { timeout: 3e3 });
14063
14199
  this.resolvedBin = candidate;
14200
+ this.logger.info("CLI binary resolved", { meta: {
14201
+ bin: candidate,
14202
+ candidatesTried: tried
14203
+ } });
14064
14204
  return candidate;
14065
- } catch {}
14205
+ } catch {
14206
+ tried.push(candidate);
14207
+ }
14066
14208
  throw new TailscaleCliError("tailscale binary not found — install Tailscale from https://tailscale.com/download");
14067
14209
  }
14068
14210
  async version() {
14069
- const { stdout } = await execFileP(await this.resolveBin(), ["version"], { timeout: 5e3 });
14211
+ const { stdout } = await execFileP(await this.resolveBin(), this.withSocket(["version"]), { timeout: 5e3 });
14070
14212
  return stdout.trim().split("\n")[0] ?? "";
14071
14213
  }
14072
14214
  async status() {
14073
14215
  const bin = await this.resolveBin();
14074
14216
  try {
14075
- const { stdout } = await execFileP(bin, ["status", "--json"], { timeout: 1e4 });
14217
+ const { stdout } = await execFileP(bin, this.withSocket(["status", "--json"]), { timeout: 1e4 });
14076
14218
  return JSON.parse(stdout);
14077
14219
  } catch (err) {
14078
14220
  const e = err;
14079
14221
  throw new TailscaleCliError(`tailscale status failed: ${e.message}`, e.stderr ?? "");
14080
14222
  }
14081
14223
  }
14224
+ /**
14225
+ * Whether the tailnet ACL policy grants THIS host the Funnel
14226
+ * attribute. Reads `Self.CapMap` / `Self.Capabilities` from
14227
+ * `tailscale status --json` — a pre-flight check so the addon can
14228
+ * fail fast with an actionable error BEFORE spending 15s on a
14229
+ * `tailscale funnel` call that the daemon would reject anyway.
14230
+ *
14231
+ * The funnel grant surfaces as a bare `funnel` key in the modern
14232
+ * CapMap; the legacy `Capabilities` string array carries either
14233
+ * `funnel` or a `.../cap/funnel` URL form.
14234
+ */
14235
+ async funnelCapable() {
14236
+ const s = await this.status();
14237
+ const capMap = s.Self?.CapMap ?? {};
14238
+ if (Object.prototype.hasOwnProperty.call(capMap, "funnel")) return true;
14239
+ const legacy = s.Self?.Capabilities ?? [];
14240
+ const capable = legacy.some((c) => c === "funnel" || c.endsWith("/cap/funnel"));
14241
+ this.logger.info("funnelCapable check", { meta: {
14242
+ capable,
14243
+ capMapKeys: Object.keys(capMap),
14244
+ legacyCount: legacy.length
14245
+ } });
14246
+ return capable;
14247
+ }
14082
14248
  /** Bring the daemon up with an auth key. Idempotent — calling
14083
14249
  * while already joined returns immediately. */
14084
14250
  async up(input) {
@@ -14091,7 +14257,7 @@ var TailscaleCli = class {
14091
14257
  ];
14092
14258
  if (input.hostname) args.push(`--hostname=${input.hostname}`);
14093
14259
  try {
14094
- await execFileP(bin, args, { timeout: 6e4 });
14260
+ await execFileP(bin, this.withSocket(args), { timeout: 6e4 });
14095
14261
  } catch (err) {
14096
14262
  const e = err;
14097
14263
  throw new TailscaleCliError(`tailscale up failed: ${e.message}`, e.stderr ?? "");
@@ -14102,7 +14268,7 @@ var TailscaleCli = class {
14102
14268
  async down() {
14103
14269
  const bin = await this.resolveBin();
14104
14270
  try {
14105
- await execFileP(bin, ["down"], { timeout: 15e3 });
14271
+ await execFileP(bin, this.withSocket(["down"]), { timeout: 15e3 });
14106
14272
  } catch (err) {
14107
14273
  const e = err;
14108
14274
  throw new TailscaleCliError(`tailscale down failed: ${e.message}`, e.stderr ?? "");
@@ -14118,11 +14284,19 @@ var TailscaleCli = class {
14118
14284
  async serve(input) {
14119
14285
  const bin = await this.resolveBin();
14120
14286
  const args = this.buildIngressArgs("serve", input);
14287
+ this.logger.info("serve: invoking CLI", { meta: {
14288
+ bin,
14289
+ args,
14290
+ ...input
14291
+ } });
14121
14292
  try {
14122
- await execFileP(bin, args, { timeout: 15e3 });
14293
+ const { stdout, stderr } = await execFileP(bin, this.withSocket(args), { timeout: 15e3 });
14294
+ this.logger.info("serve: CLI returned", { meta: {
14295
+ stdout: trimForLog(stdout),
14296
+ stderr: trimForLog(stderr)
14297
+ } });
14123
14298
  } catch (err) {
14124
- const e = err;
14125
- throw new TailscaleCliError(`tailscale serve failed: ${e.message}`, e.stderr ?? "");
14299
+ throw this.wrapCliError("tailscale serve", err, bin, args);
14126
14300
  }
14127
14301
  }
14128
14302
  /** `tailscale funnel` — exposes a local port to the open internet
@@ -14131,14 +14305,52 @@ var TailscaleCli = class {
14131
14305
  async funnel(input) {
14132
14306
  const bin = await this.resolveBin();
14133
14307
  const args = this.buildIngressArgs("funnel", input);
14308
+ this.logger.info("funnel: invoking CLI", { meta: {
14309
+ bin,
14310
+ args,
14311
+ ...input
14312
+ } });
14134
14313
  try {
14135
- await execFileP(bin, args, { timeout: 15e3 });
14314
+ const { stdout, stderr } = await execFileP(bin, this.withSocket(args), { timeout: 15e3 });
14315
+ this.logger.info("funnel: CLI returned", { meta: {
14316
+ stdout: trimForLog(stdout),
14317
+ stderr: trimForLog(stderr)
14318
+ } });
14136
14319
  } catch (err) {
14137
- const e = err;
14138
- throw new TailscaleCliError(`tailscale funnel failed: ${e.message}`, e.stderr ?? "");
14320
+ throw this.wrapCliError("tailscale funnel", err, bin, args);
14139
14321
  }
14140
14322
  }
14141
14323
  /**
14324
+ * Build a TailscaleCliError that propagates BOTH stdout and stderr
14325
+ * up to the caller. The tailscale CLI is inconsistent about which
14326
+ * stream it uses for human-readable errors — `funnel` (and at least
14327
+ * some `serve` paths) writes the actionable hint to STDOUT when
14328
+ * the host lacks the ACL grant (e.g. "Funnel is not enabled on
14329
+ * your tailnet. To enable, visit: https://login.tailscale.com/...").
14330
+ * Reading only stderr loses that hint and the operator sees a bare
14331
+ * "Command failed: …" with no recovery path.
14332
+ *
14333
+ * Special-cases the "Funnel is not enabled" outcome: extracts the
14334
+ * enable-URL the CLI prints and surfaces a concise, single-line
14335
+ * actionable error instead of dumping the raw multi-line blob.
14336
+ */
14337
+ wrapCliError(verb, err, bin, args) {
14338
+ const e = err;
14339
+ const stderr = (e.stderr ?? "").trim();
14340
+ const stdout = (e.stdout ?? "").trim();
14341
+ this.logger.error(`${verb}: CLI failed`, { meta: {
14342
+ bin,
14343
+ args,
14344
+ message: e.message,
14345
+ stderr: trimForLog(stderr),
14346
+ stdout: trimForLog(stdout)
14347
+ } });
14348
+ const combined = `${stdout}\n${stderr}`;
14349
+ if (/funnel is not enabled/i.test(combined)) return new TailscaleCliError(`Funnel is not enabled on your tailnet. Enable it for this host at: ${combined.match(/https:\/\/login\.tailscale\.com\/\S+/)?.[0] ?? "https://login.tailscale.com/admin/settings/funnel"}`, stderr.length > 0 ? stderr : stdout);
14350
+ const detail = stderr.length > 0 ? `--- stderr ---\n${stderr}` : stdout.length > 0 ? `--- stdout ---\n${stdout}` : "";
14351
+ return new TailscaleCliError(`${verb} failed: ${e.message.trim()}${detail ? `\n${detail}` : ""}`, stderr.length > 0 ? stderr : stdout);
14352
+ }
14353
+ /**
14142
14354
  * Single source of truth for the serve/funnel arg shape. The CLI
14143
14355
  * accepts the same flag layout for both verbs, so we share the
14144
14356
  * builder. `--set-path` is only emitted when the operator picks a
@@ -14154,10 +14366,17 @@ var TailscaleCli = class {
14154
14366
  }
14155
14367
  const out = [verb, "--bg"];
14156
14368
  if (path && path !== "/") out.push(`--set-path=${path}`);
14157
- out.push(`http://127.0.0.1:${input.port}`);
14369
+ const scheme = input.upstreamScheme ?? "https+insecure";
14370
+ out.push(`${scheme}://127.0.0.1:${input.port}`);
14158
14371
  return out;
14159
14372
  }
14160
14373
  };
14374
+ /** Truncate long stdout/stderr blobs so the log entry stays readable. */
14375
+ function trimForLog(s, max = 800) {
14376
+ const trimmed = s.trim();
14377
+ if (trimmed.length <= max) return trimmed;
14378
+ return `${trimmed.slice(0, max)}…[+${trimmed.length - max} bytes]`;
14379
+ }
14161
14380
  //#endregion
14162
14381
  //#region src/tailscale-ingress.addon.ts
14163
14382
  /**
@@ -14185,10 +14404,11 @@ var TailscaleCli = class {
14185
14404
  var DEFAULT_CONFIG = {
14186
14405
  mode: "serve",
14187
14406
  sourcePort: 4443,
14188
- targetPath: ""
14407
+ targetPath: "",
14408
+ upstreamScheme: "https+insecure"
14189
14409
  };
14190
14410
  var TailscaleIngressAddon = class extends BaseAddon {
14191
- cli = new TailscaleCli();
14411
+ cli;
14192
14412
  /**
14193
14413
  * Snapshot of the running ingress params so stop()/disposer can issue
14194
14414
  * the matching `off` call even if `this.config` mutates mid-flight.
@@ -14201,6 +14421,7 @@ var TailscaleIngressAddon = class extends BaseAddon {
14201
14421
  super({ ...DEFAULT_CONFIG });
14202
14422
  }
14203
14423
  async onInitialize() {
14424
+ this.cli = new TailscaleCli(this.ctx.logger.child("tailscale-cli"));
14204
14425
  try {
14205
14426
  await this.cli.version();
14206
14427
  } catch (err) {
@@ -14226,17 +14447,27 @@ var TailscaleIngressAddon = class extends BaseAddon {
14226
14447
  topic: "tailscale-ingress",
14227
14448
  phase: "verify-client"
14228
14449
  } });
14229
- await this.requireClientJoined();
14450
+ const meshStatus = await this.requireClientJoined();
14451
+ this.syncCliToDaemon(meshStatus);
14452
+ if (this.config.mode === "funnel") {
14453
+ log.info("start: phase=verify-funnel-grant", { tags: {
14454
+ topic: "tailscale-ingress",
14455
+ phase: "verify-funnel-grant"
14456
+ } });
14457
+ if (!await this.cli.funnelCapable()) throw new Error("tailscale-ingress: Funnel is not enabled for this host in the tailnet ACL policy. Enable it at https://login.tailscale.com/admin/settings/funnel (or add a `funnel` nodeAttr for this machine), then retry. Serve mode works without this grant.");
14458
+ }
14230
14459
  const next = {
14231
14460
  mode: this.config.mode,
14232
14461
  sourcePort: this.config.sourcePort,
14233
- targetPath: this.config.targetPath
14462
+ targetPath: this.config.targetPath,
14463
+ upstreamScheme: this.config.upstreamScheme
14234
14464
  };
14235
14465
  log.info("start: phase=apply-rule", {
14236
14466
  meta: {
14237
14467
  mode: next.mode,
14238
14468
  sourcePort: next.sourcePort,
14239
- targetPath: next.targetPath
14469
+ targetPath: next.targetPath,
14470
+ upstreamScheme: next.upstreamScheme
14240
14471
  },
14241
14472
  tags: {
14242
14473
  topic: "tailscale-ingress",
@@ -14376,10 +14607,27 @@ var TailscaleIngressAddon = class extends BaseAddon {
14376
14607
  const opts = {
14377
14608
  port: rule.sourcePort,
14378
14609
  enabled,
14610
+ upstreamScheme: rule.upstreamScheme,
14379
14611
  ...rule.targetPath && rule.targetPath !== "/" ? { targetPath: rule.targetPath } : {}
14380
14612
  };
14613
+ this.ctx.logger.info(`applyRule: ${rule.mode}`, {
14614
+ meta: {
14615
+ ...opts,
14616
+ mode: rule.mode
14617
+ },
14618
+ tags: {
14619
+ topic: "tailscale-ingress",
14620
+ phase: "apply-rule",
14621
+ verb: rule.mode
14622
+ }
14623
+ });
14381
14624
  if (rule.mode === "funnel") await this.cli.funnel(opts);
14382
14625
  else await this.cli.serve(opts);
14626
+ this.ctx.logger.info(`applyRule: ${rule.mode} done`, { tags: {
14627
+ topic: "tailscale-ingress",
14628
+ phase: "apply-rule-done",
14629
+ verb: rule.mode
14630
+ } });
14383
14631
  }
14384
14632
  /**
14385
14633
  * Build the canonical endpoint from the active ingress + the
@@ -14409,15 +14657,65 @@ var TailscaleIngressAddon = class extends BaseAddon {
14409
14657
  /**
14410
14658
  * Wait for `mesh-network` (the tailscale-client provider) to be
14411
14659
  * mounted AND report joined. Throws an actionable error otherwise.
14660
+ *
14661
+ * IMPORTANT: we route the check through the API surface
14662
+ * (`api.meshNetwork.getStatus.query({ addonId: 'tailscale-client' })`),
14663
+ * NOT through `ctx.capabilities.getCollectionEntries`. The latter
14664
+ * only sees providers registered in THIS process's local
14665
+ * CapabilityRegistry — and tailscale-client runs in a separate
14666
+ * `hub/tailscale-client` group runner. The API surface is
14667
+ * cross-process via Moleculer, so it can find the provider wherever
14668
+ * it lives.
14669
+ *
14670
+ * `mesh-network` is a `collection` cap (Tailscale + Headscale +
14671
+ * ZeroTier could all implement it in parallel), so we pin to
14672
+ * `tailscale-client` via the `addonId` filter — without it the API
14673
+ * would route to whichever provider registered first and we'd risk
14674
+ * emitting `tailscale serve` against e.g. a Headscale daemon that
14675
+ * doesn't know that verb.
14412
14676
  */
14413
14677
  async requireClientJoined() {
14414
- if (!(this.ctx.capabilities?.getCollectionEntries("mesh-network") ?? []).find(([id]) => id === "tailscale-client")) throw new Error(`tailscale-ingress: tailscale-client provider not registered — install + start the @camstack/addon-tailscale-client addon first.`);
14415
- if (!(await this.fetchMeshStatus()).joined) throw new Error("tailscale-ingress: tailnet not joined — open the Mesh Networks page and click \"Connect to Tailscale\" first.");
14678
+ let status;
14679
+ try {
14680
+ status = await this.fetchMeshStatus({ addonId: "tailscale-client" });
14681
+ } catch (err) {
14682
+ throw new Error("tailscale-ingress: tailscale-client provider not registered — install + start the @camstack/addon-tailscale-client addon first.", { cause: err });
14683
+ }
14684
+ if (!status.joined) throw new Error("tailscale-ingress: tailnet not joined — open the Mesh Networks page and click \"Connect to Tailscale\" first.");
14685
+ return status;
14416
14686
  }
14417
- async fetchMeshStatus() {
14687
+ /**
14688
+ * Rebuild `this.cli` to drive the tailscale-client's daemon. When
14689
+ * the client runs in `onboard` mode it spawned a private
14690
+ * `tailscaled`; the mesh status then carries `daemonSocket` +
14691
+ * `daemonCliPath` and we MUST route serve/funnel through that
14692
+ * socket — the system daemon is a different node with a different
14693
+ * mesh identity. Host mode leaves both unset → default CLI.
14694
+ */
14695
+ syncCliToDaemon(status) {
14696
+ const cliLogger = this.ctx.logger.child("tailscale-cli");
14697
+ if (status.daemonSocket) {
14698
+ this.ctx.logger.info("start: routing CLI to onboard daemon", {
14699
+ meta: {
14700
+ daemonSocket: status.daemonSocket,
14701
+ daemonCliPath: status.daemonCliPath
14702
+ },
14703
+ tags: {
14704
+ topic: "tailscale-ingress",
14705
+ phase: "daemon-route"
14706
+ }
14707
+ });
14708
+ this.cli = new TailscaleCli({
14709
+ logger: cliLogger,
14710
+ socketPath: status.daemonSocket,
14711
+ ...status.daemonCliPath ? { binPath: status.daemonCliPath } : {}
14712
+ });
14713
+ } else this.cli = new TailscaleCli({ logger: cliLogger });
14714
+ }
14715
+ async fetchMeshStatus(filter) {
14418
14716
  const api = this.ctx.api;
14419
14717
  if (!api.meshNetwork?.getStatus?.query) throw new Error("tailscale-ingress: mesh-network cap not exposed on the api surface");
14420
- return await api.meshNetwork.getStatus.query();
14718
+ return await api.meshNetwork.getStatus.query(filter);
14421
14719
  }
14422
14720
  globalSettingsSchema() {
14423
14721
  return this.schema({ sections: [{
@@ -14457,6 +14755,27 @@ var TailscaleIngressAddon = class extends BaseAddon {
14457
14755
  description: "Public-side mount path. Leave empty for root.",
14458
14756
  default: DEFAULT_CONFIG.targetPath,
14459
14757
  placeholder: "/"
14758
+ }),
14759
+ this.field({
14760
+ type: "select",
14761
+ key: "upstreamScheme",
14762
+ label: "Upstream scheme",
14763
+ description: "How Tailscale reaches the local hub. The hub listens HTTPS with a self-signed cert by default — keep \"https+insecure\". Use \"http\" ONLY if the hub runs with tls.enabled: false (a plain \"http\" rule against the TLS listener returns 502).",
14764
+ default: DEFAULT_CONFIG.upstreamScheme,
14765
+ options: [
14766
+ {
14767
+ value: "https+insecure",
14768
+ label: "HTTPS, self-signed (default)"
14769
+ },
14770
+ {
14771
+ value: "https",
14772
+ label: "HTTPS, valid cert"
14773
+ },
14774
+ {
14775
+ value: "http",
14776
+ label: "HTTP (only if hub TLS disabled)"
14777
+ }
14778
+ ]
14460
14779
  })
14461
14780
  ]
14462
14781
  }] });