@muhammedaksam/easiarr 0.5.0 → 0.5.2
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/package.json +1 -1
- package/src/api/portainer-api.ts +231 -0
- package/src/apps/registry.ts +1 -0
- package/src/config/schema.ts +2 -0
- package/src/ui/screens/ApiKeyViewer.ts +141 -4
- package/src/ui/screens/FullAutoSetup.ts +76 -1
- package/src/utils/password.ts +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portainer API Client
|
|
3
|
+
* Handles Portainer-specific API calls for initialization and management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { debugLog } from "../utils/debug"
|
|
7
|
+
import { ensureMinPasswordLength } from "../utils/password"
|
|
8
|
+
|
|
9
|
+
// Portainer requires minimum 12 character password
|
|
10
|
+
export const PORTAINER_MIN_PASSWORD_LENGTH = 12
|
|
11
|
+
|
|
12
|
+
export interface PortainerUser {
|
|
13
|
+
Id?: number
|
|
14
|
+
Username: string
|
|
15
|
+
Password?: string
|
|
16
|
+
Role?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Result from admin initialization - includes actual password used
|
|
20
|
+
export interface PortainerInitResult {
|
|
21
|
+
user: PortainerUser
|
|
22
|
+
/** The actual password used (may be padded if global was < 12 chars) */
|
|
23
|
+
actualPassword: string
|
|
24
|
+
/** True if password was modified (padded) from the original */
|
|
25
|
+
passwordWasPadded: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PortainerStatus {
|
|
29
|
+
Version: string
|
|
30
|
+
InstanceID: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PortainerSettings {
|
|
34
|
+
AuthenticationMethod: number
|
|
35
|
+
LogoURL: string
|
|
36
|
+
BlackListedLabels: string[]
|
|
37
|
+
InternalAuthSettings: {
|
|
38
|
+
RequiredPasswordLength: number
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Auth response from login
|
|
43
|
+
export interface PortainerAuthResponse {
|
|
44
|
+
jwt: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// API Key creation response
|
|
48
|
+
export interface PortainerApiKeyResponse {
|
|
49
|
+
rawAPIKey: string
|
|
50
|
+
apiKey: {
|
|
51
|
+
id: number
|
|
52
|
+
userId: number
|
|
53
|
+
description: string
|
|
54
|
+
prefix: string
|
|
55
|
+
dateCreated: number
|
|
56
|
+
lastUsed: number
|
|
57
|
+
digest: string
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Portainer API Client
|
|
63
|
+
*/
|
|
64
|
+
export class PortainerApiClient {
|
|
65
|
+
private baseUrl: string
|
|
66
|
+
private jwtToken: string | null = null
|
|
67
|
+
|
|
68
|
+
constructor(host: string, port: number) {
|
|
69
|
+
this.baseUrl = `http://${host}:${port}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
73
|
+
const url = `${this.baseUrl}/api${endpoint}`
|
|
74
|
+
const headers: Record<string, string> = {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
...(options.headers as Record<string, string>),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Add JWT token if authenticated
|
|
80
|
+
if (this.jwtToken) {
|
|
81
|
+
headers["Authorization"] = `Bearer ${this.jwtToken}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
debugLog("PortainerAPI", `${options.method || "GET"} ${url}`)
|
|
85
|
+
if (options.body) {
|
|
86
|
+
debugLog("PortainerAPI", `Request Body: ${options.body}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await fetch(url, { ...options, headers })
|
|
90
|
+
const text = await response.text()
|
|
91
|
+
|
|
92
|
+
debugLog("PortainerAPI", `Response ${response.status} from ${endpoint}`)
|
|
93
|
+
if (text && text.length < 2000) {
|
|
94
|
+
debugLog("PortainerAPI", `Response Body: ${text}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`Portainer API request failed: ${response.status} ${response.statusText} - ${text}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!text) return {} as T
|
|
102
|
+
return JSON.parse(text) as T
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if Portainer needs initial admin user setup.
|
|
107
|
+
* Returns true if no admin user exists yet.
|
|
108
|
+
*/
|
|
109
|
+
async needsInitialization(): Promise<boolean> {
|
|
110
|
+
try {
|
|
111
|
+
const response = await fetch(`${this.baseUrl}/api/users/admin/check`)
|
|
112
|
+
// 404 means no admin exists yet - needs initialization
|
|
113
|
+
// 204 means admin already exists
|
|
114
|
+
return response.status === 404
|
|
115
|
+
} catch (error) {
|
|
116
|
+
debugLog("PortainerAPI", `Check admin error: ${error}`)
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Login to Portainer and store JWT token for subsequent requests.
|
|
123
|
+
*
|
|
124
|
+
* @param username - Admin username
|
|
125
|
+
* @param password - Admin password
|
|
126
|
+
* @returns JWT token
|
|
127
|
+
*/
|
|
128
|
+
async login(username: string, password: string): Promise<string> {
|
|
129
|
+
const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
|
|
130
|
+
|
|
131
|
+
const response = await this.request<PortainerAuthResponse>("/auth", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
username,
|
|
135
|
+
password: safePassword,
|
|
136
|
+
}),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
this.jwtToken = response.jwt
|
|
140
|
+
return response.jwt
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Initialize the admin user for a fresh Portainer installation.
|
|
145
|
+
* Password will be automatically padded if shorter than 12 characters.
|
|
146
|
+
* Automatically logs in after initialization.
|
|
147
|
+
*
|
|
148
|
+
* @param username - Admin username
|
|
149
|
+
* @param password - Admin password (will be padded if needed)
|
|
150
|
+
* @returns Init result with user, actual password, and padding flag - or null if already initialized
|
|
151
|
+
*/
|
|
152
|
+
async initializeAdmin(username: string, password: string): Promise<PortainerInitResult | null> {
|
|
153
|
+
// Check if initialization is needed
|
|
154
|
+
const needsInit = await this.needsInitialization()
|
|
155
|
+
if (!needsInit) {
|
|
156
|
+
debugLog("PortainerAPI", "Admin already initialized, skipping")
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Ensure password meets Portainer's minimum length requirement
|
|
161
|
+
const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
|
|
162
|
+
const wasPadded = safePassword !== password
|
|
163
|
+
|
|
164
|
+
if (wasPadded) {
|
|
165
|
+
debugLog("PortainerAPI", `Password padded from ${password.length} to ${safePassword.length} characters`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const user = await this.request<PortainerUser>("/users/admin/init", {
|
|
169
|
+
method: "POST",
|
|
170
|
+
body: JSON.stringify({
|
|
171
|
+
Username: username,
|
|
172
|
+
Password: safePassword,
|
|
173
|
+
}),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Auto-login after initialization
|
|
177
|
+
await this.login(username, safePassword)
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
user,
|
|
181
|
+
actualPassword: safePassword,
|
|
182
|
+
passwordWasPadded: wasPadded,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Generate a permanent API key for the authenticated user.
|
|
188
|
+
* Must be logged in first (call login() or initializeAdmin()).
|
|
189
|
+
*
|
|
190
|
+
* @param userId - User ID (default: 1 for admin)
|
|
191
|
+
* @param description - Description for the API key
|
|
192
|
+
* @param password - User password for confirmation
|
|
193
|
+
* @returns Raw API key to save to .env as API_KEY_PORTAINER
|
|
194
|
+
*/
|
|
195
|
+
async generateApiKey(password: string, description: string = "easiarr-api-key", userId: number = 1): Promise<string> {
|
|
196
|
+
if (!this.jwtToken) {
|
|
197
|
+
throw new Error("Must be logged in to generate API key. Call login() first.")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const safePassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
|
|
201
|
+
|
|
202
|
+
const response = await this.request<PortainerApiKeyResponse>(`/users/${userId}/tokens`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
password: safePassword,
|
|
206
|
+
description,
|
|
207
|
+
}),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return response.rawAPIKey
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get Portainer system status
|
|
215
|
+
*/
|
|
216
|
+
async getStatus(): Promise<PortainerStatus> {
|
|
217
|
+
return this.request<PortainerStatus>("/status")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Check if Portainer is reachable
|
|
222
|
+
*/
|
|
223
|
+
async isHealthy(): Promise<boolean> {
|
|
224
|
+
try {
|
|
225
|
+
await this.getStatus()
|
|
226
|
+
return true
|
|
227
|
+
} catch {
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -448,6 +448,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
448
448
|
puid: 0,
|
|
449
449
|
pgid: 0,
|
|
450
450
|
volumes: (root) => [`${root}/config/portainer:/data`, "/var/run/docker.sock:/var/run/docker.sock"],
|
|
451
|
+
minPasswordLength: 12, // Portainer requires minimum 12 character password
|
|
451
452
|
},
|
|
452
453
|
|
|
453
454
|
huntarr: {
|
package/src/config/schema.ts
CHANGED
|
@@ -188,6 +188,8 @@ export interface AppDefinition {
|
|
|
188
188
|
prowlarrCategoryIds?: number[]
|
|
189
189
|
/** Architecture compatibility info - omit if supports all */
|
|
190
190
|
arch?: ArchCompatibility
|
|
191
|
+
/** Minimum password length requirement for user creation */
|
|
192
|
+
minPasswordLength?: number
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
export interface RootFolderMeta {
|
|
@@ -6,7 +6,8 @@ import { parse as parseYaml } from "yaml"
|
|
|
6
6
|
import { createPageLayout } from "../components/PageLayout"
|
|
7
7
|
import { EasiarrConfig, AppDefinition } from "../../config/schema"
|
|
8
8
|
import { getApp } from "../../apps/registry"
|
|
9
|
-
import { updateEnv } from "../../utils/env"
|
|
9
|
+
import { updateEnv, readEnvSync } from "../../utils/env"
|
|
10
|
+
import { PortainerApiClient } from "../../api/portainer-api"
|
|
10
11
|
|
|
11
12
|
/** Generate a random 32-character hex API key */
|
|
12
13
|
function generateApiKey(): string {
|
|
@@ -103,12 +104,18 @@ function updateIniValue(content: string, section: string, updates: Record<string
|
|
|
103
104
|
|
|
104
105
|
type KeyStatus = "found" | "missing" | "error" | "generated"
|
|
105
106
|
|
|
107
|
+
interface PortainerCredentials {
|
|
108
|
+
apiKey: string
|
|
109
|
+
password?: string // Only set if padded (different from global)
|
|
110
|
+
}
|
|
111
|
+
|
|
106
112
|
export class ApiKeyViewer extends BoxRenderable {
|
|
107
113
|
private config: EasiarrConfig
|
|
108
114
|
private keys: Array<{ appId: string; app: string; key: string; status: KeyStatus }> = []
|
|
109
115
|
private keyHandler!: (key: KeyEvent) => void
|
|
110
116
|
private cliRenderer: CliRenderer
|
|
111
117
|
private statusText: TextRenderable | null = null
|
|
118
|
+
private portainerCredentials: PortainerCredentials | null = null
|
|
112
119
|
|
|
113
120
|
constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
|
|
114
121
|
super(renderer, {
|
|
@@ -131,6 +138,12 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
131
138
|
for (const appConfig of this.config.apps) {
|
|
132
139
|
if (!appConfig.enabled) continue
|
|
133
140
|
|
|
141
|
+
// Handle Portainer separately (uses API, not config file)
|
|
142
|
+
if (appConfig.id === "portainer") {
|
|
143
|
+
this.scanPortainer(appConfig.port || 9000)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
134
147
|
const appDef = getApp(appConfig.id)
|
|
135
148
|
if (!appDef || !appDef.apiKeyMeta) continue
|
|
136
149
|
|
|
@@ -160,6 +173,41 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
160
173
|
}
|
|
161
174
|
}
|
|
162
175
|
|
|
176
|
+
private scanPortainer(_port: number) {
|
|
177
|
+
const env = readEnvSync()
|
|
178
|
+
const existingApiKey = env["API_KEY_PORTAINER"]
|
|
179
|
+
|
|
180
|
+
if (existingApiKey) {
|
|
181
|
+
this.keys.push({
|
|
182
|
+
appId: "portainer",
|
|
183
|
+
app: "Portainer",
|
|
184
|
+
key: existingApiKey,
|
|
185
|
+
status: "found",
|
|
186
|
+
})
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Will attempt to initialize/login when saving
|
|
191
|
+
const globalPassword = env["GLOBAL_PASSWORD"]
|
|
192
|
+
if (!globalPassword) {
|
|
193
|
+
this.keys.push({
|
|
194
|
+
appId: "portainer",
|
|
195
|
+
app: "Portainer",
|
|
196
|
+
key: "No GLOBAL_PASSWORD set in .env",
|
|
197
|
+
status: "missing",
|
|
198
|
+
})
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add pending entry - actual API call happens on save
|
|
203
|
+
this.keys.push({
|
|
204
|
+
appId: "portainer",
|
|
205
|
+
app: "Portainer",
|
|
206
|
+
key: "Press S to generate API key",
|
|
207
|
+
status: "missing",
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
163
211
|
private extractApiKey(
|
|
164
212
|
appDef: AppDefinition,
|
|
165
213
|
content: string,
|
|
@@ -338,20 +386,47 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
338
386
|
|
|
339
387
|
private async saveToEnv() {
|
|
340
388
|
const foundKeys = this.keys.filter((k) => k.status === "found" || k.status === "generated")
|
|
341
|
-
if (foundKeys.length === 0) return
|
|
342
389
|
|
|
343
390
|
try {
|
|
344
391
|
// Build updates object with API keys
|
|
345
392
|
const updates: Record<string, string> = {}
|
|
393
|
+
|
|
346
394
|
for (const k of foundKeys) {
|
|
347
|
-
|
|
395
|
+
if (k.appId !== "portainer") {
|
|
396
|
+
updates[`API_KEY_${k.appId.toUpperCase()}`] = k.key
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Handle Portainer separately - need to call API
|
|
401
|
+
const portainerEntry = this.keys.find((k) => k.appId === "portainer")
|
|
402
|
+
if (portainerEntry && portainerEntry.status === "missing") {
|
|
403
|
+
await this.initializePortainer(updates)
|
|
404
|
+
} else if (portainerEntry && portainerEntry.status === "found") {
|
|
405
|
+
updates["API_KEY_PORTAINER"] = portainerEntry.key
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Save Portainer credentials if we have them
|
|
409
|
+
if (this.portainerCredentials) {
|
|
410
|
+
updates["API_KEY_PORTAINER"] = this.portainerCredentials.apiKey
|
|
411
|
+
if (this.portainerCredentials.password) {
|
|
412
|
+
updates["PORTAINER_PASSWORD"] = this.portainerCredentials.password
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (Object.keys(updates).length === 0) {
|
|
417
|
+
if (this.statusText) {
|
|
418
|
+
this.statusText.content = "No keys to save"
|
|
419
|
+
this.statusText.fg = "#f1fa8c"
|
|
420
|
+
}
|
|
421
|
+
return
|
|
348
422
|
}
|
|
349
423
|
|
|
350
424
|
await updateEnv(updates)
|
|
351
425
|
|
|
352
426
|
// Update status
|
|
353
427
|
if (this.statusText) {
|
|
354
|
-
|
|
428
|
+
const count = Object.keys(updates).length
|
|
429
|
+
this.statusText.content = `✓ Saved ${count} key(s) to .env`
|
|
355
430
|
}
|
|
356
431
|
} catch (e) {
|
|
357
432
|
if (this.statusText) {
|
|
@@ -360,4 +435,66 @@ export class ApiKeyViewer extends BoxRenderable {
|
|
|
360
435
|
}
|
|
361
436
|
}
|
|
362
437
|
}
|
|
438
|
+
|
|
439
|
+
private async initializePortainer(_updates: Record<string, string>) {
|
|
440
|
+
const env = readEnvSync()
|
|
441
|
+
const globalUsername = env["GLOBAL_USERNAME"] || "admin"
|
|
442
|
+
const globalPassword = env["GLOBAL_PASSWORD"]
|
|
443
|
+
|
|
444
|
+
if (!globalPassword) return
|
|
445
|
+
|
|
446
|
+
const portainerConfig = this.config.apps.find((a) => a.id === "portainer" && a.enabled)
|
|
447
|
+
if (!portainerConfig) return
|
|
448
|
+
|
|
449
|
+
const port = portainerConfig.port || 9000
|
|
450
|
+
const client = new PortainerApiClient("localhost", port)
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
// Check if reachable
|
|
454
|
+
const healthy = await client.isHealthy()
|
|
455
|
+
if (!healthy) {
|
|
456
|
+
if (this.statusText) {
|
|
457
|
+
this.statusText.content = "Portainer not reachable"
|
|
458
|
+
this.statusText.fg = "#f1fa8c"
|
|
459
|
+
}
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Try to initialize or login
|
|
464
|
+
const result = await client.initializeAdmin(globalUsername, globalPassword)
|
|
465
|
+
|
|
466
|
+
if (result) {
|
|
467
|
+
// New initialization
|
|
468
|
+
const apiKey = await client.generateApiKey(result.actualPassword, "easiarr-api-key")
|
|
469
|
+
this.portainerCredentials = {
|
|
470
|
+
apiKey,
|
|
471
|
+
password: result.passwordWasPadded ? result.actualPassword : undefined,
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Update the display
|
|
475
|
+
const portainerEntry = this.keys.find((k) => k.appId === "portainer")
|
|
476
|
+
if (portainerEntry) {
|
|
477
|
+
portainerEntry.key = apiKey
|
|
478
|
+
portainerEntry.status = "generated"
|
|
479
|
+
}
|
|
480
|
+
} else {
|
|
481
|
+
// Already initialized, try login with saved password if available
|
|
482
|
+
const portainerPassword = env["PORTAINER_PASSWORD"] || globalPassword
|
|
483
|
+
await client.login(globalUsername, portainerPassword)
|
|
484
|
+
const apiKey = await client.generateApiKey(portainerPassword, "easiarr-api-key")
|
|
485
|
+
this.portainerCredentials = { apiKey }
|
|
486
|
+
|
|
487
|
+
const portainerEntry = this.keys.find((k) => k.appId === "portainer")
|
|
488
|
+
if (portainerEntry) {
|
|
489
|
+
portainerEntry.key = apiKey
|
|
490
|
+
portainerEntry.status = "generated"
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} catch (e) {
|
|
494
|
+
if (this.statusText) {
|
|
495
|
+
this.statusText.content = `Portainer error: ${e}`
|
|
496
|
+
this.statusText.fg = "#ff5555"
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
363
500
|
}
|
|
@@ -9,10 +9,11 @@ import type { EasiarrConfig } from "../../config/schema"
|
|
|
9
9
|
import { ArrApiClient, type AddRootFolderOptions } from "../../api/arr-api"
|
|
10
10
|
import { ProwlarrClient, type ArrAppType } from "../../api/prowlarr-api"
|
|
11
11
|
import { QBittorrentClient, type QBittorrentCategory } from "../../api/qbittorrent-api"
|
|
12
|
+
import { PortainerApiClient } from "../../api/portainer-api"
|
|
12
13
|
import { getApp } from "../../apps/registry"
|
|
13
14
|
// import type { AppId } from "../../config/schema"
|
|
14
15
|
import { getCategoriesForApps } from "../../utils/categories"
|
|
15
|
-
import { readEnvSync } from "../../utils/env"
|
|
16
|
+
import { readEnvSync, updateEnv } from "../../utils/env"
|
|
16
17
|
import { debugLog } from "../../utils/debug"
|
|
17
18
|
|
|
18
19
|
interface SetupStep {
|
|
@@ -77,6 +78,7 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
77
78
|
{ name: "Prowlarr Apps", status: "pending" },
|
|
78
79
|
{ name: "FlareSolverr", status: "pending" },
|
|
79
80
|
{ name: "qBittorrent", status: "pending" },
|
|
81
|
+
{ name: "Portainer", status: "pending" },
|
|
80
82
|
]
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -122,6 +124,9 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
122
124
|
// Step 5: qBittorrent
|
|
123
125
|
await this.setupQBittorrent()
|
|
124
126
|
|
|
127
|
+
// Step 6: Portainer
|
|
128
|
+
await this.setupPortainer()
|
|
129
|
+
|
|
125
130
|
this.isRunning = false
|
|
126
131
|
this.isDone = true
|
|
127
132
|
this.refreshContent()
|
|
@@ -330,6 +335,76 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
330
335
|
this.refreshContent()
|
|
331
336
|
}
|
|
332
337
|
|
|
338
|
+
private async setupPortainer(): Promise<void> {
|
|
339
|
+
this.updateStep("Portainer", "running")
|
|
340
|
+
this.refreshContent()
|
|
341
|
+
|
|
342
|
+
const portainerConfig = this.config.apps.find((a) => a.id === "portainer" && a.enabled)
|
|
343
|
+
if (!portainerConfig) {
|
|
344
|
+
this.updateStep("Portainer", "skipped", "Not enabled")
|
|
345
|
+
this.refreshContent()
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!this.globalPassword) {
|
|
350
|
+
this.updateStep("Portainer", "skipped", "No GLOBAL_PASSWORD set")
|
|
351
|
+
this.refreshContent()
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const port = portainerConfig.port || 9000
|
|
357
|
+
const client = new PortainerApiClient("localhost", port)
|
|
358
|
+
|
|
359
|
+
// Check if we can reach Portainer
|
|
360
|
+
const healthy = await client.isHealthy()
|
|
361
|
+
if (!healthy) {
|
|
362
|
+
this.updateStep("Portainer", "skipped", "Not reachable yet")
|
|
363
|
+
this.refreshContent()
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Initialize admin user (auto-pads password if needed)
|
|
368
|
+
const result = await client.initializeAdmin(this.globalUsername, this.globalPassword)
|
|
369
|
+
|
|
370
|
+
if (result) {
|
|
371
|
+
// Generate API key and save to .env
|
|
372
|
+
const apiKey = await client.generateApiKey(result.actualPassword, "easiarr-api-key")
|
|
373
|
+
|
|
374
|
+
const envUpdates: Record<string, string> = {
|
|
375
|
+
API_KEY_PORTAINER: apiKey,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Save password if it was padded (different from global)
|
|
379
|
+
if (result.passwordWasPadded) {
|
|
380
|
+
envUpdates.PORTAINER_PASSWORD = result.actualPassword
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
await updateEnv(envUpdates)
|
|
384
|
+
this.updateStep("Portainer", "success", "Admin + API key created")
|
|
385
|
+
} else {
|
|
386
|
+
// Already initialized, try to login and get API key if we don't have one
|
|
387
|
+
if (!this.env["API_KEY_PORTAINER"]) {
|
|
388
|
+
try {
|
|
389
|
+
// Use saved Portainer password if available (may have been padded)
|
|
390
|
+
const portainerPassword = this.env["PORTAINER_PASSWORD"] || this.globalPassword
|
|
391
|
+
await client.login(this.globalUsername, portainerPassword)
|
|
392
|
+
const apiKey = await client.generateApiKey(portainerPassword, "easiarr-api-key")
|
|
393
|
+
await updateEnv({ API_KEY_PORTAINER: apiKey })
|
|
394
|
+
this.updateStep("Portainer", "success", "API key generated")
|
|
395
|
+
} catch {
|
|
396
|
+
this.updateStep("Portainer", "skipped", "Already initialized")
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
this.updateStep("Portainer", "skipped", "Already configured")
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch (e) {
|
|
403
|
+
this.updateStep("Portainer", "error", `${e}`)
|
|
404
|
+
}
|
|
405
|
+
this.refreshContent()
|
|
406
|
+
}
|
|
407
|
+
|
|
333
408
|
private updateStep(name: string, status: SetupStep["status"], message?: string): void {
|
|
334
409
|
const step = this.steps.find((s) => s.name === name)
|
|
335
410
|
if (step) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Utilities
|
|
3
|
+
* Helpers for password validation and transformation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ensures a password meets minimum length requirements by repeating it.
|
|
8
|
+
* If the password is already long enough, returns it unchanged.
|
|
9
|
+
*
|
|
10
|
+
* @param password - The original password
|
|
11
|
+
* @param minLength - Minimum required length
|
|
12
|
+
* @returns Password padded to at least minLength characters
|
|
13
|
+
*/
|
|
14
|
+
export function ensureMinPasswordLength(password: string, minLength: number): string {
|
|
15
|
+
if (!password || password.length === 0) {
|
|
16
|
+
throw new Error("Password cannot be empty")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (password.length >= minLength) {
|
|
20
|
+
return password
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Repeat the password until it meets minimum length
|
|
24
|
+
let extendedPassword = password
|
|
25
|
+
while (extendedPassword.length < minLength) {
|
|
26
|
+
extendedPassword += password
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Return exactly minLength characters
|
|
30
|
+
return extendedPassword.slice(0, minLength)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validates if a password meets minimum length requirements.
|
|
35
|
+
*
|
|
36
|
+
* @param password - The password to validate
|
|
37
|
+
* @param minLength - Minimum required length
|
|
38
|
+
* @returns true if password meets requirements
|
|
39
|
+
*/
|
|
40
|
+
export function isPasswordValid(password: string, minLength: number): boolean {
|
|
41
|
+
return password !== undefined && password !== null && password.length >= minLength
|
|
42
|
+
}
|