@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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Traefik Configuration Generator
3
+ * Generates traefik.yml (static config) and dynamic.yml (middlewares)
4
+ */
5
+
6
+ import { writeFile, mkdir } from "node:fs/promises"
7
+ import { existsSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { createHash } from "node:crypto"
10
+ import type { EasiarrConfig } from "../config/schema"
11
+
12
+ export interface TraefikStaticConfig {
13
+ entrypoints: {
14
+ web: { address: string }
15
+ }
16
+ api: {
17
+ dashboard: boolean
18
+ insecure: boolean
19
+ }
20
+ providers: {
21
+ docker: {
22
+ endpoint: string
23
+ exposedByDefault: boolean
24
+ }
25
+ file: {
26
+ directory: string
27
+ watch: boolean
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Generate traefik.yml static configuration
34
+ */
35
+ export function generateTraefikStaticConfig(): string {
36
+ return `# Traefik Static Configuration
37
+ # Generated by easiarr
38
+
39
+ api:
40
+ dashboard: true
41
+ insecure: true
42
+
43
+ entryPoints:
44
+ web:
45
+ address: ":80"
46
+
47
+ providers:
48
+ docker:
49
+ endpoint: "unix:///var/run/docker.sock"
50
+ exposedByDefault: false
51
+ file:
52
+ directory: "/etc/traefik"
53
+ watch: true
54
+ `
55
+ }
56
+
57
+ /**
58
+ * Generate dynamic.yml with security headers middleware
59
+ */
60
+ export function generateTraefikDynamicConfig(
61
+ _middlewares: string[],
62
+ basicAuth?: { username: string; passwordHash: string }
63
+ ): string {
64
+ let yaml = `# Traefik Dynamic Configuration
65
+ # Generated by easiarr
66
+
67
+ http:
68
+ middlewares:
69
+ `
70
+
71
+ // Always include security-headers
72
+ yaml += ` security-headers:
73
+ headers:
74
+ browserXssFilter: true
75
+ contentTypeNosniff: true
76
+ forceSTSHeader: true
77
+ stsIncludeSubdomains: true
78
+ stsPreload: true
79
+ stsSeconds: 31536000
80
+ customFrameOptionsValue: "SAMEORIGIN"
81
+ `
82
+
83
+ // Add basic-auth middleware if credentials provided
84
+ if (basicAuth?.username && basicAuth?.passwordHash) {
85
+ // Escape $ in password hash for YAML
86
+ const escapedHash = basicAuth.passwordHash.replace(/\$/g, "$$$$")
87
+ yaml += `
88
+ basic-auth:
89
+ basicAuth:
90
+ users:
91
+ - "${basicAuth.username}:${escapedHash}"
92
+ `
93
+ }
94
+
95
+ return yaml
96
+ }
97
+
98
+ /**
99
+ * Generate htpasswd-compatible hash for basic auth
100
+ * Uses SHA1 hash in htpasswd format: {SHA}base64(sha1(password))
101
+ */
102
+ function generateHtpasswdHash(password: string): string {
103
+ const sha1Hash = createHash("sha1").update(password).digest("base64")
104
+ return `{SHA}${sha1Hash}`
105
+ }
106
+
107
+ /**
108
+ * Save Traefik configuration files to the config directory
109
+ */
110
+ export async function saveTraefikConfig(config: EasiarrConfig): Promise<void> {
111
+ // Check if traefik is enabled
112
+ const traefikApp = config.apps.find((a) => a.id === "traefik" && a.enabled)
113
+ if (!traefikApp) return
114
+
115
+ const traefikConfigDir = join(config.rootDir, "config", "traefik")
116
+ const letsencryptDir = join(traefikConfigDir, "letsencrypt")
117
+
118
+ try {
119
+ // Create directories if they don't exist
120
+ if (!existsSync(traefikConfigDir)) {
121
+ await mkdir(traefikConfigDir, { recursive: true })
122
+ }
123
+ if (!existsSync(letsencryptDir)) {
124
+ await mkdir(letsencryptDir, { recursive: true })
125
+ }
126
+
127
+ // Generate and save static config (traefik.yml)
128
+ const staticConfig = generateTraefikStaticConfig()
129
+ const staticPath = join(traefikConfigDir, "traefik.yml")
130
+
131
+ // Only write if file doesn't exist (don't overwrite user customizations)
132
+ if (!existsSync(staticPath)) {
133
+ await writeFile(staticPath, staticConfig, "utf-8")
134
+ }
135
+
136
+ // Check for basic auth credentials from config
137
+ let basicAuth: { username: string; passwordHash: string } | undefined
138
+ if (config.traefik?.basicAuth?.enabled) {
139
+ const username = config.traefik.basicAuth.username
140
+ const password = config.traefik.basicAuth.password
141
+ if (username && password) {
142
+ basicAuth = {
143
+ username,
144
+ passwordHash: generateHtpasswdHash(password),
145
+ }
146
+ }
147
+ }
148
+
149
+ // Generate and save dynamic config (dynamic.yml)
150
+ const dynamicConfig = generateTraefikDynamicConfig(config.traefik?.middlewares ?? [], basicAuth)
151
+ const dynamicPath = join(traefikConfigDir, "dynamic.yml")
152
+
153
+ // Always regenerate dynamic.yml as it contains auth settings
154
+ await writeFile(dynamicPath, dynamicConfig, "utf-8")
155
+
156
+ // Create acme.json with correct permissions if it doesn't exist
157
+ const acmePath = join(letsencryptDir, "acme.json")
158
+ if (!existsSync(acmePath)) {
159
+ await writeFile(acmePath, "{}", { mode: 0o600 })
160
+ }
161
+ } catch (error) {
162
+ // Permission denied - directory owned by root from Docker
163
+ // User needs to manually create configs or fix permissions
164
+ const err = error as NodeJS.ErrnoException
165
+ if (err.code === "EACCES") {
166
+ console.warn(
167
+ `[WARN] Cannot write Traefik config files (permission denied). ` +
168
+ `Fix with: sudo chown -R $(id -u):$(id -g) ${traefikConfigDir}`
169
+ )
170
+ } else {
171
+ throw error
172
+ }
173
+ }
174
+ }
@@ -111,17 +111,39 @@ export function generateBookmarksHtml(config: EasiarrConfig, useLocalUrls = fals
111
111
 
112
112
  /**
113
113
  * Get the path to the bookmarks file
114
+ * @param type - 'local' for local URLs, 'remote' for Traefik URLs
114
115
  */
115
- export function getBookmarksPath(): string {
116
- return join(homedir(), ".easiarr", "bookmarks.html")
116
+ export function getBookmarksPath(type: "local" | "remote" = "local"): string {
117
+ const filename = type === "remote" ? "bookmarks-remote.html" : "bookmarks-local.html"
118
+ return join(homedir(), ".easiarr", filename)
117
119
  }
118
120
 
119
121
  /**
120
122
  * Save bookmarks HTML file
123
+ * @param type - 'local' for local URLs, 'remote' for Traefik URLs
121
124
  */
122
- export async function saveBookmarks(config: EasiarrConfig, useLocalUrls = false): Promise<string> {
125
+ export async function saveBookmarks(config: EasiarrConfig, type: "local" | "remote" = "local"): Promise<string> {
126
+ const useLocalUrls = type === "local"
123
127
  const html = generateBookmarksHtml(config, useLocalUrls)
124
- const path = getBookmarksPath()
128
+ const path = getBookmarksPath(type)
125
129
  await writeFile(path, html, "utf-8")
126
130
  return path
127
131
  }
132
+
133
+ /**
134
+ * Save all bookmarks files
135
+ * Always saves local bookmarks, and remote bookmarks only if Traefik is enabled
136
+ */
137
+ export async function saveAllBookmarks(config: EasiarrConfig): Promise<string[]> {
138
+ const paths: string[] = []
139
+
140
+ // Always save local bookmarks
141
+ paths.push(await saveBookmarks(config, "local"))
142
+
143
+ // Save remote bookmarks only if Traefik is enabled with a domain
144
+ if (config.traefik?.enabled && config.traefik.domain) {
145
+ paths.push(await saveBookmarks(config, "remote"))
146
+ }
147
+
148
+ return paths
149
+ }
@@ -65,6 +65,12 @@ export interface TraefikConfig {
65
65
  domain: string
66
66
  entrypoint: string
67
67
  middlewares: string[]
68
+ /** Basic auth using username/password */
69
+ basicAuth?: {
70
+ enabled: boolean
71
+ username: string
72
+ password: string
73
+ }
68
74
  }
69
75
 
70
76
  export interface AppConfig {
@@ -133,6 +139,7 @@ export type AppId =
133
139
  // Reverse Proxy
134
140
  | "traefik"
135
141
  | "traefik-certs-dumper"
142
+ | "cloudflared"
136
143
  | "crowdsec"
137
144
  // Network/VPN
138
145
  | "headscale"
@@ -176,6 +183,8 @@ export interface AppDefinition {
176
183
  defaultPort: number
177
184
  /** Internal container port if different from defaultPort */
178
185
  internalPort?: number
186
+ /** Additional port mappings (e.g., "8083:8080" for dashboard ports) */
187
+ secondaryPorts?: string[]
179
188
  image: string
180
189
  puid: number
181
190
  pgid: number
@@ -186,6 +195,8 @@ export interface AppDefinition {
186
195
  secrets?: AppSecret[]
187
196
  devices?: string[]
188
197
  cap_add?: string[]
198
+ /** Custom command to run (e.g., "tunnel run" for cloudflared) */
199
+ command?: string
189
200
  apiKeyMeta?: ApiKeyMeta
190
201
  rootFolder?: RootFolderMeta
191
202
  prowlarrCategoryIds?: number[]
@@ -40,6 +40,7 @@ export class AppConfigurator extends BoxRenderable {
40
40
  // Global *arr credentials
41
41
  private globalUsername = "admin"
42
42
  private globalPassword = ""
43
+ private globalEmail = ""
43
44
  private overrideExisting = false
44
45
 
45
46
  // Download client credentials
@@ -85,6 +86,7 @@ export class AppConfigurator extends BoxRenderable {
85
86
  const env = readEnvSync()
86
87
  if (env.USERNAME_GLOBAL) this.globalUsername = env.USERNAME_GLOBAL
87
88
  this.globalPassword = env.PASSWORD_GLOBAL || "Ch4ng3m3!1234securityReasons"
89
+ if (env.EMAIL_GLOBAL) this.globalEmail = env.EMAIL_GLOBAL
88
90
  if (env.PASSWORD_QBITTORRENT) this.qbPass = env.PASSWORD_QBITTORRENT
89
91
  if (env.API_KEY_SABNZBD) this.sabApiKey = env.API_KEY_SABNZBD
90
92
  }
@@ -138,6 +140,19 @@ export class AppConfigurator extends BoxRenderable {
138
140
 
139
141
  content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
140
142
 
143
+ // Email input (for Cloudflare Access, notifications, etc.)
144
+ content.add(new TextRenderable(this.cliRenderer, { content: "Email (optional):", fg: "#aaaaaa" }))
145
+ const emailInput = new InputRenderable(this.cliRenderer, {
146
+ id: "global-email-input",
147
+ width: 40,
148
+ placeholder: "you@example.com",
149
+ value: this.globalEmail,
150
+ focusedBackgroundColor: "#1a1a1a",
151
+ })
152
+ content.add(emailInput)
153
+
154
+ content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
155
+
141
156
  // Override toggle
142
157
  const overrideText = new TextRenderable(this.cliRenderer, {
143
158
  id: "override-toggle",
@@ -160,13 +175,17 @@ export class AppConfigurator extends BoxRenderable {
160
175
  overrideText.content = `[O] Override existing: ${this.overrideExisting ? "Yes" : "No"}`
161
176
  overrideText.fg = this.overrideExisting ? "#50fa7b" : "#6272a4"
162
177
  } else if (key.name === "tab") {
163
- // Cycle focus: username -> password -> no focus (shortcuts work) -> username
178
+ // Cycle focus: username -> password -> email -> no focus (shortcuts work) -> username
164
179
  if (focusedInput === userInput) {
165
180
  userInput.blur()
166
181
  passInput.focus()
167
182
  focusedInput = passInput
168
183
  } else if (focusedInput === passInput) {
169
184
  passInput.blur()
185
+ emailInput.focus()
186
+ focusedInput = emailInput
187
+ } else if (focusedInput === emailInput) {
188
+ emailInput.blur()
170
189
  focusedInput = null // No focus state - shortcuts available
171
190
  } else {
172
191
  // No input focused, go back to username
@@ -178,6 +197,7 @@ export class AppConfigurator extends BoxRenderable {
178
197
  this.cliRenderer.keyInput.off("keypress", this.keyHandler)
179
198
  userInput.blur()
180
199
  passInput.blur()
200
+ emailInput.blur()
181
201
  focusedInput = null
182
202
  this.currentStep = "configure"
183
203
  this.runConfiguration()
@@ -185,10 +205,12 @@ export class AppConfigurator extends BoxRenderable {
185
205
  // Save and continue
186
206
  this.globalUsername = userInput.value || "admin"
187
207
  this.globalPassword = passInput.value
208
+ this.globalEmail = emailInput.value
188
209
 
189
210
  this.cliRenderer.keyInput.off("keypress", this.keyHandler)
190
211
  userInput.blur()
191
212
  passInput.blur()
213
+ emailInput.blur()
192
214
  focusedInput = null
193
215
 
194
216
  // Save credentials to .env
@@ -206,6 +228,7 @@ export class AppConfigurator extends BoxRenderable {
206
228
  const updates: Record<string, string> = {}
207
229
  if (this.globalUsername) updates.USERNAME_GLOBAL = this.globalUsername
208
230
  if (this.globalPassword) updates.PASSWORD_GLOBAL = this.globalPassword
231
+ if (this.globalEmail) updates.EMAIL_GLOBAL = this.globalEmail
209
232
  await updateEnv(updates)
210
233
  } catch {
211
234
  // Ignore errors - not critical
@@ -162,7 +162,7 @@ export class AppManager {
162
162
  this.config.traefik = {
163
163
  enabled: true,
164
164
  domain: "${CLOUDFLARE_DNS_ZONE}",
165
- entrypoint: "websecure",
165
+ entrypoint: "web",
166
166
  middlewares: [],
167
167
  }
168
168
  } else if (!enabled && this.config.traefik?.enabled) {