tmux-ide 1.2.1 → 2.0.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/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
// Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
|
|
2
|
+
|
|
3
|
+
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
|
|
4
|
+
import { log as logger } from "../log.ts";
|
|
5
|
+
import type { TunnelService, TunnelStatus } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
let spawnImpl: typeof nodeSpawn = nodeSpawn;
|
|
8
|
+
|
|
9
|
+
/** @internal Restore previous `spawn` with the returned function. */
|
|
10
|
+
export function _setSpawnForTesting(fn: typeof nodeSpawn): () => void {
|
|
11
|
+
const prev = spawnImpl;
|
|
12
|
+
spawnImpl = fn;
|
|
13
|
+
return () => {
|
|
14
|
+
spawnImpl = prev;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @internal When true, skip scanning known filesystem paths (unit tests use `which` + mocked spawn). */
|
|
19
|
+
let testSkipTailscaleFsPaths = false;
|
|
20
|
+
|
|
21
|
+
/** @internal */
|
|
22
|
+
export function _setTestSkipTailscaleFsPaths(v: boolean): void {
|
|
23
|
+
testSkipTailscaleFsPaths = v;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TailscaleServeService {
|
|
27
|
+
start(port: number, enableFunnel?: boolean): Promise<void>;
|
|
28
|
+
stop(): Promise<void>;
|
|
29
|
+
isRunning(): boolean;
|
|
30
|
+
isFunnelEnabled(): boolean;
|
|
31
|
+
getStatus(): Promise<TailscaleServeStatus>;
|
|
32
|
+
status(): Promise<TunnelStatus>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TailscaleServeStatus {
|
|
36
|
+
isRunning: boolean;
|
|
37
|
+
port?: number;
|
|
38
|
+
error?: string;
|
|
39
|
+
lastError?: string;
|
|
40
|
+
startTime?: Date;
|
|
41
|
+
isPermanentlyDisabled?: boolean;
|
|
42
|
+
funnelEnabled?: boolean;
|
|
43
|
+
funnelStartTime?: Date;
|
|
44
|
+
desiredMode?: "private" | "public"; // What the user requested
|
|
45
|
+
actualMode?: "private" | "public"; // What's actually running
|
|
46
|
+
funnelError?: string; // Specific Funnel error if it failed
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Service to manage Tailscale Serve as a background process
|
|
51
|
+
*/
|
|
52
|
+
export class TailscaleServeServiceImpl implements TailscaleServeService, TunnelService {
|
|
53
|
+
private serveProcess: ChildProcess | null = null;
|
|
54
|
+
private currentPort: number | null = null;
|
|
55
|
+
private isStarting = false;
|
|
56
|
+
private tailscaleExecutable = "tailscale"; // Default to PATH lookup
|
|
57
|
+
private lastError: string | undefined;
|
|
58
|
+
private startTime: Date | undefined;
|
|
59
|
+
private isPermanentlyDisabled = false;
|
|
60
|
+
private funnelEnabled = false;
|
|
61
|
+
private funnelStartTime: Date | undefined;
|
|
62
|
+
private desiredFunnel = false; // Track what the user requested
|
|
63
|
+
private funnelError: string | undefined; // Track specific Funnel errors
|
|
64
|
+
|
|
65
|
+
async start(port: number, enableFunnel = false): Promise<void> {
|
|
66
|
+
logger.info(
|
|
67
|
+
`🚀 Starting Tailscale Serve on port ${port} ${enableFunnel ? "with Funnel (Public Internet)" : "(Tailnet only - Private)"}`,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (this.isPermanentlyDisabled) {
|
|
71
|
+
logger.warn(`❌ Cannot start - permanently disabled on this tailnet`);
|
|
72
|
+
throw new Error("Tailscale Serve is permanently disabled on this tailnet");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.isStarting) {
|
|
76
|
+
logger.warn(`⚠️ Already starting, rejecting duplicate request`);
|
|
77
|
+
throw new Error("Tailscale Serve is already starting");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.serveProcess) {
|
|
81
|
+
logger.info("🔄 Serve already running, stopping first...");
|
|
82
|
+
await this.stop();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.isStarting = true;
|
|
86
|
+
this.lastError = undefined; // Clear previous errors
|
|
87
|
+
this.funnelError = undefined; // Clear previous Funnel errors
|
|
88
|
+
this.currentPort = port; // Set the port even if start fails
|
|
89
|
+
|
|
90
|
+
// Store what the user requested
|
|
91
|
+
this.desiredFunnel = enableFunnel;
|
|
92
|
+
logger.info(
|
|
93
|
+
`🌍 ${enableFunnel ? "PUBLIC Internet access (Funnel)" : "PRIVATE Tailnet-only access (no Funnel)"} requested`,
|
|
94
|
+
);
|
|
95
|
+
this.funnelEnabled = false; // Reset initially, will enable after Serve starts
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Check if tailscale command is available
|
|
99
|
+
await this.checkTailscaleAvailable();
|
|
100
|
+
|
|
101
|
+
// First, reset any existing serve configuration
|
|
102
|
+
try {
|
|
103
|
+
logger.debug("Resetting Tailscale Serve configuration...");
|
|
104
|
+
const resetProcess = spawnImpl(this.tailscaleExecutable, ["serve", "reset"], {
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await new Promise<void>((resolve) => {
|
|
109
|
+
resetProcess.on("exit", () => resolve());
|
|
110
|
+
resetProcess.on("error", () => resolve()); // Continue even if reset fails
|
|
111
|
+
setTimeout(resolve, 1000); // Timeout after 1 second
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
logger.debug("Failed to reset serve config (this is normal if none exists)");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Set up HTTPS proxy to local port using new CLI syntax
|
|
118
|
+
// Format: tailscale serve --bg http://localhost:4020
|
|
119
|
+
const args = ["serve", "--bg", `http://localhost:${port}`];
|
|
120
|
+
logger.info(`🚀 Starting Tailscale Serve - HTTPS proxy to localhost:${port}`);
|
|
121
|
+
logger.debug(`🔧 Command: ${this.tailscaleExecutable} ${args.join(" ")}`);
|
|
122
|
+
this.currentPort = port;
|
|
123
|
+
|
|
124
|
+
// Start the serve process
|
|
125
|
+
this.serveProcess = spawnImpl(this.tailscaleExecutable, args, {
|
|
126
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
127
|
+
detached: false, // Keep it attached to our process
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Handle process events
|
|
131
|
+
this.serveProcess.on("error", (error) => {
|
|
132
|
+
logger.info(`Tailscale Serve process error: ${error.message}`);
|
|
133
|
+
this.lastError = error.message;
|
|
134
|
+
this.cleanup();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
this.serveProcess.on("exit", (code, signal) => {
|
|
138
|
+
logger.info(`Tailscale Serve process exited with code ${code}, signal ${signal}`);
|
|
139
|
+
if (code !== 0) {
|
|
140
|
+
// Check if this is the common "Serve not enabled" error
|
|
141
|
+
if (this.lastError?.includes("Serve is not enabled on your tailnet")) {
|
|
142
|
+
// Keep the more user-friendly error message we set in stderr handler
|
|
143
|
+
logger.info("Tailscale Serve failed due to tailnet permissions");
|
|
144
|
+
} else {
|
|
145
|
+
this.lastError = `Process exited with code ${code}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
this.cleanup();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Log stdout/stderr
|
|
152
|
+
if (this.serveProcess.stdout) {
|
|
153
|
+
this.serveProcess.stdout.on("data", (data) => {
|
|
154
|
+
logger.debug(`Tailscale Serve stdout: ${data.toString().trim()}`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (this.serveProcess.stderr) {
|
|
159
|
+
this.serveProcess.stderr.on("data", (data) => {
|
|
160
|
+
const stderr = data.toString().trim();
|
|
161
|
+
logger.debug(`Tailscale Serve stderr: ${stderr}`);
|
|
162
|
+
|
|
163
|
+
// Handle specific "Serve not enabled on tailnet" error
|
|
164
|
+
if (stderr.includes("Serve is not enabled on your tailnet")) {
|
|
165
|
+
logger.warn(
|
|
166
|
+
"Tailscale Serve is not enabled on this tailnet - marking as permanently disabled",
|
|
167
|
+
);
|
|
168
|
+
this.lastError = "Tailscale Serve feature not enabled on your tailnet";
|
|
169
|
+
this.isPermanentlyDisabled = true;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Capture other common error patterns
|
|
174
|
+
if (stderr.includes("error") || stderr.includes("failed")) {
|
|
175
|
+
this.lastError = stderr;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Wait a moment to see if it starts successfully
|
|
181
|
+
await new Promise<void>((resolve, reject) => {
|
|
182
|
+
let settled = false;
|
|
183
|
+
|
|
184
|
+
const settlePromise = (isSuccess: boolean, error?: Error | string) => {
|
|
185
|
+
if (settled) return;
|
|
186
|
+
settled = true;
|
|
187
|
+
clearTimeout(timeout);
|
|
188
|
+
|
|
189
|
+
if (isSuccess) {
|
|
190
|
+
logger.info("Tailscale Serve started successfully");
|
|
191
|
+
this.startTime = new Date();
|
|
192
|
+
resolve();
|
|
193
|
+
} else {
|
|
194
|
+
const errorMessage =
|
|
195
|
+
error instanceof Error ? error.message : error || "Tailscale Serve failed to start";
|
|
196
|
+
this.lastError = errorMessage;
|
|
197
|
+
reject(new Error(errorMessage));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const timeout = setTimeout(() => {
|
|
202
|
+
if (this.serveProcess && !this.serveProcess.killed) {
|
|
203
|
+
settlePromise(true);
|
|
204
|
+
} else {
|
|
205
|
+
settlePromise(false, this.lastError);
|
|
206
|
+
}
|
|
207
|
+
}, 3000); // Wait 3 seconds
|
|
208
|
+
|
|
209
|
+
if (this.serveProcess) {
|
|
210
|
+
this.serveProcess.once("error", (error) => {
|
|
211
|
+
settlePromise(false, error);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
this.serveProcess.once("exit", (code) => {
|
|
215
|
+
// With --bg flag, the command exits immediately after configuration
|
|
216
|
+
// Exit code 0 means successful configuration
|
|
217
|
+
if (code === 0) {
|
|
218
|
+
logger.info("Tailscale Serve configured successfully (exit code 0)");
|
|
219
|
+
// The serve process is now running in background
|
|
220
|
+
this.serveProcess = null; // Clear reference as process has exited
|
|
221
|
+
// Give the configuration a moment to take effect
|
|
222
|
+
setTimeout(() => {
|
|
223
|
+
settlePromise(true); // SUCCESS - proxy is configured
|
|
224
|
+
}, 500);
|
|
225
|
+
} else {
|
|
226
|
+
settlePromise(false, `Tailscale Serve failed with exit code ${code}`);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// If Funnel is requested (Public Internet access), start Funnel after Serve
|
|
233
|
+
if (enableFunnel) {
|
|
234
|
+
logger.info(
|
|
235
|
+
`🌍 Starting Funnel for PUBLIC internet access on HTTPS port 443 (Serve to localhost:${port} completed successfully)`,
|
|
236
|
+
);
|
|
237
|
+
try {
|
|
238
|
+
await this.startFunnel(port);
|
|
239
|
+
logger.info(
|
|
240
|
+
`✅ Funnel started successfully - VibeTunnel now accessible via public internet`,
|
|
241
|
+
);
|
|
242
|
+
} catch (funnelError) {
|
|
243
|
+
const errorMsg = funnelError instanceof Error ? funnelError.message : String(funnelError);
|
|
244
|
+
this.funnelError = errorMsg;
|
|
245
|
+
logger.info(`❌ Failed to start Funnel: ${errorMsg}`);
|
|
246
|
+
logger.warn(
|
|
247
|
+
`Funnel failed to start, continuing with PRIVATE Tailnet-only access: ${errorMsg}`,
|
|
248
|
+
);
|
|
249
|
+
// Don't throw - Serve is still working for Tailnet access
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
logger.info(
|
|
253
|
+
`🔒 Running in PRIVATE mode - accessible only within your Tailnet, not from public internet`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
258
|
+
this.cleanup();
|
|
259
|
+
throw error;
|
|
260
|
+
} finally {
|
|
261
|
+
this.isStarting = false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Start Tailscale Funnel for public internet access
|
|
267
|
+
*/
|
|
268
|
+
private async startFunnel(port: number): Promise<void> {
|
|
269
|
+
// Funnel operates on the HTTPS port that Tailscale Serve uses (443), not the local port
|
|
270
|
+
const httpsPort = 443;
|
|
271
|
+
logger.info(
|
|
272
|
+
`🌍 Starting Tailscale Funnel on HTTPS port ${httpsPort} for public internet access (proxying to local port ${port})`,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// First, reset any existing Funnel configuration to avoid "foreground already exists" error
|
|
276
|
+
try {
|
|
277
|
+
logger.debug("Resetting Funnel configuration before starting...");
|
|
278
|
+
const resetProcess = spawnImpl(this.tailscaleExecutable, ["funnel", "reset"], {
|
|
279
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await new Promise<void>((resolve) => {
|
|
283
|
+
resetProcess.on("exit", () => resolve());
|
|
284
|
+
resetProcess.on("error", () => resolve()); // Continue even if reset fails
|
|
285
|
+
setTimeout(resolve, 2000); // Timeout after 2 seconds
|
|
286
|
+
});
|
|
287
|
+
logger.debug("Funnel configuration reset completed");
|
|
288
|
+
} catch {
|
|
289
|
+
logger.debug("Failed to reset Funnel config (this is normal if none exists)");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
logger.debug(
|
|
293
|
+
`🔧 Command: ${this.tailscaleExecutable} funnel --bg --https=${httpsPort} http://localhost:${port}`,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
// Enable Funnel with the correct proxy target
|
|
298
|
+
// Must specify the full target URL to avoid overriding the proxy destination
|
|
299
|
+
const funnelProcess = spawnImpl(
|
|
300
|
+
this.tailscaleExecutable,
|
|
301
|
+
["funnel", "--bg", `--https=${httpsPort}`, `http://localhost:${port}`],
|
|
302
|
+
{
|
|
303
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
logger.debug(`📝 Process spawned with PID: ${funnelProcess.pid}`);
|
|
308
|
+
|
|
309
|
+
let stdout = "";
|
|
310
|
+
let stderr = "";
|
|
311
|
+
|
|
312
|
+
if (funnelProcess.stdout) {
|
|
313
|
+
funnelProcess.stdout.on("data", (data) => {
|
|
314
|
+
stdout += data.toString();
|
|
315
|
+
logger.debug(`📤 Funnel stdout: ${data.toString().trim()}`);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (funnelProcess.stderr) {
|
|
320
|
+
funnelProcess.stderr.on("data", (data) => {
|
|
321
|
+
stderr += data.toString();
|
|
322
|
+
logger.debug(`📥 Funnel stderr: ${data.toString().trim()}`);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Wait for funnel to start
|
|
327
|
+
await new Promise<void>((resolve, reject) => {
|
|
328
|
+
const timeout = setTimeout(() => {
|
|
329
|
+
reject(new Error("Funnel start timeout"));
|
|
330
|
+
}, 10000); // 10 second timeout
|
|
331
|
+
|
|
332
|
+
funnelProcess.on("exit", (code) => {
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
logger.debug(`🔚 Funnel process exited with code: ${code}`);
|
|
335
|
+
if (stderr.trim()) {
|
|
336
|
+
logger.debug(`📥 Funnel stderr: ${stderr.trim()}`);
|
|
337
|
+
}
|
|
338
|
+
if (stdout.trim()) {
|
|
339
|
+
logger.debug(`📤 Funnel stdout: ${stdout.trim()}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (code === 0) {
|
|
343
|
+
logger.info("✅ Tailscale Funnel started successfully");
|
|
344
|
+
this.funnelEnabled = true;
|
|
345
|
+
this.funnelError = undefined; // Clear any previous Funnel errors
|
|
346
|
+
this.funnelStartTime = new Date();
|
|
347
|
+
resolve();
|
|
348
|
+
} else {
|
|
349
|
+
logger.info(`❌ Funnel failed with exit code ${code}: ${stderr || "No error message"}`);
|
|
350
|
+
reject(new Error(`Funnel failed with exit code ${code}: ${stderr}`));
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
funnelProcess.on("error", (error) => {
|
|
355
|
+
clearTimeout(timeout);
|
|
356
|
+
reject(error);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
} catch (error) {
|
|
360
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
361
|
+
logger.info(`Failed to start Tailscale Funnel: ${errorMsg}`);
|
|
362
|
+
throw new Error(`Funnel start failed: ${errorMsg}`, { cause: error });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Stop Tailscale Funnel
|
|
368
|
+
*/
|
|
369
|
+
private async stopFunnel(): Promise<void> {
|
|
370
|
+
if (!this.funnelEnabled) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
logger.info("Stopping Tailscale Funnel...");
|
|
376
|
+
|
|
377
|
+
// Reset funnel configuration
|
|
378
|
+
const resetProcess = spawnImpl(this.tailscaleExecutable, ["funnel", "reset"], {
|
|
379
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await new Promise<void>((resolve) => {
|
|
383
|
+
resetProcess.on("exit", (code) => {
|
|
384
|
+
if (code === 0) {
|
|
385
|
+
logger.info("✅ Tailscale Funnel stopped successfully");
|
|
386
|
+
} else {
|
|
387
|
+
logger.warn(`Funnel reset exited with code ${code}`);
|
|
388
|
+
}
|
|
389
|
+
resolve();
|
|
390
|
+
});
|
|
391
|
+
resetProcess.on("error", () => resolve());
|
|
392
|
+
setTimeout(resolve, 3000); // Timeout after 3 seconds
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
this.funnelEnabled = false;
|
|
396
|
+
this.funnelStartTime = undefined;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
logger.info(`Failed to stop Funnel: ${error}`);
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async stop(): Promise<void> {
|
|
404
|
+
// First, stop Funnel if it's enabled
|
|
405
|
+
if (this.funnelEnabled) {
|
|
406
|
+
try {
|
|
407
|
+
logger.info("🌍 Stopping Tailscale Funnel...");
|
|
408
|
+
await this.stopFunnel();
|
|
409
|
+
} catch (error) {
|
|
410
|
+
logger.warn(`Failed to stop Funnel: ${error}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Reset Serve configuration (since we're using --bg, there's no process to kill)
|
|
415
|
+
try {
|
|
416
|
+
logger.debug("Removing Tailscale Serve configuration...");
|
|
417
|
+
|
|
418
|
+
// Use 'reset' to completely clear all serve configuration
|
|
419
|
+
const resetProcess = spawnImpl(this.tailscaleExecutable, ["serve", "reset"], {
|
|
420
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await new Promise<void>((resolve) => {
|
|
424
|
+
resetProcess.on("exit", (code) => {
|
|
425
|
+
if (code === 0) {
|
|
426
|
+
logger.debug("Tailscale Serve configuration reset successfully");
|
|
427
|
+
}
|
|
428
|
+
resolve();
|
|
429
|
+
});
|
|
430
|
+
resetProcess.on("error", () => resolve());
|
|
431
|
+
setTimeout(resolve, 2000); // Timeout after 2 seconds
|
|
432
|
+
});
|
|
433
|
+
} catch {
|
|
434
|
+
logger.debug("Failed to reset serve config during stop");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!this.serveProcess) {
|
|
438
|
+
logger.debug("No Tailscale Serve process to stop");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
logger.info("Stopping Tailscale Serve process...");
|
|
443
|
+
|
|
444
|
+
return new Promise<void>((resolve) => {
|
|
445
|
+
if (!this.serveProcess) {
|
|
446
|
+
resolve();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const cleanup = () => {
|
|
451
|
+
this.cleanup();
|
|
452
|
+
resolve();
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// Set a timeout to force kill if graceful shutdown fails
|
|
456
|
+
const forceKillTimeout = setTimeout(() => {
|
|
457
|
+
if (this.serveProcess && !this.serveProcess.killed) {
|
|
458
|
+
logger.warn("Force killing Tailscale Serve process");
|
|
459
|
+
this.serveProcess.kill("SIGKILL");
|
|
460
|
+
}
|
|
461
|
+
cleanup();
|
|
462
|
+
}, 5000);
|
|
463
|
+
|
|
464
|
+
this.serveProcess.once("exit", () => {
|
|
465
|
+
clearTimeout(forceKillTimeout);
|
|
466
|
+
cleanup();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Try graceful shutdown first
|
|
470
|
+
this.serveProcess.kill("SIGTERM");
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
isRunning(): boolean {
|
|
475
|
+
// With --bg flag, the serve process exits immediately after configuration
|
|
476
|
+
// We track running state based on whether we've started and not stopped
|
|
477
|
+
return this.currentPort !== null && !this.isStarting;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
isFunnelEnabled(): boolean {
|
|
481
|
+
return this.funnelEnabled;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async getStatus(): Promise<TailscaleServeStatus> {
|
|
485
|
+
logger.debug("[TAILSCALE STATUS] Getting status", {
|
|
486
|
+
isPermanentlyDisabled: this.isPermanentlyDisabled,
|
|
487
|
+
lastError: this.lastError,
|
|
488
|
+
processActive: !!this.serveProcess,
|
|
489
|
+
currentPort: this.currentPort,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Debug mode: simulate errors based on environment variable
|
|
493
|
+
if (process.env.VIBETUNNEL_TAILSCALE_ERROR) {
|
|
494
|
+
return {
|
|
495
|
+
isRunning: false,
|
|
496
|
+
lastError: process.env.VIBETUNNEL_TAILSCALE_ERROR,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// IMPROVED CHECK: First verify if Tailscale Serve is actually configured and working
|
|
501
|
+
// Only mark as permanently disabled if we can't detect any working configuration
|
|
502
|
+
if (!this.isPermanentlyDisabled) {
|
|
503
|
+
logger.debug("[TAILSCALE STATUS] Checking actual Tailscale Serve configuration");
|
|
504
|
+
|
|
505
|
+
const portToCheck = this.currentPort || 4020;
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
// First, check if Serve is actually configured for our port (even if started manually)
|
|
509
|
+
const isConfigured = await this.verifyServeConfiguration(portToCheck);
|
|
510
|
+
logger.debug(
|
|
511
|
+
`[TAILSCALE STATUS] Serve configured for port ${portToCheck}: ${isConfigured}`,
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
if (isConfigured) {
|
|
515
|
+
// Serve is working! Don't mark as permanently disabled
|
|
516
|
+
logger.info(
|
|
517
|
+
`✅ [TAILSCALE STATUS] Tailscale Serve is configured for port ${portToCheck} - not permanently disabled`,
|
|
518
|
+
);
|
|
519
|
+
// Continue with normal status logic below
|
|
520
|
+
} else {
|
|
521
|
+
// No configuration found, check availability to determine if it's a permanent issue
|
|
522
|
+
const checkResult = await this.checkServeAvailability();
|
|
523
|
+
logger.debug(`[TAILSCALE STATUS] Serve availability check result: ${checkResult}`);
|
|
524
|
+
|
|
525
|
+
if (
|
|
526
|
+
checkResult.includes("Serve is not enabled") ||
|
|
527
|
+
checkResult.includes("not available") ||
|
|
528
|
+
checkResult.includes("requires admin") ||
|
|
529
|
+
checkResult.includes("unauthorized")
|
|
530
|
+
) {
|
|
531
|
+
logger.debug(
|
|
532
|
+
"[TAILSCALE STATUS] Tailscale Serve not available on tailnet - marking as permanently disabled",
|
|
533
|
+
);
|
|
534
|
+
this.isPermanentlyDisabled = true;
|
|
535
|
+
this.lastError = "Serve is not enabled on your tailnet";
|
|
536
|
+
|
|
537
|
+
// Return success (fallback mode)
|
|
538
|
+
return {
|
|
539
|
+
isRunning: false,
|
|
540
|
+
port: undefined,
|
|
541
|
+
lastError: undefined, // No error in fallback mode
|
|
542
|
+
startTime: this.startTime,
|
|
543
|
+
isPermanentlyDisabled: true,
|
|
544
|
+
funnelEnabled: false,
|
|
545
|
+
funnelStartTime: undefined,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
logger.debug(`[TAILSCALE STATUS] Failed to check configuration: ${error}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// If we're permanently disabled, return that status without error
|
|
555
|
+
// This is the expected fallback mode when admin permissions aren't available
|
|
556
|
+
if (this.isPermanentlyDisabled) {
|
|
557
|
+
logger.info("[TAILSCALE STATUS] Returning permanently disabled status (no error)");
|
|
558
|
+
return {
|
|
559
|
+
isRunning: false,
|
|
560
|
+
port: undefined,
|
|
561
|
+
// Don't report an error - fallback mode is working fine
|
|
562
|
+
lastError: undefined,
|
|
563
|
+
startTime: this.startTime,
|
|
564
|
+
isPermanentlyDisabled: true,
|
|
565
|
+
funnelEnabled: false,
|
|
566
|
+
funnelStartTime: undefined,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Check if the serve process is running
|
|
571
|
+
const processRunning = this.isRunning();
|
|
572
|
+
logger.info(`[TAILSCALE STATUS] Process running: ${processRunning}`);
|
|
573
|
+
|
|
574
|
+
// If not running and we have a permanent error, we're in fallback mode
|
|
575
|
+
if (!processRunning && this.lastError?.includes("Serve is not enabled on your tailnet")) {
|
|
576
|
+
logger.info("[TAILSCALE STATUS] Detected permanent failure, switching to fallback mode");
|
|
577
|
+
// Mark as permanently disabled and return without error
|
|
578
|
+
this.isPermanentlyDisabled = true;
|
|
579
|
+
return {
|
|
580
|
+
isRunning: false,
|
|
581
|
+
port: undefined,
|
|
582
|
+
lastError: undefined, // Don't show error in fallback mode
|
|
583
|
+
startTime: this.startTime,
|
|
584
|
+
isPermanentlyDisabled: true,
|
|
585
|
+
funnelEnabled: false,
|
|
586
|
+
funnelStartTime: undefined,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Always verify if Tailscale Serve is available, even if process isn't running
|
|
591
|
+
// This helps detect permanent failures when the process never starts
|
|
592
|
+
let actuallyRunning = processRunning;
|
|
593
|
+
let verificationError: string | undefined;
|
|
594
|
+
const portToCheck = this.currentPort || 4020; // Use default port if not set
|
|
595
|
+
|
|
596
|
+
if (!processRunning && !this.isPermanentlyDisabled) {
|
|
597
|
+
// Process isn't running - check if Tailscale Serve is configured (manual start)
|
|
598
|
+
logger.info(
|
|
599
|
+
`[TAILSCALE STATUS] Process not running, checking for manually configured Tailscale Serve`,
|
|
600
|
+
);
|
|
601
|
+
try {
|
|
602
|
+
const isConfigured = await this.verifyServeConfiguration(portToCheck);
|
|
603
|
+
logger.info(`[TAILSCALE STATUS] Manual configuration check: ${isConfigured}`);
|
|
604
|
+
|
|
605
|
+
if (isConfigured) {
|
|
606
|
+
// Great! Serve is configured manually, mark as running
|
|
607
|
+
actuallyRunning = true;
|
|
608
|
+
logger.info(
|
|
609
|
+
`✅ [TAILSCALE STATUS] Found manually configured Tailscale Serve for port ${portToCheck}`,
|
|
610
|
+
);
|
|
611
|
+
} else {
|
|
612
|
+
// No configuration found - this is a normal case, not an error
|
|
613
|
+
verificationError = "Tailscale Serve is starting up or needs reconfiguration";
|
|
614
|
+
logger.info(
|
|
615
|
+
`[TAILSCALE STATUS] No Serve configuration found for port ${portToCheck} - may need restart`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
} catch (error) {
|
|
619
|
+
logger.debug(`Failed to check Tailscale Serve configuration: ${error}`);
|
|
620
|
+
}
|
|
621
|
+
} else if (processRunning && this.currentPort) {
|
|
622
|
+
logger.info(`[TAILSCALE STATUS] Verifying configuration for port ${this.currentPort}`);
|
|
623
|
+
try {
|
|
624
|
+
const isConfigured = await this.verifyServeConfiguration(this.currentPort);
|
|
625
|
+
logger.info(`[TAILSCALE STATUS] Configuration verified: ${isConfigured}`);
|
|
626
|
+
if (!isConfigured) {
|
|
627
|
+
actuallyRunning = false;
|
|
628
|
+
// Process is running but not configured properly - this is a normal configuration error, not permanent
|
|
629
|
+
verificationError = "Tailscale Serve proxy not configured for this port";
|
|
630
|
+
logger.info(
|
|
631
|
+
"[TAILSCALE STATUS] Process running but not configured - normal configuration issue",
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
} catch (error) {
|
|
635
|
+
logger.debug(`Failed to verify Tailscale Serve configuration: ${error}`);
|
|
636
|
+
// Don't report verification errors as user-facing errors
|
|
637
|
+
actuallyRunning = false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Check if modes match - if they do, consider it running even if process check fails
|
|
642
|
+
// This handles the case where --bg makes the process exit immediately
|
|
643
|
+
const desiredMode = this.desiredFunnel ? "public" : "private";
|
|
644
|
+
const actualMode = this.funnelEnabled ? "public" : "private";
|
|
645
|
+
const modesMatch = desiredMode === actualMode;
|
|
646
|
+
|
|
647
|
+
// If desired and actual modes match, and we have a port configured, consider it running
|
|
648
|
+
// This is important because with --bg flag, the process exits immediately after configuration
|
|
649
|
+
// Also, if we're in public mode and Funnel has been enabled, trust that it's working
|
|
650
|
+
const effectivelyRunning =
|
|
651
|
+
actuallyRunning ||
|
|
652
|
+
(modesMatch && this.currentPort !== null && !this.isStarting) ||
|
|
653
|
+
(this.funnelEnabled && actualMode === "public");
|
|
654
|
+
|
|
655
|
+
const result: TailscaleServeStatus = {
|
|
656
|
+
isRunning: effectivelyRunning,
|
|
657
|
+
port: effectivelyRunning ? (this.currentPort ?? undefined) : undefined,
|
|
658
|
+
// Don't show error if modes match and we're configured
|
|
659
|
+
lastError: effectivelyRunning || modesMatch ? undefined : verificationError || this.lastError,
|
|
660
|
+
startTime: this.startTime,
|
|
661
|
+
isPermanentlyDisabled: this.isPermanentlyDisabled,
|
|
662
|
+
funnelEnabled: this.funnelEnabled,
|
|
663
|
+
funnelStartTime: this.funnelStartTime,
|
|
664
|
+
desiredMode: desiredMode,
|
|
665
|
+
actualMode: actualMode,
|
|
666
|
+
funnelError: this.funnelError,
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
logger.info("[TAILSCALE STATUS] Returning status:");
|
|
670
|
+
logger.info(` - isRunning: ${result.isRunning}`);
|
|
671
|
+
logger.info(` - lastError: ${result.lastError}`);
|
|
672
|
+
logger.info(` - isPermanentlyDisabled: ${result.isPermanentlyDisabled}`);
|
|
673
|
+
logger.info(` - desiredMode: ${result.desiredMode}`);
|
|
674
|
+
logger.info(` - actualMode: ${result.actualMode}`);
|
|
675
|
+
logger.info(` - funnelError: ${result.funnelError}`);
|
|
676
|
+
|
|
677
|
+
return result;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async status(): Promise<TunnelStatus> {
|
|
681
|
+
const s = await this.getStatus();
|
|
682
|
+
return {
|
|
683
|
+
running: s.isRunning,
|
|
684
|
+
port: s.port,
|
|
685
|
+
lastError: s.lastError,
|
|
686
|
+
meta: {
|
|
687
|
+
isPermanentlyDisabled: s.isPermanentlyDisabled,
|
|
688
|
+
funnelEnabled: s.funnelEnabled,
|
|
689
|
+
funnelStartTime: s.funnelStartTime,
|
|
690
|
+
desiredMode: s.desiredMode,
|
|
691
|
+
actualMode: s.actualMode,
|
|
692
|
+
funnelError: s.funnelError,
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Check if Tailscale Serve is available on this tailnet
|
|
699
|
+
*/
|
|
700
|
+
private async checkServeAvailability(): Promise<string> {
|
|
701
|
+
return new Promise<string>((resolve) => {
|
|
702
|
+
// Try JSON first, fallback to regular status if needed
|
|
703
|
+
const statusProcess = spawnImpl(this.tailscaleExecutable, ["serve", "status", "--json"], {
|
|
704
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
let stdout = "";
|
|
708
|
+
let stderr = "";
|
|
709
|
+
|
|
710
|
+
if (statusProcess.stdout) {
|
|
711
|
+
statusProcess.stdout.on("data", (data) => {
|
|
712
|
+
stdout += data.toString();
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (statusProcess.stderr) {
|
|
717
|
+
statusProcess.stderr.on("data", (data) => {
|
|
718
|
+
stderr += data.toString();
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
statusProcess.on("exit", (code) => {
|
|
723
|
+
// Return stderr if it contains error messages
|
|
724
|
+
if (stderr) {
|
|
725
|
+
resolve(stderr);
|
|
726
|
+
} else if (code === 0) {
|
|
727
|
+
// Success - check if we have valid JSON or just return stdout
|
|
728
|
+
try {
|
|
729
|
+
JSON.parse(stdout);
|
|
730
|
+
// If we have valid JSON, we can serve
|
|
731
|
+
resolve(stdout);
|
|
732
|
+
} catch {
|
|
733
|
+
// Not valid JSON, return as-is
|
|
734
|
+
resolve(stdout);
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
resolve(stdout || "Tailscale Serve status check failed");
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
statusProcess.on("error", (error) => {
|
|
742
|
+
resolve(error.message);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Timeout after 3 seconds
|
|
746
|
+
setTimeout(() => {
|
|
747
|
+
if (!statusProcess.killed) {
|
|
748
|
+
statusProcess.kill("SIGTERM");
|
|
749
|
+
resolve("Timeout checking Tailscale Serve availability");
|
|
750
|
+
}
|
|
751
|
+
}, 3000);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Verify that Tailscale Serve is actually configured for the given port
|
|
757
|
+
*/
|
|
758
|
+
private async verifyServeConfiguration(port: number): Promise<boolean> {
|
|
759
|
+
return new Promise<boolean>((resolve) => {
|
|
760
|
+
// Use --json flag for reliable parsing
|
|
761
|
+
const statusProcess = spawnImpl(this.tailscaleExecutable, ["serve", "status", "--json"], {
|
|
762
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
let stdout = "";
|
|
766
|
+
let stderr = "";
|
|
767
|
+
|
|
768
|
+
if (statusProcess.stdout) {
|
|
769
|
+
statusProcess.stdout.on("data", (data) => {
|
|
770
|
+
stdout += data.toString();
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (statusProcess.stderr) {
|
|
775
|
+
statusProcess.stderr.on("data", (data) => {
|
|
776
|
+
stderr += data.toString();
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
statusProcess.on("exit", (code) => {
|
|
781
|
+
if (code === 0) {
|
|
782
|
+
// First try JSON parsing, fallback to text parsing if needed
|
|
783
|
+
try {
|
|
784
|
+
const status = JSON.parse(stdout);
|
|
785
|
+
const isConfigured = this.parseServeStatusJson(status, port);
|
|
786
|
+
logger.debug(
|
|
787
|
+
`Tailscale Serve JSON status check: port ${port} configured = ${isConfigured}`,
|
|
788
|
+
);
|
|
789
|
+
resolve(isConfigured);
|
|
790
|
+
} catch {
|
|
791
|
+
logger.debug("JSON parsing failed, trying text parsing as fallback");
|
|
792
|
+
// Fallback to text parsing if JSON fails
|
|
793
|
+
const isConfigured = this.parseServeStatus(stdout, port);
|
|
794
|
+
logger.debug(
|
|
795
|
+
`Tailscale Serve text status check: port ${port} configured = ${isConfigured}`,
|
|
796
|
+
);
|
|
797
|
+
resolve(isConfigured);
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
logger.debug(`Tailscale serve status failed with code ${code}: ${stderr}`);
|
|
801
|
+
resolve(false);
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
statusProcess.on("error", (error) => {
|
|
806
|
+
logger.debug(`Failed to run tailscale serve status: ${error.message}`);
|
|
807
|
+
resolve(false);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// Timeout after 3 seconds
|
|
811
|
+
setTimeout(() => {
|
|
812
|
+
if (!statusProcess.killed) {
|
|
813
|
+
statusProcess.kill("SIGTERM");
|
|
814
|
+
resolve(false);
|
|
815
|
+
}
|
|
816
|
+
}, 3000);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Parse JSON output from 'tailscale serve status --json' to check if our port is configured
|
|
822
|
+
*/
|
|
823
|
+
private parseServeStatusJson(status: unknown, port: number): boolean {
|
|
824
|
+
const statusData = status as {
|
|
825
|
+
Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
|
|
826
|
+
Foreground?: Record<
|
|
827
|
+
string,
|
|
828
|
+
{
|
|
829
|
+
Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
|
|
830
|
+
AllowFunnel?: Record<string, boolean>;
|
|
831
|
+
}
|
|
832
|
+
>;
|
|
833
|
+
AllowFunnel?: Record<string, boolean>;
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
try {
|
|
837
|
+
logger.debug(`Parsing Tailscale serve JSON status for port ${port}:`);
|
|
838
|
+
logger.debug(`JSON status: ${JSON.stringify(status, null, 2)}`);
|
|
839
|
+
|
|
840
|
+
// Check both direct Web config and Foreground config
|
|
841
|
+
const webConfigs = [];
|
|
842
|
+
|
|
843
|
+
// Direct Web config (older format)
|
|
844
|
+
if (statusData.Web) {
|
|
845
|
+
webConfigs.push(statusData.Web);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Foreground config (newer format)
|
|
849
|
+
if (statusData.Foreground) {
|
|
850
|
+
for (const nodeId in statusData.Foreground) {
|
|
851
|
+
const nodeConfig = statusData.Foreground[nodeId];
|
|
852
|
+
if (nodeConfig?.Web) {
|
|
853
|
+
webConfigs.push(nodeConfig.Web);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Check all Web configurations found
|
|
859
|
+
for (const webConfig of webConfigs) {
|
|
860
|
+
for (const host in webConfig) {
|
|
861
|
+
const handlers = webConfig[host]?.Handlers;
|
|
862
|
+
if (handlers) {
|
|
863
|
+
logger.debug(`Checking handlers for host: ${host}`);
|
|
864
|
+
for (const path in handlers) {
|
|
865
|
+
const proxy = handlers[path]?.Proxy;
|
|
866
|
+
if (proxy) {
|
|
867
|
+
logger.debug(`Found proxy config: ${path} -> ${proxy}`);
|
|
868
|
+
|
|
869
|
+
// Check if this proxy points to our port
|
|
870
|
+
if (
|
|
871
|
+
proxy.includes(`:${port}`) ||
|
|
872
|
+
proxy.includes(`127.0.0.1:${port}`) ||
|
|
873
|
+
proxy.includes(`localhost:${port}`)
|
|
874
|
+
) {
|
|
875
|
+
logger.info(`✅ Found Tailscale Serve config for port ${port}: ${proxy}`);
|
|
876
|
+
|
|
877
|
+
// Check for Funnel status in multiple possible locations
|
|
878
|
+
let funnelEnabled = false;
|
|
879
|
+
if (statusData.AllowFunnel?.[host]) {
|
|
880
|
+
funnelEnabled = true;
|
|
881
|
+
logger.info(`🌍 Funnel is enabled for ${host}`);
|
|
882
|
+
} else if (statusData.Foreground) {
|
|
883
|
+
// Check if any foreground config allows Funnel
|
|
884
|
+
for (const nodeId in statusData.Foreground) {
|
|
885
|
+
const nodeConfig = statusData.Foreground[nodeId];
|
|
886
|
+
if (nodeConfig?.AllowFunnel?.[host]) {
|
|
887
|
+
funnelEnabled = true;
|
|
888
|
+
logger.info(`🌍 Funnel is enabled for ${host} (node ${nodeId})`);
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Update our internal Funnel status based on what we found
|
|
895
|
+
this.funnelEnabled = funnelEnabled;
|
|
896
|
+
if (funnelEnabled && !this.funnelStartTime) {
|
|
897
|
+
this.funnelStartTime = new Date();
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
logger.warn(`❌ No proxy configuration found for port ${port} in JSON status`);
|
|
909
|
+
logger.debug(`Available proxy configurations:`);
|
|
910
|
+
for (const webConfig of webConfigs) {
|
|
911
|
+
for (const host in webConfig) {
|
|
912
|
+
const handlers = webConfig[host]?.Handlers;
|
|
913
|
+
if (handlers) {
|
|
914
|
+
for (const path in handlers) {
|
|
915
|
+
const proxy = handlers[path]?.Proxy;
|
|
916
|
+
if (proxy) {
|
|
917
|
+
logger.debug(` - ${host}${path} -> ${proxy}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return false;
|
|
925
|
+
} catch (error) {
|
|
926
|
+
logger.info("Failed to parse JSON status:", error);
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Parse the output of 'tailscale serve status' to check if our port is configured
|
|
933
|
+
*/
|
|
934
|
+
private parseServeStatus(output: string, port: number): boolean {
|
|
935
|
+
logger.debug(`Parsing Tailscale serve status output for port ${port}:`);
|
|
936
|
+
logger.debug(`Raw output: ${JSON.stringify(output)}`);
|
|
937
|
+
|
|
938
|
+
// Look for lines containing our port number
|
|
939
|
+
const lines = output.split("\n");
|
|
940
|
+
logger.debug(`Split into ${lines.length} lines`);
|
|
941
|
+
|
|
942
|
+
for (const line of lines) {
|
|
943
|
+
const trimmedLine = line.trim();
|
|
944
|
+
if (trimmedLine) {
|
|
945
|
+
logger.debug(`Checking line: "${trimmedLine}"`);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Common patterns in Tailscale serve output:
|
|
949
|
+
// "https://hostname:443 proxy http://127.0.0.1:4020"
|
|
950
|
+
// "http://hostname:80 proxy http://127.0.0.1:4020"
|
|
951
|
+
if (line.includes(`127.0.0.1:${port}`) || line.includes(`localhost:${port}`)) {
|
|
952
|
+
logger.info(`Found proxy configuration for port ${port} in line: "${line.trim()}"`);
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
logger.warn(`No proxy configuration found for port ${port} in Tailscale serve status`);
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private cleanup(): void {
|
|
962
|
+
// Kill the process if it's still running
|
|
963
|
+
if (this.serveProcess && !this.serveProcess.killed) {
|
|
964
|
+
logger.debug("Terminating orphaned Tailscale Serve process");
|
|
965
|
+
try {
|
|
966
|
+
this.serveProcess.kill("SIGTERM");
|
|
967
|
+
// Give it a moment to terminate gracefully
|
|
968
|
+
setTimeout(() => {
|
|
969
|
+
if (this.serveProcess && !this.serveProcess.killed) {
|
|
970
|
+
logger.warn("Force killing Tailscale Serve process");
|
|
971
|
+
this.serveProcess.kill("SIGKILL");
|
|
972
|
+
}
|
|
973
|
+
}, 1000);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
logger.info("Failed to kill Tailscale Serve process:", error);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
this.serveProcess = null;
|
|
980
|
+
this.currentPort = null;
|
|
981
|
+
this.isStarting = false;
|
|
982
|
+
this.startTime = undefined;
|
|
983
|
+
this.funnelEnabled = false;
|
|
984
|
+
this.funnelStartTime = undefined;
|
|
985
|
+
// Keep lastError for debugging
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
private async checkTailscaleAvailable(): Promise<void> {
|
|
989
|
+
const fs = await import("node:fs/promises");
|
|
990
|
+
|
|
991
|
+
// Platform-specific paths to check
|
|
992
|
+
let tailscalePaths: string[] = [];
|
|
993
|
+
|
|
994
|
+
if (process.platform === "darwin") {
|
|
995
|
+
// macOS paths
|
|
996
|
+
tailscalePaths = [
|
|
997
|
+
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
|
998
|
+
"/usr/local/bin/tailscale",
|
|
999
|
+
"/opt/homebrew/bin/tailscale",
|
|
1000
|
+
];
|
|
1001
|
+
} else if (process.platform === "linux") {
|
|
1002
|
+
// Linux paths
|
|
1003
|
+
tailscalePaths = [
|
|
1004
|
+
"/usr/bin/tailscale",
|
|
1005
|
+
"/usr/local/bin/tailscale",
|
|
1006
|
+
"/opt/tailscale/bin/tailscale",
|
|
1007
|
+
"/snap/bin/tailscale",
|
|
1008
|
+
];
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Check platform-specific paths first
|
|
1012
|
+
if (!testSkipTailscaleFsPaths) {
|
|
1013
|
+
for (const path of tailscalePaths) {
|
|
1014
|
+
try {
|
|
1015
|
+
await fs.access(path, fs.constants.X_OK);
|
|
1016
|
+
this.tailscaleExecutable = path;
|
|
1017
|
+
logger.debug(`Found Tailscale at: ${path}`);
|
|
1018
|
+
return;
|
|
1019
|
+
} catch {
|
|
1020
|
+
// Continue checking other paths
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Fallback to checking PATH
|
|
1026
|
+
return new Promise<void>((resolve, reject) => {
|
|
1027
|
+
const checkProcess = spawnImpl("which", ["tailscale"], {
|
|
1028
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
checkProcess.on("exit", (code) => {
|
|
1032
|
+
if (code === 0) {
|
|
1033
|
+
// Keep default 'tailscale' which will use PATH
|
|
1034
|
+
resolve();
|
|
1035
|
+
} else {
|
|
1036
|
+
reject(new Error("Tailscale command not found. Please install Tailscale first."));
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
checkProcess.on("error", (error) => {
|
|
1041
|
+
reject(new Error(`Failed to check Tailscale availability: ${error.message}`));
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Singleton instance
|
|
1048
|
+
export const tailscaleServeService = new TailscaleServeServiceImpl();
|