#!/usr/bin/env node import { promisify, parseArgs } from 'util'; import { homedir, hostname, platform } from 'os'; import { exec, execSync } from 'child_process'; import { mkdirSync, writeFileSync, readFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { readFile, writeFile, access } from 'fs/promises'; async function initRepository(repoUrl, password) { try { console.log(`[restic-setup-client] Initializing repository: ${repoUrl}`); try { execSync('RESTIC_PASSWORD="' + password + '" restic -r "' + repoUrl + '" snapshots', { encoding: "utf8", stdio: "pipe" }); console.log(`[restic-setup-client] Repository already exists: ${repoUrl}`); return { success: true, repoUrl }; } catch { execSync('RESTIC_PASSWORD="' + password + '" restic -r "' + repoUrl + '" init', { encoding: "utf8", stdio: "inherit" }); console.log(`[restic-setup-client] \u2705 Repository initialized: ${repoUrl}`); return { success: true, repoUrl }; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`[restic-setup-client] \u274C Repository initialization failed: ${errorMessage}`); return { success: false, repoUrl, error: errorMessage }; } } // src/lib/client.ts var __dirname$1 = dirname(fileURLToPath(import.meta.url)); var TEMPLATES_DIR = join(__dirname$1, "../templates"); function deployTimers() { const systemdDir = join(homedir(), ".config/systemd/user"); mkdirSync(systemdDir, { recursive: true }); const files = [ "restic-backup-code.service", "restic-backup-code.timer", "restic-backup-dotfiles.service", "restic-backup-dotfiles.timer" ]; for (const file of files) { const templatePath = join(TEMPLATES_DIR, file); const template = readFileSync(templatePath, "utf8"); const targetPath = join(systemdDir, file); writeFileSync(targetPath, template, { mode: 420 }); console.log(`[restic-setup-client] Deployed: ${targetPath}`); } execSync("systemctl --user daemon-reload", { encoding: "utf8", stdio: "inherit" }); execSync("systemctl --user enable --now restic-backup-code.timer", { encoding: "utf8", stdio: "inherit" }); execSync("systemctl --user enable --now restic-backup-dotfiles.timer", { encoding: "utf8", stdio: "inherit" }); console.log("[restic-setup-client] \u2705 Timers enabled and started"); } async function setupClient(config) { const { serverUrl, hostname: hostname2 = hostname(), password, configDir = join(homedir(), ".config/restic") } = config; try { console.log(`[restic-setup-client] Setting up backup client for ${hostname2}...`); if (!serverUrl || !password) { throw new Error("serverUrl and password are required"); } mkdirSync(configDir, { recursive: true, mode: 448 }); const passwordFile = join(configDir, "password"); writeFileSync(passwordFile, password, { mode: 384 }); console.log(`[restic-setup-client] Password file created: ${passwordFile}`); const dotfilesIncludeTemplate = join(TEMPLATES_DIR, "restic-dotfiles-include.txt"); const dotfilesIncludePath = join(configDir, "dotfiles-include.txt"); const dotfilesIncludeContent = readFileSync(dotfilesIncludeTemplate, "utf8"); writeFileSync(dotfilesIncludePath, dotfilesIncludeContent, { mode: 420 }); console.log(`[restic-setup-client] Dotfiles include list created: ${dotfilesIncludePath}`); const codeRepoUrl = "rest:" + serverUrl + "/" + hostname2 + "-code"; const dotfilesRepoUrl = "rest:" + serverUrl + "/" + hostname2 + "-dotfiles"; console.log("[restic-setup-client] Initializing repositories..."); const codeResult = await initRepository(codeRepoUrl, password); if (!codeResult.success) { throw new Error("Failed to initialize code repository: " + codeResult.error); } const dotfilesResult = await initRepository(dotfilesRepoUrl, password); if (!dotfilesResult.success) { throw new Error("Failed to initialize dotfiles repository: " + dotfilesResult.error); } console.log("[restic-setup-client] Deploying systemd timers..."); deployTimers(); console.log("[restic-setup-client] \u2705 Client setup complete!"); return { success: true, codeRepoUrl, dotfilesRepoUrl }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`[restic-setup-client] \u274C Setup failed: ${errorMessage}`); return { success: false, codeRepoUrl: "rest:" + serverUrl + "/" + hostname2 + "-code", dotfilesRepoUrl: "rest:" + serverUrl + "/" + hostname2 + "-dotfiles", error: errorMessage }; } } var execAsync = promisify(exec); var VAULT_DIR = join(homedir(), ".vault"); var METADATA_FILE = join(VAULT_DIR, "restic-password.meta.json"); var PASSWORD_FILE = join(VAULT_DIR, "restic-password.txt"); var ROTATION_THRESHOLD_DAYS = 365; var CODE_REPO_NAME = "code"; var DOTFILES_REPO_NAME = "dotfiles"; async function loadPasswordMetadata() { try { const data = await readFile(METADATA_FILE, "utf-8"); return JSON.parse(data); } catch (err) { if (err.code === "ENOENT") { return null; } throw err; } } async function savePasswordMetadata(metadata) { await writeFile(METADATA_FILE, JSON.stringify(metadata, null, 2), "utf-8"); await execAsync(`chmod 600 "${METADATA_FILE}"`); } async function initializePasswordMetadata(hostname2) { const metadata = { createdAt: (/* @__PURE__ */ new Date()).toISOString(), rotationCount: 0, hostname: hostname2 }; await savePasswordMetadata(metadata); return metadata; } async function getPasswordAge() { const metadata = await loadPasswordMetadata(); if (!metadata) { throw new Error("Password metadata not found. Run restic-setup-client setup first."); } const createdAt = new Date(metadata.createdAt); const lastRotatedAt = metadata.rotatedAt ? new Date(metadata.rotatedAt) : void 0; const referenceDate = lastRotatedAt || createdAt; const now = /* @__PURE__ */ new Date(); const ageInMs = now.getTime() - referenceDate.getTime(); const ageInDays = Math.floor(ageInMs / (1e3 * 60 * 60 * 24)); const daysUntilRecommendedRotation = ROTATION_THRESHOLD_DAYS - ageInDays; const shouldRotate = ageInDays >= ROTATION_THRESHOLD_DAYS; return { ageInDays, createdAt, lastRotatedAt, rotationCount: metadata.rotationCount, shouldRotate, daysUntilRecommendedRotation }; } async function shouldPromptRotation() { try { const ageInfo = await getPasswordAge(); return ageInfo.shouldRotate; } catch { return false; } } function generatePassword() { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const length = 32; const array = new Uint8Array(length); crypto.getRandomValues(array); return Array.from(array).map((byte) => chars[byte % chars.length]).join(""); } async function hashPassword(password) { const encoder = new TextEncoder(); const data = encoder.encode(password); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } async function rotatePassword(oldPassword, newPassword, updateKeychain = true) { const result = { success: false, oldPasswordHash: "", newPasswordHash: "", repositoriesUpdated: [], errors: [] }; try { if (!oldPassword) { oldPassword = (await readFile(PASSWORD_FILE, "utf-8")).trim(); } result.oldPasswordHash = await hashPassword(oldPassword); if (!newPassword) { newPassword = generatePassword(); } result.newPasswordHash = await hashPassword(newPassword); const repos = await getResticRepositories(); for (const repo of repos) { try { await changeRepositoryPassword(repo, oldPassword, newPassword); result.repositoriesUpdated.push(repo.name); } catch (err) { const error = `Failed to update ${repo.name}: ${err.message}`; result.errors.push(error); } } if (result.repositoriesUpdated.length > 0) { await writeFile(PASSWORD_FILE, newPassword + "\n", "utf-8"); await execAsync(`chmod 600 "${PASSWORD_FILE}"`); const metadata = await loadPasswordMetadata(); if (metadata) { metadata.rotatedAt = (/* @__PURE__ */ new Date()).toISOString(); metadata.rotationCount += 1; await savePasswordMetadata(metadata); } result.success = result.errors.length === 0; if (updateKeychain && platform() === "darwin" && result.success) { try { await updateKeychainPassword(newPassword); result.keychainUpdated = true; } catch (err) { result.errors.push(`Keychain update failed: ${err.message}`); result.keychainUpdated = false; } } } else { result.success = false; result.errors.push("No repositories were updated successfully"); } } catch (err) { result.success = false; result.errors.push(`Rotation failed: ${err.message}`); } return result; } async function updateKeychainPassword(newPassword) { try { const { storeInKeychain } = await import('@lilith/vault-setup-client'); const result = await storeInKeychain({ service: "restic-backup", account: "lilith-platform-workstations", password: newPassword }); if (!result.success) { throw new Error(result.error || "Unknown Keychain error"); } } catch (err) { if (err.code === "ERR_MODULE_NOT_FOUND") { throw new Error("Keychain integration requires @lilith/vault-setup-client package"); } throw err; } } async function getResticRepositories() { const repos = []; const repoNames = [CODE_REPO_NAME, DOTFILES_REPO_NAME]; for (const name of repoNames) { const servicePath = `/etc/systemd/system/restic-backup-${name}.service`; try { await access(servicePath); const serviceContent = await readFile(servicePath, "utf-8"); const repoMatch = serviceContent.match(/Environment="RESTIC_REPOSITORY=(.+?)"/); if (repoMatch) { repos.push({ name, url: repoMatch[1], passwordFile: PASSWORD_FILE }); } } catch { } } return repos; } async function changeRepositoryPassword(repo, oldPassword, newPassword) { const tmpOldFile = `/tmp/restic-old-${Date.now()}.txt`; const tmpNewFile = `/tmp/restic-new-${Date.now()}.txt`; try { await writeFile(tmpOldFile, oldPassword, "utf-8"); await writeFile(tmpNewFile, newPassword, "utf-8"); await execAsync(`chmod 600 "${tmpOldFile}" "${tmpNewFile}"`); const cmd = `RESTIC_REPOSITORY="${repo.url}" RESTIC_PASSWORD_FILE="${tmpOldFile}" restic key passwd --new-password-file="${tmpNewFile}"`; const { stderr } = await execAsync(cmd); if (stderr && stderr.includes("error")) { throw new Error(stderr); } } finally { try { await execAsync(`rm -f "${tmpOldFile}" "${tmpNewFile}"`); } catch { } } } // src/cli.ts async function setupCommand(args) { const { values } = parseArgs({ args, options: { server: { type: "string", short: "s" }, hostname: { type: "string" }, password: { type: "string", short: "p" }, help: { type: "boolean", short: "h" } } }); if (values.help) { console.log(` Usage: restic-setup-client setup [options] Setup restic backup client on this workstation. Options: -s, --server Restic REST server URL (required) --hostname Workstation hostname (default: $(hostname)) -p, --password Restic repository password (required) -h, --help Show this help message Example: restic-setup-client setup \\ --server http://10.0.0.11:8000 \\ --password CWPVvKALTwyJfbdVE3oIq7L8Wc7MH4Pz `); process.exit(0); } if (!values.server || !values.password) { console.error("Error: --server and --password are required"); console.error('Run "restic-setup-client setup --help" for usage'); process.exit(1); } try { const host = values.hostname || hostname(); const result = await setupClient({ serverUrl: values.server, hostname: host, password: values.password }); if (result.success) { await initializePasswordMetadata(host); console.log("\n\u2705 Backup client setup successful!"); console.log(` Repositories:`); console.log(` Code: ${result.codeRepoUrl}`); console.log(` Dotfiles: ${result.dotfilesRepoUrl}`); console.log(` Systemd timers have been enabled and started.`); console.log(`Check status: systemctl --user list-timers | grep restic`); console.log(` \u{1F4A1} Password metadata initialized. Run 'restic-setup-client check-password' to view age.`); process.exit(0); } else { console.error(` \u274C Setup failed: ${result.error}`); process.exit(1); } } catch (error) { console.error("\n\u274C Error:", error instanceof Error ? error.message : String(error)); process.exit(1); } } async function checkPasswordCommand(args) { const { values } = parseArgs({ args, options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" } } }); if (values.help) { console.log(` Usage: restic-setup-client check-password [options] Check the age of the restic repository password and get rotation recommendations. Options: --json Output as JSON -h, --help Show this help message Example: restic-setup-client check-password `); process.exit(0); } try { const ageInfo = await getPasswordAge(); if (values.json) { console.log(JSON.stringify(ageInfo, null, 2)); process.exit(0); } console.log("\n\u{1F4CA} Password Age Information"); console.log("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"); console.log(`Created: ${ageInfo.createdAt.toISOString().split("T")[0]}`); if (ageInfo.lastRotatedAt) { console.log(`Last Rotated: ${ageInfo.lastRotatedAt.toISOString().split("T")[0]}`); } console.log(`Age: ${ageInfo.ageInDays} days`); console.log(`Rotations: ${ageInfo.rotationCount}`); console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`); if (ageInfo.shouldRotate) { console.log(` \u26A0\uFE0F Password is ${ageInfo.ageInDays} days old (threshold: 365 days)`); console.log(` \u{1F504} Recommendation: Rotate your password now!`); console.log(` Run: restic-setup-client rotate-password`); } else { console.log(` \u2705 Password is within recommended age`); console.log(` \u{1F4C5} Rotation recommended in: ${ageInfo.daysUntilRecommendedRotation} days`); } process.exit(ageInfo.shouldRotate ? 1 : 0); } catch (error) { console.error("\n\u274C Error:", error instanceof Error ? error.message : String(error)); process.exit(1); } } async function rotatePasswordCommand(args) { const { values } = parseArgs({ args, options: { help: { type: "boolean", short: "h" }, force: { type: "boolean", short: "f" }, "new-password": { type: "string" }, "update-keychain": { type: "boolean" }, "no-keychain": { type: "boolean" } } }); if (values.help) { console.log(` Usage: restic-setup-client rotate-password [options] Rotate the restic repository password. This updates all configured repositories. Options: -f, --force Skip age check confirmation --new-password Use specific password (default: auto-generate) --update-keychain Update macOS Keychain (default: true on macOS) --no-keychain Skip Keychain update -h, --help Show this help message Example: restic-setup-client rotate-password restic-setup-client rotate-password --force restic-setup-client rotate-password --new-password "MyNewSecurePassword123" restic-setup-client rotate-password --no-keychain `); process.exit(0); } try { if (!values.force) { const ageInfo = await getPasswordAge(); console.log(` \u{1F4CA} Current password age: ${ageInfo.ageInDays} days`); console.log(` Rotations performed: ${ageInfo.rotationCount}`); if (!ageInfo.shouldRotate) { console.log(` \u26A0\uFE0F Password is still within recommended age (${ageInfo.daysUntilRecommendedRotation} days until 365)`); console.log(` Use --force to rotate anyway`); process.exit(0); } } const updateKeychain = values["no-keychain"] ? false : values["update-keychain"] ?? true; console.log(` \u{1F504} Starting password rotation...`); console.log(` This will update all configured restic repositories`); const result = await rotatePassword( void 0, values["new-password"], updateKeychain ); if (result.success) { console.log(` \u2705 Password rotation successful!`); console.log(` Repositories updated:`); for (const repo of result.repositoriesUpdated) { console.log(` \u2713 ${repo}`); } console.log(` \u{1F4A1} New password saved to: ~/.vault/restic-password.txt`); console.log(` Old password hash: ${result.oldPasswordHash.substring(0, 16)}...`); console.log(` New password hash: ${result.newPasswordHash.substring(0, 16)}...`); if (result.keychainUpdated === true) { console.log(` \u{1F510} macOS Keychain updated`); console.log(` Service: restic-backup`); console.log(` Account: lilith-platform-workstations`); } else if (result.keychainUpdated === false) { console.log(` \u26A0\uFE0F Keychain update failed (see errors above)`); } } else { console.error(` \u274C Password rotation failed`); console.error(` Repositories updated: ${result.repositoriesUpdated.length}`); if (result.errors.length > 0) { console.error(` Errors:`); for (const error of result.errors) { console.error(` \u2717 ${error}`); } } process.exit(1); } } catch (error) { console.error("\n\u274C Error:", error instanceof Error ? error.message : String(error)); process.exit(1); } } async function main() { const args = process.argv.slice(2); const command = args[0]; if (command !== "rotate-password" && await shouldPromptRotation()) { console.log(` \u26A0\uFE0F Your restic password is over 365 days old!`); console.log(` Run: restic-setup-client check-password`); console.log(` Then: restic-setup-client rotate-password `); } if (!command || command === "--help" || command === "-h") { console.log(` restic-setup-client - Automated Restic Backup Client Setup Usage: restic-setup-client [options] Commands: setup Setup restic backup client on this workstation check-password Check password age and rotation status rotate-password Rotate the restic repository password Options: -h, --help Show this help message Examples: restic-setup-client setup --server http://10.0.0.11:8000 --password restic-setup-client check-password restic-setup-client rotate-password Run 'restic-setup-client --help' for command-specific options. `); process.exit(0); } switch (command) { case "setup": await setupCommand(args.slice(1)); break; case "check-password": await checkPasswordCommand(args.slice(1)); break; case "rotate-password": await rotatePasswordCommand(args.slice(1)); break; default: console.error(`Unknown command: ${command}`); console.error(`Run 'restic-setup-client --help' for usage`); process.exit(1); } } main(); //# sourceMappingURL=cli.js.map //# sourceMappingURL=cli.js.map