@muhammedaksam/easiarr 1.0.0 → 1.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -69,6 +69,7 @@
69
69
  "dependencies": {
70
70
  "@opentui/core": "^0.1.60",
71
71
  "bcrypt": "^6.0.0",
72
+ "socket.io-client": "^4.8.1",
72
73
  "yaml": "^2.8.2"
73
74
  },
74
75
  "engines": {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Auto-Setup Types
3
+ * Interfaces for auto-setup capability metadata and clients
4
+ */
5
+
6
+ import type { AppId } from "../config/schema"
7
+
8
+ /**
9
+ * Describes an app's auto-setup capability
10
+ */
11
+ export interface AutoSetupCapability {
12
+ /** Type of auto-setup support */
13
+ type: "full" | "partial" | "manual"
14
+ /** Human-readable description of what gets configured */
15
+ description: string
16
+ /** Other apps that must be set up first */
17
+ requires?: AppId[]
18
+ /** Environment variables required for setup */
19
+ envVars?: string[]
20
+ /** Setup function name in FullAutoSetup (for dynamic discovery) */
21
+ setupMethod?: string
22
+ }
23
+
24
+ /**
25
+ * Result of an auto-setup operation
26
+ */
27
+ export interface AutoSetupResult {
28
+ success: boolean
29
+ message?: string
30
+ /** Data to persist (e.g., API keys, tokens) */
31
+ envUpdates?: Record<string, string>
32
+ /** Additional result data (e.g., API keys, generated credentials) */
33
+ data?: Record<string, unknown>
34
+ }
35
+
36
+ /**
37
+ * Base interface for auto-setup clients
38
+ */
39
+ export interface IAutoSetupClient {
40
+ /** Check if service is reachable */
41
+ isHealthy(): Promise<boolean>
42
+
43
+ /** Check if service is already configured */
44
+ isInitialized(): Promise<boolean>
45
+
46
+ /** Run the auto-setup process */
47
+ setup(options: AutoSetupOptions): Promise<AutoSetupResult>
48
+ }
49
+
50
+ /**
51
+ * Common options for auto-setup
52
+ */
53
+ export interface AutoSetupOptions {
54
+ /** Global username for auth */
55
+ username: string
56
+ /** Global password for auth */
57
+ password: string
58
+ /** Environment variables available */
59
+ env: Record<string, string>
60
+ /** Plex authentication token (for Plex-dependent services) */
61
+ plexToken?: string
62
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { debugLog } from "../utils/debug"
7
+ import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
7
8
 
8
9
  /**
9
10
  * Bazarr System Settings (partial - auth related fields)
@@ -21,7 +22,7 @@ export interface BazarrAuthSettings {
21
22
  * Bazarr API Client
22
23
  * Note: Bazarr uses form data for POST, not JSON!
23
24
  */
24
- export class BazarrApiClient {
25
+ export class BazarrApiClient implements IAutoSetupClient {
25
26
  private baseUrl: string
26
27
  private apiKey: string | null = null
27
28
 
@@ -217,4 +218,56 @@ export class BazarrApiClient {
217
218
  throw e
218
219
  }
219
220
  }
221
+
222
+ /**
223
+ * Check if already configured (has auth set up)
224
+ */
225
+ async isInitialized(): Promise<boolean> {
226
+ try {
227
+ const settings = await this.getSettings()
228
+ const auth = (settings as { auth?: { type?: string } }).auth
229
+ return !!auth?.type && auth.type !== "None"
230
+ } catch {
231
+ return false
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Run the auto-setup process for Bazarr
237
+ */
238
+ async setup(options: AutoSetupOptions): Promise<AutoSetupResult> {
239
+ const { username, password } = options
240
+
241
+ try {
242
+ // Check if reachable
243
+ const healthy = await this.isHealthy()
244
+ if (!healthy) {
245
+ return { success: false, message: "Bazarr not reachable" }
246
+ }
247
+
248
+ // Get API key first (needed for subsequent requests)
249
+ const apiKey = await this.getApiKey()
250
+ if (apiKey) {
251
+ this.setApiKey(apiKey)
252
+ }
253
+
254
+ // Check if auth already configured
255
+ const initialized = await this.isInitialized()
256
+ let authConfigured = false
257
+
258
+ if (!initialized) {
259
+ // Enable form auth
260
+ authConfigured = await this.enableFormAuth(username, password)
261
+ }
262
+
263
+ return {
264
+ success: true,
265
+ message: initialized ? "Already configured" : authConfigured ? "Auth enabled" : "Ready",
266
+ data: { apiKey, authConfigured },
267
+ envUpdates: apiKey ? { API_KEY_BAZARR: apiKey } : undefined,
268
+ }
269
+ } catch (error) {
270
+ return { success: false, message: `${error}` }
271
+ }
272
+ }
220
273
  }
@@ -173,10 +173,12 @@ export class CloudflareApi {
173
173
 
174
174
  /**
175
175
  * Configure tunnel ingress rules
176
+ * @param warpRouting Enable WARP routing for private network access (VPN)
176
177
  */
177
178
  async configureTunnel(
178
179
  tunnelId: string,
179
- ingress: Array<{ hostname?: string; service: string; originRequest?: Record<string, unknown> }>
180
+ ingress: Array<{ hostname?: string; service: string; originRequest?: Record<string, unknown> }>,
181
+ warpRouting = false
180
182
  ): Promise<void> {
181
183
  const accountId = await this.getAccountId()
182
184
 
@@ -189,7 +191,7 @@ export class CloudflareApi {
189
191
  await this.request("PUT", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
190
192
  config: {
191
193
  ingress,
192
- "warp-routing": { enabled: false },
194
+ "warp-routing": { enabled: warpRouting },
193
195
  },
194
196
  })
195
197
  }
@@ -245,6 +247,50 @@ export class CloudflareApi {
245
247
  await this.request("DELETE", `/accounts/${accountId}/cfd_tunnel/${tunnelId}`)
246
248
  }
247
249
 
250
+ // ==================== Zero Trust Private Network API ====================
251
+
252
+ /**
253
+ * Add a private network route to a tunnel (for WARP VPN access)
254
+ * This allows WARP clients to access the specified network through the tunnel
255
+ */
256
+ async addTunnelRoute(tunnelId: string, networkCidr: string, comment = "easiarr private network"): Promise<string> {
257
+ const accountId = await this.getAccountId()
258
+ const response = await this.request<{ id: string }>("POST", `/accounts/${accountId}/teamnet/routes`, {
259
+ network: networkCidr,
260
+ tunnel_id: tunnelId,
261
+ comment,
262
+ })
263
+ return response.result.id
264
+ }
265
+
266
+ /**
267
+ * List existing tunnel routes for the account
268
+ */
269
+ async listTunnelRoutes(): Promise<Array<{ id: string; network: string; tunnel_id: string; comment?: string }>> {
270
+ const accountId = await this.getAccountId()
271
+ const response = await this.request<Array<{ id: string; network: string; tunnel_id: string; comment?: string }>>(
272
+ "GET",
273
+ `/accounts/${accountId}/teamnet/routes`
274
+ )
275
+ return response.result
276
+ }
277
+
278
+ /**
279
+ * Delete a tunnel route
280
+ */
281
+ async deleteTunnelRoute(routeId: string): Promise<void> {
282
+ const accountId = await this.getAccountId()
283
+ await this.request("DELETE", `/accounts/${accountId}/teamnet/routes/${routeId}`)
284
+ }
285
+
286
+ /**
287
+ * Check if a tunnel route already exists for the given network
288
+ */
289
+ async getTunnelRouteForNetwork(networkCidr: string): Promise<{ id: string; tunnel_id: string } | null> {
290
+ const routes = await this.listTunnelRoutes()
291
+ return routes.find((r) => r.network === networkCidr) || null
292
+ }
293
+
248
294
  // ==================== Cloudflare Access API ====================
249
295
 
250
296
  /**
@@ -321,16 +367,160 @@ export class CloudflareApi {
321
367
  }
322
368
 
323
369
  /**
324
- * Create Access application with email policy
370
+ * Create bypass policy for an Access app (e.g., for home IP)
371
+ */
372
+ async createBypassPolicy(
373
+ appId: string,
374
+ bypassIp: string,
375
+ policyName = "easiarr-web-bypass"
376
+ ): Promise<{ id: string }> {
377
+ const accountId = await this.getAccountId()
378
+
379
+ // Check if policy already exists
380
+ const existing = await this.request<Array<{ id: string; name: string }>>(
381
+ "GET",
382
+ `/accounts/${accountId}/access/apps/${appId}/policies`
383
+ )
384
+
385
+ const existingPolicy = existing.result.find((p) => p.name === policyName)
386
+ if (existingPolicy) {
387
+ return { id: existingPolicy.id }
388
+ }
389
+
390
+ // Create IP-based bypass policy
391
+ const response = await this.request<{ id: string }>(
392
+ "POST",
393
+ `/accounts/${accountId}/access/apps/${appId}/policies`,
394
+ {
395
+ name: policyName,
396
+ decision: "bypass",
397
+ include: [
398
+ {
399
+ ip: { ip: bypassIp },
400
+ },
401
+ ],
402
+ precedence: existing.result.length + 1,
403
+ }
404
+ )
405
+
406
+ return response.result
407
+ }
408
+
409
+ /**
410
+ * Create Access application with email policy and optional IP bypass
325
411
  */
326
412
  async setupAccessProtection(
327
413
  domain: string,
328
414
  allowedEmails: string[],
329
- appName = "easiarr"
330
- ): Promise<{ appId: string; policyId: string }> {
415
+ appName = "easiarr",
416
+ bypassIp?: string
417
+ ): Promise<{ appId: string; policyId: string; bypassPolicyId?: string }> {
331
418
  const app = await this.createAccessApplication(domain, appName)
332
- const policy = await this.createAccessPolicy(app.id, allowedEmails)
333
- return { appId: app.id, policyId: policy.id }
419
+ const policy = await this.createAccessPolicy(app.id, allowedEmails, "easiarr-web-allow")
420
+
421
+ let bypassPolicyId: string | undefined
422
+ if (bypassIp) {
423
+ const bypassPolicy = await this.createBypassPolicy(app.id, bypassIp, "easiarr-web-bypass")
424
+ bypassPolicyId = bypassPolicy.id
425
+ }
426
+
427
+ return { appId: app.id, policyId: policy.id, bypassPolicyId }
428
+ }
429
+
430
+ // ==================== WARP Device Enrollment API ====================
431
+
432
+ /**
433
+ * Get or create device enrollment application (type: warp)
434
+ */
435
+ async getDeviceEnrollmentApp(): Promise<{ id: string; name: string } | null> {
436
+ const accountId = await this.getAccountId()
437
+ const apps = await this.request<Array<{ id: string; name: string; type: string }>>(
438
+ "GET",
439
+ `/accounts/${accountId}/access/apps`
440
+ )
441
+ return apps.result.find((a) => a.type === "warp") || null
442
+ }
443
+
444
+ /**
445
+ * Create device enrollment policy for WARP
446
+ * This allows specified emails to enroll their devices
447
+ * Also creates a bypass policy for local network access
448
+ */
449
+ async setupDeviceEnrollment(
450
+ allowedEmails: string[],
451
+ privateNetworkCidr?: string
452
+ ): Promise<{ appId: string; allowPolicyId: string; bypassPolicyId?: string }> {
453
+ const accountId = await this.getAccountId()
454
+
455
+ // Check if WARP enrollment app exists
456
+ let warpApp = await this.getDeviceEnrollmentApp()
457
+
458
+ if (!warpApp) {
459
+ // Create WARP enrollment app
460
+ const response = await this.request<{ id: string; name: string }>("POST", `/accounts/${accountId}/access/apps`, {
461
+ type: "warp",
462
+ name: "Device Enrollment",
463
+ session_duration: "24h",
464
+ })
465
+ warpApp = response.result
466
+ }
467
+
468
+ // Get existing policies
469
+ const existingPolicies = await this.request<Array<{ id: string; name: string }>>(
470
+ "GET",
471
+ `/accounts/${accountId}/access/apps/${warpApp.id}/policies`
472
+ )
473
+
474
+ let allowPolicyId: string
475
+ let bypassPolicyId: string | undefined
476
+
477
+ // 1. Create/get email-based Allow policy
478
+ const allowPolicyName = "easiarr-vpn-allow"
479
+ const existingAllow = existingPolicies.result.find((p) => p.name === allowPolicyName)
480
+ if (existingAllow) {
481
+ allowPolicyId = existingAllow.id
482
+ } else {
483
+ const policy = await this.request<{ id: string }>(
484
+ "POST",
485
+ `/accounts/${accountId}/access/apps/${warpApp.id}/policies`,
486
+ {
487
+ name: allowPolicyName,
488
+ decision: "allow",
489
+ include: allowedEmails.map((email) => ({
490
+ email: { email },
491
+ })),
492
+ precedence: existingPolicies.result.length + 1,
493
+ }
494
+ )
495
+ allowPolicyId = policy.result.id
496
+ }
497
+
498
+ // 2. Create/get Bypass policy for local network (if CIDR provided)
499
+ if (privateNetworkCidr) {
500
+ const bypassPolicyName = "easiarr-vpn-bypass"
501
+ const existingBypass = existingPolicies.result.find((p) => p.name === bypassPolicyName)
502
+ if (existingBypass) {
503
+ bypassPolicyId = existingBypass.id
504
+ } else {
505
+ const bypassPolicy = await this.request<{ id: string }>(
506
+ "POST",
507
+ `/accounts/${accountId}/access/apps/${warpApp.id}/policies`,
508
+ {
509
+ name: bypassPolicyName,
510
+ decision: "bypass",
511
+ include: [
512
+ {
513
+ ip: { ip: privateNetworkCidr },
514
+ },
515
+ ],
516
+ precedence: existingPolicies.result.length + 2,
517
+ }
518
+ )
519
+ bypassPolicyId = bypassPolicy.result.id
520
+ }
521
+ }
522
+
523
+ return { appId: warpApp.id, allowPolicyId, bypassPolicyId }
334
524
  }
335
525
  }
336
526
 
@@ -340,10 +530,14 @@ export class CloudflareApi {
340
530
  export async function setupCloudflaredTunnel(
341
531
  apiToken: string,
342
532
  domain: string,
343
- tunnelName = "easiarr"
344
- ): Promise<{ tunnelToken: string; tunnelId: string }> {
533
+ tunnelName = "easiarr",
534
+ warpRouting = false
535
+ ): Promise<{ tunnelToken: string; tunnelId: string; accountId: string }> {
345
536
  const api = new CloudflareApi(apiToken)
346
537
 
538
+ // Get account ID first (needed for Homepage widget)
539
+ const accountId = await api.getAccountId()
540
+
347
541
  // 1. Check if tunnel already exists
348
542
  let tunnel = await api.getTunnelByName(tunnelName)
349
543
  let tunnelToken: string
@@ -358,14 +552,18 @@ export async function setupCloudflaredTunnel(
358
552
  tunnelToken = await api.getTunnelToken(tunnel.id)
359
553
  }
360
554
 
361
- // 3. Configure ingress rules
362
- await api.configureTunnel(tunnel.id, [
363
- {
364
- hostname: `*.${domain}`,
365
- service: "http://traefik:80",
366
- originRequest: {},
367
- },
368
- ])
555
+ // 3. Configure ingress rules (enable warp-routing if VPN is enabled)
556
+ await api.configureTunnel(
557
+ tunnel.id,
558
+ [
559
+ {
560
+ hostname: `*.${domain}`,
561
+ service: "http://traefik:80",
562
+ originRequest: {},
563
+ },
564
+ ],
565
+ warpRouting
566
+ )
369
567
 
370
568
  // 4. Add DNS CNAME record (wildcard)
371
569
  const zoneId = await api.getZoneId(domain)
@@ -374,5 +572,6 @@ export async function setupCloudflaredTunnel(
374
572
  return {
375
573
  tunnelToken,
376
574
  tunnelId: tunnel.id,
575
+ accountId,
377
576
  }
378
577
  }