@muhammedaksam/easiarr 0.8.5 → 0.9.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/README.md +24 -0
- package/package.json +1 -1
- package/src/api/cloudflare-api.ts +368 -0
- package/src/apps/registry.ts +34 -6
- package/src/compose/generator.ts +26 -3
- package/src/compose/index.ts +1 -0
- package/src/compose/templates.ts +5 -0
- package/src/compose/traefik-config.ts +174 -0
- package/src/config/schema.ts +11 -0
- package/src/ui/screens/AppConfigurator.ts +24 -1
- package/src/ui/screens/AppManager.ts +1 -1
- package/src/ui/screens/CloudflaredSetup.ts +758 -0
- package/src/ui/screens/FullAutoSetup.ts +72 -0
- package/src/ui/screens/MainMenu.ts +14 -0
- package/src/ui/screens/QuickSetup.ts +4 -4
- package/src/ui/screens/SettingsScreen.ts +571 -0
- package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +44 -0
- package/src/utils/migrations.ts +12 -0
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.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -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
|
+
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -721,7 +721,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
721
721
|
name: "Traefik",
|
|
722
722
|
description: "Reverse proxy and load balancer",
|
|
723
723
|
category: "infrastructure",
|
|
724
|
-
defaultPort:
|
|
724
|
+
defaultPort: 80,
|
|
725
|
+
internalPort: 80,
|
|
725
726
|
image: "traefik:latest",
|
|
726
727
|
puid: 0,
|
|
727
728
|
pgid: 0,
|
|
@@ -730,20 +731,47 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
730
731
|
`${root}/config/traefik/letsencrypt:/letsencrypt`,
|
|
731
732
|
"/var/run/docker.sock:/var/run/docker.sock:ro",
|
|
732
733
|
],
|
|
734
|
+
// Dashboard exposed on 8083 (internal 8080) for Homepage widget
|
|
735
|
+
secondaryPorts: ["8083:8080"],
|
|
733
736
|
secrets: [
|
|
734
737
|
{
|
|
735
|
-
name: "
|
|
736
|
-
description: "
|
|
738
|
+
name: "CLOUDFLARE_DNS_ZONE",
|
|
739
|
+
description: "Root Domain (e.g. example.com)",
|
|
740
|
+
required: true,
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
homepage: { icon: "traefik.png", widget: "traefik" },
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
cloudflared: {
|
|
747
|
+
id: "cloudflared",
|
|
748
|
+
name: "Cloudflared",
|
|
749
|
+
description: "Cloudflare Tunnel for secure external access without port forwarding",
|
|
750
|
+
category: "infrastructure",
|
|
751
|
+
defaultPort: 0, // No exposed port - tunnel is outbound only
|
|
752
|
+
image: "cloudflare/cloudflared:latest",
|
|
753
|
+
puid: 0,
|
|
754
|
+
pgid: 0,
|
|
755
|
+
volumes: () => [],
|
|
756
|
+
environment: {
|
|
757
|
+
TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN}",
|
|
758
|
+
},
|
|
759
|
+
command: "tunnel run",
|
|
760
|
+
dependsOn: ["traefik"],
|
|
761
|
+
secrets: [
|
|
762
|
+
{
|
|
763
|
+
name: "CLOUDFLARE_API_TOKEN",
|
|
764
|
+
description: "Cloudflare API Token (for automated tunnel setup via Menu)",
|
|
737
765
|
required: false,
|
|
738
766
|
mask: true,
|
|
739
767
|
},
|
|
740
768
|
{
|
|
741
|
-
name: "
|
|
742
|
-
description: "
|
|
769
|
+
name: "CLOUDFLARE_TUNNEL_TOKEN",
|
|
770
|
+
description: "Cloudflare Tunnel Token (auto-generated or from Zero Trust)",
|
|
743
771
|
required: true,
|
|
772
|
+
mask: true,
|
|
744
773
|
},
|
|
745
774
|
],
|
|
746
|
-
homepage: { icon: "traefik.png", widget: "traefik" },
|
|
747
775
|
},
|
|
748
776
|
|
|
749
777
|
"traefik-certs-dumper": {
|
package/src/compose/generator.ts
CHANGED
|
@@ -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
|
|
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 !== "
|
|
156
|
-
|
|
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
|
|
package/src/compose/index.ts
CHANGED
package/src/compose/templates.ts
CHANGED
|
@@ -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`
|