@muhammedaksam/easiarr 1.0.0 → 1.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/package.json +2 -1
- package/src/api/auto-setup-types.ts +62 -0
- package/src/api/bazarr-api.ts +54 -1
- package/src/api/cloudflare-api.ts +216 -17
- package/src/api/grafana-api.ts +314 -0
- package/src/api/heimdall-api.ts +209 -0
- package/src/api/homarr-api.ts +296 -0
- package/src/api/jellyfin-api.ts +61 -1
- package/src/api/jellyseerr-api.ts +49 -1
- package/src/api/overseerr-api.ts +489 -0
- package/src/api/plex-api.ts +329 -0
- package/src/api/portainer-api.ts +79 -1
- package/src/api/prowlarr-api.ts +44 -1
- package/src/api/qbittorrent-api.ts +57 -1
- package/src/api/tautulli-api.ts +277 -0
- package/src/api/uptime-kuma-api.ts +342 -0
- package/src/apps/registry.ts +32 -2
- package/src/config/homepage-config.ts +82 -38
- package/src/config/schema.ts +14 -0
- package/src/ui/screens/CloudflaredSetup.ts +225 -9
- package/src/ui/screens/FullAutoSetup.ts +496 -117
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
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
|
+
}
|
package/src/api/bazarr-api.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
}
|