/* eslint-disable no-await-in-loop */ /* eslint-disable no-restricted-syntax */ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { envMapToString, envStringToMap, execAsync, pathExists, settingsSchema } from '@runtipi/shared'; import { logger } from '../logger/logger'; import { getRepoHash } from '../../services/repo/repo.helpers'; import { ROOT_FOLDER } from '@/config/constants'; type EnvKeys = | 'APPS_REPO_ID' | 'APPS_REPO_URL' | 'TZ' | 'INTERNAL_IP' | 'DNS_IP' | 'ARCHITECTURE' | 'TIPI_VERSION' | 'JWT_SECRET' | 'ROOT_FOLDER_HOST' | 'NGINX_PORT' | 'NGINX_PORT_SSL' | 'DOMAIN' | 'STORAGE_PATH' | 'POSTGRES_PORT' | 'POSTGRES_HOST' | 'POSTGRES_DBNAME' | 'POSTGRES_PASSWORD' | 'POSTGRES_USERNAME' | 'REDIS_HOST' | 'REDIS_PASSWORD' | 'LOCAL_DOMAIN' | 'DEMO_MODE' | 'GUEST_DASHBOARD' | 'TIPI_GID' | 'TIPI_UID' // eslint-disable-next-line @typescript-eslint/ban-types | (string & {}); const OLD_DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore'; const DEFAULT_REPO_URL = 'https://github.com/runtipi/runtipi-appstore'; /** * Reads and returns the generated seed */ const getSeed = async () => { const seedFilePath = path.join(ROOT_FOLDER, 'state', 'seed'); if (!(await pathExists(seedFilePath))) { throw new Error('Seed file not found'); } const seed = await fs.promises.readFile(seedFilePath, 'utf-8'); return seed; }; /** * Derives a new entropy value from the provided entropy and the seed * @param {string} entropy - The entropy value to derive from */ const deriveEntropy = async (entropy: string) => { const seed = await getSeed(); const hmac = crypto.createHmac('sha256', seed); hmac.update(entropy); return hmac.digest('hex'); }; /** * Generates a random seed if it does not exist yet */ const generateSeed = async () => { if (!(await pathExists(path.join(ROOT_FOLDER, 'state', 'seed')))) { const randomBytes = crypto.randomBytes(32); const seed = randomBytes.toString('hex'); await fs.promises.writeFile(path.join(ROOT_FOLDER, 'state', 'seed'), seed); } }; /** * Returns the architecture of the current system */ const getArchitecture = () => { const arch = os.arch(); if (arch === 'arm64') return 'arm64'; if (arch === 'x64') return 'amd64'; throw new Error(`Unsupported architecture: ${arch}`); }; /** * Generates a valid .env file from the settings.json file */ export const generateSystemEnvFile = async () => { await fs.promises.mkdir(path.join(ROOT_FOLDER, 'state'), { recursive: true }); const settingsFilePath = path.join(ROOT_FOLDER, 'state', 'settings.json'); const envFilePath = path.join(ROOT_FOLDER, '.env'); if (!(await pathExists(envFilePath))) { await fs.promises.writeFile(envFilePath, ''); } const envFile = await fs.promises.readFile(envFilePath, 'utf-8'); const envMap: Map = envStringToMap(envFile); if (!(await pathExists(settingsFilePath))) { await fs.promises.writeFile(settingsFilePath, JSON.stringify({})); } const settingsFile = await fs.promises.readFile(settingsFilePath, 'utf-8'); const settings = settingsSchema.safeParse(JSON.parse(settingsFile)); if (!settings.success) { throw new Error(`Invalid settings.json file: ${settings.error.message}`); } await generateSeed(); const { data } = settings; if (data.appsRepoUrl === OLD_DEFAULT_REPO_URL) { data.appsRepoUrl = DEFAULT_REPO_URL; } const jwtSecret = envMap.get('JWT_SECRET') || (await deriveEntropy('jwt_secret')); const repoId = getRepoHash(data.appsRepoUrl || DEFAULT_REPO_URL); const rootFolderHost = envMap.get('ROOT_FOLDER_HOST'); const internalIp = envMap.get('INTERNAL_IP'); if (!rootFolderHost) { throw new Error('ROOT_FOLDER_HOST not set in .env file'); } if (!internalIp) { throw new Error('INTERNAL_IP not set in .env file'); } envMap.set('APPS_REPO_ID', repoId); envMap.set('APPS_REPO_URL', data.appsRepoUrl || DEFAULT_REPO_URL); envMap.set('TZ', Intl.DateTimeFormat().resolvedOptions().timeZone); envMap.set('INTERNAL_IP', data.listenIp || internalIp); envMap.set('DNS_IP', data.dnsIp || '9.9.9.9'); envMap.set('ARCHITECTURE', getArchitecture()); envMap.set('JWT_SECRET', jwtSecret); envMap.set('DOMAIN', data.domain || 'example.com'); envMap.set('STORAGE_PATH', data.storagePath || envMap.get('STORAGE_PATH') || rootFolderHost); envMap.set('POSTGRES_HOST', 'tipi-db'); envMap.set('POSTGRES_DBNAME', 'tipi'); envMap.set('POSTGRES_USERNAME', 'tipi'); envMap.set('POSTGRES_PORT', String(5432)); envMap.set('REDIS_HOST', 'tipi-redis'); envMap.set('DEMO_MODE', String(data.demoMode || 'false')); envMap.set('GUEST_DASHBOARD', String(data.guestDashboard || 'false')); envMap.set('LOCAL_DOMAIN', data.localDomain || 'tipi.lan'); envMap.set('NODE_ENV', 'production'); await fs.promises.writeFile(envFilePath, envMapToString(envMap)); return envMap; }; /** * Copies the system files from the assets folder to the current working directory */ export const copySystemFiles = async () => { // Remove old unused files if (await pathExists(path.join(ROOT_FOLDER, 'scripts'))) { logger.info('Removing old scripts folder'); await fs.promises.rmdir(path.join(ROOT_FOLDER, 'scripts'), { recursive: true }); } const assetsFolder = path.join(ROOT_FOLDER, 'assets'); // Copy traefik folder from assets logger.info('Creating traefik folders'); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'traefik', 'dynamic'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'traefik', 'shared'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'traefik', 'tls'), { recursive: true }); logger.info('Copying traefik files'); await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'traefik.yml'), path.join(ROOT_FOLDER, 'traefik', 'traefik.yml')); await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'dynamic', 'dynamic.yml'), path.join(ROOT_FOLDER, 'traefik', 'dynamic', 'dynamic.yml')); // Create base folders logger.info('Creating base folders'); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'apps'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'app-data'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'state'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'repos'), { recursive: true }); // Create media folders logger.info('Creating media folders'); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'torrents', 'watch'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'torrents', 'complete'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'torrents', 'incomplete'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'usenet', 'watch'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'usenet', 'complete'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'usenet', 'incomplete'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'downloads', 'watch'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'downloads', 'complete'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'downloads', 'incomplete'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'books'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'comics'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'movies'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'music'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'tv'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'podcasts'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'images'), { recursive: true }); await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'roms'), { recursive: true }); }; /** * Given a domain, generates the TLS certificates for it to be used with Traefik * * @param {string} data.domain The domain to generate the certificates for */ export const generateTlsCertificates = async (data: { domain?: string }) => { if (!data.domain) { return; } // If the certificate already exists, don't generate it again if (await pathExists(path.join(ROOT_FOLDER, 'traefik', 'tls', `${data.domain}.txt`))) { logger.info(`TLS certificate for ${data.domain} already exists`); return; } // Remove old certificates if (await pathExists(path.join(ROOT_FOLDER, 'traefik', 'tls', 'cert.pem'))) { logger.info('Removing old TLS certificate'); await fs.promises.unlink(path.join(ROOT_FOLDER, 'traefik', 'tls', 'cert.pem')); } if (await pathExists(path.join(ROOT_FOLDER, 'traefik', 'tls', 'key.pem'))) { logger.info('Removing old TLS key'); await fs.promises.unlink(path.join(ROOT_FOLDER, 'traefik', 'tls', 'key.pem')); } const subject = `/O=runtipi.io/OU=IT/CN=*.${data.domain}/emailAddress=webmaster@${data.domain}`; const subjectAltName = `DNS:*.${data.domain},DNS:${data.domain}`; try { logger.info(`Generating TLS certificate for ${data.domain}`); await execAsync(`openssl req -x509 -newkey rsa:4096 -keyout traefik/tls/key.pem -out traefik/tls/cert.pem -days 365 -subj "${subject}" -addext "subjectAltName = ${subjectAltName}" -nodes`); logger.info(`Writing txt file for ${data.domain}`); await fs.promises.writeFile(path.join(ROOT_FOLDER, 'traefik', 'tls', `${data.domain}.txt`), ''); } catch (error) { logger.error(error); } }; export const ensureFilePermissions = async () => { const filesAndFolders = [ path.join(ROOT_FOLDER, 'apps'), path.join(ROOT_FOLDER, 'logs'), path.join(ROOT_FOLDER, 'repos'), path.join(ROOT_FOLDER, 'state'), path.join(ROOT_FOLDER, 'traefik'), path.join(ROOT_FOLDER, '.env'), path.join(ROOT_FOLDER, 'VERSION'), path.join(ROOT_FOLDER, 'docker-compose.yml'), ]; const files600 = [path.join(ROOT_FOLDER, 'traefik', 'shared', 'acme.json')]; // Give permission to read and write to all files and folders for the current user for (const fileOrFolder of filesAndFolders) { if (await pathExists(fileOrFolder)) { await execAsync(`chmod -R a+rwx ${fileOrFolder}`).catch(() => {}); } } for (const fileOrFolder of files600) { if (await pathExists(fileOrFolder)) { await execAsync(`chmod 600 ${fileOrFolder}`).catch(() => {}); } } };