@zeroexcore/tuna 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.
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Service management - launchd service for persistent cloudflared tunnels
3
+ */
4
+
5
+ import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
6
+ import { homedir, platform } from 'os';
7
+ import { join } from 'path';
8
+ import { execa } from 'execa';
9
+ import * as yaml from 'js-yaml';
10
+ import {
11
+ getExecutablePath,
12
+ getTunaDir,
13
+ getTunnelCredentialsPath,
14
+ getTunnelConfigPath,
15
+ ensureDirectories,
16
+ } from './cloudflared.ts';
17
+ import type { IngressConfig, IngressRule, ServiceStatus, TunnelCredentials } from '../types/index.ts';
18
+
19
+ const LAUNCHD_LABEL = 'com.tuna.cloudflared';
20
+ const LAUNCH_AGENTS_DIR = join(homedir(), 'Library', 'LaunchAgents');
21
+ const PLIST_PATH = join(LAUNCH_AGENTS_DIR, `${LAUNCHD_LABEL}.plist`);
22
+
23
+ /**
24
+ * Generate ingress configuration for a tunnel
25
+ */
26
+ export function generateIngressConfig(
27
+ tunnelId: string,
28
+ hostname: string,
29
+ port: number
30
+ ): IngressConfig {
31
+ const credentialsFile = getTunnelCredentialsPath(tunnelId);
32
+
33
+ return {
34
+ tunnel: tunnelId,
35
+ credentials_file: credentialsFile,
36
+ ingress: [
37
+ {
38
+ hostname,
39
+ service: `http://localhost:${port}`,
40
+ },
41
+ {
42
+ service: 'http_status:404',
43
+ },
44
+ ],
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Write ingress configuration to YAML file
50
+ * Note: cloudflared expects 'credentials-file' (hyphen) not 'credentials_file' (underscore)
51
+ */
52
+ export function writeIngressConfig(tunnelId: string, config: IngressConfig): string {
53
+ ensureDirectories();
54
+ const configPath = getTunnelConfigPath(tunnelId);
55
+
56
+ // Convert to cloudflared's expected format (hyphenated keys)
57
+ const cloudflaredConfig = {
58
+ tunnel: config.tunnel,
59
+ 'credentials-file': config.credentials_file,
60
+ ingress: config.ingress,
61
+ };
62
+
63
+ const yamlContent = yaml.dump(cloudflaredConfig, {
64
+ lineWidth: -1, // Don't wrap lines
65
+ noRefs: true,
66
+ });
67
+ writeFileSync(configPath, yamlContent, { mode: 0o600 });
68
+ return configPath;
69
+ }
70
+
71
+ /**
72
+ * Read existing ingress configuration
73
+ */
74
+ export function readIngressConfig(tunnelId: string): IngressConfig | null {
75
+ const configPath = getTunnelConfigPath(tunnelId);
76
+ if (!existsSync(configPath)) {
77
+ return null;
78
+ }
79
+ const content = readFileSync(configPath, 'utf-8');
80
+ return yaml.load(content) as IngressConfig;
81
+ }
82
+
83
+ /**
84
+ * Add or update an ingress rule in the configuration
85
+ */
86
+ export function updateIngressRule(
87
+ config: IngressConfig,
88
+ hostname: string,
89
+ port: number
90
+ ): IngressConfig {
91
+ const newRule: IngressRule = {
92
+ hostname,
93
+ service: `http://localhost:${port}`,
94
+ };
95
+
96
+ // Find existing rule for this hostname
97
+ const existingIndex = config.ingress.findIndex((r) => r.hostname === hostname);
98
+
99
+ if (existingIndex >= 0) {
100
+ // Update existing rule
101
+ config.ingress[existingIndex] = newRule;
102
+ } else {
103
+ // Add new rule before the catch-all 404
104
+ const catchAllIndex = config.ingress.findIndex((r) => !r.hostname);
105
+ if (catchAllIndex >= 0) {
106
+ config.ingress.splice(catchAllIndex, 0, newRule);
107
+ } else {
108
+ config.ingress.push(newRule);
109
+ config.ingress.push({ service: 'http_status:404' });
110
+ }
111
+ }
112
+
113
+ return config;
114
+ }
115
+
116
+ /**
117
+ * Remove an ingress rule from the configuration
118
+ */
119
+ export function removeIngressRule(config: IngressConfig, hostname: string): IngressConfig {
120
+ config.ingress = config.ingress.filter((r) => r.hostname !== hostname);
121
+ return config;
122
+ }
123
+
124
+ /**
125
+ * Save tunnel credentials to file
126
+ */
127
+ export function saveTunnelCredentials(
128
+ tunnelId: string,
129
+ accountId: string,
130
+ tunnelSecret: string
131
+ ): string {
132
+ ensureDirectories();
133
+ const credentialsPath = getTunnelCredentialsPath(tunnelId);
134
+
135
+ const credentials: TunnelCredentials = {
136
+ AccountTag: accountId,
137
+ TunnelSecret: tunnelSecret,
138
+ TunnelID: tunnelId,
139
+ };
140
+
141
+ writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), { mode: 0o600 });
142
+ return credentialsPath;
143
+ }
144
+
145
+ /**
146
+ * Check if tunnel credentials exist
147
+ */
148
+ export function tunnelCredentialsExist(tunnelId: string): boolean {
149
+ return existsSync(getTunnelCredentialsPath(tunnelId));
150
+ }
151
+
152
+ /**
153
+ * Delete tunnel credentials file
154
+ */
155
+ export function deleteTunnelCredentials(tunnelId: string): boolean {
156
+ const path = getTunnelCredentialsPath(tunnelId);
157
+ if (existsSync(path)) {
158
+ unlinkSync(path);
159
+ return true;
160
+ }
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Delete tunnel config file
166
+ */
167
+ export function deleteTunnelConfig(tunnelId: string): boolean {
168
+ const path = getTunnelConfigPath(tunnelId);
169
+ if (existsSync(path)) {
170
+ unlinkSync(path);
171
+ return true;
172
+ }
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * Generate launchd plist content for the service
178
+ */
179
+ function generatePlistContent(configPath: string): string {
180
+ const cloudflaredPath = getExecutablePath();
181
+
182
+ return `<?xml version="1.0" encoding="UTF-8"?>
183
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
184
+ <plist version="1.0">
185
+ <dict>
186
+ <key>Label</key>
187
+ <string>${LAUNCHD_LABEL}</string>
188
+ <key>ProgramArguments</key>
189
+ <array>
190
+ <string>${cloudflaredPath}</string>
191
+ <string>tunnel</string>
192
+ <string>--config</string>
193
+ <string>${configPath}</string>
194
+ <string>run</string>
195
+ </array>
196
+ <key>RunAtLoad</key>
197
+ <true/>
198
+ <key>KeepAlive</key>
199
+ <true/>
200
+ <key>StandardOutPath</key>
201
+ <string>${getTunaDir()}/cloudflared.log</string>
202
+ <key>StandardErrorPath</key>
203
+ <string>${getTunaDir()}/cloudflared.err</string>
204
+ </dict>
205
+ </plist>`;
206
+ }
207
+
208
+ /**
209
+ * Install cloudflared as a launchd service (macOS only)
210
+ */
211
+ export async function installService(tunnelId: string): Promise<void> {
212
+ if (platform() !== 'darwin') {
213
+ throw new Error('Service installation is only supported on macOS');
214
+ }
215
+
216
+ const configPath = getTunnelConfigPath(tunnelId);
217
+ if (!existsSync(configPath)) {
218
+ throw new Error(`Tunnel config not found: ${configPath}`);
219
+ }
220
+
221
+ // Create LaunchAgents directory if it doesn't exist
222
+ const { mkdirSync } = await import('fs');
223
+ if (!existsSync(LAUNCH_AGENTS_DIR)) {
224
+ mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
225
+ }
226
+
227
+ // Write plist file
228
+ const plistContent = generatePlistContent(configPath);
229
+ writeFileSync(PLIST_PATH, plistContent);
230
+
231
+ // Load the service
232
+ await execa('launchctl', ['load', PLIST_PATH]);
233
+ }
234
+
235
+ /**
236
+ * Uninstall the cloudflared launchd service
237
+ */
238
+ export async function uninstallService(): Promise<void> {
239
+ if (platform() !== 'darwin') {
240
+ throw new Error('Service management is only supported on macOS');
241
+ }
242
+
243
+ if (!existsSync(PLIST_PATH)) {
244
+ return; // Not installed
245
+ }
246
+
247
+ try {
248
+ // Unload the service
249
+ await execa('launchctl', ['unload', PLIST_PATH]);
250
+ } catch {
251
+ // Ignore errors if service wasn't loaded
252
+ }
253
+
254
+ // Remove plist file
255
+ if (existsSync(PLIST_PATH)) {
256
+ unlinkSync(PLIST_PATH);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Start the cloudflared service
262
+ */
263
+ export async function startService(): Promise<void> {
264
+ if (platform() !== 'darwin') {
265
+ throw new Error('Service management is only supported on macOS');
266
+ }
267
+
268
+ if (!existsSync(PLIST_PATH)) {
269
+ throw new Error('Service not installed. Run tuna first to set up the tunnel.');
270
+ }
271
+
272
+ await execa('launchctl', ['start', LAUNCHD_LABEL]);
273
+ }
274
+
275
+ /**
276
+ * Stop the cloudflared service
277
+ */
278
+ export async function stopService(): Promise<void> {
279
+ if (platform() !== 'darwin') {
280
+ throw new Error('Service management is only supported on macOS');
281
+ }
282
+
283
+ if (!existsSync(PLIST_PATH)) {
284
+ return; // Not installed
285
+ }
286
+
287
+ try {
288
+ await execa('launchctl', ['stop', LAUNCHD_LABEL]);
289
+ } catch {
290
+ // Ignore errors if service wasn't running
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Restart the cloudflared service
296
+ */
297
+ export async function restartService(): Promise<void> {
298
+ await stopService();
299
+ await startService();
300
+ }
301
+
302
+ /**
303
+ * Get the current service status
304
+ */
305
+ export async function getServiceStatus(): Promise<ServiceStatus> {
306
+ if (platform() !== 'darwin') {
307
+ return { running: false };
308
+ }
309
+
310
+ if (!existsSync(PLIST_PATH)) {
311
+ return { running: false };
312
+ }
313
+
314
+ try {
315
+ const result = await execa('launchctl', ['list', LAUNCHD_LABEL]);
316
+ const output = result.stdout;
317
+
318
+ // Parse PID from output
319
+ // Format: "PID\tStatus\tLabel"
320
+ const match = output.match(/^(\d+)/);
321
+ const pid = match ? parseInt(match[1], 10) : undefined;
322
+
323
+ return {
324
+ running: pid !== undefined && pid > 0,
325
+ pid,
326
+ };
327
+ } catch {
328
+ return { running: false };
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Check if the service is installed
334
+ */
335
+ export function isServiceInstalled(): boolean {
336
+ return existsSync(PLIST_PATH);
337
+ }
338
+
339
+ /**
340
+ * Update the service configuration (reload with new config)
341
+ */
342
+ export async function updateServiceConfig(tunnelId: string): Promise<void> {
343
+ const status = await getServiceStatus();
344
+
345
+ // Update plist with new config path
346
+ const configPath = getTunnelConfigPath(tunnelId);
347
+ const plistContent = generatePlistContent(configPath);
348
+ writeFileSync(PLIST_PATH, plistContent);
349
+
350
+ if (status.running) {
351
+ // Reload service to pick up new config
352
+ await restartService();
353
+ }
354
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Type definitions for Cloudflare API and tunnel structures
3
+ */
4
+
5
+ export interface Credentials {
6
+ apiToken: string;
7
+ accountId: string;
8
+ domain: string;
9
+ }
10
+
11
+ export interface Tunnel {
12
+ id: string;
13
+ name: string;
14
+ created_at: string;
15
+ deleted_at?: string;
16
+ status: 'healthy' | 'down' | 'degraded' | 'inactive';
17
+ connections?: Connection[];
18
+ conns_active_at?: string;
19
+ conns_inactive_at?: string;
20
+ account_tag?: string;
21
+ tun_type?: string;
22
+ remote_config?: boolean;
23
+ }
24
+
25
+ export interface Connection {
26
+ id: string;
27
+ client_id: string;
28
+ client_version?: string;
29
+ colo_name: string;
30
+ opened_at: string;
31
+ origin_ip: string;
32
+ uuid?: string;
33
+ is_pending_reconnect?: boolean;
34
+ }
35
+
36
+ export interface DnsRecord {
37
+ id?: string;
38
+ type: 'CNAME' | 'A' | 'AAAA';
39
+ name: string;
40
+ content: string;
41
+ proxied: boolean;
42
+ ttl: number;
43
+ zone_id?: string;
44
+ zone_name?: string;
45
+ comment?: string;
46
+ }
47
+
48
+ export interface Zone {
49
+ id: string;
50
+ name: string;
51
+ status: string;
52
+ paused?: boolean;
53
+ type?: string;
54
+ }
55
+
56
+ export interface IngressRule {
57
+ hostname?: string;
58
+ service: string;
59
+ }
60
+
61
+ export interface IngressConfig {
62
+ tunnel: string;
63
+ credentials_file: string;
64
+ ingress: IngressRule[];
65
+ }
66
+
67
+ export interface ServiceStatus {
68
+ running: boolean;
69
+ pid?: number;
70
+ }
71
+
72
+ export interface TunnelCredentials {
73
+ AccountTag: string;
74
+ TunnelSecret: string;
75
+ TunnelID: string;
76
+ }
77
+
78
+ export interface CloudflareApiResponse<T> {
79
+ success: boolean;
80
+ errors: CloudflareApiError[];
81
+ messages: CloudflareApiMessage[];
82
+ result: T;
83
+ result_info?: {
84
+ count: number;
85
+ page: number;
86
+ per_page: number;
87
+ total_count: number;
88
+ };
89
+ }
90
+
91
+ export interface CloudflareApiError {
92
+ code: number;
93
+ message: string;
94
+ documentation_url?: string;
95
+ source?: {
96
+ pointer: string;
97
+ };
98
+ }
99
+
100
+ export interface CloudflareApiMessage {
101
+ code: number;
102
+ message: string;
103
+ documentation_url?: string;
104
+ source?: {
105
+ pointer: string;
106
+ };
107
+ }
108
+
109
+ // Zero Trust Access types
110
+
111
+ export interface AccessApplication {
112
+ id: string;
113
+ name: string;
114
+ domain: string;
115
+ type: 'self_hosted' | 'saas' | 'ssh' | 'vnc' | 'bookmark';
116
+ session_duration?: string;
117
+ created_at?: string;
118
+ updated_at?: string;
119
+ aud?: string;
120
+ }
121
+
122
+ export interface AccessPolicy {
123
+ id: string;
124
+ name: string;
125
+ decision: 'allow' | 'deny' | 'bypass' | 'non_identity';
126
+ include: AccessRule[];
127
+ exclude?: AccessRule[];
128
+ require?: AccessRule[];
129
+ precedence?: number;
130
+ created_at?: string;
131
+ updated_at?: string;
132
+ }
133
+
134
+ // Access rule types - union of different rule kinds
135
+ export type AccessRule =
136
+ | { email: { email: string } }
137
+ | { email_domain: { domain: string } }
138
+ | { everyone: Record<string, never> }
139
+ | { ip: { ip: string } }
140
+ | { group: { id: string } };
141
+
142
+ export interface AccessApplicationCreate {
143
+ name: string;
144
+ domain: string;
145
+ type: 'self_hosted';
146
+ session_duration?: string;
147
+ }
148
+
149
+ export interface AccessPolicyCreate {
150
+ name: string;
151
+ decision: 'allow' | 'deny' | 'bypass' | 'non_identity';
152
+ include: AccessRule[];
153
+ exclude?: AccessRule[];
154
+ require?: AccessRule[];
155
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Type definitions for Tuna configuration
3
+ */
4
+
5
+ /**
6
+ * Access control configuration for Zero Trust
7
+ * Array of emails and/or email domains:
8
+ * - Strings starting with @ are email domains: "@company.com"
9
+ * - Other strings are specific emails: "alice@gmail.com"
10
+ * Example: ["@company.com", "bob@contractor.io"]
11
+ */
12
+ export type AccessConfig = string[];
13
+
14
+ export interface TunaConfig {
15
+ forward: string; // Domain to expose (e.g., my-app.example.com or $USER-app.example.com)
16
+ port: number; // Local port to forward to
17
+ access?: AccessConfig; // Optional Zero Trust access control
18
+ }
19
+
20
+ export interface PackageJson {
21
+ name?: string;
22
+ version?: string;
23
+ tuna?: TunaConfig;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ /**
28
+ * Parsed access config - separated into emails and domains
29
+ */
30
+ export interface ParsedAccessConfig {
31
+ emails: string[]; // ["alice@gmail.com", "bob@contractor.io"]
32
+ emailDomains: string[]; // ["company.com"] (without @)
33
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Central export point for all type definitions
3
+ */
4
+
5
+ export * from './config.ts';
6
+ export * from './cloudflare.ts';