diff --git a/app/client/components/create-repository-form.tsx b/app/client/components/create-repository-form.tsx index 40d4ffc..0424b14 100644 --- a/app/client/components/create-repository-form.tsx +++ b/app/client/components/create-repository-form.tsx @@ -1,6 +1,6 @@ import { arktypeResolver } from "@hookform/resolvers/arktype"; import { type } from "arktype"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { cn, slugify } from "~/client/lib/utils"; import { deepClean } from "~/utils/object"; @@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { useSystemInfo } from "~/client/hooks/use-system-info"; import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic"; import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen"; +import { Checkbox } from "./ui/checkbox"; export const formSchema = type({ name: "2<=string<=32", @@ -59,9 +60,12 @@ export const CreateRepositoryForm = ({ }, }); - const { watch } = form; + const { watch, setValue } = form; const watchedBackend = watch("backend"); + const watchedIsExistingRepository = watch("isExistingRepository"); + + const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default"); const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({ ...listRcloneRemotesOptions(), @@ -70,6 +74,8 @@ export const CreateRepositoryForm = ({ useEffect(() => { form.reset({ name: form.getValues().name, + isExistingRepository: form.getValues().isExistingRepository, + customPassword: form.getValues().customPassword, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType], }); }, [watchedBackend, form]); @@ -163,6 +169,81 @@ export const CreateRepositoryForm = ({ )} /> + ( + + + { + field.onChange(checked); + if (!checked) { + setPasswordMode("default"); + setValue("customPassword", undefined); + } + }} + /> + +
+ Import existing repository + Check this if the repository already exists at the specified location +
+
+ )} + /> + {watchedIsExistingRepository && ( + <> + + Repository Password + + + Choose whether to use Ironmount's master password or enter a custom password for the existing + repository. + + + + {passwordMode === "custom" && ( + ( + + Repository Password + + + + + The password used to encrypt this repository. It will be stored securely. + + + + )} + /> + )} + + )} + {watchedBackend === "s3" && ( <> - R2 endpoint (without https://). Find in R2 dashboard under bucket settings. + + R2 endpoint (without https://). Find in R2 dashboard under bucket settings. + )} diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts index 57340a6..b15815e 100644 --- a/app/schemas/restic.ts +++ b/app/schemas/restic.ts @@ -11,13 +11,19 @@ export const REPOSITORY_BACKENDS = { export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS; +// Common fields for all repository configs +const baseRepositoryConfigSchema = type({ + isExistingRepository: "boolean?", + customPassword: "string?", +}); + export const s3RepositoryConfigSchema = type({ backend: "'s3'", endpoint: "string", bucket: "string", accessKeyId: "string", secretAccessKey: "string", -}); +}).and(baseRepositoryConfigSchema); export const r2RepositoryConfigSchema = type({ backend: "'r2'", @@ -25,19 +31,19 @@ export const r2RepositoryConfigSchema = type({ bucket: "string", accessKeyId: "string", secretAccessKey: "string", -}); +}).and(baseRepositoryConfigSchema); export const localRepositoryConfigSchema = type({ backend: "'local'", name: "string", -}); +}).and(baseRepositoryConfigSchema); export const gcsRepositoryConfigSchema = type({ backend: "'gcs'", bucket: "string", projectId: "string", credentialsJson: "string", -}); +}).and(baseRepositoryConfigSchema); export const azureRepositoryConfigSchema = type({ backend: "'azure'", @@ -45,13 +51,13 @@ export const azureRepositoryConfigSchema = type({ accountName: "string", accountKey: "string", endpointSuffix: "string?", -}); +}).and(baseRepositoryConfigSchema); export const rcloneRepositoryConfigSchema = type({ backend: "'rclone'", remote: "string", path: "string", -}); +}).and(baseRepositoryConfigSchema); export const repositoryConfigSchema = s3RepositoryConfigSchema .or(r2RepositoryConfigSchema) diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index aa63cc3..80bb8e8 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -15,7 +15,11 @@ const listRepositories = async () => { }; const encryptConfig = async (config: RepositoryConfig): Promise => { - const encryptedConfig: Record = { ...config }; + const encryptedConfig: Record = { ...config }; + + if (config.customPassword) { + encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword); + } switch (config.backend) { case "s3": @@ -65,23 +69,30 @@ const createRepository = async (name: string, config: RepositoryConfig, compress throw new InternalServerError("Failed to create repository"); } - const { success, error } = await restic.init(encryptedConfig); + let error: string | null = null; - if (success) { + if (config.isExistingRepository) { + const result = await restic + .snapshots(encryptedConfig) + .then(() => ({ error: null })) + .catch((error) => ({ error })); + + error = result.error; + } else { + const initResult = await restic.init(encryptedConfig); + error = initResult.error; + } + + if (!error) { await db .update(repositoriesTable) - .set({ - status: "healthy", - lastChecked: Date.now(), - lastError: null, - }) + .set({ status: "healthy", lastChecked: Date.now(), lastError: null }) .where(eq(repositoriesTable.id, id)); return { repository: created, status: 201 }; } const errorMessage = toMessage(error); - await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id)); throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 1bb3746..a85757a 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -75,7 +75,7 @@ const buildRepoUrl = (config: RepositoryConfig): string => { case "s3": return `s3:${config.endpoint}/${config.bucket}`; case "r2": { - const endpoint = config.endpoint.replace(/^https?:\/\//, ''); + const endpoint = config.endpoint.replace(/^https?:\/\//, ""); return `s3:${endpoint}/${config.bucket}`; } case "gcs": @@ -93,10 +93,19 @@ const buildRepoUrl = (config: RepositoryConfig): string => { const buildEnv = async (config: RepositoryConfig) => { const env: Record = { RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache", - RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE, PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", }; + if (config.isExistingRepository && config.customPassword) { + const decryptedPassword = await cryptoUtils.decrypt(config.customPassword); + const passwordFilePath = path.join("/tmp", `ironmount-pass-${crypto.randomBytes(8).toString("hex")}.txt`); + + await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 }); + env.RESTIC_PASSWORD_FILE = passwordFilePath; + } else { + env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE; + } + switch (config.backend) { case "s3": env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); @@ -110,7 +119,7 @@ const buildEnv = async (config: RepositoryConfig) => { break; case "gcs": { const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson); - const credentialsPath = path.join("/tmp", `gcs-credentials-${crypto.randomBytes(8).toString("hex")}.json`); + const credentialsPath = path.join("/tmp", `ironmount-gcs-${crypto.randomBytes(8).toString("hex")}.json`); await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 }); env.GOOGLE_PROJECT_ID = config.projectId; env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; @@ -139,7 +148,7 @@ const init = async (config: RepositoryConfig) => { if (res.exitCode !== 0) { logger.error(`Restic init failed: ${res.stderr}`); - return { success: false, error: res.stderr }; + return { success: false, error: res.stderr.toString() }; } logger.info(`Restic repository initialized: ${repoUrl}`);