@muhammedaksam/easiarr 1.4.0 → 1.6.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.
Files changed (109) hide show
  1. package/dist/index.js +29750 -0
  2. package/package.json +15 -15
  3. package/src/VersionInfo.ts +0 -12
  4. package/src/api/arr-api.ts +0 -521
  5. package/src/api/auto-setup-types.ts +0 -64
  6. package/src/api/base-api.ts +0 -216
  7. package/src/api/bazarr-api.ts +0 -297
  8. package/src/api/cloudflare-api.ts +0 -493
  9. package/src/api/custom-format-api.ts +0 -224
  10. package/src/api/grafana-api.ts +0 -230
  11. package/src/api/heimdall-api.ts +0 -141
  12. package/src/api/homarr-api.ts +0 -209
  13. package/src/api/huntarr-api.ts +0 -457
  14. package/src/api/index.ts +0 -9
  15. package/src/api/jellyfin-api.ts +0 -389
  16. package/src/api/jellyseerr-api.ts +0 -559
  17. package/src/api/maintainerr-api.ts +0 -461
  18. package/src/api/naming-config.ts +0 -101
  19. package/src/api/overseerr-api.ts +0 -392
  20. package/src/api/plex-api.ts +0 -308
  21. package/src/api/portainer-api.ts +0 -374
  22. package/src/api/profilarr-api.ts +0 -391
  23. package/src/api/prowlarr-api.ts +0 -488
  24. package/src/api/qbittorrent-api.ts +0 -300
  25. package/src/api/quality-profile-api.ts +0 -272
  26. package/src/api/tautulli-api.ts +0 -244
  27. package/src/api/uptime-kuma-api.ts +0 -343
  28. package/src/apps/categories.ts +0 -14
  29. package/src/apps/index.ts +0 -2
  30. package/src/apps/registry.ts +0 -1275
  31. package/src/compose/caddy-config.ts +0 -130
  32. package/src/compose/generator.ts +0 -287
  33. package/src/compose/index.ts +0 -7
  34. package/src/compose/templates.ts +0 -78
  35. package/src/compose/traefik-config.ts +0 -187
  36. package/src/config/bookmarks-generator.ts +0 -160
  37. package/src/config/defaults.ts +0 -37
  38. package/src/config/homepage-config.ts +0 -381
  39. package/src/config/index.ts +0 -6
  40. package/src/config/manager.ts +0 -150
  41. package/src/config/recyclarr-config.ts +0 -181
  42. package/src/config/schema.ts +0 -310
  43. package/src/config/slskd-config.ts +0 -86
  44. package/src/config/soularr-config.ts +0 -105
  45. package/src/config/trash-quality-definitions.ts +0 -213
  46. package/src/data/lidarr-custom-formats.ts +0 -127
  47. package/src/data/trash-profiles.ts +0 -333
  48. package/src/docker/client.ts +0 -312
  49. package/src/docker/index.ts +0 -18
  50. package/src/index.ts +0 -77
  51. package/src/setup/actions/arr-common.ts +0 -307
  52. package/src/setup/actions/dashboards.ts +0 -79
  53. package/src/setup/actions/download-clients.ts +0 -54
  54. package/src/setup/actions/jellyseerr.ts +0 -331
  55. package/src/setup/actions/media-servers.ts +0 -68
  56. package/src/setup/actions/monitoring.ts +0 -120
  57. package/src/setup/actions/prowlarr.ts +0 -137
  58. package/src/setup/actions/utilities.ts +0 -490
  59. package/src/setup/index.ts +0 -78
  60. package/src/setup/types.ts +0 -144
  61. package/src/structure/manager.ts +0 -129
  62. package/src/ui/App.ts +0 -111
  63. package/src/ui/components/ApplicationSelector.ts +0 -275
  64. package/src/ui/components/CredentialsForm.ts +0 -180
  65. package/src/ui/components/DownloadClientForm.ts +0 -114
  66. package/src/ui/components/FileEditor.ts +0 -91
  67. package/src/ui/components/FooterHint.ts +0 -119
  68. package/src/ui/components/PageLayout.ts +0 -160
  69. package/src/ui/components/UpdateNotification.ts +0 -102
  70. package/src/ui/index.ts +0 -7
  71. package/src/ui/screens/AdvancedSettings.ts +0 -188
  72. package/src/ui/screens/ApiKeyViewer.ts +0 -511
  73. package/src/ui/screens/AppConfigurator.ts +0 -543
  74. package/src/ui/screens/AppManager.ts +0 -284
  75. package/src/ui/screens/ContainerControl.ts +0 -581
  76. package/src/ui/screens/LogsViewer.ts +0 -478
  77. package/src/ui/screens/MainMenu.ts +0 -299
  78. package/src/ui/screens/MonitorDashboard.ts +0 -881
  79. package/src/ui/screens/SecretsEditor.ts +0 -216
  80. package/src/ui/screens/SettingsScreen.ts +0 -633
  81. package/src/ui/screens/index.ts +0 -22
  82. package/src/ui/screens/setup/BaseAppSetupScreen.ts +0 -375
  83. package/src/ui/screens/setup/CloudflaredSetup.ts +0 -460
  84. package/src/ui/screens/setup/FullAutoSetup.ts +0 -766
  85. package/src/ui/screens/setup/HomepageSetup.ts +0 -165
  86. package/src/ui/screens/setup/JellyfinSetup.ts +0 -328
  87. package/src/ui/screens/setup/JellyseerrSetup.ts +0 -242
  88. package/src/ui/screens/setup/ProwlarrSetup.ts +0 -500
  89. package/src/ui/screens/setup/QBittorrentSetup.ts +0 -221
  90. package/src/ui/screens/setup/QuickSetup.ts +0 -1169
  91. package/src/ui/screens/setup/RecyclarrSetup.ts +0 -270
  92. package/src/ui/screens/setup/TRaSHProfileSetup.ts +0 -381
  93. package/src/utils/arch.ts +0 -78
  94. package/src/utils/browser.ts +0 -27
  95. package/src/utils/categories.ts +0 -61
  96. package/src/utils/debug.ts +0 -84
  97. package/src/utils/env.ts +0 -138
  98. package/src/utils/index.ts +0 -8
  99. package/src/utils/logs.ts +0 -119
  100. package/src/utils/migrations/1765626338_rename_env_variables.ts +0 -102
  101. package/src/utils/migrations/1765707135_rename_easiarr_status.ts +0 -91
  102. package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +0 -45
  103. package/src/utils/migrations/1769515650_migrate_to_xdg.ts +0 -114
  104. package/src/utils/migrations.ts +0 -165
  105. package/src/utils/password.ts +0 -42
  106. package/src/utils/paths.ts +0 -33
  107. package/src/utils/unraid.ts +0 -103
  108. package/src/utils/update-checker.ts +0 -210
  109. package/src/utils/url-utils.ts +0 -38
@@ -1,130 +0,0 @@
1
- /**
2
- * Caddy Configuration Generator
3
- * Generates Caddyfile for automatic HTTPS reverse proxy
4
- */
5
-
6
- import { existsSync } from "node:fs"
7
- import { mkdir, writeFile } from "node:fs/promises"
8
- import { join } from "node:path"
9
-
10
- import type { EasiarrConfig } from "~/config/schema"
11
- import { getApp } from "~/apps/registry"
12
- import { debugLog } from "~/utils/debug"
13
-
14
- /**
15
- * Generate Caddyfile content for enabled apps
16
- */
17
- export function generateCaddyfile(config: EasiarrConfig): string {
18
- const domain = config.caddy?.domain || "localhost"
19
- const email = config.caddy?.email
20
-
21
- let caddyfile = `# Caddyfile
22
- # Generated by easiarr - https://github.com/muhammedaksam/easiarr
23
- #
24
- # Caddy provides automatic HTTPS via Let's Encrypt.
25
- # For local dev, use 'localhost' or ':80' as domain.
26
- #
27
- # To manually reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile
28
-
29
- `
30
-
31
- // Global options block (if email is set for ACME)
32
- if (email) {
33
- caddyfile += `{
34
- email ${email}
35
- }
36
-
37
- `
38
- }
39
-
40
- // Get enabled apps that should be proxied
41
- const proxyApps = config.apps.filter((app) => {
42
- if (!app.enabled) return false
43
- // Skip caddy itself and apps without ports
44
- if (app.id === "caddy" || app.id === "cloudflared") return false
45
- const def = getApp(app.id)
46
- if (!def || def.defaultPort === 0) return false
47
- return true
48
- })
49
-
50
- // Generate reverse proxy blocks for each app
51
- for (const app of proxyApps) {
52
- const def = getApp(app.id)
53
- if (!def) continue
54
-
55
- const port = app.port ?? def.defaultPort
56
- const subdomain = app.id
57
- const internalPort = def.internalPort ?? port
58
-
59
- // Format: subdomain.domain.com
60
- const host = domain === "localhost" ? `${subdomain}.localhost` : `${subdomain}.${domain}`
61
-
62
- caddyfile += `# ${def.name}
63
- ${host} {
64
- reverse_proxy ${app.id}:${internalPort}
65
- }
66
-
67
- `
68
- }
69
-
70
- return caddyfile
71
- }
72
-
73
- /**
74
- * Save Caddyfile to the config directory
75
- */
76
- export async function saveCaddyConfig(config: EasiarrConfig): Promise<string> {
77
- // Check if Caddy is enabled
78
- const caddyApp = config.apps.find((a) => a.id === "caddy" && a.enabled)
79
- if (!caddyApp) {
80
- debugLog("Caddy", "Caddy not enabled, skipping config generation")
81
- return ""
82
- }
83
-
84
- const caddyDir = join(config.rootDir, "config", "caddy")
85
- const dataDir = join(caddyDir, "data")
86
- const configDir = join(caddyDir, "config")
87
-
88
- debugLog("Caddy", `Generating Caddyfile in ${caddyDir}`)
89
-
90
- try {
91
- // Create directories if they don't exist
92
- if (!existsSync(caddyDir)) {
93
- await mkdir(caddyDir, { recursive: true })
94
- }
95
- if (!existsSync(dataDir)) {
96
- await mkdir(dataDir, { recursive: true })
97
- }
98
- if (!existsSync(configDir)) {
99
- await mkdir(configDir, { recursive: true })
100
- }
101
-
102
- // Generate Caddyfile
103
- const caddyfileContent = generateCaddyfile(config)
104
- const caddyfilePath = join(caddyDir, "Caddyfile")
105
-
106
- // Always overwrite Caddyfile to reflect current app config
107
- await writeFile(caddyfilePath, caddyfileContent, "utf-8")
108
- debugLog("Caddy", `Saved Caddyfile to ${caddyfilePath}`)
109
-
110
- return caddyfilePath
111
- } catch (error) {
112
- const err = error as NodeJS.ErrnoException
113
- if (err.code === "EACCES") {
114
- console.warn(
115
- `[WARN] Cannot write Caddy config files (permission denied). ` +
116
- `Fix with: sudo chown -R $(id -u):$(id -g) ${caddyDir}`
117
- )
118
- } else {
119
- throw error
120
- }
121
- return ""
122
- }
123
- }
124
-
125
- /**
126
- * Get the path to the Caddyfile
127
- */
128
- export function getCaddyfilePath(config: EasiarrConfig): string {
129
- return join(config.rootDir, "config", "caddy", "Caddyfile")
130
- }
@@ -1,287 +0,0 @@
1
- /**
2
- * Docker Compose Generator
3
- * Generates docker-compose.yml from easiarr configuration
4
- */
5
-
6
- import { writeFile } from "node:fs/promises"
7
-
8
- import type { AppConfig, AppId, EasiarrConfig, TraefikConfig } from "~/config/schema"
9
- import { getApp } from "~/apps/registry"
10
- import { getComposePath } from "~/config/manager"
11
- import { debugLog } from "~/utils/debug"
12
- import { getLocalIp, updateEnv } from "~/utils/env"
13
- import { saveCaddyConfig } from "./caddy-config"
14
- import { generateServiceYaml } from "./templates"
15
- import { saveTraefikConfig } from "./traefik-config"
16
-
17
- export interface ComposeService {
18
- image: string
19
- container_name: string
20
- environment: Record<string, string | number>
21
- volumes: string[]
22
- ports: string[]
23
- restart: string
24
- depends_on?: string[]
25
- network_mode?: string
26
- labels?: string[]
27
- devices?: string[]
28
- cap_add?: string[]
29
- command?: string
30
- /** Docker user directive (e.g., "1000:1000") for apps that don't support PUID/PGID */
31
- user?: string
32
- }
33
-
34
- export interface ComposeFile {
35
- services: Record<string, ComposeService>
36
- }
37
-
38
- export function generateCompose(config: EasiarrConfig): string {
39
- debugLog(
40
- "ComposeGenerator",
41
- `Generating compose for ${config.apps.filter((a) => a.enabled).length} enabled apps`
42
- )
43
- const services: Record<string, ComposeService> = {}
44
-
45
- // Track ports to move to Gluetun
46
- const gluetunPorts: string[] = []
47
- // Track routing decisions
48
- const routedApps = new Set<string>()
49
-
50
- // 1. Build all services first
51
- for (const appConfig of config.apps) {
52
- if (!appConfig.enabled) continue
53
-
54
- const appDef = getApp(appConfig.id)
55
- if (!appDef) continue
56
-
57
- debugLog("ComposeGenerator", `Building service: ${appConfig.id}`)
58
- const service = buildService(appDef, appConfig, config)
59
- services[appConfig.id] = service
60
- }
61
-
62
- // 2. Apply VPN routing if enabled
63
- if (config.vpn && config.vpn.mode !== "none" && services["gluetun"]) {
64
- const vpnMode = config.vpn.mode
65
-
66
- for (const [id, service] of Object.entries(services)) {
67
- if (id === "gluetun") continue
68
-
69
- const appDef = getApp(id as AppId)
70
- if (!appDef) continue
71
-
72
- // Determine if app should be routed
73
- let shouldRoute = false
74
-
75
- // Mini: Downloaders only
76
- if (vpnMode === "mini" && appDef.category === "downloader") {
77
- shouldRoute = true
78
- }
79
- // Full: Downloaders, Indexers, Requests, MediaServers, Servarr
80
- else if (
81
- vpnMode === "full" &&
82
- ["downloader", "indexer", "request", "mediaserver", "servarr"].includes(appDef.category)
83
- ) {
84
- shouldRoute = true
85
- }
86
-
87
- if (shouldRoute) {
88
- // Move ports to Gluetun
89
- if (service.ports && service.ports.length > 0) {
90
- gluetunPorts.push(...service.ports)
91
- service.ports = []
92
- }
93
-
94
- // Set network mode
95
- service.network_mode = "service:gluetun"
96
- routedApps.add(id)
97
-
98
- // Remove depends_on gluetun if it exists (circular check, though service:gluetun implies dependency)
99
- // Actually docker-compose handles implied dependency for network_mode: service:xxx
100
- }
101
- }
102
-
103
- // 3. Add ports to Gluetun
104
- if (gluetunPorts.length > 0) {
105
- services["gluetun"].ports = [
106
- ...new Set([...(services["gluetun"].ports || []), ...gluetunPorts]),
107
- ]
108
- }
109
- }
110
-
111
- return formatComposeYaml({ services })
112
- }
113
-
114
- function buildService(
115
- appDef: ReturnType<typeof getApp>,
116
- appConfig: AppConfig,
117
- config: EasiarrConfig
118
- ): ComposeService {
119
- if (!appDef) throw new Error("App definition not found")
120
-
121
- const port = appConfig.port ?? appDef.defaultPort
122
- // Use ${ROOT_DIR} for volumes
123
- const volumes = [...appDef.volumes("${ROOT_DIR}"), ...(appConfig.customVolumes ?? [])]
124
-
125
- // Add log volume mount if logMount is enabled and app has logVolume defined
126
- if (config.logMount && appDef.logVolume) {
127
- volumes.push(`\${ROOT_DIR}/logs/${appDef.id}:${appDef.logVolume}`)
128
- }
129
-
130
- // Build environment
131
- const environment: Record<string, string | number> = {
132
- TZ: "${TIMEZONE}",
133
- }
134
-
135
- // Add PUID/PGID (Use globals)
136
- if (appDef.puid > 0 || appDef.pgid > 0 || ["jellyfin", "tautulli"].includes(appDef.id)) {
137
- environment.PUID = "${PUID}"
138
- environment.PGID = "${PGID}"
139
- environment.UMASK = "${UMASK}"
140
- }
141
-
142
- // Add app-specific environment
143
- if (appDef.environment) {
144
- Object.assign(environment, appDef.environment)
145
- }
146
-
147
- // Add custom environment from config
148
- if (appConfig.customEnv) {
149
- Object.assign(environment, appConfig.customEnv)
150
- }
151
-
152
- // Build ports array
153
- let ports: string[] = []
154
- if (appDef.id !== "plex" && port !== 0 && appDef.defaultPort !== 0) {
155
- ports.push(`"${port}:${appDef.internalPort ?? appDef.defaultPort}"`)
156
- }
157
- // Add secondary ports (e.g., dashboard ports)
158
- if (appDef.secondaryPorts) {
159
- ports = ports.concat(appDef.secondaryPorts.map((p) => `"${p}"`))
160
- }
161
-
162
- const service: ComposeService = {
163
- image: appDef.image,
164
- container_name: appDef.id,
165
- environment,
166
- volumes,
167
- ports,
168
- restart: "unless-stopped",
169
- }
170
-
171
- // Add devices/caps
172
- if (appDef.devices) service.devices = [...appDef.devices]
173
- if (appDef.cap_add) service.cap_add = [...appDef.cap_add]
174
-
175
- // Add command (e.g., cloudflared)
176
- if (appDef.command) service.command = appDef.command
177
-
178
- // Use Docker user directive for apps that don't support PUID/PGID (e.g., slskd)
179
- if (appDef.useDockerUser) {
180
- service.user = "${PUID}:${PGID}"
181
- // Remove PUID/PGID env vars since they're not used
182
- delete service.environment.PUID
183
- delete service.environment.PGID
184
- delete service.environment.UMASK
185
- }
186
-
187
- // Plex uses network_mode: host
188
- if (appDef.id === "plex") {
189
- service.network_mode = "host"
190
- }
191
-
192
- // Add dependencies
193
- if (appDef.dependsOn && appDef.dependsOn.length > 0) {
194
- const enabledDeps = appDef.dependsOn.filter((dep) =>
195
- config.apps.some((a) => a.id === dep && a.enabled)
196
- )
197
- if (enabledDeps.length > 0) {
198
- service.depends_on = enabledDeps
199
- }
200
- }
201
-
202
- if (
203
- config.traefik?.enabled &&
204
- appDef.id !== "plex" &&
205
- appDef.id !== "cloudflared" &&
206
- appDef.defaultPort !== 0
207
- ) {
208
- if (appDef.id === "traefik") {
209
- // Special labels for Traefik dashboard (accessible via traefik.domain on port 8080)
210
- service.labels = generateTraefikLabels("traefik", 8080, config.traefik)
211
- } else {
212
- service.labels = generateTraefikLabels(
213
- appDef.id,
214
- appDef.internalPort ?? appDef.defaultPort,
215
- config.traefik
216
- )
217
- }
218
- }
219
-
220
- return service
221
- }
222
-
223
- function generateTraefikLabels(
224
- serviceName: string,
225
- port: number,
226
- traefik: TraefikConfig
227
- ): string[] {
228
- const labels: string[] = [
229
- "traefik.enable=true",
230
- // Router
231
- `traefik.http.routers.${serviceName}.service=${serviceName}`,
232
- `traefik.http.routers.${serviceName}.rule=Host(\`${serviceName}.${traefik.domain}\`)`,
233
- `traefik.http.routers.${serviceName}.entrypoints=${traefik.entrypoint}`,
234
- ]
235
-
236
- // Add middlewares if configured
237
- if (traefik.middlewares.length > 0) {
238
- labels.push(`traefik.http.routers.${serviceName}.middlewares=${traefik.middlewares.join(",")}`)
239
- }
240
-
241
- // Service/Load balancer
242
- labels.push(
243
- `traefik.http.services.${serviceName}.loadbalancer.server.scheme=http`,
244
- `traefik.http.services.${serviceName}.loadbalancer.server.port=${port}`
245
- )
246
-
247
- return labels
248
- }
249
-
250
- function formatComposeYaml(compose: ComposeFile): string {
251
- let yaml = "---\nservices:\n"
252
-
253
- for (const [name, service] of Object.entries(compose.services)) {
254
- yaml += generateServiceYaml(name, service)
255
- }
256
-
257
- return yaml
258
- }
259
-
260
- export async function saveCompose(config: EasiarrConfig): Promise<string> {
261
- const yaml = generateCompose(config)
262
- const path = getComposePath()
263
- await writeFile(path, yaml, "utf-8")
264
-
265
- // Update .env
266
- await updateEnvFile(config)
267
-
268
- // Generate Traefik config files if Traefik is enabled
269
- await saveTraefikConfig(config)
270
-
271
- // Generate Caddy config files if Caddy is enabled
272
- await saveCaddyConfig(config)
273
-
274
- return path
275
- }
276
-
277
- async function updateEnvFile(config: EasiarrConfig) {
278
- // Update .env with global configuration values
279
- await updateEnv({
280
- ROOT_DIR: config.rootDir,
281
- TIMEZONE: config.timezone,
282
- PUID: config.uid.toString(),
283
- PGID: config.gid.toString(),
284
- UMASK: config.umask,
285
- LOCAL_DOCKER_IP: getLocalIp(),
286
- })
287
- }
@@ -1,7 +0,0 @@
1
- export { saveCompose } from "./generator"
2
- export {
3
- generateTraefikStaticConfig,
4
- generateTraefikDynamicConfig,
5
- saveTraefikConfig,
6
- } from "./traefik-config"
7
- export { generateCaddyfile, saveCaddyConfig, getCaddyfilePath } from "./caddy-config"
@@ -1,78 +0,0 @@
1
- /**
2
- * Docker Compose YAML Templates
3
- * Generates properly formatted YAML for services
4
- */
5
-
6
- import type { ComposeService } from "./generator"
7
-
8
- export function generateServiceYaml(name: string, service: ComposeService): string {
9
- let yaml = ` ${name}:\n`
10
- yaml += ` image: ${service.image}\n`
11
- yaml += ` container_name: ${service.container_name}\n`
12
-
13
- // Command (for cloudflared etc.)
14
- if (service.command) {
15
- yaml += ` command: ${service.command}\n`
16
- }
17
-
18
- // Network mode (for Plex)
19
- if (service.network_mode) {
20
- yaml += ` network_mode: ${service.network_mode}\n`
21
- }
22
-
23
- // Dependencies
24
- if (service.depends_on && service.depends_on.length > 0) {
25
- yaml += ` depends_on:\n`
26
- for (const dep of service.depends_on) {
27
- yaml += ` - ${dep}\n`
28
- }
29
- }
30
-
31
- // Environment
32
- if (Object.keys(service.environment).length > 0) {
33
- yaml += ` environment:\n`
34
- for (const [key, value] of Object.entries(service.environment)) {
35
- yaml += ` - ${key}=${value}\n`
36
- }
37
- }
38
-
39
- // Volumes
40
- if (service.volumes.length > 0) {
41
- yaml += ` volumes:\n`
42
- for (const volume of service.volumes) {
43
- yaml += ` - ${volume}\n`
44
- }
45
- }
46
-
47
- // Ports (skip for network_mode: host)
48
- if (service.ports.length > 0 && !service.network_mode) {
49
- yaml += ` ports:\n`
50
- for (const port of service.ports) {
51
- yaml += ` - ${port}\n`
52
- }
53
- }
54
-
55
- // Labels (for Traefik etc.)
56
- if (service.labels && service.labels.length > 0) {
57
- yaml += ` labels:\n`
58
- for (const label of service.labels) {
59
- yaml += ` - ${label}\n`
60
- }
61
- }
62
-
63
- // User directive (for apps like slskd that don't support PUID/PGID)
64
- if (service.user) {
65
- yaml += ` user: ${service.user}\n`
66
- }
67
-
68
- yaml += ` restart: ${service.restart}\n\n`
69
-
70
- return yaml
71
- }
72
-
73
- export function generateNetworkYaml(name: string, driver: string): string {
74
- return `networks:
75
- ${name}:
76
- driver: ${driver}
77
- `
78
- }
@@ -1,187 +0,0 @@
1
- /**
2
- * Traefik Configuration Generator
3
- * Generates traefik.yml (static config) and dynamic.yml (middlewares)
4
- */
5
-
6
- import { existsSync } from "node:fs"
7
- import { mkdir, writeFile } from "node:fs/promises"
8
- import { join } from "node:path"
9
-
10
- import { hashSync } from "bcrypt"
11
-
12
- import type { EasiarrConfig } from "~/config/schema"
13
- import { debugLog } from "~/utils/debug"
14
-
15
- export interface TraefikStaticConfig {
16
- entrypoints: {
17
- web: { address: string }
18
- }
19
- api: {
20
- dashboard: boolean
21
- insecure: boolean
22
- }
23
- providers: {
24
- docker: {
25
- endpoint: string
26
- exposedByDefault: boolean
27
- }
28
- file: {
29
- directory: string
30
- watch: boolean
31
- }
32
- }
33
- }
34
-
35
- /**
36
- * Generate traefik.yml static configuration
37
- */
38
- export function generateTraefikStaticConfig(): string {
39
- return `# Traefik Static Configuration
40
- # Generated by easiarr
41
-
42
- api:
43
- dashboard: true
44
- insecure: true
45
-
46
- entryPoints:
47
- web:
48
- address: ":80"
49
-
50
- providers:
51
- docker:
52
- endpoint: "unix:///var/run/docker.sock"
53
- exposedByDefault: false
54
- file:
55
- directory: "/etc/traefik"
56
- watch: true
57
- `
58
- }
59
-
60
- /**
61
- * Generate dynamic.yml with security headers middleware
62
- */
63
- export function generateTraefikDynamicConfig(
64
- _middlewares: string[],
65
- basicAuth?: { username: string; passwordHash: string }
66
- ): string {
67
- let yaml = `# Traefik Dynamic Configuration
68
- # Generated by easiarr
69
-
70
- http:
71
- middlewares:
72
- `
73
-
74
- // Always include security-headers
75
- yaml += ` security-headers:
76
- headers:
77
- browserXssFilter: true
78
- contentTypeNosniff: true
79
- forceSTSHeader: true
80
- stsIncludeSubdomains: true
81
- stsPreload: true
82
- stsSeconds: 31536000
83
- customFrameOptionsValue: "SAMEORIGIN"
84
- `
85
-
86
- // Add basic-auth middleware if credentials provided
87
- if (basicAuth?.username && basicAuth?.passwordHash) {
88
- // Escape $ in password hash for YAML
89
- const escapedHash = basicAuth.passwordHash.replace(/\$/g, "$$$$")
90
- yaml += `
91
- basic-auth:
92
- basicAuth:
93
- users:
94
- - "${basicAuth.username}:${escapedHash}"
95
- `
96
- }
97
-
98
- return yaml
99
- }
100
-
101
- /**
102
- * Generate htpasswd-compatible hash for basic auth
103
- * Uses bcrypt for secure password hashing (Traefik supports bcrypt format)
104
- * Format: $2b$... (bcrypt hash compatible with htpasswd)
105
- */
106
- function generateHtpasswdHash(password: string): string {
107
- // Use bcrypt with cost factor 10 (standard security level)
108
- const hash = hashSync(password, 10)
109
- // Traefik expects bcrypt hashes with $2y$ prefix (PHP-compatible)
110
- // Node's bcrypt uses $2b$, which Traefik also accepts
111
- return hash
112
- }
113
-
114
- /**
115
- * Save Traefik configuration files to the config directory
116
- */
117
- export async function saveTraefikConfig(config: EasiarrConfig): Promise<void> {
118
- // Check if traefik is enabled
119
- const traefikApp = config.apps.find((a) => a.id === "traefik" && a.enabled)
120
- if (!traefikApp) {
121
- debugLog("Traefik", "Traefik not enabled, skipping config generation")
122
- return
123
- }
124
-
125
- const traefikConfigDir = join(config.rootDir, "config", "traefik")
126
- const letsencryptDir = join(traefikConfigDir, "letsencrypt")
127
- debugLog("Traefik", `Generating config in ${traefikConfigDir}`)
128
-
129
- try {
130
- // Create directories if they don't exist
131
- if (!existsSync(traefikConfigDir)) {
132
- await mkdir(traefikConfigDir, { recursive: true })
133
- debugLog("Traefik", `Created directory: ${traefikConfigDir}`)
134
- }
135
- if (!existsSync(letsencryptDir)) {
136
- await mkdir(letsencryptDir, { recursive: true })
137
- debugLog("Traefik", `Created directory: ${letsencryptDir}`)
138
- }
139
-
140
- // Generate and save static config (traefik.yml)
141
- const staticConfig = generateTraefikStaticConfig()
142
- const staticPath = join(traefikConfigDir, "traefik.yml")
143
-
144
- // Only write if file doesn't exist (don't overwrite user customizations)
145
- if (!existsSync(staticPath)) {
146
- await writeFile(staticPath, staticConfig, "utf-8")
147
- }
148
-
149
- // Check for basic auth credentials from config
150
- let basicAuth: { username: string; passwordHash: string } | undefined
151
- if (config.traefik?.basicAuth?.enabled) {
152
- const username = config.traefik.basicAuth.username
153
- const password = config.traefik.basicAuth.password
154
- if (username && password) {
155
- basicAuth = {
156
- username,
157
- passwordHash: generateHtpasswdHash(password),
158
- }
159
- }
160
- }
161
-
162
- // Generate and save dynamic config (dynamic.yml)
163
- const dynamicConfig = generateTraefikDynamicConfig(config.traefik?.middlewares ?? [], basicAuth)
164
- const dynamicPath = join(traefikConfigDir, "dynamic.yml")
165
-
166
- // Always regenerate dynamic.yml as it contains auth settings
167
- await writeFile(dynamicPath, dynamicConfig, "utf-8")
168
-
169
- // Create acme.json with correct permissions if it doesn't exist
170
- const acmePath = join(letsencryptDir, "acme.json")
171
- if (!existsSync(acmePath)) {
172
- await writeFile(acmePath, "{}", { mode: 0o600 })
173
- }
174
- } catch (error) {
175
- // Permission denied - directory owned by root from Docker
176
- // User needs to manually create configs or fix permissions
177
- const err = error as NodeJS.ErrnoException
178
- if (err.code === "EACCES") {
179
- console.warn(
180
- `[WARN] Cannot write Traefik config files (permission denied). ` +
181
- `Fix with: sudo chown -R $(id -u):$(id -g) ${traefikConfigDir}`
182
- )
183
- } else {
184
- throw error
185
- }
186
- }
187
- }