Merge branch 'tvories-feat/rest-tls-support'

This commit is contained in:
Nicolas Meienberger
2026-01-02 17:37:56 +01:00
6 changed files with 167 additions and 12 deletions

View File

@@ -838,7 +838,9 @@ export type ListRepositoriesResponses = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -917,7 +919,9 @@ export type CreateRepositoryData = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1058,7 +1062,9 @@ export type GetRepositoryResponses = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1164,7 +1170,9 @@ export type UpdateRepositoryResponses = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1487,7 +1495,9 @@ export type ListBackupSchedulesResponses = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1749,7 +1759,9 @@ export type GetBackupScheduleResponses = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1992,7 +2004,9 @@ export type GetBackupScheduleForVolumeResponses = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -2211,6 +2225,7 @@ export type GetScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2310,6 +2325,7 @@ export type UpdateScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2420,7 +2436,9 @@ export type GetScheduleMirrorsResponses = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -2526,7 +2544,9 @@ export type UpdateScheduleMirrorsResponses = {
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -2646,6 +2666,7 @@ export type ListNotificationDestinationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2716,6 +2737,7 @@ export type CreateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2785,6 +2807,7 @@ export type CreateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2901,6 +2924,7 @@ export type GetNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2971,6 +2995,7 @@ export type UpdateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -3050,6 +3075,7 @@ export type UpdateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;

View File

@@ -9,13 +9,20 @@ import {
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import { Textarea } from "../../../../components/ui/textarea";
import { Checkbox } from "../../../../components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip";
import type { RepositoryFormValues } from "../create-repository-form";
import { cn } from "~/client/lib/utils";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const RestRepositoryForm = ({ form }: Props) => {
const insecureTls = form.watch("insecureTls");
const cacert = form.watch("cacert");
return (
<>
<FormField
@@ -32,6 +39,83 @@ export const RestRepositoryForm = ({ form }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="insecureTls"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<div>
<Checkbox
checked={field.value ?? false}
disabled={!!cacert}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !cacert })}>
<p className="max-w-xs">
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip TLS
validation instead.
</p>
</TooltipContent>
</Tooltip>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Skip TLS certificate verification</FormLabel>
<FormDescription>
Disable TLS certificate verification if rest server is https and uses a self-signed certificate. This is
insecure and should only be used for testing.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cacert"
render={({ field }) => (
<FormItem>
<FormLabel>CA Certificate (Optional)</FormLabel>
<FormControl>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<div>
<Textarea
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
rows={6}
disabled={insecureTls}
{...field}
/>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !insecureTls })}>
<p className="max-w-xs">
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
Verification" to provide a custom CA certificate.
</p>
</TooltipContent>
</Tooltip>
</FormControl>
<FormDescription>
Custom CA certificate for self-signed certificates (PEM format).&nbsp;
<a
href="https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#rest-server"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Learn more
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"

View File

@@ -116,6 +116,26 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
{repository.config.backend === "rest" && repository.config.cacert && (
<div>
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
<p className="mt-1 text-sm">
<span className="text-green-500">configured</span>
</p>
</div>
)}
{repository.config.backend === "rest" && "insecureTls" in repository.config && (
<div>
<div className="text-sm font-medium text-muted-foreground">TLS Certificate Validation</div>
<p className="mt-1 text-sm">
{repository.config.insecureTls ? (
<span className="text-red-500">disabled</span>
) : (
<span className="text-green-500">enabled</span>
)}
</p>
</div>
)}
</div>
</div>

View File

@@ -68,6 +68,8 @@ export const restRepositoryConfigSchema = type({
username: "string?",
password: "string?",
path: "string?",
cacert: "string?",
insecureTls: "boolean?",
}).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigSchema = type({

View File

@@ -53,6 +53,9 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
if (config.password) {
encryptedConfig.password = await cryptoUtils.sealSecret(config.password);
}
if (config.cacert) {
encryptedConfig.cacert = await cryptoUtils.sealSecret(config.cacert);
}
break;
case "sftp":
encryptedConfig.privateKey = await cryptoUtils.sealSecret(config.privateKey);

View File

@@ -155,6 +155,15 @@ export const buildEnv = async (config: RepositoryConfig) => {
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": {
@@ -846,8 +855,8 @@ const copy = async (
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(sourceConfig, sourceEnv);
await cleanupTemporaryKeys(destConfig, destEnv);
await cleanupTemporaryKeys(sourceEnv);
await cleanupTemporaryKeys(destEnv);
const { stdout, stderr } = res;
@@ -863,19 +872,26 @@ const copy = async (
};
};
export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
if (config.backend === "sftp") {
if (env._SFTP_KEY_PATH) {
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
}
if (env._SFTP_KNOWN_HOSTS_PATH) {
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
}
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
export const cleanupTemporaryKeys = async (env: Record<string, string>) => {
if (env._SFTP_KEY_PATH) {
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
}
if (env._SFTP_KNOWN_HOSTS_PATH) {
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
}
if (env.RESTIC_PASSWORD_FILE) {
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
}
if (env.GOOGLE_APPLICATION_CREDENTIALS) {
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
}
if (env.RESTIC_CACERT) {
await fs.unlink(env.RESTIC_CACERT).catch(() => {});
}
};
export const addCommonArgs = (args: string[], env: Record<string, string>) => {
@@ -884,6 +900,10 @@ export const addCommonArgs = (args: string[], env: Record<string, string>) => {
if (env._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
}
if (env._REST_INSECURE_TLS === "true") {
args.push("--insecure-tls");
}
};
export const restic = {