@muhammedaksam/easiarr 0.10.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/src/apps/registry.ts
CHANGED
|
@@ -148,7 +148,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
148
148
|
generateIfMissing: true,
|
|
149
149
|
},
|
|
150
150
|
prowlarrCategoryIds: [7030], // Comics
|
|
151
|
-
homepage: { icon: "mylar.png" },
|
|
151
|
+
homepage: { icon: "mylar.png", widget: "mylar" },
|
|
152
152
|
// Note: Mylar3 is NOT an *arr app - has different API format (?cmd=<endpoint>)
|
|
153
153
|
// Root folder is configured via Web UI settings, not API
|
|
154
154
|
},
|
|
@@ -191,6 +191,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
191
191
|
`${root}/data/media/podcasts:/podcasts`,
|
|
192
192
|
`${root}/data/media/audiobookshelf-metadata:/metadata`,
|
|
193
193
|
],
|
|
194
|
+
homepage: { icon: "audiobookshelf.png", widget: "audiobookshelf" },
|
|
194
195
|
},
|
|
195
196
|
|
|
196
197
|
// === INDEXERS ===
|
|
@@ -229,6 +230,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
229
230
|
parser: "json",
|
|
230
231
|
selector: "APIKey",
|
|
231
232
|
},
|
|
233
|
+
homepage: { icon: "jackett.png", widget: "jackett" },
|
|
232
234
|
},
|
|
233
235
|
|
|
234
236
|
flaresolverr: {
|
|
@@ -320,6 +322,11 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
320
322
|
selector: 'PlexOnlineToken="([^"]+)"',
|
|
321
323
|
},
|
|
322
324
|
homepage: { icon: "plex.png", widget: "plex" },
|
|
325
|
+
autoSetup: {
|
|
326
|
+
type: "full",
|
|
327
|
+
description: "Claim server with token, create media libraries",
|
|
328
|
+
envVars: ["PLEX_CLAIM"],
|
|
329
|
+
},
|
|
323
330
|
},
|
|
324
331
|
|
|
325
332
|
jellyfin: {
|
|
@@ -352,6 +359,11 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
352
359
|
selector: "api_key\\s*=\\s*(.+)",
|
|
353
360
|
},
|
|
354
361
|
homepage: { icon: "tautulli.png", widget: "tautulli" },
|
|
362
|
+
autoSetup: {
|
|
363
|
+
type: "partial",
|
|
364
|
+
description: "Connect to Plex, enable API",
|
|
365
|
+
requires: ["plex"],
|
|
366
|
+
},
|
|
355
367
|
},
|
|
356
368
|
|
|
357
369
|
tdarr: {
|
|
@@ -370,6 +382,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
370
382
|
`${root}/data/media:/data`,
|
|
371
383
|
],
|
|
372
384
|
environment: { serverIP: "0.0.0.0", internalNode: "true" },
|
|
385
|
+
homepage: { icon: "tdarr.png", widget: "tdarr" },
|
|
373
386
|
},
|
|
374
387
|
|
|
375
388
|
// === REQUEST MANAGEMENT ===
|
|
@@ -390,6 +403,11 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
390
403
|
selector: "main.apiKey",
|
|
391
404
|
},
|
|
392
405
|
homepage: { icon: "overseerr.png", widget: "overseerr" },
|
|
406
|
+
autoSetup: {
|
|
407
|
+
type: "full",
|
|
408
|
+
description: "Connect to Plex, configure Radarr/Sonarr",
|
|
409
|
+
requires: ["plex"],
|
|
410
|
+
},
|
|
393
411
|
},
|
|
394
412
|
|
|
395
413
|
jellyseerr: {
|
|
@@ -427,6 +445,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
427
445
|
`${root}/config/homarr/data:/data`,
|
|
428
446
|
"/var/run/docker.sock:/var/run/docker.sock",
|
|
429
447
|
],
|
|
448
|
+
homepage: { icon: "homarr.png" }, // No widget, just icon (it's a dashboard itself)
|
|
430
449
|
},
|
|
431
450
|
|
|
432
451
|
heimdall: {
|
|
@@ -439,6 +458,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
439
458
|
puid: 0,
|
|
440
459
|
pgid: 13000,
|
|
441
460
|
volumes: (root) => [`${root}/config/heimdall:/config`],
|
|
461
|
+
homepage: { icon: "heimdall.png" }, // No widget, just icon (it's a dashboard itself)
|
|
442
462
|
},
|
|
443
463
|
|
|
444
464
|
homepage: {
|
|
@@ -673,6 +693,11 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
673
693
|
pgid: 13000,
|
|
674
694
|
volumes: (root) => [`${root}/config/grafana:/var/lib/grafana`],
|
|
675
695
|
homepage: { icon: "grafana.png", widget: "grafana" },
|
|
696
|
+
autoSetup: {
|
|
697
|
+
type: "full",
|
|
698
|
+
description: "Setup admin user, configure Prometheus datasource",
|
|
699
|
+
requires: ["prometheus"],
|
|
700
|
+
},
|
|
676
701
|
},
|
|
677
702
|
|
|
678
703
|
prometheus: {
|
|
@@ -685,7 +710,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
685
710
|
puid: 0,
|
|
686
711
|
pgid: 13000,
|
|
687
712
|
volumes: (root) => [`${root}/config/prometheus:/prometheus`],
|
|
688
|
-
homepage: { icon: "prometheus.png"
|
|
713
|
+
homepage: { icon: "prometheus.png", widget: "prometheus" },
|
|
689
714
|
},
|
|
690
715
|
|
|
691
716
|
dozzle: {
|
|
@@ -714,6 +739,10 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
714
739
|
pgid: 0,
|
|
715
740
|
volumes: (root) => [`${root}/config/uptime-kuma:/app/data`, "/var/run/docker.sock:/var/run/docker.sock"],
|
|
716
741
|
homepage: { icon: "uptime-kuma.png", widget: "uptimekuma" },
|
|
742
|
+
autoSetup: {
|
|
743
|
+
type: "full",
|
|
744
|
+
description: "Create admin user, add monitors for enabled apps",
|
|
745
|
+
},
|
|
717
746
|
},
|
|
718
747
|
|
|
719
748
|
// === INFRASTRUCTURE ===
|
|
@@ -773,6 +802,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
773
802
|
mask: true,
|
|
774
803
|
},
|
|
775
804
|
],
|
|
805
|
+
homepage: { icon: "cloudflare-zero-trust.png", widget: "cloudflared" },
|
|
776
806
|
},
|
|
777
807
|
|
|
778
808
|
"traefik-certs-dumper": {
|
|
@@ -30,7 +30,7 @@ interface HomepageService {
|
|
|
30
30
|
ping?: string
|
|
31
31
|
widget?: {
|
|
32
32
|
type: string
|
|
33
|
-
url
|
|
33
|
+
url?: string // Optional - cloudflared doesn't need url
|
|
34
34
|
key?: string
|
|
35
35
|
[key: string]: unknown
|
|
36
36
|
}
|
|
@@ -56,7 +56,11 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
|
|
|
56
56
|
if (appDef.id === "homepage") continue
|
|
57
57
|
|
|
58
58
|
const port = appConfig.port ?? appDef.defaultPort
|
|
59
|
+
const internalPort = appDef.internalPort ?? port
|
|
60
|
+
// External URL for user browser access (href, ping)
|
|
59
61
|
const baseUrl = `http://${localIp}:${port}`
|
|
62
|
+
// Internal Docker URL for container-to-container API calls (widgets)
|
|
63
|
+
const dockerUrl = `http://${appDef.id}:${internalPort}`
|
|
60
64
|
|
|
61
65
|
const service: HomepageService = {
|
|
62
66
|
href: baseUrl,
|
|
@@ -74,55 +78,95 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
|
|
|
74
78
|
// Add ping for monitoring
|
|
75
79
|
service.ping = baseUrl
|
|
76
80
|
|
|
77
|
-
// Add widget if defined
|
|
81
|
+
// Add widget if defined
|
|
78
82
|
if (appDef.homepage?.widget) {
|
|
79
83
|
const apiKey = env[`API_KEY_${appDef.id.toUpperCase()}`]
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
// Some widgets require specific config - skip if not available
|
|
86
|
+
const widgetType = appDef.homepage.widget
|
|
87
|
+
|
|
88
|
+
// Cloudflared requires accountid and tunnelid
|
|
89
|
+
if (appDef.id === "cloudflared") {
|
|
90
|
+
const accountId = env["CLOUDFLARE_ACCOUNT_ID"]
|
|
91
|
+
const tunnelId = env["CLOUDFLARE_TUNNEL_ID"]
|
|
92
|
+
const apiToken = env["CLOUDFLARE_API_TOKEN"]
|
|
93
|
+
|
|
94
|
+
if (accountId && tunnelId && apiToken) {
|
|
95
|
+
service.widget = {
|
|
96
|
+
type: widgetType,
|
|
97
|
+
accountid: accountId,
|
|
98
|
+
tunnelid: tunnelId,
|
|
99
|
+
key: apiToken,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Skip widget entirely if missing required params
|
|
84
103
|
}
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
104
|
+
// Headscale requires API key
|
|
105
|
+
else if (appDef.id === "headscale") {
|
|
106
|
+
const headscaleKey = env["API_KEY_HEADSCALE"]
|
|
107
|
+
if (headscaleKey) {
|
|
108
|
+
service.widget = {
|
|
109
|
+
type: widgetType,
|
|
110
|
+
url: dockerUrl,
|
|
111
|
+
key: headscaleKey,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Skip widget if no API key
|
|
88
115
|
}
|
|
116
|
+
// Most widgets need API key - only add if available
|
|
117
|
+
else if (apiKey || ["qbittorrent", "gluetun", "traefik"].includes(appDef.id)) {
|
|
118
|
+
service.widget = {
|
|
119
|
+
type: widgetType,
|
|
120
|
+
url: dockerUrl,
|
|
121
|
+
}
|
|
89
122
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const password = env["PASSWORD_QBITTORRENT"]
|
|
94
|
-
if (username) service.widget.username = username
|
|
95
|
-
if (password) service.widget.password = password
|
|
96
|
-
}
|
|
123
|
+
if (apiKey) {
|
|
124
|
+
service.widget.key = apiKey
|
|
125
|
+
}
|
|
97
126
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
service.widget.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
// Add widget-specific credentials from env
|
|
128
|
+
if (appDef.id === "qbittorrent") {
|
|
129
|
+
const username = env["USERNAME_QBITTORRENT"]
|
|
130
|
+
const password = env["PASSWORD_QBITTORRENT"]
|
|
131
|
+
if (username) service.widget.username = username
|
|
132
|
+
if (password) service.widget.password = password
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Traefik widget needs the dashboard/API port (8080 internal)
|
|
136
|
+
if (appDef.id === "traefik") {
|
|
137
|
+
service.widget.url = `http://traefik:8080`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (appDef.id === "portainer") {
|
|
141
|
+
// Try to auto-detect Portainer environment ID
|
|
142
|
+
// User can override with PORTAINER_ENV in .env file
|
|
143
|
+
if (env["PORTAINER_ENV"]) {
|
|
144
|
+
service.widget.env = env["PORTAINER_ENV"]
|
|
145
|
+
} else {
|
|
146
|
+
// Auto-detect from Portainer API
|
|
147
|
+
const portainerPort = appConfig.port ?? appDef.defaultPort
|
|
148
|
+
const portainerClient = new PortainerApiClient(localIp, portainerPort)
|
|
149
|
+
const portainerApiKey = env["API_KEY_PORTAINER"]
|
|
150
|
+
if (portainerApiKey) {
|
|
151
|
+
portainerClient.setApiKey(portainerApiKey)
|
|
152
|
+
}
|
|
153
|
+
const localEnvId = await portainerClient.getLocalEnvironmentId()
|
|
154
|
+
const envIdStr = localEnvId?.toString() ?? "1"
|
|
155
|
+
service.widget.env = envIdStr
|
|
114
156
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
157
|
+
// Persist the detected env ID to .env for future use
|
|
158
|
+
if (localEnvId) {
|
|
159
|
+
await updateEnv({ PORTAINER_ENV: envIdStr })
|
|
160
|
+
}
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
|
-
}
|
|
121
163
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
164
|
+
// Add any custom widget fields
|
|
165
|
+
if (appDef.homepage.widgetFields) {
|
|
166
|
+
Object.assign(service.widget, appDef.homepage.widgetFields)
|
|
167
|
+
}
|
|
125
168
|
}
|
|
169
|
+
// If widget requires API key and none is set, skip widget but keep ping/icon
|
|
126
170
|
}
|
|
127
171
|
|
|
128
172
|
// Add to category group
|
package/src/config/schema.ts
CHANGED
|
@@ -206,6 +206,20 @@ export interface AppDefinition {
|
|
|
206
206
|
minPasswordLength?: number
|
|
207
207
|
/** Homepage dashboard configuration */
|
|
208
208
|
homepage?: HomepageMeta
|
|
209
|
+
/** Auto-setup capability metadata */
|
|
210
|
+
autoSetup?: AutoSetupCapability
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Auto-setup capability for an app */
|
|
214
|
+
export interface AutoSetupCapability {
|
|
215
|
+
/** Type of auto-setup support: full (complete config), partial (basic config), manual (user must configure) */
|
|
216
|
+
type: "full" | "partial" | "manual"
|
|
217
|
+
/** Human-readable description of what gets configured */
|
|
218
|
+
description: string
|
|
219
|
+
/** Other apps that must be set up first */
|
|
220
|
+
requires?: AppId[]
|
|
221
|
+
/** Environment variables required for setup */
|
|
222
|
+
envVars?: string[]
|
|
209
223
|
}
|
|
210
224
|
|
|
211
225
|
/** Homepage dashboard widget/service configuration */
|
|
@@ -15,7 +15,7 @@ import { saveConfig } from "../../config"
|
|
|
15
15
|
import { saveCompose } from "../../compose"
|
|
16
16
|
import { CloudflareApi, setupCloudflaredTunnel } from "../../api/cloudflare-api"
|
|
17
17
|
|
|
18
|
-
type SetupStep = "api_token" | "domain" | "confirm" | "progress" | "done"
|
|
18
|
+
type SetupStep = "api_token" | "domain" | "vpn" | "confirm" | "progress" | "done"
|
|
19
19
|
|
|
20
20
|
export class CloudflaredSetup extends BoxRenderable {
|
|
21
21
|
private cliRenderer: CliRenderer
|
|
@@ -29,6 +29,8 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
29
29
|
private domain = ""
|
|
30
30
|
private tunnelName = "easiarr"
|
|
31
31
|
private accessEmail = "" // Optional: email for Cloudflare Access protection
|
|
32
|
+
private enableVpn = false // Enable Zero Trust VPN access
|
|
33
|
+
private privateNetworkCidr = "" // e.g., 192.168.1.0/24
|
|
32
34
|
|
|
33
35
|
// Status
|
|
34
36
|
private statusMessages: string[] = []
|
|
@@ -85,6 +87,9 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
85
87
|
case "domain":
|
|
86
88
|
this.renderDomainStep(content)
|
|
87
89
|
break
|
|
90
|
+
case "vpn":
|
|
91
|
+
this.renderVpnStep(content)
|
|
92
|
+
break
|
|
88
93
|
case "confirm":
|
|
89
94
|
this.renderConfirmStep(content)
|
|
90
95
|
break
|
|
@@ -100,13 +105,15 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
100
105
|
private getStepInfo(): string {
|
|
101
106
|
switch (this.step) {
|
|
102
107
|
case "api_token":
|
|
103
|
-
return "Step 1/
|
|
108
|
+
return "Step 1/5: Enter Cloudflare API Token"
|
|
104
109
|
case "domain":
|
|
105
|
-
return "Step 2/
|
|
110
|
+
return "Step 2/5: Configure Domain"
|
|
111
|
+
case "vpn":
|
|
112
|
+
return "Step 3/5: Zero Trust VPN (Optional)"
|
|
106
113
|
case "confirm":
|
|
107
|
-
return "Step
|
|
114
|
+
return "Step 4/5: Confirm Settings"
|
|
108
115
|
case "progress":
|
|
109
|
-
return "Step
|
|
116
|
+
return "Step 5/5: Setting up tunnel..."
|
|
110
117
|
case "done":
|
|
111
118
|
return "Setup Complete!"
|
|
112
119
|
default:
|
|
@@ -139,6 +146,12 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
139
146
|
fg: "#50fa7b",
|
|
140
147
|
})
|
|
141
148
|
)
|
|
149
|
+
content.add(
|
|
150
|
+
new TextRenderable(this.cliRenderer, {
|
|
151
|
+
content: " • Account:Zero Trust:Edit (for VPN access)",
|
|
152
|
+
fg: "#50fa7b",
|
|
153
|
+
})
|
|
154
|
+
)
|
|
142
155
|
content.add(
|
|
143
156
|
new TextRenderable(this.cliRenderer, {
|
|
144
157
|
content: " • Account:Access: Apps and Policies:Edit (optional)",
|
|
@@ -378,7 +391,14 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
378
391
|
this.renderContent()
|
|
379
392
|
} else if (index === 1) {
|
|
380
393
|
if (!this.domain.trim()) return
|
|
381
|
-
|
|
394
|
+
// Auto-detect private network CIDR from local IP
|
|
395
|
+
const env = readEnvSync()
|
|
396
|
+
const localIp = env["LOCAL_DOCKER_IP"] || "192.168.1.1"
|
|
397
|
+
const parts = localIp.split(".")
|
|
398
|
+
if (parts.length === 4) {
|
|
399
|
+
this.privateNetworkCidr = `${parts[0]}.${parts[1]}.${parts[2]}.0/24`
|
|
400
|
+
}
|
|
401
|
+
this.step = "vpn"
|
|
382
402
|
this.renderContent()
|
|
383
403
|
} else {
|
|
384
404
|
this.cleanup()
|
|
@@ -408,6 +428,121 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
408
428
|
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
409
429
|
}
|
|
410
430
|
|
|
431
|
+
private renderVpnStep(content: BoxRenderable): void {
|
|
432
|
+
content.add(
|
|
433
|
+
new TextRenderable(this.cliRenderer, {
|
|
434
|
+
content: "Zero Trust VPN Access (Optional)",
|
|
435
|
+
fg: "#4a9eff",
|
|
436
|
+
})
|
|
437
|
+
)
|
|
438
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
439
|
+
|
|
440
|
+
content.add(
|
|
441
|
+
new TextRenderable(this.cliRenderer, {
|
|
442
|
+
content: "Enable this to access your private network from anywhere using",
|
|
443
|
+
fg: "#888888",
|
|
444
|
+
})
|
|
445
|
+
)
|
|
446
|
+
content.add(
|
|
447
|
+
new TextRenderable(this.cliRenderer, {
|
|
448
|
+
content: "the Cloudflare WARP client on your phone, laptop, etc.",
|
|
449
|
+
fg: "#888888",
|
|
450
|
+
})
|
|
451
|
+
)
|
|
452
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
453
|
+
|
|
454
|
+
// Enable VPN toggle display
|
|
455
|
+
content.add(
|
|
456
|
+
new TextRenderable(this.cliRenderer, {
|
|
457
|
+
content: `Enable VPN Access: ${this.enableVpn ? "✓ Yes" : "✗ No"}`,
|
|
458
|
+
fg: this.enableVpn ? "#50fa7b" : "#888888",
|
|
459
|
+
})
|
|
460
|
+
)
|
|
461
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
462
|
+
|
|
463
|
+
// CIDR input (only shown if VPN enabled)
|
|
464
|
+
if (this.enableVpn) {
|
|
465
|
+
const cidrRow = new BoxRenderable(this.cliRenderer, {
|
|
466
|
+
width: "100%",
|
|
467
|
+
height: 1,
|
|
468
|
+
flexDirection: "row",
|
|
469
|
+
})
|
|
470
|
+
cidrRow.add(
|
|
471
|
+
new TextRenderable(this.cliRenderer, {
|
|
472
|
+
content: "Private Network CIDR: ",
|
|
473
|
+
fg: "#aaaaaa",
|
|
474
|
+
})
|
|
475
|
+
)
|
|
476
|
+
const cidrInput = new InputRenderable(this.cliRenderer, {
|
|
477
|
+
id: "cf-cidr",
|
|
478
|
+
width: 20,
|
|
479
|
+
placeholder: "192.168.1.0/24",
|
|
480
|
+
value: this.privateNetworkCidr,
|
|
481
|
+
})
|
|
482
|
+
cidrInput.onPaste = (v) => {
|
|
483
|
+
this.privateNetworkCidr = v.text.replace(/[\r\n]/g, "")
|
|
484
|
+
cidrInput.value = this.privateNetworkCidr
|
|
485
|
+
}
|
|
486
|
+
cidrInput.on(InputRenderableEvents.CHANGE, (v) => (this.privateNetworkCidr = v))
|
|
487
|
+
cidrRow.add(cidrInput)
|
|
488
|
+
content.add(cidrRow)
|
|
489
|
+
|
|
490
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
491
|
+
content.add(
|
|
492
|
+
new TextRenderable(this.cliRenderer, {
|
|
493
|
+
content: "This allows access to all devices in your network via WARP.",
|
|
494
|
+
fg: "#888888",
|
|
495
|
+
})
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
500
|
+
|
|
501
|
+
// Navigation
|
|
502
|
+
const nav = new SelectRenderable(this.cliRenderer, {
|
|
503
|
+
id: "cf-vpn-nav",
|
|
504
|
+
width: "100%",
|
|
505
|
+
height: 10,
|
|
506
|
+
options: [
|
|
507
|
+
{ name: this.enableVpn ? "✗ Disable VPN" : "✓ Enable VPN", description: "Toggle VPN access" },
|
|
508
|
+
{ name: "◀ Back", description: "Go back to domain settings" },
|
|
509
|
+
{ name: "➡️ Continue", description: "Proceed to confirmation" },
|
|
510
|
+
{ name: "✕ Cancel", description: "Return to main menu" },
|
|
511
|
+
],
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
nav.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
|
|
515
|
+
if (index === 0) {
|
|
516
|
+
// Toggle VPN
|
|
517
|
+
this.enableVpn = !this.enableVpn
|
|
518
|
+
this.renderContent()
|
|
519
|
+
} else if (index === 1) {
|
|
520
|
+
// Back
|
|
521
|
+
this.step = "domain"
|
|
522
|
+
this.renderContent()
|
|
523
|
+
} else if (index === 2) {
|
|
524
|
+
// Continue
|
|
525
|
+
this.step = "confirm"
|
|
526
|
+
this.renderContent()
|
|
527
|
+
} else {
|
|
528
|
+
// Cancel
|
|
529
|
+
this.cleanup()
|
|
530
|
+
this.onBack()
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
content.add(nav)
|
|
535
|
+
nav.focus()
|
|
536
|
+
|
|
537
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
538
|
+
if (key.name === "escape") {
|
|
539
|
+
this.step = "domain"
|
|
540
|
+
this.renderContent()
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
544
|
+
}
|
|
545
|
+
|
|
411
546
|
private renderConfirmStep(content: BoxRenderable): void {
|
|
412
547
|
content.add(
|
|
413
548
|
new TextRenderable(this.cliRenderer, {
|
|
@@ -475,6 +610,14 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
475
610
|
})
|
|
476
611
|
)
|
|
477
612
|
}
|
|
613
|
+
if (this.enableVpn) {
|
|
614
|
+
content.add(
|
|
615
|
+
new TextRenderable(this.cliRenderer, {
|
|
616
|
+
content: ` ${this.accessEmail.trim() ? "6" : "5"}. Enable VPN access for: ${this.privateNetworkCidr}`,
|
|
617
|
+
fg: "#50fa7b",
|
|
618
|
+
})
|
|
619
|
+
)
|
|
620
|
+
}
|
|
478
621
|
|
|
479
622
|
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
480
623
|
|
|
@@ -633,7 +776,7 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
633
776
|
this.statusMessages.push("Creating/updating Cloudflare Tunnel...")
|
|
634
777
|
this.renderContent()
|
|
635
778
|
|
|
636
|
-
const result = await setupCloudflaredTunnel(this.apiToken, this.domain, this.tunnelName)
|
|
779
|
+
const result = await setupCloudflaredTunnel(this.apiToken, this.domain, this.tunnelName, this.enableVpn)
|
|
637
780
|
|
|
638
781
|
this.statusMessages.pop()
|
|
639
782
|
this.statusMessages.push(`✓ Tunnel created: ${this.tunnelName}`)
|
|
@@ -648,6 +791,8 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
648
791
|
await updateEnv({
|
|
649
792
|
CLOUDFLARE_API_TOKEN: this.apiToken,
|
|
650
793
|
CLOUDFLARE_TUNNEL_TOKEN: result.tunnelToken,
|
|
794
|
+
CLOUDFLARE_TUNNEL_ID: result.tunnelId,
|
|
795
|
+
CLOUDFLARE_ACCOUNT_ID: result.accountId,
|
|
651
796
|
CLOUDFLARE_DNS_ZONE: this.domain,
|
|
652
797
|
})
|
|
653
798
|
|
|
@@ -688,10 +833,37 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
688
833
|
this.renderContent()
|
|
689
834
|
|
|
690
835
|
const api = new CloudflareApi(this.apiToken)
|
|
691
|
-
|
|
836
|
+
|
|
837
|
+
// Auto-detect public IP for bypass policy
|
|
838
|
+
let publicIp: string | undefined
|
|
839
|
+
try {
|
|
840
|
+
// Try Cloudflare trace (most reliable)
|
|
841
|
+
const res = await fetch("https://1.1.1.1/cdn-cgi/trace")
|
|
842
|
+
const text = await res.text()
|
|
843
|
+
const match = text.match(/ip=(.+)/)
|
|
844
|
+
if (match && match[1]) {
|
|
845
|
+
publicIp = `${match[1].trim()}/32`
|
|
846
|
+
} else {
|
|
847
|
+
// Fallback to ifconfig.me
|
|
848
|
+
const res2 = await fetch("https://ifconfig.me/ip")
|
|
849
|
+
const ip = await res2.text()
|
|
850
|
+
if (ip.trim()) {
|
|
851
|
+
publicIp = `${ip.trim()}/32`
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
} catch {
|
|
855
|
+
// Ignore - IP bypass is optional
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
await api.setupAccessProtection(this.domain, [this.accessEmail.trim()], "easiarr", publicIp)
|
|
692
859
|
|
|
693
860
|
this.statusMessages.pop()
|
|
694
|
-
|
|
861
|
+
if (publicIp) {
|
|
862
|
+
this.statusMessages.push(`✓ Cloudflare Access created for: ${this.accessEmail}`)
|
|
863
|
+
this.statusMessages.push(`✓ Bypass policy added for home IP: ${publicIp}`)
|
|
864
|
+
} else {
|
|
865
|
+
this.statusMessages.push(`✓ Cloudflare Access created for: ${this.accessEmail}`)
|
|
866
|
+
}
|
|
695
867
|
this.renderContent()
|
|
696
868
|
} else {
|
|
697
869
|
// No Cloudflare Access - enable basic auth with global credentials
|
|
@@ -729,6 +901,50 @@ export class CloudflaredSetup extends BoxRenderable {
|
|
|
729
901
|
}
|
|
730
902
|
}
|
|
731
903
|
|
|
904
|
+
// Step 5: Optional VPN setup
|
|
905
|
+
if (this.enableVpn && this.privateNetworkCidr.trim()) {
|
|
906
|
+
this.statusMessages.push("Setting up Zero Trust VPN access...")
|
|
907
|
+
this.renderContent()
|
|
908
|
+
|
|
909
|
+
try {
|
|
910
|
+
const api = new CloudflareApi(this.apiToken)
|
|
911
|
+
|
|
912
|
+
// Check if route already exists
|
|
913
|
+
const existingRoute = await api.getTunnelRouteForNetwork(this.privateNetworkCidr)
|
|
914
|
+
if (existingRoute) {
|
|
915
|
+
this.statusMessages.pop()
|
|
916
|
+
this.statusMessages.push(`✓ VPN route already exists for: ${this.privateNetworkCidr}`)
|
|
917
|
+
} else {
|
|
918
|
+
// Add tunnel route for private network
|
|
919
|
+
await api.addTunnelRoute(result.tunnelId, this.privateNetworkCidr)
|
|
920
|
+
|
|
921
|
+
// Save to .env
|
|
922
|
+
await updateEnv({
|
|
923
|
+
CLOUDFLARE_PRIVATE_NETWORK: this.privateNetworkCidr,
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
this.statusMessages.pop()
|
|
927
|
+
this.statusMessages.push(`✓ VPN access enabled for: ${this.privateNetworkCidr}`)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Create device enrollment policy if access email is set
|
|
931
|
+
if (this.accessEmail.trim()) {
|
|
932
|
+
this.statusMessages.push("Creating device enrollment policy...")
|
|
933
|
+
this.renderContent()
|
|
934
|
+
|
|
935
|
+
await api.setupDeviceEnrollment([this.accessEmail.trim()], this.privateNetworkCidr)
|
|
936
|
+
|
|
937
|
+
this.statusMessages.pop()
|
|
938
|
+
this.statusMessages.push(`✓ Device enrollment policy created for: ${this.accessEmail}`)
|
|
939
|
+
}
|
|
940
|
+
} catch (vpnErr) {
|
|
941
|
+
this.statusMessages.pop()
|
|
942
|
+
this.statusMessages.push(`⚠️ VPN setup failed: ${(vpnErr as Error).message}`)
|
|
943
|
+
this.statusMessages.push(" (Tunnel still works, VPN requires Zero Trust permission)")
|
|
944
|
+
}
|
|
945
|
+
this.renderContent()
|
|
946
|
+
}
|
|
947
|
+
|
|
732
948
|
// Done!
|
|
733
949
|
this.step = "done"
|
|
734
950
|
this.renderContent()
|