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.
- package/.claude/settings.json +9 -0
- package/.github/workflows/claude.yml +30 -0
- package/CLAUDE.md +37 -0
- package/README.md +128 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +308 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +4 -0
- package/package.json +29 -0
- package/src/index.ts +360 -0
- package/src/types.ts +95 -0
- package/tsconfig.json +15 -0
|
@@ -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
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/types.d.ts
ADDED
|
@@ -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
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
|
+
}
|