@muhammedaksam/easiarr 0.8.4 → 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/api/jellyfin-api.ts +1 -1
- package/src/apps/registry.ts +43 -12
- package/src/compose/generator.ts +27 -4
- 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/bookmarks-generator.ts +127 -0
- package/src/config/homepage-config.ts +7 -7
- package/src/config/schema.ts +13 -2
- package/src/index.ts +1 -1
- 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/JellyfinSetup.ts +1 -1
- package/src/ui/screens/MainMenu.ts +45 -1
- package/src/ui/screens/QuickSetup.ts +7 -7
- package/src/ui/screens/SettingsScreen.ts +571 -0
- package/src/utils/browser.ts +26 -0
- package/src/utils/debug.ts +2 -2
- package/src/utils/migrations/1765707135_rename_easiarr_status.ts +90 -0
- package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +44 -0
- package/src/utils/migrations.ts +24 -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/api/jellyfin-api.ts
CHANGED
|
@@ -136,7 +136,7 @@ export class JellyfinClient {
|
|
|
136
136
|
"Content-Type": "application/json",
|
|
137
137
|
// Jellyfin requires client identification
|
|
138
138
|
"X-Emby-Authorization":
|
|
139
|
-
'MediaBrowser Client="
|
|
139
|
+
'MediaBrowser Client="easiarr", Device="Server", DeviceId="easiarr-setup", Version="1.0.0"' +
|
|
140
140
|
(this.accessToken ? `, Token="${this.accessToken}"` : ""),
|
|
141
141
|
...((options.headers as Record<string, string>) || {}),
|
|
142
142
|
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -583,17 +583,20 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
583
583
|
volumes: (root) => [`${root}/config/ddns-updater:/data`],
|
|
584
584
|
},
|
|
585
585
|
|
|
586
|
-
|
|
587
|
-
id: "easiarr
|
|
588
|
-
name: "
|
|
589
|
-
description: "Exposes
|
|
586
|
+
easiarr: {
|
|
587
|
+
id: "easiarr",
|
|
588
|
+
name: "easiarr",
|
|
589
|
+
description: "Exposes easiarr config and bookmarks for Homepage dashboard",
|
|
590
590
|
category: "utility",
|
|
591
591
|
defaultPort: 3010,
|
|
592
592
|
internalPort: 8080,
|
|
593
593
|
image: "halverneus/static-file-server:latest",
|
|
594
594
|
puid: 0,
|
|
595
595
|
pgid: 0,
|
|
596
|
-
volumes: () => [
|
|
596
|
+
volumes: () => [
|
|
597
|
+
"${HOME}/.easiarr/config.json:/web/config.json:ro",
|
|
598
|
+
"${HOME}/.easiarr/bookmarks.html:/web/bookmarks.html:ro",
|
|
599
|
+
],
|
|
597
600
|
environment: {
|
|
598
601
|
FOLDER: "/web",
|
|
599
602
|
CORS: "true",
|
|
@@ -602,7 +605,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
602
605
|
icon: "mdi-docker",
|
|
603
606
|
widget: "customapi",
|
|
604
607
|
widgetFields: {
|
|
605
|
-
url: "http://easiarr
|
|
608
|
+
url: "http://easiarr:8080/config.json",
|
|
606
609
|
mappings: JSON.stringify([{ field: "version", label: "Installed" }]),
|
|
607
610
|
},
|
|
608
611
|
},
|
|
@@ -718,7 +721,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
718
721
|
name: "Traefik",
|
|
719
722
|
description: "Reverse proxy and load balancer",
|
|
720
723
|
category: "infrastructure",
|
|
721
|
-
defaultPort:
|
|
724
|
+
defaultPort: 80,
|
|
725
|
+
internalPort: 80,
|
|
722
726
|
image: "traefik:latest",
|
|
723
727
|
puid: 0,
|
|
724
728
|
pgid: 0,
|
|
@@ -727,20 +731,47 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
727
731
|
`${root}/config/traefik/letsencrypt:/letsencrypt`,
|
|
728
732
|
"/var/run/docker.sock:/var/run/docker.sock:ro",
|
|
729
733
|
],
|
|
734
|
+
// Dashboard exposed on 8083 (internal 8080) for Homepage widget
|
|
735
|
+
secondaryPorts: ["8083:8080"],
|
|
736
|
+
secrets: [
|
|
737
|
+
{
|
|
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"],
|
|
730
761
|
secrets: [
|
|
731
762
|
{
|
|
732
|
-
name: "
|
|
733
|
-
description: "Cloudflare
|
|
763
|
+
name: "CLOUDFLARE_API_TOKEN",
|
|
764
|
+
description: "Cloudflare API Token (for automated tunnel setup via Menu)",
|
|
734
765
|
required: false,
|
|
735
766
|
mask: true,
|
|
736
767
|
},
|
|
737
768
|
{
|
|
738
|
-
name: "
|
|
739
|
-
description: "
|
|
769
|
+
name: "CLOUDFLARE_TUNNEL_TOKEN",
|
|
770
|
+
description: "Cloudflare Tunnel Token (auto-generated or from Zero Trust)",
|
|
740
771
|
required: true,
|
|
772
|
+
mask: true,
|
|
741
773
|
},
|
|
742
774
|
],
|
|
743
|
-
homepage: { icon: "traefik.png", widget: "traefik" },
|
|
744
775
|
},
|
|
745
776
|
|
|
746
777
|
"traefik-certs-dumper": {
|
package/src/compose/generator.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Docker Compose Generator
|
|
3
|
-
* Generates docker-compose.yml from
|
|
3
|
+
* Generates docker-compose.yml from easiarr configuration
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { writeFile } from "node:fs/promises"
|
|
@@ -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`
|