Compare commits

..

13 Commits

Author SHA1 Message Date
Nicolas Meienberger
8fcc9ada74 feat: cleanup dangling volumes and folders on startup and on schedule 2025-10-17 23:01:47 +02:00
Nicolas Meienberger
8a9d5fc3c8 refactor(statfs): switch back to statfs but convert values for smb 2025-10-17 22:22:22 +02:00
Nicolas Meienberger
219dec1c9c fix(statfs): fix usage graph by using df command 2025-10-17 21:34:40 +02:00
Nicolas Meienberger
3bda6e81ae feat(details): keep tab in url to preserve active tab on reload 2025-10-17 21:23:33 +02:00
Nicolas Meienberger
269116c25e refactor: improve file explorer performance by pre-fetching on hover 2025-10-17 21:19:58 +02:00
Nicolas Meienberger
c8fc5a1273 chore: sanitize sensitive data from logs and error messages 2025-10-17 21:11:12 +02:00
Nicolas Meienberger
ae592481af feat: base restic repo schemas 2025-10-17 21:03:13 +02:00
Nicolas Meienberger
65a7f436fe fix: clean undefined values before posting form 2025-10-17 21:01:32 +02:00
Nicolas Meienberger
8af0bac63b feat: restic pass file generation 2025-10-17 13:15:24 +02:00
Nicolas Meienberger
41756e087a feat: report an issue button 2025-10-15 22:16:48 +02:00
Nicolas Meienberger
71ca5d3309 chore: bump bun to 1.3.0 2025-10-15 22:16:40 +02:00
Nico
e29908757f Revise warning message in README
Updated warning message to encourage feature requests.
2025-10-10 19:05:45 +02:00
Nicolas Meienberger
15f0dc637d chore: improve logging
Update README with version warning

Update Ironmount image version to v0.2.0
2025-10-09 22:41:56 +02:00
37 changed files with 736 additions and 88 deletions

View File

@@ -1,8 +1,8 @@
ARG BUN_VERSION="1.2.23" ARG BUN_VERSION="1.3.0"
FROM oven/bun:${BUN_VERSION}-alpine AS runner_base FROM oven/bun:${BUN_VERSION}-alpine AS runner_base
RUN apk add --no-cache davfs2=1.6.1-r2 RUN apk add --no-cache davfs2 restic
# ------------------------------ # ------------------------------
# DEVELOPMENT # DEVELOPMENT

View File

@@ -15,7 +15,8 @@
</figure> </figure>
</div> </div>
<br /> > [!WARNING]
> Ironmount is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
## Intro ## Intro
@@ -41,7 +42,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml ```yaml
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.1.1 image: ghcr.io/nicotsx/ironmount:v0.3.0
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -53,7 +54,7 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /run/docker/plugins:/run/docker/plugins - /run/docker/plugins:/run/docker/plugins
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared - /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
- ironmount_data:/data - ironmount_data:/data
volumes: volumes:

View File

@@ -39,7 +39,7 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<ScrollArea className="h-[500px]"> <ScrollArea className="h-[500px] p-4">
<DialogHeader> <DialogHeader>
<DialogTitle>Create volume</DialogTitle> <DialogTitle>Create volume</DialogTitle>
</DialogHeader> </DialogHeader>

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen"; import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
import { cn, slugify } from "~/lib/utils"; import { cn, slugify } from "~/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
@@ -15,6 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
}).and(volumeConfigSchema); }).and(volumeConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn; export type FormValues = typeof formSchema.inferIn;
@@ -36,7 +38,7 @@ const defaultValuesForType = {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: arktypeResolver(formSchema), resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues, defaultValues: initialValues,
resetOptions: { resetOptions: {
keepDefaultValues: true, keepDefaultValues: true,
@@ -311,7 +313,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Server</FormLabel> <FormLabel>Server</FormLabel>
<FormControl> <FormControl>
<Input placeholder="192.168.1.100" value={field.value ?? ""} onChange={field.onChange} /> <Input placeholder="192.168.1.100" value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>SMB server IP address or hostname.</FormDescription> <FormDescription>SMB server IP address or hostname.</FormDescription>
<FormMessage /> <FormMessage />
@@ -325,7 +327,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Share</FormLabel> <FormLabel>Share</FormLabel>
<FormControl> <FormControl>
<Input placeholder="myshare" value={field.value ?? ""} onChange={field.onChange} /> <Input placeholder="myshare" value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>SMB share name on the server.</FormDescription> <FormDescription>SMB share name on the server.</FormDescription>
<FormMessage /> <FormMessage />
@@ -339,7 +341,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input placeholder="admin" value={field.value ?? ""} onChange={field.onChange} /> <Input placeholder="admin" value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>Username for SMB authentication.</FormDescription> <FormDescription>Username for SMB authentication.</FormDescription>
<FormMessage /> <FormMessage />
@@ -353,7 +355,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} /> <Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>Password for SMB authentication.</FormDescription> <FormDescription>Password for SMB authentication.</FormDescription>
<FormMessage /> <FormMessage />
@@ -450,11 +452,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</div> </div>
{testMessage && ( {testMessage && (
<div <div
className={`text-xs p-2 rounded-md ${ className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
testMessage.success "bg-green-50 text-green-700 border border-green-200": testMessage.success,
? "bg-green-50 text-green-700 border border-green-200" "bg-red-50 text-red-700 border border-red-200": !testMessage.success,
: "bg-red-50 text-red-700 border border-red-200" })}
}`}
> >
{testMessage.message} {testMessage.message}
</div> </div>

View File

@@ -27,6 +27,7 @@ interface Props {
selectedFile?: string; selectedFile?: string;
onFileSelect?: (filePath: string) => void; onFileSelect?: (filePath: string) => void;
onFolderExpand?: (folderPath: string) => void; onFolderExpand?: (folderPath: string) => void;
onFolderHover?: (folderPath: string) => void;
expandedFolders?: Set<string>; expandedFolders?: Set<string>;
loadingFolders?: Set<string>; loadingFolders?: Set<string>;
className?: string; className?: string;
@@ -38,6 +39,7 @@ export const FileTree = memo((props: Props) => {
onFileSelect, onFileSelect,
selectedFile, selectedFile,
onFolderExpand, onFolderExpand,
onFolderHover,
expandedFolders = new Set(), expandedFolders = new Set(),
loadingFolders = new Set(), loadingFolders = new Set(),
className, className,
@@ -137,6 +139,7 @@ export const FileTree = memo((props: Props) => {
collapsed={collapsedFolders.has(fileOrFolder.fullPath)} collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
loading={loadingFolders.has(fileOrFolder.fullPath)} loading={loadingFolders.has(fileOrFolder.fullPath)}
onToggle={toggleCollapseState} onToggle={toggleCollapseState}
onHover={onFolderHover}
/> />
); );
} }
@@ -154,9 +157,10 @@ interface FolderProps {
collapsed: boolean; collapsed: boolean;
loading?: boolean; loading?: boolean;
onToggle: (fullPath: string) => void; onToggle: (fullPath: string) => void;
onHover?: (fullPath: string) => void;
} }
const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => { const Folder = memo(({ folder, collapsed, loading, onToggle, onHover }: FolderProps) => {
const { depth, name, fullPath } = folder; const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
@@ -164,6 +168,12 @@ const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
onToggle(fullPath); onToggle(fullPath);
}, [onToggle, fullPath]); }, [onToggle, fullPath]);
const handleMouseEnter = useCallback(() => {
if (collapsed) {
onHover?.(fullPath);
}
}, [onHover, fullPath, collapsed]);
return ( return (
<NodeButton <NodeButton
className={cn("group hover:bg-accent/50 text-foreground")} className={cn("group hover:bg-accent/50 text-foreground")}
@@ -178,6 +188,7 @@ const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
) )
} }
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleMouseEnter}
> >
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" /> <FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
@@ -219,9 +230,10 @@ interface ButtonProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
onMouseEnter?: () => void;
} }
const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonProps) => { const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]); const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return ( return (
@@ -230,6 +242,7 @@ const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonPr
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)} className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }} style={{ paddingLeft }}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter}
> >
{icon} {icon}
<div className="truncate w-full flex items-center gap-2">{children}</div> <div className="truncate w-full flex items-center gap-2">{children}</div>

View File

@@ -1,4 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { LifeBuoy } from "lucide-react";
import { Outlet, useNavigate } from "react-router"; import { Outlet, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen"; import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
@@ -40,9 +41,22 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span> Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
</span> </span>
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}> <Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
Logout Logout
</Button> </Button>
<Button variant="default" size="sm" className="relative overflow-hidden">
<a
href="https://github.com/nicotsx/ironmount/issues/new"
target="_blank"
rel="noreferrer"
className="flex items-center gap-2"
>
<span className="flex items-center gap-2">
<LifeBuoy />
<span>Report an issue</span>
</span>
</a>
</Button>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,11 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen"; import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { listFiles } from "~/api-client/sdk.gen"; import { listFiles } from "~/api-client/sdk.gen";
import { FileTree } from "~/components/file-tree"; import { FileTree } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { parseError } from "~/lib/errors";
import type { Volume } from "~/lib/types"; import type { Volume } from "~/lib/types";
type Props = { type Props = {
@@ -21,6 +20,7 @@ interface FileEntry {
} }
export const FilesTabContent = ({ volume }: Props) => { export const FilesTabContent = ({ volume }: Props) => {
const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"])); const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set()); const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
@@ -88,6 +88,21 @@ export const FilesTabContent = ({ volume }: Props) => {
[volume.name, fetchedFolders], [volume.name, fetchedFolders],
); );
// Prefetch folder contents on hover
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(
listFilesOptions({
path: { name: volume.name },
query: { path: folderPath },
}),
);
}
},
[volume.name, fetchedFolders, loadingFolders, queryClient],
);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]); const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
if (volume.status !== "mounted") { if (volume.status !== "mounted") {
@@ -133,6 +148,7 @@ export const FilesTabContent = ({ volume }: Props) => {
<FileTree <FileTree
files={fileArray} files={fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders} expandedFolders={expandedFolders}
loadingFolders={loadingFolders} loadingFolders={loadingFolders}
className="p-2" className="p-2"

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteVolumeMutation, deleteVolumeMutation,
@@ -38,6 +38,8 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
export default function DetailsPage({ loaderData }: Route.ComponentProps) { export default function DetailsPage({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info";
const { data } = useQuery({ const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }), ...getVolumeOptions({ path: { name: name ?? "" } }),
@@ -131,7 +133,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
</Button> </Button>
</div> </div>
</div> </div>
<Tabs defaultValue="info" className="mt-4"> <Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
<TabsList className="mb-2"> <TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger> <TabsTrigger value="files">Files</TabsTrigger>

View File

@@ -0,0 +1,14 @@
export function deepClean<T>(obj: T): T {
if (Array.isArray(obj)) {
return obj.map(deepClean).filter((v) => v !== undefined && v !== null) as T;
}
if (obj && typeof obj === "object") {
return Object.entries(obj).reduce((acc, [key, value]) => {
const cleaned = deepClean(value);
if (cleaned !== undefined) acc[key as keyof T] = cleaned;
return acc;
}, {} as T);
}
return obj;
}

View File

@@ -25,7 +25,7 @@
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"arktype": "^2.1.22", "arktype": "^2.1.23",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",

View File

@@ -0,0 +1,15 @@
CREATE TABLE `repositories_table` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`backend` text NOT NULL,
`config` text NOT NULL,
`compression_mode` text DEFAULT 'auto',
`status` text DEFAULT 'unknown',
`last_checked` integer,
`last_error` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint
ALTER TABLE `volumes_table` DROP COLUMN `path`;

View File

@@ -0,0 +1,311 @@
{
"version": "6",
"dialect": "sqlite",
"id": "16f360b6-fb61-44f3-a7f7-2bae78ebf7ca",
"prevId": "75f0aac0-aa63-4577-bfb6-4638a008935f",
"tables": {
"repositories_table": {
"name": "repositories_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backend": {
"name": "backend",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"repositories_table_name_unique": {
"name": "repositories_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -43,6 +43,13 @@
"when": 1759416698274, "when": 1759416698274,
"tag": "0005_simple_alice", "tag": "0005_simple_alice",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1760734377440,
"tag": "0006_secret_micromacro",
"breakpoints": true
} }
] ]
} }

View File

@@ -26,7 +26,7 @@
}, },
"devDependencies": { "devDependencies": {
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@types/bun": "^1.2.20", "@types/bun": "^1.3.0",
"@types/dockerode": "^3.3.44", "@types/dockerode": "^3.3.44",
"drizzle-kit": "^0.31.5" "drizzle-kit": "^0.31.5"
} }

View File

@@ -1,3 +1,4 @@
export const OPERATION_TIMEOUT = 5000; export const OPERATION_TIMEOUT = 5000;
export const VOLUME_MOUNT_BASE = "/var/lib/docker/volumes/ironmount"; export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
export const DATABASE_URL = "/data/ironmount.db"; export const DATABASE_URL = "/data/ironmount.db";
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";

View File

@@ -1,11 +1,18 @@
import type { BackendStatus, BackendType, volumeConfigSchema } from "@ironmount/schemas"; import type {
BackendStatus,
BackendType,
CompressionMode,
RepositoryBackend,
RepositoryStatus,
repositoryConfigSchema,
volumeConfigSchema,
} from "@ironmount/schemas";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const volumesTable = sqliteTable("volumes_table", { export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
name: text().notNull().unique(), name: text().notNull().unique(),
path: text().notNull(),
type: text().$type<BackendType>().notNull(), type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"), status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"), lastError: text("last_error"),
@@ -38,3 +45,18 @@ export const sessionsTable = sqliteTable("sessions_table", {
}); });
export type Session = typeof sessionsTable.$inferSelect; export type Session = typeof sessionsTable.$inferSelect;
export const repositoriesTable = sqliteTable("repositories_table", {
id: text().primaryKey(),
name: text().notNull().unique(),
backend: text().$type<RepositoryBackend>().notNull(),
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "timestamp" }),
lastError: text("last_error"),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
});
export type Repository = typeof repositoriesTable.$inferSelect;

View File

@@ -1,12 +1,12 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as npath from "node:path"; import * as npath from "node:path";
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas"; import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
import type { VolumeBackend } from "../backend";
import { logger } from "../../../utils/logger";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import type { VolumeBackend } from "../backend";
const mount = async (_config: BackendConfig, path: string) => { const mount = async (_config: BackendConfig, path: string) => {
logger.info("Mounting directory volume..."); logger.info("Mounting directory volume...", path);
await fs.mkdir(path, { recursive: true }); await fs.mkdir(path, { recursive: true });
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
}; };

View File

@@ -1,12 +1,12 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas"; import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
import type { VolumeBackend } from "../backend";
import { logger } from "../../../utils/logger";
import { withTimeout } from "../../../utils/timeout";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils"; import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
const mount = async (config: BackendConfig, path: string) => { const mount = async (config: BackendConfig, path: string) => {

View File

@@ -1,10 +1,10 @@
import * as npath from "node:path";
import * as fs from "node:fs/promises";
import { execFile as execFileCb } from "node:child_process"; import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs/promises";
import * as npath from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { logger } from "../../../utils/logger";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
const execFile = promisify(execFileCb); const execFile = promisify(execFileCb);

View File

@@ -43,9 +43,17 @@ export const driverController = new Hono()
Err: "", Err: "",
}); });
}) })
.post("/VolumeDriver.Path", (c) => { .post("/VolumeDriver.Path", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
return c.json({ return c.json({
Mountpoint: `/mnt/something`, Mountpoint: `${VOLUME_MOUNT_BASE}/${volume.name}/_data`,
}); });
}) })
.post("/VolumeDriver.Get", async (c) => { .post("/VolumeDriver.Get", async (c) => {

View File

@@ -0,0 +1,42 @@
import fs from "node:fs/promises";
import path from "node:path";
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import { toMessage } from "../../utils/errors";
import { logger } from "../../utils/logger";
import { readMountInfo } from "../../utils/mountinfo";
import { executeUnmount } from "../backends/utils/backend-utils";
import { getVolumePath } from "../volumes/helpers";
import { volumeService } from "../volumes/volume.service";
export const cleanupDanglingMounts = async () => {
const allVolumes = await volumeService.listVolumes();
const allSystemMounts = await readMountInfo();
for (const mount of allSystemMounts) {
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === mount.mountPoint);
if (!matchingVolume) {
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
await executeUnmount(mount.mountPoint);
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
logger.warn(`Failed to remove dangling mount directory ${path.dirname(mount.mountPoint)}: ${toMessage(err)}`);
});
}
}
}
const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
for (const dir of allIronmountDirs) {
const volumePath = getVolumePath(dir);
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === volumePath);
if (!matchingVolume) {
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
await fs.rmdir(fullPath, { recursive: true }).catch((err) => {
logger.warn(`Failed to remove dangling mount directory ${fullPath}: ${toMessage(err)}`);
});
}
}
};

View File

@@ -3,9 +3,14 @@ import { getTasks, schedule } from "node-cron";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { volumesTable } from "../../db/schema"; import { volumesTable } from "../../db/schema";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { restic } from "../../utils/restic";
import { volumeService } from "../volumes/volume.service"; import { volumeService } from "../volumes/volume.service";
import { cleanupDanglingMounts } from "./cleanup";
export const startup = async () => { export const startup = async () => {
await restic.ensurePassfile();
cleanupDanglingMounts();
const volumes = await db.query.volumesTable.findMany({ const volumes = await db.query.volumesTable.findMany({
where: or( where: or(
eq(volumesTable.status, "mounted"), eq(volumesTable.status, "mounted"),
@@ -20,8 +25,13 @@ export const startup = async () => {
const existingTasks = getTasks(); const existingTasks = getTasks();
existingTasks.forEach(async (task) => await task.destroy()); existingTasks.forEach(async (task) => await task.destroy());
schedule("0 * * * *", async () => {
logger.debug("Running hourly cleanup of dangling mounts...");
await cleanupDanglingMounts();
});
schedule("* * * * *", async () => { schedule("* * * * *", async () => {
logger.info("Running health check for all volumes..."); logger.debug("Running health check for all volumes...");
const volumes = await db.query.volumesTable.findMany({ const volumes = await db.query.volumesTable.findMany({
where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.status, "error")), where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.status, "error")),

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,5 @@
import { VOLUME_MOUNT_BASE } from "../../core/constants";
export const getVolumePath = (name: string) => {
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
};

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { validator } from "hono-openapi"; import { validator } from "hono-openapi";
import { getVolumePath } from "./helpers";
import { import {
createVolumeBody, createVolumeBody,
createVolumeDto, createVolumeDto,
@@ -29,6 +30,7 @@ export const volumeController = new Hono()
const response = { const response = {
volumes: volumes.map((volume) => ({ volumes: volumes.map((volume) => ({
path: getVolumePath(volume.name),
...volume, ...volume,
updatedAt: volume.updatedAt.getTime(), updatedAt: volume.updatedAt.getTime(),
createdAt: volume.createdAt.getTime(), createdAt: volume.createdAt.getTime(),
@@ -63,6 +65,7 @@ export const volumeController = new Hono()
const response = { const response = {
volume: { volume: {
...res.volume, ...res.volume,
path: getVolumePath(res.volume.name),
createdAt: res.volume.createdAt.getTime(), createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(), updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(), lastHealthCheck: res.volume.lastHealthCheck.getTime(),
@@ -95,6 +98,7 @@ export const volumeController = new Hono()
message: "Volume updated", message: "Volume updated",
volume: { volume: {
...res.volume, ...res.volume,
path: getVolumePath(res.volume.name),
createdAt: res.volume.createdAt.getTime(), createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(), updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(), lastHealthCheck: res.volume.lastHealthCheck.getTime(),
@@ -131,5 +135,7 @@ export const volumeController = new Hono()
path: result.path, path: result.path,
} satisfies ListFilesResponseDto; } satisfies ListFilesResponseDto;
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
return c.json(response, 200); return c.json(response, 200);
}); });

View File

@@ -1,4 +1,4 @@
import { volumeConfigSchemaNoUndefined } from "@ironmount/schemas"; import { volumeConfigSchema } from "@ironmount/schemas";
import { type } from "arktype"; import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
@@ -11,7 +11,7 @@ const volumeSchema = type({
createdAt: "number", createdAt: "number",
updatedAt: "number", updatedAt: "number",
lastHealthCheck: "number", lastHealthCheck: "number",
config: volumeConfigSchemaNoUndefined, config: volumeConfigSchema,
autoRemount: "boolean", autoRemount: "boolean",
}); });
@@ -46,7 +46,7 @@ export const listVolumesDto = describeRoute({
*/ */
export const createVolumeBody = type({ export const createVolumeBody = type({
name: "string", name: "string",
config: volumeConfigSchemaNoUndefined, config: volumeConfigSchema,
}); });
export const createVolumeResponse = type({ export const createVolumeResponse = type({
@@ -135,7 +135,7 @@ export const getVolumeDto = describeRoute({
*/ */
export const updateVolumeBody = type({ export const updateVolumeBody = type({
autoRemount: "boolean?", autoRemount: "boolean?",
config: volumeConfigSchemaNoUndefined.optional(), config: volumeConfigSchema.optional(),
}); });
export type UpdateVolumeBody = typeof updateVolumeBody.infer; export type UpdateVolumeBody = typeof updateVolumeBody.infer;
@@ -170,7 +170,7 @@ export type UpdateVolumeResponseDto = typeof updateVolumeResponse.infer;
* Test connection * Test connection
*/ */
export const testConnectionBody = type({ export const testConnectionBody = type({
config: volumeConfigSchemaNoUndefined, config: volumeConfigSchema,
}); });
export const testConnectionResponse = type({ export const testConnectionResponse = type({

View File

@@ -13,6 +13,7 @@ import { toMessage } from "../../utils/errors";
import { getStatFs, type StatFs } from "../../utils/mountinfo"; import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { createVolumeBackend } from "../backends/backend"; import { createVolumeBackend } from "../backends/backend";
import type { UpdateVolumeBody } from "./volume.dto"; import type { UpdateVolumeBody } from "./volume.dto";
import { getVolumePath } from "./helpers";
const listVolumes = async () => { const listVolumes = async () => {
const volumes = await db.query.volumesTable.findMany({}); const volumes = await db.query.volumesTable.findMany({});
@@ -31,14 +32,11 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
throw new ConflictError("Volume already exists"); throw new ConflictError("Volume already exists");
} }
const volumePathHost = path.join(VOLUME_MOUNT_BASE);
const [created] = await db const [created] = await db
.insert(volumesTable) .insert(volumesTable)
.values({ .values({
name: slug, name: slug,
config: backendConfig, config: backendConfig,
path: path.join(volumePathHost, slug, "_data"),
type: backendConfig.backend, type: backendConfig.backend,
}) })
.returning(); .returning();
@@ -120,7 +118,7 @@ const getVolume = async (name: string) => {
let statfs: Partial<StatFs> = {}; let statfs: Partial<StatFs> = {};
if (volume.status === "mounted") { if (volume.status === "mounted") {
statfs = (await getStatFs(`${VOLUME_MOUNT_BASE}/${name}/_data`).catch(() => {})) ?? {}; statfs = await getStatFs(`${VOLUME_MOUNT_BASE}/${name}/_data`).catch(() => ({}));
} }
return { volume, statfs }; return { volume, statfs };
@@ -266,10 +264,12 @@ const listFiles = async (name: string, subPath?: string) => {
throw new InternalServerError("Volume is not mounted"); throw new InternalServerError("Volume is not mounted");
} }
const requestedPath = subPath ? path.join(volume.path, subPath) : volume.path; const volumePath = getVolumePath(volume.name);
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
const normalizedPath = path.normalize(requestedPath); const normalizedPath = path.normalize(requestedPath);
if (!normalizedPath.startsWith(volume.path)) { if (!normalizedPath.startsWith(volumePath)) {
throw new InternalServerError("Invalid path"); throw new InternalServerError("Invalid path");
} }
@@ -279,7 +279,7 @@ const listFiles = async (name: string, subPath?: string) => {
const files = await Promise.all( const files = await Promise.all(
entries.map(async (entry) => { entries.map(async (entry) => {
const fullPath = path.join(normalizedPath, entry.name); const fullPath = path.join(normalizedPath, entry.name);
const relativePath = path.relative(volume.path, fullPath); const relativePath = path.relative(volumePath, fullPath);
try { try {
const stats = await fs.stat(fullPath); const stats = await fs.stat(fullPath);

View File

@@ -1,17 +1,19 @@
import { ConflictError, NotFoundError } from "http-errors-enhanced"; import { ConflictError, NotFoundError } from "http-errors-enhanced";
import { sanitizeSensitiveData } from "./sanitize";
export const handleServiceError = (error: unknown) => { export const handleServiceError = (error: unknown) => {
if (error instanceof ConflictError) { if (error instanceof ConflictError) {
return { message: error.message, status: 409 as const }; return { message: sanitizeSensitiveData(error.message), status: 409 as const };
} }
if (error instanceof NotFoundError) { if (error instanceof NotFoundError) {
return { message: error.message, status: 404 as const }; return { message: sanitizeSensitiveData(error.message), status: 404 as const };
} }
return { message: toMessage(error), status: 500 as const }; return { message: sanitizeSensitiveData(toMessage(error)), status: 500 as const };
}; };
export const toMessage = (err: unknown): string => { export const toMessage = (err: unknown): string => {
return err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
return sanitizeSensitiveData(message);
}; };

View File

@@ -1,4 +1,5 @@
import { createLogger, format, transports } from "winston"; import { createLogger, format, transports } from "winston";
import { sanitizeSensitiveData } from "./sanitize";
const { printf, combine, colorize } = format; const { printf, combine, colorize } = format;
@@ -14,14 +15,14 @@ const winstonLogger = createLogger({
const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => { const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {
const stringMessages = messages.flatMap((m) => { const stringMessages = messages.flatMap((m) => {
if (m instanceof Error) { if (m instanceof Error) {
return [m.message, m.stack]; return [sanitizeSensitiveData(m.message), m.stack ? sanitizeSensitiveData(m.stack) : undefined].filter(Boolean);
} }
if (typeof m === "object") { if (typeof m === "object") {
return JSON.stringify(m, null, 2); return sanitizeSensitiveData(JSON.stringify(m, null, 2));
} }
return m; return sanitizeSensitiveData(String(m));
}); });
winstonLogger.log(level, stringMessages.join(" ")); winstonLogger.log(level, stringMessages.join(" "));

View File

@@ -1,5 +1,5 @@
import path from "node:path";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
type MountInfo = { type MountInfo = {
mountPoint: string; mountPoint: string;
@@ -21,7 +21,7 @@ function unescapeMount(s: string): string {
return s.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8))); return s.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
} }
async function readMountInfo(): Promise<MountInfo[]> { export async function readMountInfo(): Promise<MountInfo[]> {
const text = await fs.readFile("/proc/self/mountinfo", "utf-8"); const text = await fs.readFile("/proc/self/mountinfo", "utf-8");
const result: MountInfo[] = []; const result: MountInfo[] = [];
@@ -59,11 +59,27 @@ export async function getMountForPath(p: string): Promise<MountInfo | undefined>
} }
export async function getStatFs(mountPoint: string) { export async function getStatFs(mountPoint: string) {
const stats = await fs.statfs(mountPoint); const s = await fs.statfs(mountPoint, { bigint: true });
const total = Number(stats.blocks) * Number(stats.bsize); const unit = s.bsize > 0n ? s.bsize : 1n;
const free = Number(stats.bfree) * Number(stats.bsize);
const used = total - free;
return { total, used, free }; const blocks = s.blocks > 0n ? s.blocks : 0n;
let bfree = s.bfree > 0n ? s.bfree : 0n;
if (bfree > blocks) bfree = blocks;
const bavail = s.bavail > 0n ? s.bavail : 0n;
const totalB = blocks * unit;
const usedB = (blocks - bfree) * unit;
const freeB = bavail * unit;
const MAX = BigInt(Number.MAX_SAFE_INTEGER);
const toNumber = (x: bigint) => (x > MAX ? Number.MAX_SAFE_INTEGER : Number(x));
return {
total: toNumber(totalB),
used: toNumber(usedB),
free: toNumber(freeB),
};
} }

View File

@@ -0,0 +1,65 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { type } from "arktype";
import { $ } from "bun";
import { RESTIC_PASS_FILE } from "../core/constants";
import { logger } from "./logger";
const backupOutputSchema = type({
message_type: "'summary'",
files_new: "number",
files_changed: "number",
files_unmodified: "number",
dirs_new: "number",
dirs_changed: "number",
dirs_unmodified: "number",
data_blobs: "number",
tree_blobs: "number",
data_added: "number",
total_files_processed: "number",
total_bytes_processed: "number",
total_duration: "number",
snapshot_id: "string",
});
const ensurePassfile = async () => {
await fs.mkdir(path.dirname(RESTIC_PASS_FILE), { recursive: true });
try {
await fs.access(RESTIC_PASS_FILE);
} catch {
logger.info("Restic passfile not found, creating a new one...");
await fs.writeFile(RESTIC_PASS_FILE, crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
}
};
const init = async (name: string) => {
const res =
await $`restic init --repo /data/repositories/${name} --password-file /data/secrets/restic.pass --json`.nothrow();
};
const backup = async (repo: string, source: string) => {
const res =
await $`restic --repo /data/repositories/${repo} backup ${source} --password-file /data/secrets/restic.pass --json`.nothrow();
if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`);
throw new Error(`Restic backup failed: ${res.stderr}`);
}
const result = backupOutputSchema(res.json());
if (result instanceof type.errors) {
logger.error(`Restic backup output validation failed: ${result}`);
throw new Error(`Restic backup output validation failed: ${result}`);
}
return result;
};
export const restic = {
ensurePassfile,
init,
backup,
};

View File

@@ -0,0 +1,18 @@
/**
* Sanitizes sensitive information from strings
* This removes passwords and credentials from logs and error messages
*/
export const sanitizeSensitiveData = (text: string): string => {
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
sanitized = sanitized.replace(/(\S+)\s+(\S+)\s+(\S+)/g, (match, url, user, _pass) => {
if (url.startsWith("http://") || url.startsWith("https://")) {
return `${url} ${user} ***`;
}
return match;
});
return sanitized;
};

View File

@@ -27,7 +27,7 @@
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"arktype": "^2.1.22", "arktype": "^2.1.23",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -79,7 +79,7 @@
}, },
"devDependencies": { "devDependencies": {
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@types/bun": "^1.2.20", "@types/bun": "^1.3.0",
"@types/dockerode": "^3.3.44", "@types/dockerode": "^3.3.44",
"drizzle-kit": "^0.31.5", "drizzle-kit": "^0.31.5",
}, },
@@ -96,9 +96,11 @@
"@tailwindcss/oxide", "@tailwindcss/oxide",
], ],
"packages": { "packages": {
"@ark/schema": ["@ark/schema@0.49.0", "", { "dependencies": { "@ark/util": "0.49.0" } }, "sha512-GphZBLpW72iS0v4YkeUtV3YIno35Gimd7+ezbPO9GwEi9kzdUrPVjvf6aXSBAfHikaFc/9pqZOpv3pOXnC71tw=="], "@ark/regex": ["@ark/regex@0.0.0", "", { "dependencies": { "@ark/util": "0.50.0" } }, "sha512-p4vsWnd/LRGOdGQglbwOguIVhPmCAf5UzquvnDoxqhhPWTP84wWgi1INea8MgJ4SnI2gp37f13oA4Waz9vwNYg=="],
"@ark/util": ["@ark/util@0.49.0", "", {}, "sha512-/BtnX7oCjNkxi2vi6y1399b+9xd1jnCrDYhZ61f0a+3X8x8DxlK52VgEEzyuC2UQMPACIfYrmHkhD3lGt2GaMA=="], "@ark/schema": ["@ark/schema@0.50.0", "", { "dependencies": { "@ark/util": "0.50.0" } }, "sha512-hfmP82GltBZDadIOeR3argKNlYYyB2wyzHp0eeAqAOFBQguglMV/S7Ip2q007bRtKxIMLDqFY6tfPie1dtssaQ=="],
"@ark/util": ["@ark/util@0.50.0", "", {}, "sha512-tIkgIMVRpkfXRQIEf0G2CJryZVtHVrqcWHMDa5QKo0OEEBu0tHkRSIMm4Ln8cd8Bn9TPZtvc/kE2Gma8RESPSg=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
@@ -512,7 +514,7 @@
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -570,7 +572,7 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"arktype": ["arktype@2.1.22", "", { "dependencies": { "@ark/schema": "0.49.0", "@ark/util": "0.49.0" } }, "sha512-xdzl6WcAhrdahvRRnXaNwsipCgHuNoLobRqhiP8RjnfL9Gp947abGlo68GAIyLtxbD+MLzNyH2YR4kEqioMmYQ=="], "arktype": ["arktype@2.1.23", "", { "dependencies": { "@ark/regex": "0.0.0", "@ark/schema": "0.50.0", "@ark/util": "0.50.0" } }, "sha512-tyxNWX6xJVMb2EPJJ3OjgQS1G/vIeQRrZuY4DeBNQmh8n7geS+czgbauQWB6Pr+RXiOO8ChEey44XdmxsqGmfQ=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
@@ -604,7 +606,7 @@
"buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="], "buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="],
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1294,6 +1296,8 @@
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
"@ironmount/server/arktype": ["arktype@2.1.22", "", { "dependencies": { "@ark/schema": "0.49.0", "@ark/util": "0.49.0" } }, "sha512-xdzl6WcAhrdahvRRnXaNwsipCgHuNoLobRqhiP8RjnfL9Gp947abGlo68GAIyLtxbD+MLzNyH2YR4kEqioMmYQ=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -1332,8 +1336,6 @@
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"bun-types/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -1428,6 +1430,10 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@ironmount/server/arktype/@ark/schema": ["@ark/schema@0.49.0", "", { "dependencies": { "@ark/util": "0.49.0" } }, "sha512-GphZBLpW72iS0v4YkeUtV3YIno35Gimd7+ezbPO9GwEi9kzdUrPVjvf6aXSBAfHikaFc/9pqZOpv3pOXnC71tw=="],
"@ironmount/server/arktype/@ark/util": ["@ark/util@0.49.0", "", {}, "sha512-/BtnX7oCjNkxi2vi6y1399b+9xd1jnCrDYhZ61f0a+3X8x8DxlK52VgEEzyuC2UQMPACIfYrmHkhD3lGt2GaMA=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -1464,8 +1470,6 @@
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],

View File

@@ -17,7 +17,7 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /run/docker/plugins:/run/docker/plugins - /run/docker/plugins:/run/docker/plugins
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared - /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
- ironmount_data:/data - ironmount_data:/data
- ./apps/client/app:/app/apps/client/app - ./apps/client/app:/app/apps/client/app
@@ -39,7 +39,7 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /run/docker/plugins:/run/docker/plugins - /run/docker/plugins:/run/docker/plugins
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared - /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
- ironmount_data:/data - ironmount_data:/data
volumes: volumes:

View File

@@ -1,7 +1,7 @@
{ {
"name": "ironmount", "name": "ironmount",
"private": true, "private": true,
"packageManager": "bun@1.2.23", "packageManager": "bun@1.3.0",
"scripts": { "scripts": {
"dev": "turbo dev", "dev": "turbo dev",
"tsc": "turbo run tsc", "tsc": "turbo run tsc",

View File

@@ -24,7 +24,7 @@ export const smbConfigSchema = type({
username: "string", username: "string",
password: "string", password: "string",
vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"), vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"),
domain: "string | undefined?", domain: "string?",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445), port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
}); });
@@ -36,17 +36,12 @@ export const webdavConfigSchema = type({
backend: "'webdav'", backend: "'webdav'",
server: "string", server: "string",
path: "string", path: "string",
username: "string | undefined?", username: "string?",
password: "string | undefined?", password: "string?",
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80), port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
ssl: "boolean?", ssl: "boolean?",
}); });
export const volumeConfigSchemaNoUndefined = nfsConfigSchema
.or(smbConfigSchema.omit("domain").and(type({ domain: "string?" })))
.or(webdavConfigSchema.omit("username", "password").and(type({ username: "string?", password: "string?" })))
.or(directoryConfigSchema);
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema); export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
export type BackendConfig = typeof volumeConfigSchema.infer; export type BackendConfig = typeof volumeConfigSchema.infer;
@@ -58,3 +53,61 @@ export const BACKEND_STATUS = {
} as const; } as const;
export type BackendStatus = keyof typeof BACKEND_STATUS; export type BackendStatus = keyof typeof BACKEND_STATUS;
export const REPOSITORY_BACKENDS = {
local: "local",
sftp: "sftp",
s3: "s3",
} as const;
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
export const localRepositoryConfigSchema = type({
backend: "'local'",
path: "string",
password: "string",
});
export const sftpRepositoryConfigSchema = type({
backend: "'sftp'",
host: "string",
user: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
path: "string",
sftpPassword: "string?",
sftpPrivateKey: "string?",
sftpCommand: "string?",
sftpArgs: "string?",
});
export const s3RepositoryConfigSchema = type({
backend: "'s3'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
});
export const repositoryConfigSchema = localRepositoryConfigSchema
.or(sftpRepositoryConfigSchema)
.or(s3RepositoryConfigSchema);
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
export const COMPRESSION_MODES = {
off: "off",
auto: "auto",
fastest: "fastest",
better: "better",
max: "max",
} as const;
export type CompressionMode = keyof typeof COMPRESSION_MODES;
export const REPOSITORY_STATUS = {
healthy: "healthy",
error: "error",
unknown: "unknown",
} as const;
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;