@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.
@@ -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" }, // No widget, just icon
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: string
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 and has API key
81
+ // Add widget if defined
78
82
  if (appDef.homepage?.widget) {
79
83
  const apiKey = env[`API_KEY_${appDef.id.toUpperCase()}`]
80
84
 
81
- service.widget = {
82
- type: appDef.homepage.widget,
83
- url: baseUrl,
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 (apiKey) {
87
- service.widget.key = apiKey
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
- // Add widget-specific credentials from env
91
- if (appDef.id === "qbittorrent") {
92
- const username = env["USERNAME_QBITTORRENT"]
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
- if (appDef.id === "portainer") {
99
- // Try to auto-detect Portainer environment ID
100
- // User can override with PORTAINER_ENV in .env file
101
- if (env["PORTAINER_ENV"]) {
102
- service.widget.env = env["PORTAINER_ENV"]
103
- } else {
104
- // Auto-detect from Portainer API
105
- const portainerPort = appConfig.port ?? appDef.defaultPort
106
- const portainerClient = new PortainerApiClient(localIp, portainerPort)
107
- const apiKey = env["API_KEY_PORTAINER"]
108
- if (apiKey) {
109
- portainerClient.setApiKey(apiKey)
110
- }
111
- const localEnvId = await portainerClient.getLocalEnvironmentId()
112
- const envIdStr = localEnvId?.toString() ?? "1"
113
- service.widget.env = envIdStr
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
- // Persist the detected env ID to .env for future use
116
- if (localEnvId) {
117
- await updateEnv({ PORTAINER_ENV: envIdStr })
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
- // Add any custom widget fields
123
- if (appDef.homepage.widgetFields) {
124
- Object.assign(service.widget, appDef.homepage.widgetFields)
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
@@ -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/4: Enter Cloudflare API Token"
108
+ return "Step 1/5: Enter Cloudflare API Token"
104
109
  case "domain":
105
- return "Step 2/4: Configure Domain"
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 3/4: Confirm Settings"
114
+ return "Step 4/5: Confirm Settings"
108
115
  case "progress":
109
- return "Step 4/4: Setting up tunnel..."
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
- this.step = "confirm"
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
- await api.setupAccessProtection(this.domain, [this.accessEmail.trim()], "easiarr")
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
- this.statusMessages.push(`✓ Cloudflare Access created for: ${this.accessEmail}`)
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()