@rubytech/taskmaster 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,8 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-C12OTik-.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-D6WTmXJM.css">
9
+ <script type="module" crossorigin src="./assets/index-DeyIviHA.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-DSF8vENW.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -1,5 +1,5 @@
1
1
  import net from "node:net";
2
- import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
2
+ import { listTailnetAddresses, pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6, } from "../infra/tailnet.js";
3
3
  /**
4
4
  * Check if an IPv4 address belongs to a private (RFC 1918) or link-local range.
5
5
  * These addresses are non-routable on the public internet, so any request
@@ -25,6 +25,26 @@ function isPrivateIPv4(ip) {
25
25
  return true; // 169.254.0.0/16 (link-local)
26
26
  return false;
27
27
  }
28
+ /**
29
+ * Check if an IPv4 address is in the Tailscale CGNAT range (100.64.0.0/10)
30
+ * AND this device has a Tailscale interface. This identifies tailnet peers —
31
+ * other devices on the user's private Tailscale network.
32
+ */
33
+ function isTailnetPeerAddress(ip) {
34
+ const parts = ip.split(".");
35
+ if (parts.length !== 4)
36
+ return false;
37
+ const a = parseInt(parts[0], 10);
38
+ const b = parseInt(parts[1], 10);
39
+ if (Number.isNaN(a) || Number.isNaN(b))
40
+ return false;
41
+ // Tailscale CGNAT range: 100.64.0.0/10 (100.64.x.x – 100.127.x.x)
42
+ if (!(a === 100 && b >= 64 && b <= 127))
43
+ return false;
44
+ // Only treat as local if this device is itself on a tailnet
45
+ const addrs = listTailnetAddresses();
46
+ return addrs.ipv4.length > 0 || addrs.ipv6.length > 0;
47
+ }
28
48
  export function isLoopbackAddress(ip) {
29
49
  if (!ip)
30
50
  return false;
@@ -107,6 +127,14 @@ export function isLocalGatewayAddress(ip) {
107
127
  // These are non-routable on the public internet, so they're safe.
108
128
  if (isPrivateIPv4(normalized))
109
129
  return true;
130
+ // Tailscale CGNAT addresses (100.64.0.0/10) are tailnet peers — devices on
131
+ // the user's private Tailscale network. Treat them as local when this device
132
+ // is itself on a tailnet (has a Tailscale interface), so tailnet peers can
133
+ // access the control panel via Tailscale Serve. Public internet requests via
134
+ // Funnel carry real public IPs, not 100.x.x.x, so this doesn't weaken the
135
+ // Funnel restriction.
136
+ if (isTailnetPeerAddress(normalized))
137
+ return true;
110
138
  return false;
111
139
  }
112
140
  /**
@@ -146,12 +146,23 @@ export function createGatewayHttpServer(opts) {
146
146
  // on the port, peeks at the first byte of each connection to detect TLS vs
147
147
  // plain HTTP, and dispatches accordingly. Plain HTTP gets a 301 redirect to
148
148
  // HTTPS so bookmarks and muscle-memory URLs keep working.
149
+ //
150
+ // Exception: when a reverse proxy (e.g. Tailscale Serve) terminates TLS and
151
+ // forwards as plain HTTP with X-Forwarded-Proto: https, the request is
152
+ // handled normally — redirecting would create an infinite loop.
149
153
  let httpServer;
150
154
  if (opts.tlsOptions) {
151
155
  const httpsServer = createHttpsServer(opts.tlsOptions, (req, res) => {
152
156
  void handleRequest(req, res);
153
157
  });
154
158
  const redirectServer = createHttpServer((req, res) => {
159
+ // If a TLS-terminating reverse proxy (Tailscale Serve, nginx, etc.)
160
+ // forwarded this request, handle it normally instead of redirecting.
161
+ const forwardedProto = req.headers["x-forwarded-proto"];
162
+ if (forwardedProto === "https") {
163
+ void handleRequest(req, res);
164
+ return;
165
+ }
155
166
  const host = req.headers.host ?? "localhost";
156
167
  res.writeHead(301, { Location: `https://${host}${req.url ?? "/"}`, Connection: "close" });
157
168
  res.end();
@@ -171,6 +182,11 @@ export function createGatewayHttpServer(opts) {
171
182
  httpsServer.on("upgrade", (req, socket, head) => {
172
183
  tcpServer.emit("upgrade", req, socket, head);
173
184
  });
185
+ // Forward upgrade events from the redirect server too — reverse proxies
186
+ // (Tailscale Serve) send WebSocket upgrades as plain HTTP.
187
+ redirectServer.on("upgrade", (req, socket, head) => {
188
+ tcpServer.emit("upgrade", req, socket, head);
189
+ });
174
190
  httpServer = tcpServer;
175
191
  }
176
192
  else {
@@ -50,29 +50,47 @@ export const tailscaleHandlers = {
50
50
  }
51
51
  const cfg = loadConfig();
52
52
  const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
53
- // Check actual funnel state — config may say "funnel" but the command
54
- // may have failed (e.g. tailnet ACL doesn't allow funnel).
53
+ // Check actual serve/funnel state — config may say "serve" or "funnel"
54
+ // but the command may have failed (e.g. tailnet ACL doesn't allow funnel).
55
+ let serveActive = false;
55
56
  let funnelActive = false;
56
- if (tailscaleMode === "funnel" && loggedIn) {
57
+ if ((tailscaleMode === "serve" || tailscaleMode === "funnel") && loggedIn) {
57
58
  try {
58
- const { stdout } = await runExec(binary, ["funnel", "status"], {
59
+ const { stdout } = await runExec(binary, ["serve", "status"], {
59
60
  timeoutMs: 5_000,
60
61
  maxBuffer: 200_000,
61
62
  });
62
- // "No serve config" means funnel isn't actually proxying anything
63
- funnelActive = !!stdout.trim() && !stdout.includes("No serve config");
63
+ // "No serve config" means nothing is proxying
64
+ serveActive = !!stdout.trim() && !stdout.includes("No serve config");
64
65
  }
65
66
  catch {
66
- // Command failed — funnel not active
67
+ // Command failed — serve not active
68
+ }
69
+ if (tailscaleMode === "funnel" && serveActive) {
70
+ try {
71
+ const { stdout } = await runExec(binary, ["funnel", "status"], {
72
+ timeoutMs: 5_000,
73
+ maxBuffer: 200_000,
74
+ });
75
+ funnelActive = !!stdout.trim() && !stdout.includes("No serve config");
76
+ }
77
+ catch {
78
+ // Command failed — funnel not active
79
+ }
67
80
  }
68
81
  }
82
+ const serveEnabled = tailscaleMode === "serve" || tailscaleMode === "funnel";
69
83
  const funnelEnabled = tailscaleMode === "funnel";
84
+ const tailnetUrl = serveActive && hostname ? `https://${hostname}` : null;
70
85
  const publicUrl = funnelActive && hostname ? `https://${hostname}` : null;
71
86
  respond(true, {
72
87
  installed: true,
73
88
  running,
74
89
  loggedIn,
75
90
  hostname,
91
+ serveEnabled,
92
+ serveActive,
93
+ tailnetUrl,
76
94
  funnelEnabled,
77
95
  funnelActive,
78
96
  publicUrl,
@@ -138,6 +156,82 @@ export const tailscaleHandlers = {
138
156
  respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to start Tailscale login"));
139
157
  }
140
158
  },
159
+ /**
160
+ * Enable Tailscale Serve — makes the gateway reachable to all devices on
161
+ * the user's Tailscale network via a trusted HTTPS URL (Let's Encrypt cert
162
+ * provisioned by Tailscale for the *.ts.net domain).
163
+ */
164
+ "tailscale.serve.enable": async ({ respond, context }) => {
165
+ try {
166
+ const binary = await findTailscaleBinary();
167
+ if (!binary) {
168
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Tailscale is not installed"));
169
+ return;
170
+ }
171
+ // Verify Tailscale is logged in
172
+ try {
173
+ const status = await readTailscaleStatusJson();
174
+ if (status.BackendState !== "Running") {
175
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "Tailscale is not logged in — complete login first"));
176
+ return;
177
+ }
178
+ }
179
+ catch {
180
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Cannot reach Tailscale — is it running?"));
181
+ return;
182
+ }
183
+ // Pre-flight: attempt `tailscale serve` to verify it works
184
+ const cfg = loadConfig();
185
+ const port = cfg.gateway?.port ?? 18789;
186
+ try {
187
+ await runExec(binary, ["serve", "--bg", "--yes", `${port}`], {
188
+ timeoutMs: 15_000,
189
+ maxBuffer: 200_000,
190
+ });
191
+ }
192
+ catch (serveErr) {
193
+ const errObj = serveErr;
194
+ const combined = `${typeof errObj.stdout === "string" ? errObj.stdout : ""}\n${typeof errObj.stderr === "string" ? errObj.stderr : ""}`;
195
+ context.logGateway.warn(`tailscale.serve.enable pre-flight failed: ${combined.slice(0, 500)}`);
196
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `Serve command failed — ${combined.trim().split("\n")[0] || "unknown error"}`));
197
+ return;
198
+ }
199
+ // Serve is active. Patch config so it persists across restarts.
200
+ // No restart needed — loadConfig() re-reads from disk per request
201
+ // (200ms cache), so getEffectiveTrustedProxies picks up the new mode.
202
+ await patchTailscaleMode("serve", respond, context.logGateway, { skipRestart: true });
203
+ }
204
+ catch (err) {
205
+ context.logGateway.warn(`tailscale.serve.enable failed: ${err instanceof Error ? err.message : String(err)}`);
206
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to enable Serve"));
207
+ }
208
+ },
209
+ /**
210
+ * Disable Tailscale Serve by resetting the serve config and patching
211
+ * config to mode=off. If funnel was active, it is also disabled.
212
+ */
213
+ "tailscale.serve.disable": async ({ respond, context }) => {
214
+ try {
215
+ // Reset the actual tailscale serve config to stop proxying
216
+ const binary = await findTailscaleBinary();
217
+ if (binary) {
218
+ try {
219
+ await runExec(binary, ["serve", "reset"], {
220
+ timeoutMs: 15_000,
221
+ maxBuffer: 200_000,
222
+ });
223
+ }
224
+ catch {
225
+ // Best effort — continue with config patch
226
+ }
227
+ }
228
+ await patchTailscaleMode("off", respond, context.logGateway, { skipRestart: true });
229
+ }
230
+ catch (err) {
231
+ context.logGateway.warn(`tailscale.serve.disable failed: ${err instanceof Error ? err.message : String(err)}`);
232
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to disable Serve"));
233
+ }
234
+ },
141
235
  /**
142
236
  * Enable Tailscale Funnel by pre-flighting the `tailscale funnel` command,
143
237
  * then patching the config if it succeeds. The pre-flight catches the
@@ -192,7 +286,8 @@ export const tailscaleHandlers = {
192
286
  return;
193
287
  }
194
288
  // Funnel is active. Patch config so it persists across restarts.
195
- await patchTailscaleMode("funnel", respond, context.logGateway);
289
+ // No restart needed — loadConfig() re-reads from disk per request.
290
+ await patchTailscaleMode("funnel", respond, context.logGateway, { skipRestart: true });
196
291
  }
197
292
  catch (err) {
198
293
  context.logGateway.warn(`tailscale.funnel.enable failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -200,12 +295,25 @@ export const tailscaleHandlers = {
200
295
  }
201
296
  },
202
297
  /**
203
- * Disable Tailscale Funnel by patching config to mode=off.
204
- * Triggers gateway restart.
298
+ * Disable Tailscale Funnel by resetting the serve config (which also
299
+ * stops funnel) and patching config to mode=off. No restart needed —
300
+ * `tailscale serve reset` already stops the proxy.
205
301
  */
206
302
  "tailscale.funnel.disable": async ({ respond, context }) => {
207
303
  try {
208
- await patchTailscaleMode("off", respond, context.logGateway);
304
+ const binary = await findTailscaleBinary();
305
+ if (binary) {
306
+ try {
307
+ await runExec(binary, ["serve", "reset"], {
308
+ timeoutMs: 15_000,
309
+ maxBuffer: 200_000,
310
+ });
311
+ }
312
+ catch {
313
+ // Best effort — continue with config patch
314
+ }
315
+ }
316
+ await patchTailscaleMode("off", respond, context.logGateway, { skipRestart: true });
209
317
  }
210
318
  catch (err) {
211
319
  context.logGateway.warn(`tailscale.funnel.disable failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -214,9 +322,13 @@ export const tailscaleHandlers = {
214
322
  },
215
323
  };
216
324
  /**
217
- * Patch `gateway.tailscale.mode` in the config file and schedule a restart.
325
+ * Patch `gateway.tailscale.mode` in the config file and optionally restart.
326
+ *
327
+ * Disabling serve/funnel doesn't need a restart because `tailscale serve reset`
328
+ * already stops the reverse proxy — no traffic arrives via Tailscale afterward,
329
+ * so the stale trusted-proxy list is harmless until the next natural restart.
218
330
  */
219
- async function patchTailscaleMode(mode, respond, log) {
331
+ async function patchTailscaleMode(mode, respond, log, opts) {
220
332
  const snapshot = await readConfigFileSnapshot();
221
333
  if (!snapshot.valid) {
222
334
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "config file is invalid — fix before changing Tailscale settings"));
@@ -246,13 +358,16 @@ async function patchTailscaleMode(mode, respond, log) {
246
358
  catch {
247
359
  log.warn("tailscale: failed to write restart sentinel");
248
360
  }
249
- const restart = scheduleGatewaySigusr1Restart({
250
- reason: `tailscale.${mode === "off" ? "disable" : "enable"}`,
251
- });
361
+ let restart;
362
+ if (!opts?.skipRestart) {
363
+ restart = scheduleGatewaySigusr1Restart({
364
+ reason: `tailscale.${mode === "off" ? "disable" : "enable"}`,
365
+ });
366
+ }
252
367
  respond(true, {
253
368
  ok: true,
254
369
  mode,
255
- restart,
370
+ restart: restart ?? false,
256
371
  path: CONFIG_PATH_TASKMASTER,
257
372
  });
258
373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -226,6 +226,41 @@ You only need to do this once per browser. After accepting, the warning won't ap
226
226
  > ```
227
227
  > Enter your Mac password when prompted. After this, all browsers on that Mac will trust Taskmaster's certificate.
228
228
 
229
+ > **Recommended:** To avoid certificate warnings entirely, enable **Remote Access** (see below). Remote Access uses Tailscale to provide a trusted HTTPS certificate — no browser warnings on any device.
230
+
231
+ ---
232
+
233
+ ## Remote Access
234
+
235
+ Remote Access lets you reach the control panel from anywhere — your phone, another computer, or while away from home. It uses **Tailscale** to create a private, encrypted connection with a trusted HTTPS certificate (no browser warnings).
236
+
237
+ ### What You Need
238
+
239
+ - **Tailscale installed on the Taskmaster device** (the Raspberry Pi or Mac running Taskmaster)
240
+ - **Tailscale installed on every device** you want to access the control panel from (your phone, laptop, etc.)
241
+ - **All devices logged into the same Tailscale account**
242
+
243
+ Tailscale is free for personal use (up to 100 devices). Apps are available for iOS, Android, Mac, Windows, and Linux.
244
+
245
+ ### Enabling Remote Access
246
+
247
+ 1. Open the control panel setup page
248
+ 2. Find the **Remote Access** row in the status dashboard
249
+ 3. If Tailscale is not connected, click **Connect** — scan the QR code with your phone and sign in with your Google or Microsoft account
250
+ 4. Once connected, click **Enable**
251
+ 5. The control panel restarts. After reconnecting, you'll see a URL like `https://taskmaster.tail0e0afb.ts.net`
252
+ 6. **Bookmark this URL** on your other devices — it's your personal, trusted HTTPS address for the control panel
253
+
254
+ ### Accessing from Other Devices
255
+
256
+ 1. Install Tailscale on the device (phone, laptop, etc.)
257
+ 2. Log in with the same account you used on the Taskmaster device
258
+ 3. Open the bookmarked URL — no certificate warnings, just a normal secure connection
259
+
260
+ ### Disabling Remote Access
261
+
262
+ Click **Disable** on the Remote Access row. You'll be asked to confirm — click **Confirm** to proceed. The tailnet URL stops working immediately (no gateway restart needed).
263
+
229
264
  ---
230
265
 
231
266
  ## Two Assistants, One WhatsApp
@@ -1437,15 +1472,16 @@ By default, Taskmaster is only reachable on your local network. If you want peop
1437
1472
 
1438
1473
  This uses **Tailscale Funnel**, a free service that gives your device a public HTTPS address. Only the public chat is exposed; the control panel stays local-network-only.
1439
1474
 
1475
+ > **Note:** Internet Access exposes your public chat to the internet. If you only need to access the control panel from your own devices (phone, laptop), use **Remote Access** instead — it's private to your Tailscale network and doesn't expose anything publicly.
1476
+
1440
1477
  ### Enabling Internet Access
1441
1478
 
1442
1479
  1. Go to the **Setup** page (you must be logged in as admin)
1443
- 2. Find the **Internet Access** row in the status dashboard
1444
- 3. Click **Connect** a QR code appears
1445
- 4. Scan the QR code with your phone and sign in with your Google or Microsoft account (this creates a free Tailscale account)
1446
- 5. Wait a few seconds — the status changes to "Connected (LAN only)"
1447
- 6. Click **Enable** if this is your first time, you'll see a message asking you to enable Funnel on your Tailscale account. Click the link, toggle Funnel on, then come back and click Enable again
1448
- 7. Your public URL appears (e.g. `https://taskmaster.tail0e0afb.ts.net`)
1480
+ 2. Find the **Remote Access** row in the status dashboard
1481
+ 3. If Remote Access is not already enabled, enable it first (see above)
1482
+ 4. Once remote access is active, click **Enable internet access** on the same row
1483
+ 5. If this is your first time, you may see a message asking you to enable Funnel on your Tailscale account click the link, toggle Funnel on, then come back and try again
1484
+ 6. Your public URL appears (e.g. `https://taskmaster.tail0e0afb.ts.net`)
1449
1485
 
1450
1486
  ### What gets exposed
1451
1487
 
@@ -1455,11 +1491,11 @@ This uses **Tailscale Funnel**, a free service that gives your device a public H
1455
1491
 
1456
1492
  ### Disabling
1457
1493
 
1458
- Click **Disable** on the Internet Access row. The public URL stops working immediately. Your device stays connected to Tailscale but nothing is exposed.
1494
+ Click **Disable internet access** on the Remote Access row. The public URL stops working immediately. Remote access to the control panel remains active.
1459
1495
 
1460
- ### Copy your public URL
1496
+ ### Copy your URL
1461
1497
 
1462
- When Internet Access is active, click the copy icon next to the URL to copy it to your clipboard. You can share this URL or embed it on your website.
1498
+ When Remote Access is active, click the copy icon next to the URL to copy it. If Internet Access is also enabled, the same URL serves as your public URL share it or embed it on your website.
1463
1499
 
1464
1500
  ### Embedding the chat widget on your website
1465
1501
 
@@ -1475,7 +1511,7 @@ Once Internet Access is enabled, you can add a floating chat button to any websi
1475
1511
  </script>
1476
1512
  ```
1477
1513
 
1478
- Replace `YOUR-PUBLIC-URL` with the URL shown on the Internet Access row (e.g. `https://taskmaster.tail0e0afb.ts.net`) and `your-account-id` with your account name (e.g. `taskmaster`).
1514
+ Replace `YOUR-PUBLIC-URL` with the URL shown on the Remote Access row (e.g. `https://taskmaster.tail0e0afb.ts.net`) and `your-account-id` with your account name (e.g. `taskmaster`).
1479
1515
 
1480
1516
  A chat button appears in the bottom-right corner of the page. Clicking it opens a chat window where visitors can talk to your assistant — no WhatsApp required.
1481
1517