restic-setup-client/dist/cli.js
2026-01-21 11:37:53 -08:00

583 lines
No EOL
20 KiB
JavaScript
Executable file

#!/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 <url> Restic REST server URL (required)
--hostname <hostname> Workstation hostname (default: $(hostname))
-p, --password <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 <pwd> 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 <command> [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 <pwd>
restic-setup-client check-password
restic-setup-client rotate-password
Run 'restic-setup-client <command> --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