From b8fe2a985b9af41c6aabe1e89aafd691bd89480e Mon Sep 17 00:00:00 2001
From: Nico <47644445+nicotsx@users.noreply.github.com>
Date: Fri, 2 Jan 2026 18:13:19 +0100
Subject: [PATCH] feat: insecure tls & cacert for self-hosted repos (#277)
* feat: insecure tls & cacert for self-hosted repos
* fix: extra arg
---
app/client/components/ui/collapsible.tsx | 98 +++++++++++++++
.../components/create-repository-form.tsx | 3 +
.../repository-forms/advanced-tls-form.tsx | 112 ++++++++++++++++++
.../components/repository-forms/index.ts | 1 +
.../repository-forms/rest-repository-form.tsx | 84 -------------
app/client/modules/repositories/tabs/info.tsx | 10 +-
app/schemas/restic.ts | 4 +-
app/server/modules/lifecycle/migration.ts | 2 +-
.../repositories/repositories.service.ts | 7 +-
app/server/utils/restic.ts | 50 ++++----
bun.lock | 3 +
package.json | 1 +
12 files changed, 259 insertions(+), 116 deletions(-)
create mode 100644 app/client/components/ui/collapsible.tsx
create mode 100644 app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx
diff --git a/app/client/components/ui/collapsible.tsx b/app/client/components/ui/collapsible.tsx
new file mode 100644
index 00000000..5e13272d
--- /dev/null
+++ b/app/client/components/ui/collapsible.tsx
@@ -0,0 +1,98 @@
+import * as React from "react";
+import { ChevronDown } from "lucide-react";
+import { cn } from "~/client/lib/utils";
+
+interface CollapsibleProps extends React.HTMLAttributes {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ defaultOpen?: boolean;
+}
+
+const CollapsibleContext = React.createContext<{
+ open: boolean;
+ setOpen: React.Dispatch>;
+}>({
+ open: false,
+ setOpen: () => {},
+});
+
+const Collapsible = React.forwardRef(
+ ({ className, open: controlledOpen, onOpenChange, defaultOpen = false, children, ...props }, ref) => {
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
+
+ const isControlled = controlledOpen !== undefined;
+ const open = isControlled ? controlledOpen : uncontrolledOpen;
+
+ const setOpen = React.useCallback(
+ (value: React.SetStateAction) => {
+ const newValue = typeof value === "function" ? value(open) : value;
+ if (!isControlled) {
+ setUncontrolledOpen(newValue);
+ }
+ onOpenChange?.(newValue);
+ },
+ [isControlled, open, onOpenChange],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ },
+);
+Collapsible.displayName = "Collapsible";
+
+interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes {}
+
+const CollapsibleTrigger = React.forwardRef(
+ ({ className, children, ...props }, ref) => {
+ const { open, setOpen } = React.useContext(CollapsibleContext);
+
+ return (
+
+ );
+ },
+);
+CollapsibleTrigger.displayName = "CollapsibleTrigger";
+
+interface CollapsibleContentProps extends React.HTMLAttributes {}
+
+const CollapsibleContent = React.forwardRef(
+ ({ className, children, ...props }, ref) => {
+ const { open } = React.useContext(CollapsibleContext);
+
+ return (
+
+ {open && children}
+
+ );
+ },
+);
+CollapsibleContent.displayName = "CollapsibleContent";
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx
index 08fa6e0b..827cb2cf 100644
--- a/app/client/modules/repositories/components/create-repository-form.tsx
+++ b/app/client/modules/repositories/components/create-repository-form.tsx
@@ -31,6 +31,7 @@ import {
RcloneRepositoryForm,
RestRepositoryForm,
SftpRepositoryForm,
+ AdvancedForm,
} from "./repository-forms";
export const formSchema = type({
@@ -268,6 +269,8 @@ export const CreateRepositoryForm = ({
{watchedBackend === "rest" && }
{watchedBackend === "sftp" && }
+
+
{mode === "update" && (
- {repository.config.backend === "rest" && repository.config.cacert && (
+ {config.cacert && (
TLS Certificate Validation
- {repository.config.insecureTls ? (
+ {config.insecureTls ? (
disabled
) : (
enabled
diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts
index 1b41a5a7..f6001fb1 100644
--- a/app/schemas/restic.ts
+++ b/app/schemas/restic.ts
@@ -17,6 +17,8 @@ export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
const baseRepositoryConfigSchema = type({
isExistingRepository: "boolean?",
customPassword: "string?",
+ cacert: "string?",
+ insecureTls: "boolean?",
});
export const s3RepositoryConfigSchema = type({
@@ -68,8 +70,6 @@ export const restRepositoryConfigSchema = type({
username: "string?",
password: "string?",
path: "string?",
- cacert: "string?",
- insecureTls: "boolean?",
}).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigSchema = type({
diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migration.ts
index 32fb6a52..b324a543 100644
--- a/app/server/modules/lifecycle/migration.ts
+++ b/app/server/modules/lifecycle/migration.ts
@@ -77,7 +77,7 @@ const migrateSnapshotsToShortIdTag = async (): Promise => {
logger.info(`Migrating snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(repository.config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic tag failed: ${res.stderr}`);
diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts
index 01b1983a..ea0bf792 100644
--- a/app/server/modules/repositories/repositories.service.ts
+++ b/app/server/modules/repositories/repositories.service.ts
@@ -34,6 +34,10 @@ const encryptConfig = async (config: RepositoryConfig): Promise {
if (config.password) {
env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password);
}
- if (config.cacert) {
- const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
- const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
- await fs.writeFile(certPath, decryptedCert, { mode: 0o600 });
- env.RESTIC_CACERT = certPath;
- }
- if (config.insecureTls) {
- env._REST_INSECURE_TLS = "true";
- }
break;
}
case "sftp": {
@@ -214,6 +205,17 @@ export const buildEnv = async (config: RepositoryConfig) => {
}
}
+ if (config.cacert) {
+ const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
+ const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
+ await fs.writeFile(certPath, decryptedCert, { mode: 0o600 });
+ env.RESTIC_CACERT = certPath;
+ }
+
+ if (config.insecureTls) {
+ env._INSECURE_TLS = "true";
+ }
+
return env;
};
@@ -230,7 +232,7 @@ const init = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic init failed: ${res.stderr}`);
@@ -358,7 +360,7 @@ const backup = async (
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
excludeFile && (await fs.unlink(excludeFile).catch(() => {}));
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
},
});
@@ -456,7 +458,7 @@ const restore = async (
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`);
@@ -517,7 +519,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
@@ -566,7 +568,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic forget failed: ${res.stderr}`);
@@ -588,7 +590,7 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[])
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
@@ -637,7 +639,7 @@ const tagSnapshots = async (
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshot tagging failed: ${res.stderr}`);
@@ -687,7 +689,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`);
@@ -738,7 +740,7 @@ const unlock = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`);
@@ -762,7 +764,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
const { stdout, stderr } = res;
@@ -795,7 +797,7 @@ const repairIndex = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
const { stdout, stderr } = res;
@@ -881,7 +883,7 @@ export const cleanupTemporaryKeys = async (env: Record) => {
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
}
- if (env.RESTIC_PASSWORD_FILE) {
+ if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) {
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
}
@@ -901,9 +903,13 @@ export const addCommonArgs = (args: string[], env: Record) => {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
}
- if (env._REST_INSECURE_TLS === "true") {
+ if (env._INSECURE_TLS === "true") {
args.push("--insecure-tls");
}
+
+ if (env.RESTIC_CACERT) {
+ args.push("--cacert", env.RESTIC_CACERT);
+ }
};
export const restic = {
diff --git a/bun.lock b/bun.lock
index 936ed491..63e98aba 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
@@ -323,6 +324,8 @@
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
+ "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
+
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
diff --git a/package.json b/package.json
index 385dfc3c..44501a2e 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",