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