@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 +24 -0
- package/package.json +1 -1
- package/src/api/cloudflare-api.ts +368 -0
- package/src/apps/registry.ts +36 -7
- 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/bookmarks-generator.ts +26 -4
- 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 +21 -6
- 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
|
@@ -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
|
-
|
|
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,
|
|
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
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|