heyhank 0.1.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/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Social Media Types
|
|
2
|
+
|
|
3
|
+
export type SocialBackendId = "postiz" | "ayrshare" | "buffer";
|
|
4
|
+
|
|
5
|
+
export type SocialPlatform = "twitter" | "instagram" | "linkedin" | "facebook" | "tiktok" | "threads";
|
|
6
|
+
|
|
7
|
+
export interface SocialBackendConfig {
|
|
8
|
+
url?: string; // For Postiz (self-hosted URL)
|
|
9
|
+
apiKey: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SocialProfile {
|
|
13
|
+
id: string;
|
|
14
|
+
platform: SocialPlatform;
|
|
15
|
+
name: string;
|
|
16
|
+
picture?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreatePostInput {
|
|
20
|
+
text: string;
|
|
21
|
+
platforms: SocialPlatform[];
|
|
22
|
+
scheduledAt?: string | null;
|
|
23
|
+
mediaUrls?: string[];
|
|
24
|
+
// Rich post fields (Buffer-style)
|
|
25
|
+
title?: string;
|
|
26
|
+
firstComment?: string;
|
|
27
|
+
videoUrl?: string;
|
|
28
|
+
thumbnailUrl?: string;
|
|
29
|
+
isDraft?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SocialPost {
|
|
33
|
+
id: string;
|
|
34
|
+
text: string;
|
|
35
|
+
platforms: SocialPlatform[];
|
|
36
|
+
scheduledAt?: string | null;
|
|
37
|
+
mediaUrls: string[];
|
|
38
|
+
status: "published" | "scheduled" | "failed" | "draft";
|
|
39
|
+
backendId: SocialBackendId | null;
|
|
40
|
+
backendPostId?: string | null;
|
|
41
|
+
backendData?: unknown;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
updatedAt: string;
|
|
44
|
+
// Rich post fields
|
|
45
|
+
title?: string;
|
|
46
|
+
firstComment?: string;
|
|
47
|
+
videoUrl?: string;
|
|
48
|
+
thumbnailUrl?: string;
|
|
49
|
+
createdBy?: "user" | "gemini" | "agent";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PostAnalytics {
|
|
53
|
+
impressions: number;
|
|
54
|
+
likes: number;
|
|
55
|
+
shares: number;
|
|
56
|
+
comments: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AccountAnalytics {
|
|
60
|
+
followers: number;
|
|
61
|
+
following: number;
|
|
62
|
+
posts: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SocialComment {
|
|
66
|
+
id: string;
|
|
67
|
+
author: string;
|
|
68
|
+
text: string;
|
|
69
|
+
createdAt?: string;
|
|
70
|
+
likes?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SocialMediaSettings {
|
|
74
|
+
backend: SocialBackendId | null;
|
|
75
|
+
backends: Partial<Record<SocialBackendId, SocialBackendConfig>>;
|
|
76
|
+
defaultPlatforms: SocialPlatform[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ListPostsOpts {
|
|
80
|
+
status?: string;
|
|
81
|
+
platform?: string;
|
|
82
|
+
limit?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const DEFAULT_SOCIAL_SETTINGS: SocialMediaSettings = {
|
|
86
|
+
backend: null,
|
|
87
|
+
backends: {},
|
|
88
|
+
defaultPlatforms: [],
|
|
89
|
+
};
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailscale CLI wrapper for Funnel integration.
|
|
3
|
+
*
|
|
4
|
+
* Detects the `tailscale` binary, checks connection status, and manages
|
|
5
|
+
* Tailscale Funnel to expose HeyHank over HTTPS. Persists funnel
|
|
6
|
+
* state to ~/.heyhank/tailscale-state.json for restoration across
|
|
7
|
+
* server restarts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawnSync, spawn } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { resolveBinary } from "./path-resolver.js";
|
|
15
|
+
import { getSettings, updateSettings } from "./settings-manager.js";
|
|
16
|
+
|
|
17
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Keep in sync with web/src/api.ts TailscaleStatus */
|
|
20
|
+
export interface TailscaleStatus {
|
|
21
|
+
/** Whether the `tailscale` binary was found on PATH */
|
|
22
|
+
installed: boolean;
|
|
23
|
+
/** Resolved absolute path to the binary, or null */
|
|
24
|
+
binaryPath: string | null;
|
|
25
|
+
/** Whether Tailscale is connected to a tailnet */
|
|
26
|
+
connected: boolean;
|
|
27
|
+
/** Machine DNS name, e.g. "my-machine.tail1234.ts.net" */
|
|
28
|
+
dnsName: string | null;
|
|
29
|
+
/** Whether Funnel is currently active for our port */
|
|
30
|
+
funnelActive: boolean;
|
|
31
|
+
/** HTTPS Funnel URL when active, e.g. "https://my-machine.tail1234.ts.net" */
|
|
32
|
+
funnelUrl: string | null;
|
|
33
|
+
/** Error message if the last operation failed */
|
|
34
|
+
error: string | null;
|
|
35
|
+
/** True when on Linux and Tailscale operator mode is not configured */
|
|
36
|
+
needsOperatorMode?: boolean;
|
|
37
|
+
/** Non-blocking warning (e.g. DNS not resolving publicly) */
|
|
38
|
+
warning?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PersistedFunnelState {
|
|
42
|
+
wasActive: boolean;
|
|
43
|
+
port: number;
|
|
44
|
+
funnelUrl: string;
|
|
45
|
+
activatedAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Internal state ──────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const STATE_PATH = join(homedir(), ".heyhank", "tailscale-state.json");
|
|
51
|
+
const CMD_TIMEOUT = 15_000;
|
|
52
|
+
const BINARY_CACHE_TTL = 60_000; // 1 minute — allows detecting install/uninstall without restart
|
|
53
|
+
|
|
54
|
+
let cachedBinaryPath: string | null | undefined; // undefined = not yet checked
|
|
55
|
+
let binaryCacheTime = 0;
|
|
56
|
+
|
|
57
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function findBinary(): string | null {
|
|
60
|
+
if (cachedBinaryPath !== undefined && Date.now() - binaryCacheTime < BINARY_CACHE_TTL) {
|
|
61
|
+
return cachedBinaryPath;
|
|
62
|
+
}
|
|
63
|
+
cachedBinaryPath = resolveBinary("tailscale");
|
|
64
|
+
binaryCacheTime = Date.now();
|
|
65
|
+
return cachedBinaryPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run a command asynchronously using spawn with explicit argument array
|
|
70
|
+
* (no shell interpolation — eliminates command injection).
|
|
71
|
+
*/
|
|
72
|
+
function execAsync(binary: string, args: string[]): Promise<string> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const proc = spawn(binary, args, {
|
|
75
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
76
|
+
timeout: CMD_TIMEOUT,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let stdout = "";
|
|
80
|
+
let stderr = "";
|
|
81
|
+
|
|
82
|
+
proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); });
|
|
83
|
+
proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
|
|
84
|
+
|
|
85
|
+
proc.on("error", (err) => reject(err));
|
|
86
|
+
proc.on("close", (code) => {
|
|
87
|
+
if (code === 0) {
|
|
88
|
+
resolve(stdout.trim());
|
|
89
|
+
} else {
|
|
90
|
+
reject(new Error(stderr.trim() || `Process exited with code ${code}`));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if operator mode is needed but not configured (Linux only).
|
|
98
|
+
* On macOS the Tailscale GUI app handles permissions, so this is a no-op.
|
|
99
|
+
*/
|
|
100
|
+
async function checkNeedsOperatorMode(binary: string): Promise<boolean> {
|
|
101
|
+
if (process.platform !== "linux") return false;
|
|
102
|
+
try {
|
|
103
|
+
const output = await execAsync(binary, ["debug", "prefs"]);
|
|
104
|
+
const prefs = JSON.parse(output);
|
|
105
|
+
return !prefs.OperatorUser;
|
|
106
|
+
} catch {
|
|
107
|
+
return false; // Can't determine — assume ok
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a hostname resolves via public DNS (Google 8.8.8.8).
|
|
113
|
+
* We explicitly use a public resolver to avoid Tailscale's MagicDNS
|
|
114
|
+
* returning private CGNAT addresses (100.64.x.x) for .ts.net hostnames.
|
|
115
|
+
*/
|
|
116
|
+
async function checkFunnelDnsResolves(hostname: string): Promise<boolean> {
|
|
117
|
+
try {
|
|
118
|
+
const { Resolver } = await import("node:dns/promises");
|
|
119
|
+
const resolver = new Resolver();
|
|
120
|
+
resolver.setServers(["8.8.8.8"]);
|
|
121
|
+
const addresses = await resolver.resolve4(hostname);
|
|
122
|
+
return addresses.length > 0;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function loadPersistedState(): PersistedFunnelState | null {
|
|
129
|
+
try {
|
|
130
|
+
if (!existsSync(STATE_PATH)) return null;
|
|
131
|
+
const raw = JSON.parse(readFileSync(STATE_PATH, "utf-8")) as PersistedFunnelState;
|
|
132
|
+
if (raw && typeof raw.wasActive === "boolean" && typeof raw.port === "number") {
|
|
133
|
+
return raw;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function persistState(state: PersistedFunnelState): void {
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dirname(STATE_PATH), { recursive: true });
|
|
144
|
+
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), "utf-8");
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.warn("[tailscale] Failed to persist state:", err);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function clearPersistedState(): void {
|
|
151
|
+
try {
|
|
152
|
+
if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH);
|
|
153
|
+
} catch {
|
|
154
|
+
// best-effort
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parse `tailscale status --json` to get connection state and DNS name.
|
|
160
|
+
*/
|
|
161
|
+
async function parseConnectionStatus(binary: string): Promise<{ connected: boolean; dnsName: string | null }> {
|
|
162
|
+
try {
|
|
163
|
+
const output = await execAsync(binary, ["status", "--json"]);
|
|
164
|
+
const status = JSON.parse(output) as {
|
|
165
|
+
BackendState?: string;
|
|
166
|
+
Self?: { DNSName?: string };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const backendState = status.BackendState ?? "";
|
|
170
|
+
const connected = backendState === "Running";
|
|
171
|
+
let dnsName: string | null = null;
|
|
172
|
+
|
|
173
|
+
if (connected && status.Self?.DNSName) {
|
|
174
|
+
// DNSName typically ends with a trailing dot — strip it
|
|
175
|
+
dnsName = status.Self.DNSName.replace(/\.$/, "");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { connected, dnsName };
|
|
179
|
+
} catch {
|
|
180
|
+
return { connected: false, dnsName: null };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse `tailscale serve status --json` to determine if a given port is being
|
|
186
|
+
* funneled. The output looks like:
|
|
187
|
+
* {
|
|
188
|
+
* "Web": { "machine.ts.net:443": { "Handlers": { "/": { "Proxy": "http://127.0.0.1:PORT" } } } },
|
|
189
|
+
* "AllowFunnel": { "machine.ts.net:443": true }
|
|
190
|
+
* }
|
|
191
|
+
*/
|
|
192
|
+
async function parseFunnelStatus(binary: string, port: number): Promise<{ active: boolean; funnelUrl: string | null }> {
|
|
193
|
+
try {
|
|
194
|
+
const output = await execAsync(binary, ["serve", "status", "--json"]);
|
|
195
|
+
const config = JSON.parse(output) as {
|
|
196
|
+
Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
|
|
197
|
+
AllowFunnel?: Record<string, boolean>;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (!config.Web || !config.AllowFunnel) {
|
|
201
|
+
return { active: false, funnelUrl: null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Match port precisely: the Proxy URL ends with ":PORT" (no trailing path beyond optional /)
|
|
205
|
+
const portSuffix = `:${port}`;
|
|
206
|
+
for (const [hostPort, isFunnel] of Object.entries(config.AllowFunnel)) {
|
|
207
|
+
if (!isFunnel) continue;
|
|
208
|
+
const handlers = config.Web[hostPort]?.Handlers;
|
|
209
|
+
if (!handlers) continue;
|
|
210
|
+
for (const handler of Object.values(handlers)) {
|
|
211
|
+
if (!handler.Proxy) continue;
|
|
212
|
+
// Exact port match: URL ends with ":PORT" or ":PORT/"
|
|
213
|
+
if (handler.Proxy.endsWith(portSuffix) || handler.Proxy.endsWith(`${portSuffix}/`)) {
|
|
214
|
+
// Extract the hostname from "machine.ts.net:443"
|
|
215
|
+
const hostname = hostPort.split(":")[0];
|
|
216
|
+
return { active: true, funnelUrl: `https://${hostname}` };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { active: false, funnelUrl: null };
|
|
222
|
+
} catch {
|
|
223
|
+
return { active: false, funnelUrl: null };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get full Tailscale status: binary availability, connection, and funnel state.
|
|
231
|
+
*/
|
|
232
|
+
export async function getTailscaleStatus(port: number): Promise<TailscaleStatus> {
|
|
233
|
+
const binary = findBinary();
|
|
234
|
+
if (!binary) {
|
|
235
|
+
return {
|
|
236
|
+
installed: false,
|
|
237
|
+
binaryPath: null,
|
|
238
|
+
connected: false,
|
|
239
|
+
dnsName: null,
|
|
240
|
+
funnelActive: false,
|
|
241
|
+
funnelUrl: null,
|
|
242
|
+
error: null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { connected, dnsName } = await parseConnectionStatus(binary);
|
|
247
|
+
if (!connected) {
|
|
248
|
+
return {
|
|
249
|
+
installed: true,
|
|
250
|
+
binaryPath: binary,
|
|
251
|
+
connected: false,
|
|
252
|
+
dnsName: null,
|
|
253
|
+
funnelActive: false,
|
|
254
|
+
funnelUrl: null,
|
|
255
|
+
error: null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const { active, funnelUrl } = await parseFunnelStatus(binary, port);
|
|
260
|
+
const needsOperatorMode = !active ? await checkNeedsOperatorMode(binary) : undefined;
|
|
261
|
+
|
|
262
|
+
// If funnel is active, check if the URL actually resolves publicly
|
|
263
|
+
let warning: string | undefined;
|
|
264
|
+
if (active && dnsName) {
|
|
265
|
+
const dnsOk = await checkFunnelDnsResolves(dnsName);
|
|
266
|
+
if (!dnsOk) {
|
|
267
|
+
warning = "DNS for this hostname is not resolving publicly. Ensure Funnel is enabled in your Tailscale admin console (admin.tailscale.com \u2192 Access Controls \u2192 nodeAttrs). DNS propagation can take up to 10 minutes on first use.";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
installed: true,
|
|
273
|
+
binaryPath: binary,
|
|
274
|
+
connected: true,
|
|
275
|
+
dnsName,
|
|
276
|
+
funnelActive: active,
|
|
277
|
+
funnelUrl,
|
|
278
|
+
error: null,
|
|
279
|
+
...(needsOperatorMode && { needsOperatorMode }),
|
|
280
|
+
...(warning && { warning }),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Start Tailscale Funnel for the given port.
|
|
286
|
+
* Automatically updates publicUrl in settings and persists funnel state.
|
|
287
|
+
*/
|
|
288
|
+
export async function startFunnel(port: number): Promise<TailscaleStatus> {
|
|
289
|
+
const binary = findBinary();
|
|
290
|
+
if (!binary) {
|
|
291
|
+
return { installed: false, binaryPath: null, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not installed" };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const { connected, dnsName } = await parseConnectionStatus(binary);
|
|
295
|
+
if (!connected) {
|
|
296
|
+
return { installed: true, binaryPath: binary, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not connected. Run `tailscale up` to connect." };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
await execAsync(binary, ["funnel", "--bg", String(port)]);
|
|
301
|
+
} catch (err: unknown) {
|
|
302
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
303
|
+
const isPermissionError = process.platform === "linux" && /permission|sudo|access denied/i.test(message);
|
|
304
|
+
return {
|
|
305
|
+
installed: true, binaryPath: binary, connected: true, dnsName,
|
|
306
|
+
funnelActive: false, funnelUrl: null,
|
|
307
|
+
error: isPermissionError
|
|
308
|
+
? "Tailscale requires operator mode on Linux to manage Funnel."
|
|
309
|
+
: `Failed to start Funnel: ${message}`,
|
|
310
|
+
...(isPermissionError && { needsOperatorMode: true }),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Verify it's running and get the URL
|
|
315
|
+
const { active, funnelUrl } = await parseFunnelStatus(binary, port);
|
|
316
|
+
|
|
317
|
+
// DNS reachability is NOT checked here — it takes seconds to minutes for
|
|
318
|
+
// Tailscale to provision public DNS records after first enablement.
|
|
319
|
+
// The check runs in getTailscaleStatus() on subsequent polls instead.
|
|
320
|
+
|
|
321
|
+
if (!active || !funnelUrl) {
|
|
322
|
+
// Funnel command succeeded but we can't detect it yet — construct URL from DNS name
|
|
323
|
+
const constructedUrl = dnsName ? `https://${dnsName}` : null;
|
|
324
|
+
if (constructedUrl) {
|
|
325
|
+
updateSettings({ publicUrl: constructedUrl });
|
|
326
|
+
persistState({ wasActive: true, port, funnelUrl: constructedUrl, activatedAt: Date.now() });
|
|
327
|
+
return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: true, funnelUrl: constructedUrl, error: null };
|
|
328
|
+
}
|
|
329
|
+
return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: false, funnelUrl: null, error: "Funnel started but could not determine URL" };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
updateSettings({ publicUrl: funnelUrl });
|
|
333
|
+
persistState({ wasActive: true, port, funnelUrl, activatedAt: Date.now() });
|
|
334
|
+
console.log(`[tailscale] Funnel started: ${funnelUrl} → localhost:${port}`);
|
|
335
|
+
|
|
336
|
+
return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: true, funnelUrl, error: null };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Stop Tailscale Funnel for the given port.
|
|
341
|
+
* Clears publicUrl if it still matches the Funnel URL.
|
|
342
|
+
*/
|
|
343
|
+
export async function stopFunnel(port: number): Promise<TailscaleStatus> {
|
|
344
|
+
const binary = findBinary();
|
|
345
|
+
if (!binary) {
|
|
346
|
+
return { installed: false, binaryPath: null, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not installed" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Read persisted URL before stopping
|
|
350
|
+
const persisted = loadPersistedState();
|
|
351
|
+
const previousUrl = persisted?.funnelUrl ?? null;
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await execAsync(binary, ["funnel", String(port), "off"]);
|
|
355
|
+
} catch (err: unknown) {
|
|
356
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
357
|
+
// Re-query actual state — funnel is likely still running after a failed stop
|
|
358
|
+
const { connected, dnsName } = await parseConnectionStatus(binary).catch(() => ({ connected: true, dnsName: null as string | null }));
|
|
359
|
+
const { active, funnelUrl } = await parseFunnelStatus(binary, port).catch(() => ({ active: true, funnelUrl: null as string | null }));
|
|
360
|
+
return { installed: true, binaryPath: binary, connected, dnsName, funnelActive: active, funnelUrl, error: `Failed to stop Funnel: ${message}` };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
clearPersistedState();
|
|
364
|
+
|
|
365
|
+
// Clear publicUrl only if it matches the Funnel URL (don't overwrite manual URL)
|
|
366
|
+
if (previousUrl) {
|
|
367
|
+
const currentPublicUrl = getSettings().publicUrl;
|
|
368
|
+
if (currentPublicUrl === previousUrl) {
|
|
369
|
+
updateSettings({ publicUrl: "" });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
console.log(`[tailscale] Funnel stopped for port ${port}`);
|
|
374
|
+
|
|
375
|
+
const { connected, dnsName } = await parseConnectionStatus(binary);
|
|
376
|
+
return { installed: true, binaryPath: binary, connected, dnsName, funnelActive: false, funnelUrl: null, error: null };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Check if Tailscale Funnel was previously active and verify it's still running.
|
|
381
|
+
* Called on server startup to keep publicUrl in sync.
|
|
382
|
+
*/
|
|
383
|
+
export async function restoreIfNeeded(port: number): Promise<void> {
|
|
384
|
+
const persisted = loadPersistedState();
|
|
385
|
+
if (!persisted?.wasActive) return;
|
|
386
|
+
|
|
387
|
+
const binary = findBinary();
|
|
388
|
+
if (!binary) {
|
|
389
|
+
console.log("[tailscale] Binary not found, clearing persisted funnel state");
|
|
390
|
+
clearPersistedState();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const { connected } = await parseConnectionStatus(binary);
|
|
395
|
+
if (!connected) {
|
|
396
|
+
console.log("[tailscale] Not connected, clearing persisted funnel state");
|
|
397
|
+
clearPersistedState();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Check if funnel is still active (--bg makes it a daemon, so it should survive restarts)
|
|
402
|
+
const { active, funnelUrl } = await parseFunnelStatus(binary, port);
|
|
403
|
+
if (active && funnelUrl) {
|
|
404
|
+
console.log(`[tailscale] Funnel still active: ${funnelUrl}`);
|
|
405
|
+
// Ensure publicUrl is in sync
|
|
406
|
+
const currentPublicUrl = getSettings().publicUrl;
|
|
407
|
+
if (currentPublicUrl !== funnelUrl) {
|
|
408
|
+
updateSettings({ publicUrl: funnelUrl });
|
|
409
|
+
console.log(`[tailscale] Updated publicUrl to match active Funnel: ${funnelUrl}`);
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
console.log("[tailscale] Funnel no longer active, clearing persisted state");
|
|
413
|
+
clearPersistedState();
|
|
414
|
+
// Clear publicUrl if it still points to the old Funnel URL
|
|
415
|
+
const currentPublicUrl = getSettings().publicUrl;
|
|
416
|
+
if (persisted.funnelUrl && currentPublicUrl === persisted.funnelUrl) {
|
|
417
|
+
updateSettings({ publicUrl: "" });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Best-effort cleanup on server shutdown. Uses spawnSync since process.exit follows.
|
|
424
|
+
* By default, leaves Funnel running (it's a system daemon).
|
|
425
|
+
* Set HEYHANK_TAILSCALE_CLEANUP_ON_EXIT=1 to stop on shutdown.
|
|
426
|
+
*/
|
|
427
|
+
export function cleanup(port: number): void {
|
|
428
|
+
const shouldCleanup = (process.env.HEYHANK_TAILSCALE_CLEANUP_ON_EXIT || process.env.COMPANION_TAILSCALE_CLEANUP_ON_EXIT) === "1";
|
|
429
|
+
if (!shouldCleanup) return;
|
|
430
|
+
|
|
431
|
+
const binary = findBinary();
|
|
432
|
+
if (!binary) return;
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
spawnSync(binary, ["funnel", String(port), "off"], {
|
|
436
|
+
encoding: "utf-8",
|
|
437
|
+
timeout: 5_000,
|
|
438
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
439
|
+
});
|
|
440
|
+
clearPersistedState();
|
|
441
|
+
console.log(`[tailscale] Funnel stopped on shutdown for port ${port}`);
|
|
442
|
+
} catch {
|
|
443
|
+
// best-effort
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Reset cached state for testing. */
|
|
448
|
+
export function _resetForTest(): void {
|
|
449
|
+
cachedBinaryPath = undefined;
|
|
450
|
+
binaryCacheTime = 0;
|
|
451
|
+
}
|