@muhammedaksam/easiarr 0.8.5 → 0.9.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/README.md CHANGED
@@ -118,6 +118,7 @@ bun run start
118
118
  ### Infrastructure
119
119
 
120
120
  - **Traefik** - Reverse proxy and load balancer
121
+ - **Cloudflared** - Cloudflare Tunnel for secure external access
121
122
  - **Traefik Certs Dumper** - Extracts certificates from Traefik
122
123
  - **CrowdSec** - Intrusion prevention system
123
124
  - **Headscale** - Open-source Tailscale control server
@@ -127,6 +128,29 @@ bun run start
127
128
  - **PostgreSQL** - Database server
128
129
  - **Valkey** - Redis-compatible key-value store
129
130
 
131
+ ## Cloudflare Tunnel Setup
132
+
133
+ Expose your services securely without port forwarding using Cloudflare Tunnel.
134
+
135
+ ### Automated Setup (Recommended)
136
+
137
+ 1. Create a Cloudflare API Token at [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) with:
138
+ - `Account:Account Settings:Read` (required)
139
+ - `Account:Cloudflare Tunnel:Edit`
140
+ - `Zone:DNS:Edit`
141
+ - `Account:Access: Apps and Policies:Edit` (optional - protects services with email login)
142
+
143
+ 2. Run easiarr → **Main Menu** → **☁️ Cloudflare Tunnel**
144
+
145
+ 3. Paste your API token and follow the wizard
146
+
147
+ The wizard will automatically:
148
+
149
+ - Create the tunnel
150
+ - Add DNS records
151
+ - Configure ingress rules
152
+ - Optionally set up email authentication via Cloudflare Access
153
+
130
154
  ## Configuration
131
155
 
132
156
  easiarr stores its configuration in `~/.easiarr/`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.8.5",
3
+ "version": "0.9.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",
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Cloudflare API client for tunnel and DNS management
3
+ */
4
+
5
+ const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
6
+
7
+ interface CloudflareResponse<T> {
8
+ success: boolean
9
+ errors: Array<{ code: number; message: string }>
10
+ messages: string[]
11
+ result: T
12
+ }
13
+
14
+ interface Zone {
15
+ id: string
16
+ name: string
17
+ status: string
18
+ }
19
+
20
+ interface Tunnel {
21
+ id: string
22
+ name: string
23
+ status: string
24
+ created_at: string
25
+ connections: Array<{
26
+ id: string
27
+ is_pending_reconnect: boolean
28
+ }>
29
+ }
30
+
31
+ interface TunnelCredentials {
32
+ account_tag: string
33
+ tunnel_secret: string
34
+ tunnel_id: string
35
+ tunnel_name: string
36
+ }
37
+
38
+ interface DnsRecord {
39
+ id: string
40
+ name: string
41
+ type: string
42
+ content: string
43
+ proxied: boolean
44
+ }
45
+
46
+ export class CloudflareApi {
47
+ private apiToken: string
48
+ private accountId: string | null = null
49
+
50
+ constructor(apiToken: string) {
51
+ this.apiToken = apiToken
52
+ }
53
+
54
+ private async request<T>(method: string, endpoint: string, body?: unknown): Promise<CloudflareResponse<T>> {
55
+ const response = await fetch(`${CLOUDFLARE_API_BASE}${endpoint}`, {
56
+ method,
57
+ headers: {
58
+ Authorization: `Bearer ${this.apiToken}`,
59
+ "Content-Type": "application/json",
60
+ },
61
+ body: body ? JSON.stringify(body) : undefined,
62
+ })
63
+
64
+ const data = (await response.json()) as CloudflareResponse<T>
65
+
66
+ if (!data.success) {
67
+ const errors = data.errors.map((e) => e.message).join(", ")
68
+ throw new Error(`Cloudflare API error: ${errors}`)
69
+ }
70
+
71
+ return data
72
+ }
73
+
74
+ /**
75
+ * Get account ID from token
76
+ */
77
+ async getAccountId(): Promise<string> {
78
+ if (this.accountId) return this.accountId
79
+
80
+ const response = await this.request<{ id: string }[]>("GET", "/accounts")
81
+ if (response.result.length === 0) {
82
+ throw new Error(
83
+ "No Cloudflare accounts found. Your API token is missing the 'Account Settings:Read' permission. " +
84
+ "Please edit your token in the Cloudflare dashboard and add: Account → Account Settings → Read"
85
+ )
86
+ }
87
+
88
+ this.accountId = response.result[0].id
89
+ return this.accountId
90
+ }
91
+
92
+ /**
93
+ * List all zones (domains) in the account
94
+ */
95
+ async listZones(): Promise<Zone[]> {
96
+ const response = await this.request<Zone[]>("GET", "/zones")
97
+ return response.result
98
+ }
99
+
100
+ /**
101
+ * Get zone ID by domain name
102
+ */
103
+ async getZoneId(domain: string): Promise<string> {
104
+ const response = await this.request<Zone[]>("GET", `/zones?name=${encodeURIComponent(domain)}`)
105
+ if (response.result.length === 0) {
106
+ throw new Error(`Zone not found for domain: ${domain}`)
107
+ }
108
+ return response.result[0].id
109
+ }
110
+
111
+ /**
112
+ * List all tunnels in the account
113
+ */
114
+ async listTunnels(): Promise<Tunnel[]> {
115
+ const accountId = await this.getAccountId()
116
+ const response = await this.request<Tunnel[]>("GET", `/accounts/${accountId}/cfd_tunnel`)
117
+ return response.result
118
+ }
119
+
120
+ /**
121
+ * Get tunnel by name
122
+ */
123
+ async getTunnelByName(name: string): Promise<Tunnel | null> {
124
+ const tunnels = await this.listTunnels()
125
+ return tunnels.find((t) => t.name === name) || null
126
+ }
127
+
128
+ /**
129
+ * Create a new tunnel
130
+ */
131
+ async createTunnel(name: string): Promise<{ tunnel: Tunnel; credentials: TunnelCredentials }> {
132
+ const accountId = await this.getAccountId()
133
+
134
+ // Generate a random secret for the tunnel
135
+ const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64")
136
+
137
+ const response = await this.request<Tunnel>("POST", `/accounts/${accountId}/cfd_tunnel`, {
138
+ name,
139
+ tunnel_secret: secret,
140
+ config_src: "cloudflare", // Manage config from Cloudflare dashboard/API
141
+ })
142
+
143
+ return {
144
+ tunnel: response.result,
145
+ credentials: {
146
+ account_tag: accountId,
147
+ tunnel_secret: secret,
148
+ tunnel_id: response.result.id,
149
+ tunnel_name: name,
150
+ },
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Get tunnel token (for TUNNEL_TOKEN env var)
156
+ * The token is base64-encoded JSON containing account_tag, tunnel_id, and tunnel_secret
157
+ */
158
+ async getTunnelToken(tunnelId: string): Promise<string> {
159
+ const accountId = await this.getAccountId()
160
+ const response = await this.request<string>("GET", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`)
161
+ return response.result
162
+ }
163
+
164
+ /**
165
+ * Configure tunnel ingress rules
166
+ */
167
+ async configureTunnel(
168
+ tunnelId: string,
169
+ ingress: Array<{ hostname?: string; service: string; originRequest?: Record<string, unknown> }>
170
+ ): Promise<void> {
171
+ const accountId = await this.getAccountId()
172
+
173
+ // Ensure there's a catch-all rule at the end
174
+ const hasChatchAll = ingress.some((r) => !r.hostname)
175
+ if (!hasChatchAll) {
176
+ ingress.push({ service: "http_status:404" })
177
+ }
178
+
179
+ await this.request("PUT", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
180
+ config: {
181
+ ingress,
182
+ "warp-routing": { enabled: false },
183
+ },
184
+ })
185
+ }
186
+
187
+ /**
188
+ * List DNS records for a zone
189
+ */
190
+ async listDnsRecords(zoneId: string): Promise<DnsRecord[]> {
191
+ const response = await this.request<DnsRecord[]>("GET", `/zones/${zoneId}/dns_records`)
192
+ return response.result
193
+ }
194
+
195
+ /**
196
+ * Create a CNAME DNS record pointing to the tunnel
197
+ */
198
+ async createDnsRecord(zoneId: string, name: string, tunnelId: string, proxied = true): Promise<DnsRecord> {
199
+ const target = `${tunnelId}.cfargotunnel.com`
200
+
201
+ // Check if record already exists
202
+ const existing = await this.request<DnsRecord[]>(
203
+ "GET",
204
+ `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(name)}`
205
+ )
206
+
207
+ if (existing.result.length > 0) {
208
+ // Update existing record
209
+ const recordId = existing.result[0].id
210
+ const response = await this.request<DnsRecord>("PATCH", `/zones/${zoneId}/dns_records/${recordId}`, {
211
+ type: "CNAME",
212
+ name,
213
+ content: target,
214
+ proxied,
215
+ })
216
+ return response.result
217
+ }
218
+
219
+ // Create new record
220
+ const response = await this.request<DnsRecord>("POST", `/zones/${zoneId}/dns_records`, {
221
+ type: "CNAME",
222
+ name,
223
+ content: target,
224
+ proxied,
225
+ })
226
+
227
+ return response.result
228
+ }
229
+
230
+ /**
231
+ * Delete a tunnel
232
+ */
233
+ async deleteTunnel(tunnelId: string): Promise<void> {
234
+ const accountId = await this.getAccountId()
235
+ await this.request("DELETE", `/accounts/${accountId}/cfd_tunnel/${tunnelId}`)
236
+ }
237
+
238
+ // ==================== Cloudflare Access API ====================
239
+
240
+ /**
241
+ * Create an Access application
242
+ */
243
+ async createAccessApplication(
244
+ domain: string,
245
+ name = "easiarr",
246
+ sessionDuration = "24h"
247
+ ): Promise<{ id: string; name: string }> {
248
+ const accountId = await this.getAccountId()
249
+
250
+ // Check if app already exists
251
+ const existing = await this.request<Array<{ id: string; name: string; domain: string }>>(
252
+ "GET",
253
+ `/accounts/${accountId}/access/apps`
254
+ )
255
+
256
+ const existingApp = existing.result.find((app) => app.name === name || app.domain === `*.${domain}`)
257
+ if (existingApp) {
258
+ return { id: existingApp.id, name: existingApp.name }
259
+ }
260
+
261
+ // Create new application
262
+ const response = await this.request<{ id: string; name: string }>("POST", `/accounts/${accountId}/access/apps`, {
263
+ name,
264
+ domain: `*.${domain}`,
265
+ type: "self_hosted",
266
+ session_duration: sessionDuration,
267
+ auto_redirect_to_identity: true,
268
+ })
269
+
270
+ return response.result
271
+ }
272
+
273
+ /**
274
+ * Create an Access policy for an application
275
+ */
276
+ async createAccessPolicy(
277
+ appId: string,
278
+ allowedEmails: string[],
279
+ policyName = "Allow Emails"
280
+ ): Promise<{ id: string }> {
281
+ const accountId = await this.getAccountId()
282
+
283
+ // Check if policy already exists
284
+ const existing = await this.request<Array<{ id: string; name: string }>>(
285
+ "GET",
286
+ `/accounts/${accountId}/access/apps/${appId}/policies`
287
+ )
288
+
289
+ const existingPolicy = existing.result.find((p) => p.name === policyName)
290
+ if (existingPolicy) {
291
+ return { id: existingPolicy.id }
292
+ }
293
+
294
+ // Create email-based allow policy
295
+ // Each email needs to be a separate include rule
296
+ // Use next available precedence (existing count + 1)
297
+ const response = await this.request<{ id: string }>(
298
+ "POST",
299
+ `/accounts/${accountId}/access/apps/${appId}/policies`,
300
+ {
301
+ name: policyName,
302
+ decision: "allow",
303
+ include: allowedEmails.map((email) => ({
304
+ email: { email },
305
+ })),
306
+ precedence: existing.result.length + 1,
307
+ }
308
+ )
309
+
310
+ return response.result
311
+ }
312
+
313
+ /**
314
+ * Create Access application with email policy
315
+ */
316
+ async setupAccessProtection(
317
+ domain: string,
318
+ allowedEmails: string[],
319
+ appName = "easiarr"
320
+ ): Promise<{ appId: string; policyId: string }> {
321
+ const app = await this.createAccessApplication(domain, appName)
322
+ const policy = await this.createAccessPolicy(app.id, allowedEmails)
323
+ return { appId: app.id, policyId: policy.id }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Helper to create a fully configured tunnel with DNS
329
+ */
330
+ export async function setupCloudflaredTunnel(
331
+ apiToken: string,
332
+ domain: string,
333
+ tunnelName = "easiarr"
334
+ ): Promise<{ tunnelToken: string; tunnelId: string }> {
335
+ const api = new CloudflareApi(apiToken)
336
+
337
+ // 1. Check if tunnel already exists
338
+ let tunnel = await api.getTunnelByName(tunnelName)
339
+ let tunnelToken: string
340
+
341
+ if (tunnel) {
342
+ // Get existing tunnel token
343
+ tunnelToken = await api.getTunnelToken(tunnel.id)
344
+ } else {
345
+ // 2. Create new tunnel
346
+ const result = await api.createTunnel(tunnelName)
347
+ tunnel = result.tunnel
348
+ tunnelToken = await api.getTunnelToken(tunnel.id)
349
+ }
350
+
351
+ // 3. Configure ingress rules
352
+ await api.configureTunnel(tunnel.id, [
353
+ {
354
+ hostname: `*.${domain}`,
355
+ service: "http://traefik:80",
356
+ originRequest: {},
357
+ },
358
+ ])
359
+
360
+ // 4. Add DNS CNAME record (wildcard)
361
+ const zoneId = await api.getZoneId(domain)
362
+ await api.createDnsRecord(zoneId, `*.${domain}`, tunnel.id)
363
+
364
+ return {
365
+ tunnelToken,
366
+ tunnelId: tunnel.id,
367
+ }
368
+ }
@@ -595,7 +595,8 @@ export const APPS: Record<AppId, AppDefinition> = {
595
595
  pgid: 0,
596
596
  volumes: () => [
597
597
  "${HOME}/.easiarr/config.json:/web/config.json:ro",
598
- "${HOME}/.easiarr/bookmarks.html:/web/bookmarks.html:ro",
598
+ "${HOME}/.easiarr/bookmarks-local.html:/web/bookmarks-local.html:ro",
599
+ "${HOME}/.easiarr/bookmarks-remote.html:/web/bookmarks-remote.html:ro",
599
600
  ],
600
601
  environment: {
601
602
  FOLDER: "/web",
@@ -721,7 +722,8 @@ export const APPS: Record<AppId, AppDefinition> = {
721
722
  name: "Traefik",
722
723
  description: "Reverse proxy and load balancer",
723
724
  category: "infrastructure",
724
- defaultPort: 8083,
725
+ defaultPort: 80,
726
+ internalPort: 80,
725
727
  image: "traefik:latest",
726
728
  puid: 0,
727
729
  pgid: 0,
@@ -730,20 +732,47 @@ export const APPS: Record<AppId, AppDefinition> = {
730
732
  `${root}/config/traefik/letsencrypt:/letsencrypt`,
731
733
  "/var/run/docker.sock:/var/run/docker.sock:ro",
732
734
  ],
735
+ // Dashboard exposed on 8083 (internal 8080) for Homepage widget
736
+ secondaryPorts: ["8083:8080"],
733
737
  secrets: [
734
738
  {
735
- name: "CLOUDFLARE_DNS_API_TOKEN",
736
- description: "Cloudflare DNS API Token for Traefik",
739
+ name: "CLOUDFLARE_DNS_ZONE",
740
+ description: "Root Domain (e.g. example.com)",
741
+ required: true,
742
+ },
743
+ ],
744
+ homepage: { icon: "traefik.png", widget: "traefik" },
745
+ },
746
+
747
+ cloudflared: {
748
+ id: "cloudflared",
749
+ name: "Cloudflared",
750
+ description: "Cloudflare Tunnel for secure external access without port forwarding",
751
+ category: "infrastructure",
752
+ defaultPort: 0, // No exposed port - tunnel is outbound only
753
+ image: "cloudflare/cloudflared:latest",
754
+ puid: 0,
755
+ pgid: 0,
756
+ volumes: () => [],
757
+ environment: {
758
+ TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN}",
759
+ },
760
+ command: "tunnel run",
761
+ dependsOn: ["traefik"],
762
+ secrets: [
763
+ {
764
+ name: "CLOUDFLARE_API_TOKEN",
765
+ description: "Cloudflare API Token (for automated tunnel setup via Menu)",
737
766
  required: false,
738
767
  mask: true,
739
768
  },
740
769
  {
741
- name: "CLOUDFLARE_DNS_ZONE",
742
- description: "Root Domain (e.g. example.com)",
770
+ name: "CLOUDFLARE_TUNNEL_TOKEN",
771
+ description: "Cloudflare Tunnel Token (auto-generated or from Zero Trust)",
743
772
  required: true,
773
+ mask: true,
744
774
  },
745
775
  ],
746
- homepage: { icon: "traefik.png", widget: "traefik" },
747
776
  },
748
777
 
749
778
  "traefik-certs-dumper": {
@@ -9,6 +9,7 @@ import { getComposePath } from "../config/manager"
9
9
  import { getApp } from "../apps/registry"
10
10
  import { generateServiceYaml } from "./templates"
11
11
  import { updateEnv, getLocalIp } from "../utils/env"
12
+ import { saveTraefikConfig } from "./traefik-config"
12
13
 
13
14
  export interface ComposeService {
14
15
  image: string
@@ -22,6 +23,7 @@ export interface ComposeService {
22
23
  labels?: string[]
23
24
  devices?: string[]
24
25
  cap_add?: string[]
26
+ command?: string
25
27
  }
26
28
 
27
29
  export interface ComposeFile {
@@ -126,12 +128,22 @@ function buildService(appDef: ReturnType<typeof getApp>, appConfig: AppConfig, c
126
128
  Object.assign(environment, appConfig.customEnv)
127
129
  }
128
130
 
131
+ // Build ports array
132
+ let ports: string[] = []
133
+ if (appDef.id !== "plex" && port !== 0 && appDef.defaultPort !== 0) {
134
+ ports.push(`"${port}:${appDef.internalPort ?? appDef.defaultPort}"`)
135
+ }
136
+ // Add secondary ports (e.g., dashboard ports)
137
+ if (appDef.secondaryPorts) {
138
+ ports = ports.concat(appDef.secondaryPorts.map((p) => `"${p}"`))
139
+ }
140
+
129
141
  const service: ComposeService = {
130
142
  image: appDef.image,
131
143
  container_name: appDef.id,
132
144
  environment,
133
145
  volumes,
134
- ports: appDef.id === "plex" ? [] : [`"${port}:${appDef.internalPort ?? appDef.defaultPort}"`],
146
+ ports,
135
147
  restart: "unless-stopped",
136
148
  }
137
149
 
@@ -139,6 +151,9 @@ function buildService(appDef: ReturnType<typeof getApp>, appConfig: AppConfig, c
139
151
  if (appDef.devices) service.devices = [...appDef.devices]
140
152
  if (appDef.cap_add) service.cap_add = [...appDef.cap_add]
141
153
 
154
+ // Add command (e.g., cloudflared)
155
+ if (appDef.command) service.command = appDef.command
156
+
142
157
  // Plex uses network_mode: host
143
158
  if (appDef.id === "plex") {
144
159
  service.network_mode = "host"
@@ -152,8 +167,13 @@ function buildService(appDef: ReturnType<typeof getApp>, appConfig: AppConfig, c
152
167
  }
153
168
  }
154
169
 
155
- if (config.traefik?.enabled && appDef.id !== "traefik" && appDef.id !== "plex") {
156
- service.labels = generateTraefikLabels(appDef.id, appDef.internalPort ?? appDef.defaultPort, config.traefik)
170
+ if (config.traefik?.enabled && appDef.id !== "plex" && appDef.id !== "cloudflared") {
171
+ if (appDef.id === "traefik") {
172
+ // Special labels for Traefik dashboard (accessible via traefik.domain on port 8080)
173
+ service.labels = generateTraefikLabels("traefik", 8080, config.traefik)
174
+ } else {
175
+ service.labels = generateTraefikLabels(appDef.id, appDef.internalPort ?? appDef.defaultPort, config.traefik)
176
+ }
157
177
  }
158
178
 
159
179
  return service
@@ -200,6 +220,9 @@ export async function saveCompose(config: EasiarrConfig): Promise<string> {
200
220
  // Update .env
201
221
  await updateEnvFile(config)
202
222
 
223
+ // Generate Traefik config files if Traefik is enabled
224
+ await saveTraefikConfig(config)
225
+
203
226
  return path
204
227
  }
205
228
 
@@ -1,2 +1,3 @@
1
1
  export * from "./generator"
2
2
  export * from "./templates"
3
+ export * from "./traefik-config"
@@ -10,6 +10,11 @@ export function generateServiceYaml(name: string, service: ComposeService): stri
10
10
  yaml += ` image: ${service.image}\n`
11
11
  yaml += ` container_name: ${service.container_name}\n`
12
12
 
13
+ // Command (for cloudflared etc.)
14
+ if (service.command) {
15
+ yaml += ` command: ${service.command}\n`
16
+ }
17
+
13
18
  // Network mode (for Plex)
14
19
  if (service.network_mode) {
15
20
  yaml += ` network_mode: ${service.network_mode}\n`