wopr-plugin-tailscale-funnel 1.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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "pluginMarketplaces": [
3
+ {
4
+ "name": "wopr-marketplace",
5
+ "source": "https://github.com/wopr-network/wopr-claude-hooks"
6
+ }
7
+ ],
8
+ "enabledPlugins": ["wopr-hooks@wopr-marketplace"]
9
+ }
@@ -0,0 +1,30 @@
1
+ name: Claude Code Review
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, reopened]
6
+ issue_comment:
7
+ types: [created]
8
+ pull_request_review_comment:
9
+ types: [created]
10
+
11
+ jobs:
12
+ claude:
13
+ if: |
14
+ github.event_name == 'pull_request' ||
15
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
16
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
17
+ runs-on: [self-hosted, Linux, X64]
18
+ permissions:
19
+ contents: read
20
+ pull-requests: write
21
+ issues: write
22
+ id-token: write
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ with:
26
+ fetch-depth: 0
27
+ - uses: anthropics/claude-code-action@v1
28
+ with:
29
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
30
+ github_token: ${{ secrets.GITHUB_TOKEN }}
package/CLAUDE.md ADDED
@@ -0,0 +1,37 @@
1
+ # wopr-plugin-tailscale-funnel
2
+
3
+ Expose WOPR services externally via Tailscale Funnel — provides a public HTTPS URL without port forwarding.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ npm run build # tsc
9
+ npm run check # biome check + tsc --noEmit (run before committing)
10
+ npm run format # biome format --write src/
11
+ npm test # vitest run
12
+ ```
13
+
14
+ ## Architecture
15
+
16
+ ```
17
+ src/
18
+ index.ts # Plugin entry — starts tailscale funnel, registers public URL
19
+ types.ts # Plugin-local types
20
+ ```
21
+
22
+ ## Key Details
23
+
24
+ - **Requires Tailscale** installed and authenticated on the host (`tailscale` CLI must be in PATH)
25
+ - Runs `tailscale funnel <port>` to expose the WOPR daemon publicly
26
+ - Registers the resulting public URL with plugins that need it (webhooks, GitHub, Slack HTTP mode, etc.)
27
+ - **Use case**: local dev with webhooks — avoids ngrok, cloudflare tunnel, etc.
28
+ - **Gotcha**: Tailscale Funnel must be enabled in the Tailscale admin console for the device before this works
29
+ - **Gotcha**: Funnel URL is stable per device name — bookmark it
30
+
31
+ ## Plugin Contract
32
+
33
+ Imports only from `@wopr-network/plugin-types`. Never import from `@wopr-network/wopr` core.
34
+
35
+ ## Issue Tracking
36
+
37
+ All issues in **Linear** (team: WOPR). Issue descriptions start with `**Repo:** wopr-network/wopr-plugin-tailscale-funnel`.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # wopr-plugin-tailscale-funnel
2
+
3
+ Expose WOPR services to the internet via [Tailscale Funnel](https://tailscale.com/kb/1223/funnel).
4
+
5
+ > **Note:** Tailscale Funnel only supports **one port at a time**. Exposing a new port will automatically stop the previous funnel.
6
+
7
+ ## Prerequisites
8
+
9
+ - [Tailscale](https://tailscale.com/download) installed and running (`tailscale up`)
10
+ - Funnel enabled on your tailnet (see [Tailscale docs](https://tailscale.com/kb/1223/funnel#setup))
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ wopr plugin add wopr-network/wopr-plugin-tailscale-funnel
16
+ ```
17
+
18
+ ## Configuration
19
+
20
+ In your WOPR config (`~/.wopr/config.json`):
21
+
22
+ ```json
23
+ {
24
+ "plugins": {
25
+ "wopr-plugin-tailscale-funnel": {
26
+ "enabled": true,
27
+ "expose": { "port": 7437, "path": "/" }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### Options
34
+
35
+ | Option | Type | Default | Description |
36
+ |--------|------|---------|-------------|
37
+ | `enabled` | boolean | `true` | Enable/disable the plugin |
38
+ | `expose` | object | - | Port to auto-expose on startup (one port only) |
39
+
40
+ ## CLI Commands
41
+
42
+ ```bash
43
+ # Check funnel status
44
+ wopr funnel status
45
+
46
+ # Expose a port (replaces any existing funnel)
47
+ wopr funnel expose 8080
48
+
49
+ # Stop exposing a port
50
+ wopr funnel unexpose 8080
51
+ ```
52
+
53
+ ## Extension API
54
+
55
+ Other plugins can use the funnel extension:
56
+
57
+ ```typescript
58
+ const funnel = ctx.getExtension("funnel") as FunnelExtension;
59
+
60
+ // Check availability
61
+ const available = await funnel.isAvailable();
62
+
63
+ // Get public hostname
64
+ const hostname = await funnel.getHostname();
65
+
66
+ // Expose a port and get public URL (replaces any existing funnel)
67
+ const url = await funnel.expose(8080, "/api");
68
+
69
+ // Stop exposing
70
+ await funnel.unexpose(8080);
71
+
72
+ // Get URL for the currently exposed port
73
+ const existingUrl = funnel.getUrl(8080);
74
+
75
+ // Get full status
76
+ const status = funnel.getStatus();
77
+ ```
78
+
79
+ ## How It Works
80
+
81
+ 1. Plugin checks if Tailscale is installed and connected
82
+ 2. When exposing a port, it runs `tailscale funnel <port>` in the background
83
+ 3. Traffic to `https://<your-hostname>.ts.net/` routes to `localhost:<port>`
84
+ 4. Other plugins (like `wopr-plugin-github`) can use the extension to get public URLs
85
+ 5. **Only one funnel can be active** - exposing a new port stops the previous one
86
+
87
+ ## Limitations
88
+
89
+ - **Single port only:** Tailscale Funnel supports one exposed port at a time per machine
90
+ - Exposing a new port will automatically stop any existing funnel
91
+
92
+ ## Operational Notes
93
+
94
+ ### Tailscale Must Be Running First
95
+
96
+ Tailscale daemon (`tailscaled`) must be running before WOPR starts. In containerized environments:
97
+
98
+ ```bash
99
+ # Ensure tailscale is up
100
+ tailscale up --authkey=<your-key>
101
+
102
+ # Clear any conflicting listeners before starting funnel
103
+ tailscale serve reset
104
+
105
+ # Then start WOPR
106
+ wopr daemon
107
+ ```
108
+
109
+ ### Container Setup
110
+
111
+ For Docker containers, run in privileged mode and ensure Tailscale auto-starts:
112
+
113
+ ```dockerfile
114
+ # In your entrypoint
115
+ tailscaled --state=/var/lib/tailscale/tailscaled.state &
116
+ sleep 2
117
+ tailscale up --authkey=${TAILSCALE_AUTHKEY}
118
+ ```
119
+
120
+ ### Troubleshooting
121
+
122
+ - **"listener already exists"**: Run `tailscale serve reset` to clear existing funnels
123
+ - **Funnel not accessible**: Verify funnel is enabled on your tailnet in the admin console
124
+ - **Port not exposed**: Check `tailscale funnel status` for current state
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,10 @@
1
+ /**
2
+ * WOPR Tailscale Funnel Plugin
3
+ *
4
+ * Exposes local WOPR services to the internet via Tailscale Funnel.
5
+ * Other plugins can use the funnel extension to get public URLs.
6
+ */
7
+ import type { WOPRPlugin, FunnelExtension, FunnelStatus, FunnelInfo } from "./types.js";
8
+ declare const plugin: WOPRPlugin;
9
+ export default plugin;
10
+ export type { FunnelExtension, FunnelStatus, FunnelInfo };
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * WOPR Tailscale Funnel Plugin
3
+ *
4
+ * Exposes local WOPR services to the internet via Tailscale Funnel.
5
+ * Other plugins can use the funnel extension to get public URLs.
6
+ */
7
+ import { spawn, execSync, spawnSync } from "node:child_process";
8
+ // ============================================================================
9
+ // State
10
+ // ============================================================================
11
+ let ctx = null;
12
+ let hostname = null;
13
+ let available = null;
14
+ // Tailscale Funnel only supports ONE active funnel at a time
15
+ // Exposing a new port will replace the previous one
16
+ let activeFunnel = null;
17
+ // ============================================================================
18
+ // Tailscale CLI Helpers
19
+ // ============================================================================
20
+ function exec(cmd) {
21
+ try {
22
+ return execSync(cmd, { encoding: "utf-8", timeout: 10000 }).trim();
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ async function checkTailscaleAvailable() {
29
+ if (available !== null)
30
+ return available;
31
+ // Check if tailscale CLI exists (cross-platform: 'where' on Windows, 'which' elsewhere)
32
+ const isWindows = process.platform === "win32";
33
+ const whichCmd = isWindows ? "where tailscale" : "which tailscale";
34
+ const which = exec(whichCmd);
35
+ if (!which) {
36
+ ctx?.log.warn("Tailscale CLI not found. Install from https://tailscale.com/download");
37
+ available = false;
38
+ return false;
39
+ }
40
+ // Check if tailscale is running and connected
41
+ const status = exec("tailscale status --json");
42
+ if (!status) {
43
+ ctx?.log.warn("Tailscale not running or not connected");
44
+ available = false;
45
+ return false;
46
+ }
47
+ try {
48
+ const parsed = JSON.parse(status);
49
+ if (parsed.BackendState !== "Running") {
50
+ ctx?.log.warn(`Tailscale backend state: ${parsed.BackendState}`);
51
+ available = false;
52
+ return false;
53
+ }
54
+ // Extract hostname
55
+ hostname = parsed.Self?.DNSName?.replace(/\.$/, "") || null;
56
+ if (hostname) {
57
+ ctx?.log.info(`Tailscale connected: ${hostname}`);
58
+ }
59
+ available = true;
60
+ return true;
61
+ }
62
+ catch {
63
+ available = false;
64
+ return false;
65
+ }
66
+ }
67
+ async function getTailscaleHostname() {
68
+ if (hostname)
69
+ return hostname;
70
+ await checkTailscaleAvailable();
71
+ return hostname;
72
+ }
73
+ async function startFunnel(port, path = "/") {
74
+ if (!(await checkTailscaleAvailable())) {
75
+ return null;
76
+ }
77
+ if (!hostname) {
78
+ ctx?.log.error("No Tailscale hostname available");
79
+ return null;
80
+ }
81
+ // Check if already exposed on this port
82
+ if (activeFunnel?.active && activeFunnel.port === port) {
83
+ ctx?.log.debug?.(`Port ${port} already exposed at ${activeFunnel.publicUrl}`);
84
+ return activeFunnel.publicUrl;
85
+ }
86
+ // Tailscale only supports ONE funnel at a time - stop any existing funnel
87
+ if (activeFunnel?.active) {
88
+ ctx?.log.info(`Replacing existing funnel on port ${activeFunnel.port} with port ${port}`);
89
+ await stopFunnel(activeFunnel.port);
90
+ }
91
+ // Build public URL
92
+ // Funnel always uses HTTPS on port 443
93
+ const publicUrl = `https://${hostname}${path === "/" ? "" : path}`;
94
+ try {
95
+ // Start funnel in background
96
+ // tailscale funnel <port> exposes on 443 by default
97
+ const funnelProcess = spawn("tailscale", ["funnel", String(port)], {
98
+ detached: true,
99
+ stdio: "ignore",
100
+ });
101
+ // Handle spawn errors (e.g., tailscale not found)
102
+ funnelProcess.on("error", (err) => {
103
+ ctx?.log.error(`Funnel process error for port ${port}: ${err.message}`);
104
+ if (activeFunnel?.port === port) {
105
+ activeFunnel = null;
106
+ }
107
+ });
108
+ funnelProcess.unref();
109
+ // Store state - note: we can't verify funnel actually started since it's detached
110
+ // The error handler above will clear state if spawn fails
111
+ activeFunnel = {
112
+ port,
113
+ path,
114
+ publicUrl,
115
+ active: true,
116
+ pid: funnelProcess.pid,
117
+ };
118
+ ctx?.log.info(`Funnel started: ${publicUrl} -> localhost:${port}`);
119
+ return publicUrl;
120
+ }
121
+ catch (err) {
122
+ ctx?.log.error(`Failed to spawn funnel for port ${port}: ${err}`);
123
+ return null;
124
+ }
125
+ }
126
+ async function stopFunnel(port) {
127
+ if (!activeFunnel || activeFunnel.port !== port) {
128
+ return false;
129
+ }
130
+ // Stop the funnel using spawnSync with args array (safer than shell string)
131
+ const result = spawnSync("tailscale", ["funnel", String(port), "off"], {
132
+ encoding: "utf-8",
133
+ timeout: 10000,
134
+ });
135
+ if (result.status !== 0) {
136
+ ctx?.log.warn(`tailscale funnel ${port} off may have failed: ${result.stderr || ""}`);
137
+ }
138
+ // Also try to kill the process if we have the PID
139
+ if (activeFunnel.pid) {
140
+ try {
141
+ process.kill(activeFunnel.pid, "SIGTERM");
142
+ }
143
+ catch {
144
+ // Process may already be dead
145
+ }
146
+ }
147
+ activeFunnel = null;
148
+ ctx?.log.info(`Funnel stopped for port ${port}`);
149
+ return true;
150
+ }
151
+ // ============================================================================
152
+ // Extension
153
+ // ============================================================================
154
+ const funnelExtension = {
155
+ async isAvailable() {
156
+ return checkTailscaleAvailable();
157
+ },
158
+ async getHostname() {
159
+ return getTailscaleHostname();
160
+ },
161
+ async expose(port, path) {
162
+ return startFunnel(port, path || "/");
163
+ },
164
+ async unexpose(port) {
165
+ return stopFunnel(port);
166
+ },
167
+ getUrl(port) {
168
+ return activeFunnel?.port === port ? activeFunnel.publicUrl : null;
169
+ },
170
+ getStatus() {
171
+ return {
172
+ available: available ?? false,
173
+ hostname: hostname || undefined,
174
+ funnels: activeFunnel ? [activeFunnel] : [],
175
+ };
176
+ },
177
+ };
178
+ // ============================================================================
179
+ // Plugin
180
+ // ============================================================================
181
+ const plugin = {
182
+ name: "wopr-plugin-tailscale-funnel",
183
+ version: "1.0.0",
184
+ description: "Expose WOPR services externally via Tailscale Funnel",
185
+ configSchema: {
186
+ title: "Tailscale Funnel",
187
+ description: "Expose a local service to the internet via Tailscale Funnel (one port at a time)",
188
+ fields: [
189
+ {
190
+ name: "enabled",
191
+ type: "boolean",
192
+ label: "Enable Funnel",
193
+ description: "Enable Tailscale Funnel integration",
194
+ default: true,
195
+ },
196
+ {
197
+ name: "expose",
198
+ type: "object",
199
+ label: "Auto-expose port",
200
+ description: "Port to automatically expose on startup (only one supported)",
201
+ },
202
+ ],
203
+ },
204
+ commands: [
205
+ {
206
+ name: "funnel",
207
+ description: "Tailscale Funnel management",
208
+ usage: "wopr funnel <status|expose|unexpose> [port]",
209
+ async handler(cmdCtx, args) {
210
+ const [subcommand, portArg] = args;
211
+ if (subcommand === "status") {
212
+ const status = funnelExtension.getStatus();
213
+ if (!status.available) {
214
+ cmdCtx.log.info("Tailscale Funnel: not available");
215
+ cmdCtx.log.info(" Make sure Tailscale is installed and running");
216
+ return;
217
+ }
218
+ cmdCtx.log.info(`Tailscale Funnel: available`);
219
+ cmdCtx.log.info(` Hostname: ${status.hostname}`);
220
+ cmdCtx.log.info(` Active funnels: ${status.funnels.length}`);
221
+ for (const f of status.funnels) {
222
+ cmdCtx.log.info(` - ${f.publicUrl} -> localhost:${f.port}`);
223
+ }
224
+ return;
225
+ }
226
+ if (subcommand === "expose") {
227
+ if (!portArg) {
228
+ cmdCtx.log.error("Usage: wopr funnel expose <port>");
229
+ return;
230
+ }
231
+ const port = parseInt(portArg, 10);
232
+ if (isNaN(port)) {
233
+ cmdCtx.log.error("Invalid port number");
234
+ return;
235
+ }
236
+ const url = await funnelExtension.expose(port);
237
+ if (url) {
238
+ cmdCtx.log.info(`Exposed: ${url} -> localhost:${port}`);
239
+ }
240
+ else {
241
+ cmdCtx.log.error("Failed to expose port");
242
+ }
243
+ return;
244
+ }
245
+ if (subcommand === "unexpose") {
246
+ if (!portArg) {
247
+ cmdCtx.log.error("Usage: wopr funnel unexpose <port>");
248
+ return;
249
+ }
250
+ const port = parseInt(portArg, 10);
251
+ if (isNaN(port)) {
252
+ cmdCtx.log.error("Invalid port number");
253
+ return;
254
+ }
255
+ const success = await funnelExtension.unexpose(port);
256
+ if (success) {
257
+ cmdCtx.log.info(`Stopped funnel for port ${port}`);
258
+ }
259
+ else {
260
+ cmdCtx.log.error("Failed to stop funnel");
261
+ }
262
+ return;
263
+ }
264
+ cmdCtx.log.info("Usage: wopr funnel <status|expose|unexpose> [port]");
265
+ },
266
+ },
267
+ ],
268
+ async init(pluginCtx) {
269
+ ctx = pluginCtx;
270
+ const config = ctx.getConfig();
271
+ if (config?.enabled === false) {
272
+ ctx.log.info("Tailscale Funnel plugin loaded (disabled)");
273
+ return;
274
+ }
275
+ // Check availability first
276
+ const isAvailable = await checkTailscaleAvailable();
277
+ if (!isAvailable) {
278
+ ctx.log.warn("Tailscale Funnel not available - install Tailscale and run 'tailscale up'");
279
+ // Don't register extension if Tailscale isn't available
280
+ return;
281
+ }
282
+ // Register extension only after confirming Tailscale is available
283
+ ctx.registerExtension("funnel", funnelExtension);
284
+ // Auto-expose configured port (only one supported by Tailscale)
285
+ if (config?.expose) {
286
+ // Support both old array format (use first item) and new object format
287
+ const exposeConfig = Array.isArray(config.expose) ? config.expose[0] : config.expose;
288
+ if (exposeConfig?.port) {
289
+ const url = await startFunnel(exposeConfig.port, exposeConfig.path);
290
+ if (url) {
291
+ ctx.log.info(`Auto-exposed: ${url}`);
292
+ }
293
+ }
294
+ }
295
+ ctx.log.info("Tailscale Funnel plugin initialized");
296
+ },
297
+ async shutdown() {
298
+ // Stop active funnel if any
299
+ if (activeFunnel) {
300
+ await stopFunnel(activeFunnel.port);
301
+ }
302
+ ctx?.unregisterExtension("funnel");
303
+ ctx = null;
304
+ hostname = null;
305
+ available = null;
306
+ },
307
+ };
308
+ export default plugin;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tailscale Funnel Plugin Types
3
+ */
4
+ export interface FunnelConfig {
5
+ enabled?: boolean;
6
+ /**
7
+ * Auto-expose this port on init.
8
+ * Note: Tailscale Funnel only supports ONE port at a time.
9
+ * Also accepts array for backwards compatibility (only first item used).
10
+ */
11
+ expose?: FunnelExpose | FunnelExpose[];
12
+ }
13
+ export interface FunnelExpose {
14
+ /** Local port to expose */
15
+ port: number;
16
+ /** Optional path prefix (default: /) */
17
+ path?: string;
18
+ }
19
+ export interface FunnelStatus {
20
+ available: boolean;
21
+ hostname?: string;
22
+ funnels: FunnelInfo[];
23
+ }
24
+ export interface FunnelInfo {
25
+ port: number;
26
+ path: string;
27
+ publicUrl: string;
28
+ active: boolean;
29
+ }
30
+ /**
31
+ * Extension interface exposed to other plugins
32
+ */
33
+ export interface FunnelExtension {
34
+ /** Check if Tailscale Funnel is available */
35
+ isAvailable(): Promise<boolean>;
36
+ /** Get the Tailscale hostname (e.g., wopr.tailnet.ts.net) */
37
+ getHostname(): Promise<string | null>;
38
+ /** Expose a local port via funnel, returns public URL */
39
+ expose(port: number, path?: string): Promise<string | null>;
40
+ /** Stop exposing a port */
41
+ unexpose(port: number): Promise<boolean>;
42
+ /** Get public URL for an exposed port */
43
+ getUrl(port: number): string | null;
44
+ /** Get status of all funnels */
45
+ getStatus(): FunnelStatus;
46
+ }
47
+ export interface WOPRPluginContext {
48
+ log: {
49
+ info(msg: string): void;
50
+ warn(msg: string): void;
51
+ error(msg: string): void;
52
+ debug?(msg: string): void;
53
+ };
54
+ getConfig<T>(): T | undefined;
55
+ registerExtension(name: string, extension: unknown): void;
56
+ unregisterExtension(name: string): void;
57
+ getExtension(name: string): unknown;
58
+ }
59
+ export interface WOPRPlugin {
60
+ name: string;
61
+ version: string;
62
+ description?: string;
63
+ configSchema?: {
64
+ title: string;
65
+ description: string;
66
+ fields: Array<{
67
+ name: string;
68
+ type: string;
69
+ label?: string;
70
+ description?: string;
71
+ required?: boolean;
72
+ default?: unknown;
73
+ }>;
74
+ };
75
+ commands?: Array<{
76
+ name: string;
77
+ description: string;
78
+ usage?: string;
79
+ handler: (ctx: WOPRPluginContext, args: string[]) => Promise<void>;
80
+ }>;
81
+ init?(ctx: WOPRPluginContext): Promise<void>;
82
+ shutdown?(): Promise<void>;
83
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tailscale Funnel Plugin Types
3
+ */
4
+ export {};
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "author": "WOPR",
3
+ "description": "Expose WOPR services externally via Tailscale Funnel",
4
+ "devDependencies": {
5
+ "@biomejs/biome": "^2.3.14",
6
+ "@types/node": "^25.2.0",
7
+ "typescript": "^5.3.3"
8
+ },
9
+ "keywords": [
10
+ "wopr",
11
+ "plugin",
12
+ "tailscale",
13
+ "funnel",
14
+ "tunnel"
15
+ ],
16
+ "license": "MIT",
17
+ "main": "dist/index.js",
18
+ "name": "wopr-plugin-tailscale-funnel",
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "check": "biome check src/ && tsc --noEmit",
22
+ "dev": "tsc --watch",
23
+ "format": "biome format --write src/",
24
+ "lint": "biome check src/",
25
+ "lint:fix": "biome check --fix src/"
26
+ },
27
+ "type": "module",
28
+ "version": "1.0.0"
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * WOPR Tailscale Funnel Plugin
3
+ *
4
+ * Exposes local WOPR services to the internet via Tailscale Funnel.
5
+ * Other plugins can use the funnel extension to get public URLs.
6
+ */
7
+
8
+ import { spawn, execSync, spawnSync } from "node:child_process";
9
+ import type {
10
+ WOPRPlugin,
11
+ WOPRPluginContext,
12
+ FunnelConfig,
13
+ FunnelExtension,
14
+ FunnelStatus,
15
+ FunnelInfo,
16
+ } from "./types.js";
17
+
18
+ // ============================================================================
19
+ // State
20
+ // ============================================================================
21
+
22
+ let ctx: WOPRPluginContext | null = null;
23
+ let hostname: string | null = null;
24
+ let available: boolean | null = null;
25
+
26
+ // Tailscale Funnel only supports ONE active funnel at a time
27
+ // Exposing a new port will replace the previous one
28
+ let activeFunnel: (FunnelInfo & { pid?: number }) | null = null;
29
+
30
+ // ============================================================================
31
+ // Tailscale CLI Helpers
32
+ // ============================================================================
33
+
34
+ function exec(cmd: string): string | null {
35
+ try {
36
+ return execSync(cmd, { encoding: "utf-8", timeout: 10000 }).trim();
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ async function checkTailscaleAvailable(): Promise<boolean> {
43
+ if (available !== null) return available;
44
+
45
+ // Check if tailscale CLI exists (cross-platform: 'where' on Windows, 'which' elsewhere)
46
+ const isWindows = process.platform === "win32";
47
+ const whichCmd = isWindows ? "where tailscale" : "which tailscale";
48
+ const which = exec(whichCmd);
49
+ if (!which) {
50
+ ctx?.log.warn("Tailscale CLI not found. Install from https://tailscale.com/download");
51
+ available = false;
52
+ return false;
53
+ }
54
+
55
+ // Check if tailscale is running and connected
56
+ const status = exec("tailscale status --json");
57
+ if (!status) {
58
+ ctx?.log.warn("Tailscale not running or not connected");
59
+ available = false;
60
+ return false;
61
+ }
62
+
63
+ try {
64
+ const parsed = JSON.parse(status);
65
+ if (parsed.BackendState !== "Running") {
66
+ ctx?.log.warn(`Tailscale backend state: ${parsed.BackendState}`);
67
+ available = false;
68
+ return false;
69
+ }
70
+
71
+ // Extract hostname
72
+ hostname = parsed.Self?.DNSName?.replace(/\.$/, "") || null;
73
+ if (hostname) {
74
+ ctx?.log.info(`Tailscale connected: ${hostname}`);
75
+ }
76
+
77
+ available = true;
78
+ return true;
79
+ } catch {
80
+ available = false;
81
+ return false;
82
+ }
83
+ }
84
+
85
+ async function getTailscaleHostname(): Promise<string | null> {
86
+ if (hostname) return hostname;
87
+ await checkTailscaleAvailable();
88
+ return hostname;
89
+ }
90
+
91
+ async function startFunnel(port: number, path: string = "/"): Promise<string | null> {
92
+ if (!(await checkTailscaleAvailable())) {
93
+ return null;
94
+ }
95
+
96
+ if (!hostname) {
97
+ ctx?.log.error("No Tailscale hostname available");
98
+ return null;
99
+ }
100
+
101
+ // Check if already exposed on this port
102
+ if (activeFunnel?.active && activeFunnel.port === port) {
103
+ ctx?.log.debug?.(`Port ${port} already exposed at ${activeFunnel.publicUrl}`);
104
+ return activeFunnel.publicUrl;
105
+ }
106
+
107
+ // Tailscale only supports ONE funnel at a time - stop any existing funnel
108
+ if (activeFunnel?.active) {
109
+ ctx?.log.info(`Replacing existing funnel on port ${activeFunnel.port} with port ${port}`);
110
+ await stopFunnel(activeFunnel.port);
111
+ }
112
+
113
+ // Build public URL
114
+ // Funnel always uses HTTPS on port 443
115
+ const publicUrl = `https://${hostname}${path === "/" ? "" : path}`;
116
+
117
+ try {
118
+ // Start funnel in background
119
+ // tailscale funnel <port> exposes on 443 by default
120
+ const funnelProcess = spawn("tailscale", ["funnel", String(port)], {
121
+ detached: true,
122
+ stdio: "ignore",
123
+ });
124
+
125
+ // Handle spawn errors (e.g., tailscale not found)
126
+ funnelProcess.on("error", (err) => {
127
+ ctx?.log.error(`Funnel process error for port ${port}: ${err.message}`);
128
+ if (activeFunnel?.port === port) {
129
+ activeFunnel = null;
130
+ }
131
+ });
132
+
133
+ funnelProcess.unref();
134
+
135
+ // Store state - note: we can't verify funnel actually started since it's detached
136
+ // The error handler above will clear state if spawn fails
137
+ activeFunnel = {
138
+ port,
139
+ path,
140
+ publicUrl,
141
+ active: true,
142
+ pid: funnelProcess.pid,
143
+ };
144
+
145
+ ctx?.log.info(`Funnel started: ${publicUrl} -> localhost:${port}`);
146
+ return publicUrl;
147
+ } catch (err) {
148
+ ctx?.log.error(`Failed to spawn funnel for port ${port}: ${err}`);
149
+ return null;
150
+ }
151
+ }
152
+
153
+ async function stopFunnel(port: number): Promise<boolean> {
154
+ if (!activeFunnel || activeFunnel.port !== port) {
155
+ return false;
156
+ }
157
+
158
+ // Stop the funnel using spawnSync with args array (safer than shell string)
159
+ const result = spawnSync("tailscale", ["funnel", String(port), "off"], {
160
+ encoding: "utf-8",
161
+ timeout: 10000,
162
+ });
163
+ if (result.status !== 0) {
164
+ ctx?.log.warn(`tailscale funnel ${port} off may have failed: ${result.stderr || ""}`);
165
+ }
166
+
167
+ // Also try to kill the process if we have the PID
168
+ if (activeFunnel.pid) {
169
+ try {
170
+ process.kill(activeFunnel.pid, "SIGTERM");
171
+ } catch {
172
+ // Process may already be dead
173
+ }
174
+ }
175
+
176
+ activeFunnel = null;
177
+ ctx?.log.info(`Funnel stopped for port ${port}`);
178
+ return true;
179
+ }
180
+
181
+ // ============================================================================
182
+ // Extension
183
+ // ============================================================================
184
+
185
+ const funnelExtension: FunnelExtension = {
186
+ async isAvailable() {
187
+ return checkTailscaleAvailable();
188
+ },
189
+
190
+ async getHostname() {
191
+ return getTailscaleHostname();
192
+ },
193
+
194
+ async expose(port: number, path?: string) {
195
+ return startFunnel(port, path || "/");
196
+ },
197
+
198
+ async unexpose(port: number) {
199
+ return stopFunnel(port);
200
+ },
201
+
202
+ getUrl(port: number) {
203
+ return activeFunnel?.port === port ? activeFunnel.publicUrl : null;
204
+ },
205
+
206
+ getStatus(): FunnelStatus {
207
+ return {
208
+ available: available ?? false,
209
+ hostname: hostname || undefined,
210
+ funnels: activeFunnel ? [activeFunnel] : [],
211
+ };
212
+ },
213
+ };
214
+
215
+ // ============================================================================
216
+ // Plugin
217
+ // ============================================================================
218
+
219
+ const plugin: WOPRPlugin = {
220
+ name: "wopr-plugin-tailscale-funnel",
221
+ version: "1.0.0",
222
+ description: "Expose WOPR services externally via Tailscale Funnel",
223
+
224
+ configSchema: {
225
+ title: "Tailscale Funnel",
226
+ description: "Expose a local service to the internet via Tailscale Funnel (one port at a time)",
227
+ fields: [
228
+ {
229
+ name: "enabled",
230
+ type: "boolean",
231
+ label: "Enable Funnel",
232
+ description: "Enable Tailscale Funnel integration",
233
+ default: true,
234
+ },
235
+ {
236
+ name: "expose",
237
+ type: "object",
238
+ label: "Auto-expose port",
239
+ description: "Port to automatically expose on startup (only one supported)",
240
+ },
241
+ ],
242
+ },
243
+
244
+ commands: [
245
+ {
246
+ name: "funnel",
247
+ description: "Tailscale Funnel management",
248
+ usage: "wopr funnel <status|expose|unexpose> [port]",
249
+ async handler(cmdCtx, args) {
250
+ const [subcommand, portArg] = args;
251
+
252
+ if (subcommand === "status") {
253
+ const status = funnelExtension.getStatus();
254
+ if (!status.available) {
255
+ cmdCtx.log.info("Tailscale Funnel: not available");
256
+ cmdCtx.log.info(" Make sure Tailscale is installed and running");
257
+ return;
258
+ }
259
+ cmdCtx.log.info(`Tailscale Funnel: available`);
260
+ cmdCtx.log.info(` Hostname: ${status.hostname}`);
261
+ cmdCtx.log.info(` Active funnels: ${status.funnels.length}`);
262
+ for (const f of status.funnels) {
263
+ cmdCtx.log.info(` - ${f.publicUrl} -> localhost:${f.port}`);
264
+ }
265
+ return;
266
+ }
267
+
268
+ if (subcommand === "expose") {
269
+ if (!portArg) {
270
+ cmdCtx.log.error("Usage: wopr funnel expose <port>");
271
+ return;
272
+ }
273
+ const port = parseInt(portArg, 10);
274
+ if (isNaN(port)) {
275
+ cmdCtx.log.error("Invalid port number");
276
+ return;
277
+ }
278
+ const url = await funnelExtension.expose(port);
279
+ if (url) {
280
+ cmdCtx.log.info(`Exposed: ${url} -> localhost:${port}`);
281
+ } else {
282
+ cmdCtx.log.error("Failed to expose port");
283
+ }
284
+ return;
285
+ }
286
+
287
+ if (subcommand === "unexpose") {
288
+ if (!portArg) {
289
+ cmdCtx.log.error("Usage: wopr funnel unexpose <port>");
290
+ return;
291
+ }
292
+ const port = parseInt(portArg, 10);
293
+ if (isNaN(port)) {
294
+ cmdCtx.log.error("Invalid port number");
295
+ return;
296
+ }
297
+ const success = await funnelExtension.unexpose(port);
298
+ if (success) {
299
+ cmdCtx.log.info(`Stopped funnel for port ${port}`);
300
+ } else {
301
+ cmdCtx.log.error("Failed to stop funnel");
302
+ }
303
+ return;
304
+ }
305
+
306
+ cmdCtx.log.info("Usage: wopr funnel <status|expose|unexpose> [port]");
307
+ },
308
+ },
309
+ ],
310
+
311
+ async init(pluginCtx) {
312
+ ctx = pluginCtx;
313
+ const config = ctx.getConfig<FunnelConfig>();
314
+
315
+ if (config?.enabled === false) {
316
+ ctx.log.info("Tailscale Funnel plugin loaded (disabled)");
317
+ return;
318
+ }
319
+
320
+ // Check availability first
321
+ const isAvailable = await checkTailscaleAvailable();
322
+ if (!isAvailable) {
323
+ ctx.log.warn("Tailscale Funnel not available - install Tailscale and run 'tailscale up'");
324
+ // Don't register extension if Tailscale isn't available
325
+ return;
326
+ }
327
+
328
+ // Register extension only after confirming Tailscale is available
329
+ ctx.registerExtension("funnel", funnelExtension);
330
+
331
+ // Auto-expose configured port (only one supported by Tailscale)
332
+ if (config?.expose) {
333
+ // Support both old array format (use first item) and new object format
334
+ const exposeConfig = Array.isArray(config.expose) ? config.expose[0] : config.expose;
335
+ if (exposeConfig?.port) {
336
+ const url = await startFunnel(exposeConfig.port, exposeConfig.path);
337
+ if (url) {
338
+ ctx.log.info(`Auto-exposed: ${url}`);
339
+ }
340
+ }
341
+ }
342
+
343
+ ctx.log.info("Tailscale Funnel plugin initialized");
344
+ },
345
+
346
+ async shutdown() {
347
+ // Stop active funnel if any
348
+ if (activeFunnel) {
349
+ await stopFunnel(activeFunnel.port);
350
+ }
351
+
352
+ ctx?.unregisterExtension("funnel");
353
+ ctx = null;
354
+ hostname = null;
355
+ available = null;
356
+ },
357
+ };
358
+
359
+ export default plugin;
360
+ export type { FunnelExtension, FunnelStatus, FunnelInfo };
package/src/types.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tailscale Funnel Plugin Types
3
+ */
4
+
5
+ export interface FunnelConfig {
6
+ enabled?: boolean;
7
+ /**
8
+ * Auto-expose this port on init.
9
+ * Note: Tailscale Funnel only supports ONE port at a time.
10
+ * Also accepts array for backwards compatibility (only first item used).
11
+ */
12
+ expose?: FunnelExpose | FunnelExpose[];
13
+ }
14
+
15
+ export interface FunnelExpose {
16
+ /** Local port to expose */
17
+ port: number;
18
+ /** Optional path prefix (default: /) */
19
+ path?: string;
20
+ }
21
+
22
+ export interface FunnelStatus {
23
+ available: boolean;
24
+ hostname?: string;
25
+ funnels: FunnelInfo[];
26
+ }
27
+
28
+ export interface FunnelInfo {
29
+ port: number;
30
+ path: string;
31
+ publicUrl: string;
32
+ active: boolean;
33
+ }
34
+
35
+ /**
36
+ * Extension interface exposed to other plugins
37
+ */
38
+ export interface FunnelExtension {
39
+ /** Check if Tailscale Funnel is available */
40
+ isAvailable(): Promise<boolean>;
41
+
42
+ /** Get the Tailscale hostname (e.g., wopr.tailnet.ts.net) */
43
+ getHostname(): Promise<string | null>;
44
+
45
+ /** Expose a local port via funnel, returns public URL */
46
+ expose(port: number, path?: string): Promise<string | null>;
47
+
48
+ /** Stop exposing a port */
49
+ unexpose(port: number): Promise<boolean>;
50
+
51
+ /** Get public URL for an exposed port */
52
+ getUrl(port: number): string | null;
53
+
54
+ /** Get status of all funnels */
55
+ getStatus(): FunnelStatus;
56
+ }
57
+
58
+ export interface WOPRPluginContext {
59
+ log: {
60
+ info(msg: string): void;
61
+ warn(msg: string): void;
62
+ error(msg: string): void;
63
+ debug?(msg: string): void;
64
+ };
65
+ getConfig<T>(): T | undefined;
66
+ registerExtension(name: string, extension: unknown): void;
67
+ unregisterExtension(name: string): void;
68
+ getExtension(name: string): unknown;
69
+ }
70
+
71
+ export interface WOPRPlugin {
72
+ name: string;
73
+ version: string;
74
+ description?: string;
75
+ configSchema?: {
76
+ title: string;
77
+ description: string;
78
+ fields: Array<{
79
+ name: string;
80
+ type: string;
81
+ label?: string;
82
+ description?: string;
83
+ required?: boolean;
84
+ default?: unknown;
85
+ }>;
86
+ };
87
+ commands?: Array<{
88
+ name: string;
89
+ description: string;
90
+ usage?: string;
91
+ handler: (ctx: WOPRPluginContext, args: string[]) => Promise<void>;
92
+ }>;
93
+ init?(ctx: WOPRPluginContext): Promise<void>;
94
+ shutdown?(): Promise<void>;
95
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }