@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,191 @@
1
+ /**
2
+ * Zero Trust Access management - handles Access applications and policies
3
+ * Uses snapshot-based diffing to sync config with Cloudflare
4
+ */
5
+
6
+ import { CloudflareAPI } from './api.ts';
7
+ import type {
8
+ Credentials,
9
+ AccessConfig,
10
+ ParsedAccessConfig,
11
+ AccessRule,
12
+ AccessPolicyCreate,
13
+ } from '../types/index.ts';
14
+
15
+ const TUNA_POLICY_NAME = 'tuna-access-policy';
16
+
17
+ /**
18
+ * Parse access config array into emails and domains
19
+ * - Strings starting with @ are email domains
20
+ * - Other strings are specific emails
21
+ */
22
+ export function parseAccessConfig(access: AccessConfig): ParsedAccessConfig {
23
+ const emails: string[] = [];
24
+ const emailDomains: string[] = [];
25
+
26
+ for (const entry of access) {
27
+ if (entry.startsWith('@')) {
28
+ // Email domain - remove the @ prefix
29
+ emailDomains.push(entry.slice(1));
30
+ } else {
31
+ // Specific email
32
+ emails.push(entry);
33
+ }
34
+ }
35
+
36
+ return { emails, emailDomains };
37
+ }
38
+
39
+ /**
40
+ * Build Access rules from parsed config
41
+ */
42
+ function buildAccessRules(parsed: ParsedAccessConfig): AccessRule[] {
43
+ const rules: AccessRule[] = [];
44
+
45
+ // Add email rules
46
+ for (const email of parsed.emails) {
47
+ rules.push({ email: { email } });
48
+ }
49
+
50
+ // Add email domain rules
51
+ for (const domain of parsed.emailDomains) {
52
+ rules.push({ email_domain: { domain } });
53
+ }
54
+
55
+ return rules;
56
+ }
57
+
58
+ /**
59
+ * Compare two access rule sets to check if they're equivalent
60
+ */
61
+ function accessRulesEqual(a: AccessRule[], b: AccessRule[]): boolean {
62
+ if (a.length !== b.length) return false;
63
+
64
+ // Normalize and sort for comparison
65
+ const normalize = (rules: AccessRule[]): string[] => {
66
+ return rules.map((rule) => {
67
+ if ('email' in rule) return `email:${rule.email.email}`;
68
+ if ('email_domain' in rule) return `domain:${rule.email_domain.domain}`;
69
+ return JSON.stringify(rule);
70
+ }).sort();
71
+ };
72
+
73
+ const aNorm = normalize(a);
74
+ const bNorm = normalize(b);
75
+
76
+ return aNorm.every((val, i) => val === bNorm[i]);
77
+ }
78
+
79
+ /**
80
+ * Ensure Access application and policy exist and are in sync with config
81
+ * Returns the app ID if access is configured, null otherwise
82
+ */
83
+ export async function ensureAccess(
84
+ credentials: Credentials,
85
+ hostname: string,
86
+ access: AccessConfig | undefined
87
+ ): Promise<string | null> {
88
+ const api = new CloudflareAPI(credentials);
89
+
90
+ // Check if there's an existing Access app for this hostname
91
+ const existingApp = await api.getAccessApplicationByDomain(hostname);
92
+
93
+ // No access config - remove any existing Access app
94
+ if (!access || access.length === 0) {
95
+ if (existingApp) {
96
+ await api.deleteAccessApplication(existingApp.id);
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // Parse the access config
102
+ const parsed = parseAccessConfig(access);
103
+ const desiredRules = buildAccessRules(parsed);
104
+
105
+ if (desiredRules.length === 0) {
106
+ // No valid rules - remove existing if any
107
+ if (existingApp) {
108
+ await api.deleteAccessApplication(existingApp.id);
109
+ }
110
+ return null;
111
+ }
112
+
113
+ // Create or get the Access application
114
+ let appId: string;
115
+
116
+ if (existingApp) {
117
+ appId = existingApp.id;
118
+ } else {
119
+ // Create new Access application
120
+ const app = await api.createAccessApplication({
121
+ name: `tuna-${hostname}`,
122
+ domain: hostname,
123
+ type: 'self_hosted',
124
+ session_duration: '24h',
125
+ });
126
+ appId = app.id;
127
+ }
128
+
129
+ // Now sync the policy
130
+ const existingPolicies = await api.listAccessPolicies(appId);
131
+ const tunaPolicy = existingPolicies.find((p) => p.name === TUNA_POLICY_NAME);
132
+
133
+ const desiredPolicy: AccessPolicyCreate = {
134
+ name: TUNA_POLICY_NAME,
135
+ decision: 'allow',
136
+ include: desiredRules,
137
+ };
138
+
139
+ if (tunaPolicy) {
140
+ // Check if policy needs updating
141
+ if (!accessRulesEqual(tunaPolicy.include, desiredRules)) {
142
+ await api.updateAccessPolicy(appId, tunaPolicy.id, desiredPolicy);
143
+ }
144
+ // else: policy is already in sync, no action needed
145
+ } else {
146
+ // Create new policy
147
+ await api.createAccessPolicy(appId, desiredPolicy);
148
+ }
149
+
150
+ return appId;
151
+ }
152
+
153
+ /**
154
+ * Remove Access application for a hostname
155
+ */
156
+ export async function removeAccess(
157
+ credentials: Credentials,
158
+ hostname: string
159
+ ): Promise<boolean> {
160
+ const api = new CloudflareAPI(credentials);
161
+
162
+ const existingApp = await api.getAccessApplicationByDomain(hostname);
163
+ if (existingApp) {
164
+ await api.deleteAccessApplication(existingApp.id);
165
+ return true;
166
+ }
167
+
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * Get access rules description for display
173
+ */
174
+ export function getAccessDescription(access: AccessConfig): string {
175
+ const parsed = parseAccessConfig(access);
176
+ const parts: string[] = [];
177
+
178
+ if (parsed.emailDomains.length > 0) {
179
+ parts.push(...parsed.emailDomains.map((d) => `@${d}`));
180
+ }
181
+
182
+ if (parsed.emails.length > 0) {
183
+ if (parsed.emails.length <= 3) {
184
+ parts.push(...parsed.emails);
185
+ } else {
186
+ parts.push(`${parsed.emails.length} emails`);
187
+ }
188
+ }
189
+
190
+ return parts.join(', ');
191
+ }
package/src/lib/api.ts ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Cloudflare API client for tunnel and DNS management
3
+ */
4
+
5
+ import type {
6
+ Credentials,
7
+ Tunnel,
8
+ DnsRecord,
9
+ Zone,
10
+ CloudflareApiResponse,
11
+ AccessApplication,
12
+ AccessPolicy,
13
+ AccessApplicationCreate,
14
+ AccessPolicyCreate,
15
+ } from '../types/index.ts';
16
+
17
+ const BASE_URL = 'https://api.cloudflare.com/client/v4';
18
+
19
+ export class CloudflareAPI {
20
+ private apiToken: string;
21
+ private accountId: string;
22
+
23
+ constructor(credentials: Credentials) {
24
+ this.accountId = credentials.accountId;
25
+ this.apiToken = credentials.apiToken;
26
+ }
27
+
28
+ /**
29
+ * Make an authenticated request to the Cloudflare API
30
+ */
31
+ private async request<T>(
32
+ path: string,
33
+ options: RequestInit = {}
34
+ ): Promise<CloudflareApiResponse<T>> {
35
+ const url = `${BASE_URL}${path}`;
36
+
37
+ const response = await fetch(url, {
38
+ ...options,
39
+ headers: {
40
+ Authorization: `Bearer ${this.apiToken}`,
41
+ 'Content-Type': 'application/json',
42
+ ...options.headers,
43
+ },
44
+ });
45
+
46
+ // Handle rate limiting with retry
47
+ if (response.status === 429) {
48
+ const retryAfter = parseInt(response.headers.get('retry-after') || '5');
49
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
50
+ return this.request<T>(path, options);
51
+ }
52
+
53
+ const data = (await response.json()) as CloudflareApiResponse<T>;
54
+
55
+ if (!response.ok) {
56
+ this.handleApiError(response.status, data);
57
+ }
58
+
59
+ return data;
60
+ }
61
+
62
+ /**
63
+ * Validate API token and get account ID
64
+ */
65
+ async validateToken(): Promise<string> {
66
+ const verifyResponse = await this.request<{ status: string }>('/user/tokens/verify');
67
+
68
+ if (!verifyResponse.success) {
69
+ throw new Error('Invalid API token');
70
+ }
71
+
72
+ // Get account ID
73
+ const accountsResponse = await this.request<Array<{ id: string }>>('/accounts');
74
+ const accounts = accountsResponse.result;
75
+
76
+ if (!accounts || accounts.length === 0) {
77
+ throw new Error('No accounts found for this token');
78
+ }
79
+
80
+ return accounts[0].id;
81
+ }
82
+
83
+ /**
84
+ * Create a new tunnel
85
+ */
86
+ async createTunnel(name: string, secret: string): Promise<Tunnel> {
87
+ const response = await this.request<Tunnel>(`/accounts/${this.accountId}/cfd_tunnel`, {
88
+ method: 'POST',
89
+ body: JSON.stringify({
90
+ name,
91
+ tunnel_secret: secret,
92
+ config_src: 'local',
93
+ }),
94
+ });
95
+
96
+ if (!response.success) {
97
+ throw new Error(`Failed to create tunnel: ${response.errors[0]?.message}`);
98
+ }
99
+
100
+ return response.result;
101
+ }
102
+
103
+ /**
104
+ * List all tunnels
105
+ */
106
+ async listTunnels(): Promise<Tunnel[]> {
107
+ const response = await this.request<Tunnel[]>(`/accounts/${this.accountId}/cfd_tunnel`);
108
+
109
+ if (!response.success) {
110
+ throw new Error('Failed to list tunnels');
111
+ }
112
+
113
+ return response.result;
114
+ }
115
+
116
+ /**
117
+ * Get tunnel details
118
+ */
119
+ async getTunnel(tunnelId: string): Promise<Tunnel> {
120
+ const response = await this.request<Tunnel>(
121
+ `/accounts/${this.accountId}/cfd_tunnel/${tunnelId}`
122
+ );
123
+
124
+ if (!response.success) {
125
+ throw new Error('Tunnel not found');
126
+ }
127
+
128
+ return response.result;
129
+ }
130
+
131
+ /**
132
+ * Delete a tunnel
133
+ */
134
+ async deleteTunnel(tunnelId: string): Promise<void> {
135
+ const response = await this.request<{ id: string }>(
136
+ `/accounts/${this.accountId}/cfd_tunnel/${tunnelId}`,
137
+ { method: 'DELETE' }
138
+ );
139
+
140
+ if (!response.success) {
141
+ throw new Error('Failed to delete tunnel');
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get zone by domain name
147
+ */
148
+ async getZoneByName(domain: string): Promise<Zone> {
149
+ const response = await this.request<Zone[]>(`/zones?name=${encodeURIComponent(domain)}`);
150
+
151
+ if (!response.success || response.result.length === 0) {
152
+ throw new Error(
153
+ `Zone not found: ${domain}. Make sure the domain is added to your Cloudflare account.`
154
+ );
155
+ }
156
+
157
+ return response.result[0];
158
+ }
159
+
160
+ /**
161
+ * Create DNS record
162
+ */
163
+ async createDnsRecord(zoneId: string, record: DnsRecord): Promise<DnsRecord> {
164
+ const response = await this.request<DnsRecord>(`/zones/${zoneId}/dns_records`, {
165
+ method: 'POST',
166
+ body: JSON.stringify(record),
167
+ });
168
+
169
+ if (!response.success) {
170
+ throw new Error('Failed to create DNS record');
171
+ }
172
+
173
+ return response.result;
174
+ }
175
+
176
+ /**
177
+ * List DNS records
178
+ */
179
+ async listDnsRecords(zoneId: string, name?: string): Promise<DnsRecord[]> {
180
+ const params = name ? `?name=${encodeURIComponent(name)}` : '';
181
+ const response = await this.request<DnsRecord[]>(`/zones/${zoneId}/dns_records${params}`);
182
+
183
+ if (!response.success) {
184
+ throw new Error('Failed to list DNS records');
185
+ }
186
+
187
+ return response.result;
188
+ }
189
+
190
+ /**
191
+ * Update DNS record
192
+ */
193
+ async updateDnsRecord(zoneId: string, recordId: string, record: DnsRecord): Promise<DnsRecord> {
194
+ const response = await this.request<DnsRecord>(`/zones/${zoneId}/dns_records/${recordId}`, {
195
+ method: 'PUT',
196
+ body: JSON.stringify(record),
197
+ });
198
+
199
+ if (!response.success) {
200
+ throw new Error('Failed to update DNS record');
201
+ }
202
+
203
+ return response.result;
204
+ }
205
+
206
+ /**
207
+ * Delete DNS record
208
+ */
209
+ async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
210
+ const response = await this.request<{ id: string }>(
211
+ `/zones/${zoneId}/dns_records/${recordId}`,
212
+ { method: 'DELETE' }
213
+ );
214
+
215
+ if (!response.success) {
216
+ throw new Error('Failed to delete DNS record');
217
+ }
218
+ }
219
+
220
+ // ============================================
221
+ // Zero Trust Access API Methods
222
+ // ============================================
223
+
224
+ /**
225
+ * List Access applications
226
+ */
227
+ async listAccessApplications(): Promise<AccessApplication[]> {
228
+ const response = await this.request<AccessApplication[]>(
229
+ `/accounts/${this.accountId}/access/apps`
230
+ );
231
+
232
+ if (!response.success) {
233
+ throw new Error('Failed to list Access applications');
234
+ }
235
+
236
+ return response.result;
237
+ }
238
+
239
+ /**
240
+ * Get Access application by domain
241
+ */
242
+ async getAccessApplicationByDomain(domain: string): Promise<AccessApplication | null> {
243
+ const apps = await this.listAccessApplications();
244
+ return apps.find((app) => app.domain === domain) || null;
245
+ }
246
+
247
+ /**
248
+ * Create Access application
249
+ */
250
+ async createAccessApplication(app: AccessApplicationCreate): Promise<AccessApplication> {
251
+ const response = await this.request<AccessApplication>(
252
+ `/accounts/${this.accountId}/access/apps`,
253
+ {
254
+ method: 'POST',
255
+ body: JSON.stringify(app),
256
+ }
257
+ );
258
+
259
+ if (!response.success) {
260
+ throw new Error(`Failed to create Access application: ${response.errors[0]?.message}`);
261
+ }
262
+
263
+ return response.result;
264
+ }
265
+
266
+ /**
267
+ * Delete Access application
268
+ */
269
+ async deleteAccessApplication(appId: string): Promise<void> {
270
+ const response = await this.request<{ id: string }>(
271
+ `/accounts/${this.accountId}/access/apps/${appId}`,
272
+ { method: 'DELETE' }
273
+ );
274
+
275
+ if (!response.success) {
276
+ throw new Error('Failed to delete Access application');
277
+ }
278
+ }
279
+
280
+ /**
281
+ * List policies for an Access application
282
+ */
283
+ async listAccessPolicies(appId: string): Promise<AccessPolicy[]> {
284
+ const response = await this.request<AccessPolicy[]>(
285
+ `/accounts/${this.accountId}/access/apps/${appId}/policies`
286
+ );
287
+
288
+ if (!response.success) {
289
+ throw new Error('Failed to list Access policies');
290
+ }
291
+
292
+ return response.result;
293
+ }
294
+
295
+ /**
296
+ * Create Access policy for an application
297
+ */
298
+ async createAccessPolicy(appId: string, policy: AccessPolicyCreate): Promise<AccessPolicy> {
299
+ const response = await this.request<AccessPolicy>(
300
+ `/accounts/${this.accountId}/access/apps/${appId}/policies`,
301
+ {
302
+ method: 'POST',
303
+ body: JSON.stringify(policy),
304
+ }
305
+ );
306
+
307
+ if (!response.success) {
308
+ throw new Error(`Failed to create Access policy: ${response.errors[0]?.message}`);
309
+ }
310
+
311
+ return response.result;
312
+ }
313
+
314
+ /**
315
+ * Update Access policy
316
+ */
317
+ async updateAccessPolicy(
318
+ appId: string,
319
+ policyId: string,
320
+ policy: AccessPolicyCreate
321
+ ): Promise<AccessPolicy> {
322
+ const response = await this.request<AccessPolicy>(
323
+ `/accounts/${this.accountId}/access/apps/${appId}/policies/${policyId}`,
324
+ {
325
+ method: 'PUT',
326
+ body: JSON.stringify(policy),
327
+ }
328
+ );
329
+
330
+ if (!response.success) {
331
+ throw new Error(`Failed to update Access policy: ${response.errors[0]?.message}`);
332
+ }
333
+
334
+ return response.result;
335
+ }
336
+
337
+ /**
338
+ * Delete Access policy
339
+ */
340
+ async deleteAccessPolicy(appId: string, policyId: string): Promise<void> {
341
+ const response = await this.request<{ id: string }>(
342
+ `/accounts/${this.accountId}/access/apps/${appId}/policies/${policyId}`,
343
+ { method: 'DELETE' }
344
+ );
345
+
346
+ if (!response.success) {
347
+ throw new Error('Failed to delete Access policy');
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Handle API errors with user-friendly messages
353
+ */
354
+ private handleApiError(status: number, data: CloudflareApiResponse<unknown>): never {
355
+ if (status === 401) {
356
+ throw new Error('Invalid API token. Run: tuna --login');
357
+ }
358
+
359
+ if (status === 403) {
360
+ throw new Error(
361
+ 'Insufficient permissions. Your API token needs:\n' +
362
+ ' - Account → Cloudflare Tunnel → Edit\n' +
363
+ ' - Account → Access: Apps and Policies → Edit\n' +
364
+ ' - Zone → DNS → Edit\n' +
365
+ ' - Account → Account Settings → Read'
366
+ );
367
+ }
368
+
369
+ if (status === 404) {
370
+ throw new Error('Resource not found. Check tunnel ID or domain.');
371
+ }
372
+
373
+ if (status === 429) {
374
+ throw new Error('Rate limited. Please wait a moment and try again.');
375
+ }
376
+
377
+ if (data?.errors?.[0]) {
378
+ throw new Error(`Cloudflare API error: ${data.errors[0].message}`);
379
+ }
380
+
381
+ throw new Error(`API request failed with status ${status}`);
382
+ }
383
+ }