@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
|
@@ -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
|
+
}
|
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
|