@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.
- package/dist/build-info.json +3 -3
- package/dist/control-ui/assets/index-DSF8vENW.css +1 -0
- package/dist/control-ui/assets/{index-C12OTik-.js → index-DeyIviHA.js} +756 -745
- package/dist/control-ui/assets/index-DeyIviHA.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/net.js +29 -1
- package/dist/gateway/server-http.js +16 -0
- package/dist/gateway/server-methods/tailscale.js +132 -17
- package/package.json +1 -1
- package/taskmaster-docs/USER-GUIDE.md +46 -10
- package/dist/control-ui/assets/index-C12OTik-.js.map +0 -1
- package/dist/control-ui/assets/index-D6WTmXJM.css +0 -1
|
@@ -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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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>
|
package/dist/gateway/net.js
CHANGED
|
@@ -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 "
|
|
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, ["
|
|
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
|
|
63
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
|
204
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
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
|
@@ -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 **
|
|
1444
|
-
3.
|
|
1445
|
-
4.
|
|
1446
|
-
5.
|
|
1447
|
-
6.
|
|
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
|
|
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
|
|
1496
|
+
### Copy your URL
|
|
1461
1497
|
|
|
1462
|
-
When
|
|
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
|
|
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
|
|