mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
4 Commits
v0.11.1
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
967091df22 | ||
|
|
043f73ea87 | ||
|
|
518700eef6 | ||
|
|
a250c442f8 |
@@ -2,7 +2,7 @@ ARG BUN_VERSION="1.3.1"
|
|||||||
|
|
||||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||||
|
|
||||||
RUN apk add --no-cache davfs2=1.6.1-r2
|
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -36,7 +36,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.11
|
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -72,7 +72,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.11
|
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -138,7 +138,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.11
|
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -195,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ze
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.11
|
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -224,7 +224,7 @@ In order to enable this feature, you need to run Zerobyte with several items sha
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.11
|
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "./ui/alert-dialog";
|
} from "./ui/alert-dialog";
|
||||||
|
import { Textarea } from "./ui/textarea";
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
@@ -53,6 +54,7 @@ const defaultValuesForType = {
|
|||||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||||
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||||
|
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateRepositoryForm = ({
|
export const CreateRepositoryForm = ({
|
||||||
@@ -141,6 +143,7 @@ export const CreateRepositoryForm = ({
|
|||||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||||
<SelectItem value="rest">REST Server</SelectItem>
|
<SelectItem value="rest">REST Server</SelectItem>
|
||||||
|
<SelectItem value="sftp">SFTP</SelectItem>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||||
@@ -268,18 +271,11 @@ export const CreateRepositoryForm = ({
|
|||||||
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||||
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowPathWarning(true)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Change
|
Change
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormDescription>
|
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
||||||
The directory where the repository will be stored.
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||||
@@ -290,13 +286,9 @@ export const CreateRepositoryForm = ({
|
|||||||
Important: Host Mount Required
|
Important: Host Mount Required
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="space-y-3">
|
<AlertDialogDescription className="space-y-3">
|
||||||
<p>
|
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
|
||||||
When selecting a custom path, ensure it is mounted from the host machine into the
|
|
||||||
container.
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
If the path is not a host mount, you will lose your repository data when the container
|
If the path is not a host mount, you will lose your repository data when the container restarts.
|
||||||
restarts.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
||||||
@@ -703,6 +695,89 @@ export const CreateRepositoryForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{watchedBackend === "sftp" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="host"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Host</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SFTP server hostname or IP address.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="22"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SSH port (default: 22).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backup-user" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SSH username for authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backups/ironmount" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Repository path on the SFTP server. </FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="privateKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SSH Private Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === "update" && (
|
{mode === "update" && (
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{watchedBackend !== "directory" && (
|
{watchedBackend && watchedBackend !== "directory" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
|
|||||||
case "gcs":
|
case "gcs":
|
||||||
return <Cloud className={className} />;
|
return <Cloud className={className} />;
|
||||||
case "rest":
|
case "rest":
|
||||||
|
case "sftp":
|
||||||
return <Server className={className} />;
|
return <Server className={className} />;
|
||||||
default:
|
default:
|
||||||
return <Database className={className} />;
|
return <Database className={className} />;
|
||||||
|
|||||||
@@ -181,8 +181,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
Are you sure you want to delete the repository <strong>{data.name}</strong>? This will not remove the
|
||||||
and will remove all backup data.
|
actual data from the backend storage, only the repository configuration will be deleted.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const REPOSITORY_BACKENDS = {
|
|||||||
azure: "azure",
|
azure: "azure",
|
||||||
rclone: "rclone",
|
rclone: "rclone",
|
||||||
rest: "rest",
|
rest: "rest",
|
||||||
|
sftp: "sftp",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||||
@@ -69,13 +70,23 @@ export const restRepositoryConfigSchema = type({
|
|||||||
path: "string?",
|
path: "string?",
|
||||||
}).and(baseRepositoryConfigSchema);
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const sftpRepositoryConfigSchema = type({
|
||||||
|
backend: "'sftp'",
|
||||||
|
host: "string",
|
||||||
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||||
|
user: "string",
|
||||||
|
path: "string",
|
||||||
|
privateKey: "string",
|
||||||
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||||
.or(r2RepositoryConfigSchema)
|
.or(r2RepositoryConfigSchema)
|
||||||
.or(localRepositoryConfigSchema)
|
.or(localRepositoryConfigSchema)
|
||||||
.or(gcsRepositoryConfigSchema)
|
.or(gcsRepositoryConfigSchema)
|
||||||
.or(azureRepositoryConfigSchema)
|
.or(azureRepositoryConfigSchema)
|
||||||
.or(rcloneRepositoryConfigSchema)
|
.or(rcloneRepositoryConfigSchema)
|
||||||
.or(restRepositoryConfigSchema);
|
.or(restRepositoryConfigSchema)
|
||||||
|
.or(sftpRepositoryConfigSchema);
|
||||||
|
|
||||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||||||
volumeId: int("volume_id")
|
volumeId: int("volume_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => volumesTable.id, { onDelete: "cascade" }),
|
.references(() => volumesTable.id, { onDelete: "cascade" }),
|
||||||
repositoryId: text("repository_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => repositoriesTable.id, { onDelete: "cascade" }),
|
|
||||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
cronExpression: text("cron_expression").notNull(),
|
cronExpression: text("cron_expression").notNull(),
|
||||||
retentionPolicy: text("retention_policy", { mode: "json" }).$type<{
|
retentionPolicy: text("retention_policy", { mode: "json" }).$type<{
|
||||||
@@ -90,14 +87,43 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one }) => ({
|
|
||||||
|
/**
|
||||||
|
* Junction Table: Backup Schedules <-> Repositories (Many-to-Many)
|
||||||
|
*/
|
||||||
|
export const backupScheduleRepositoriesTable = sqliteTable(
|
||||||
|
"backup_schedule_repositories_table",
|
||||||
|
{
|
||||||
|
scheduleId: int("schedule_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => backupSchedulesTable.id, { onDelete: "cascade" }),
|
||||||
|
repositoryId: text("repository_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => repositoriesTable.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: { name: "pk_schedule_repository", columns: [table.scheduleId, table.repositoryId] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
|
||||||
volume: one(volumesTable, {
|
volume: one(volumesTable, {
|
||||||
fields: [backupSchedulesTable.volumeId],
|
fields: [backupSchedulesTable.volumeId],
|
||||||
references: [volumesTable.id],
|
references: [volumesTable.id],
|
||||||
}),
|
}),
|
||||||
|
repositories: many(backupScheduleRepositoriesTable),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const backupScheduleRepositoryRelations = relations(backupScheduleRepositoriesTable, ({ one }) => ({
|
||||||
|
schedule: one(backupSchedulesTable, {
|
||||||
|
fields: [backupScheduleRepositoriesTable.scheduleId],
|
||||||
|
references: [backupSchedulesTable.id],
|
||||||
|
}),
|
||||||
repository: one(repositoriesTable, {
|
repository: one(repositoriesTable, {
|
||||||
fields: [backupSchedulesTable.repositoryId],
|
fields: [backupScheduleRepositoriesTable.repositoryId],
|
||||||
references: [repositoriesTable.id],
|
references: [repositoriesTable.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
||||||
|
export type BackupScheduleRepository = typeof backupScheduleRepositoriesTable.$inferSelect;
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ export const repositoriesController = new Hono()
|
|||||||
const { name, snapshotId } = c.req.param();
|
const { name, snapshotId } = c.req.param();
|
||||||
const { path } = c.req.valid("query");
|
const { path } = c.req.valid("query");
|
||||||
|
|
||||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
|
const decodedPath = path ? decodeURIComponent(path) : undefined;
|
||||||
|
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, decodedPath);
|
||||||
|
|
||||||
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||||||
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "sftp":
|
||||||
|
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return encryptedConfig as RepositoryConfig;
|
return encryptedConfig as RepositoryConfig;
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
|||||||
const path = config.path ? `/${config.path}` : "";
|
const path = config.path ? `/${config.path}` : "";
|
||||||
return `rest:${config.url}${path}`;
|
return `rest:${config.url}${path}`;
|
||||||
}
|
}
|
||||||
|
case "sftp":
|
||||||
|
return `sftp:${config.user}@${config.host}:${config.path}`;
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
||||||
}
|
}
|
||||||
@@ -146,6 +148,43 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "sftp": {
|
||||||
|
const decryptedKey = await cryptoUtils.decrypt(config.privateKey);
|
||||||
|
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
|
||||||
|
|
||||||
|
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
||||||
|
if (!normalizedKey.endsWith("\n")) {
|
||||||
|
normalizedKey += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKey.includes("ENCRYPTED")) {
|
||||||
|
logger.error("SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.");
|
||||||
|
throw new Error("Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
env._SFTP_KEY_PATH = keyPath;
|
||||||
|
|
||||||
|
const sshArgs = [
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
"-o",
|
||||||
|
"UserKnownHostsFile=/dev/null",
|
||||||
|
"-o",
|
||||||
|
"LogLevel=VERBOSE",
|
||||||
|
"-i",
|
||||||
|
keyPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (config.port && config.port !== 22) {
|
||||||
|
sshArgs.push("-p", String(config.port));
|
||||||
|
}
|
||||||
|
|
||||||
|
env._SFTP_SSH_ARGS = sshArgs.join(" ");
|
||||||
|
logger.info(`SFTP: SSH args: ${env._SFTP_SSH_ARGS}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
@@ -160,7 +199,11 @@ const init = async (config: RepositoryConfig) => {
|
|||||||
|
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
|
const args = ["init", "--repo", repoUrl, "--json"];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic init failed: ${res.stderr}`);
|
logger.error(`Restic init failed: ${res.stderr}`);
|
||||||
@@ -225,6 +268,7 @@ const backup = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const logData = throttle((data: string) => {
|
const logData = throttle((data: string) => {
|
||||||
@@ -265,6 +309,7 @@ const backup = async (
|
|||||||
},
|
},
|
||||||
finally: async () => {
|
finally: async () => {
|
||||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,11 +380,13 @@ const restore = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
console.log("Restic restore command:", ["restic", ...args].join(" "));
|
console.log("Restic restore command:", ["restic", ...args].join(" "));
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||||
@@ -397,9 +444,11 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||||
@@ -445,9 +494,11 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||||||
}
|
}
|
||||||
|
|
||||||
args.push("--prune");
|
args.push("--prune");
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||||
@@ -462,8 +513,10 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
|||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||||
@@ -510,7 +563,10 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||||||
args.push(path);
|
args.push(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await safeSpawn({ command: "restic", args, env });
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||||
@@ -518,7 +574,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
||||||
const stdout = res.text();
|
const stdout = res.stdout;
|
||||||
const lines = stdout
|
const lines = stdout
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -557,7 +613,11 @@ const unlock = async (config: RepositoryConfig) => {
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||||
@@ -578,7 +638,10 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
|||||||
args.push("--read-data");
|
args.push("--read-data");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
const stdout = res.text();
|
const stdout = res.text();
|
||||||
const stderr = res.stderr.toString();
|
const stderr = res.stderr.toString();
|
||||||
@@ -608,7 +671,11 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic repair index --repo ${repoUrl}`.env(env).nothrow();
|
const args = ["repair", "index", "--repo", repoUrl];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
const stdout = res.text();
|
const stdout = res.text();
|
||||||
const stderr = res.stderr.toString();
|
const stderr = res.stderr.toString();
|
||||||
@@ -626,6 +693,22 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
|
||||||
|
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
||||||
|
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
||||||
|
if (config.backend === "sftp" && env._SFTP_KEY_PATH) {
|
||||||
|
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
|
||||||
|
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
|
||||||
|
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
|
||||||
|
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||||
|
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const restic = {
|
export const restic = {
|
||||||
ensurePassfile,
|
ensurePassfile,
|
||||||
init,
|
init,
|
||||||
|
|||||||
Reference in New Issue
Block a user