Merge branch 'tvarohohlavy-secrets-management'
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled

This commit is contained in:
Nicolas Meienberger 2025-12-16 20:47:29 +01:00
commit f77fd86694
23 changed files with 295 additions and 69 deletions

View file

@ -1,4 +1,4 @@
*
**
!turbo.json
!bun.lock

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ CLAUDE.md
mutagen.yml.lock
notes.md
smb-password.txt

View file

@ -148,6 +148,25 @@ Repositories are optimized for storage efficiency and data integrity, leveraging
To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings.
## Secret references (env:// and file://)
Any field that is normally stored encrypted in Zerobyte (passwords, tokens, access keys, etc.) also accepts secret references.
- `env://VAR_NAME` reads the value from `process.env.VAR_NAME` inside the Zerobyte container.
- `file://secret_name` reads the value from `/run/secrets/secret_name` (Docker secrets).
If you enter a normal value (not starting with `env://` or `file://`), Zerobyte will encrypt it before storing it in the database (values will look like `encv1:...`).
Examples:
```yaml
# SMB volume password from an env var
password: env://SMB_PASSWORD
# S3 secret access key from a Docker secret
secretAccessKey: file://s3-secret-access-key
```
### Using rclone for cloud storage
Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage providers including Google Drive, Dropbox, OneDrive, Box, pCloud, Mega, and many more. This gives you the flexibility to store your backups on virtually any cloud storage service.

View file

@ -173,3 +173,9 @@ body {
--chart-5: oklch(0.645 0.246 16.439);
}
}
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
[data-secret-input] input[type="password"]::-ms-reveal,
[data-secret-input] input[type="password"]::-ms-clear {
display: none;
}

View file

@ -0,0 +1,59 @@
import { Eye, EyeOff } from "lucide-react";
import type * as React from "react";
import { useMemo, useState } from "react";
import { cn } from "~/client/lib/utils";
import { Button } from "./button";
import { Input } from "./input";
export const isStoredSecretValue = (value?: string): boolean => {
if (typeof value !== "string" || value.length === 0) {
return false;
}
return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:");
};
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">
export const SecretInput = ({ className, value, ...props }: SecretInputProps) => {
const [revealed, setRevealed] = useState(false);
const showAsPlaintext = useMemo(() => {
if (typeof value !== "string") {
return false;
}
return isStoredSecretValue(value);
}, [value]);
const type = useMemo(() => {
if (showAsPlaintext) {
return "text";
}
return revealed ? "text" : "password";
}, [showAsPlaintext, revealed]);
return (
<div className="relative" data-secret-input>
<Input
{...props}
value={value}
type={type}
className={cn(!showAsPlaintext && "pr-10", className)}
/>
{!showAsPlaintext && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
onClick={() => setRevealed((v) => !v)}
aria-label={revealed ? "Hide secret" : "Show secret"}
>
{revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
)}
</div>
);
};

View file

@ -14,6 +14,7 @@ import {
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchema } from "~/schemas/notifications";
@ -213,7 +214,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
@ -415,7 +416,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application token from Gotify.</FormDescription>
<FormMessage />
@ -510,7 +511,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage />
@ -567,7 +568,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application API token from your Pushover application.</FormDescription>
<FormMessage />
@ -627,7 +628,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
<SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl>
<FormDescription>
Telegram bot token. Get this from BotFather when you create your bot.

View file

@ -16,6 +16,7 @@ import {
FormMessage,
} from "../../../components/ui/form";
import { Input } from "../../../components/ui/input";
import { SecretInput } from "../../../components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
@ -241,7 +242,11 @@ export const CreateRepositoryForm = ({
<FormItem>
<FormLabel>Repository Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter repository password" {...field} />
<SecretInput
placeholder="Enter repository password"
value={field.value ?? ""}
onChange={field.onChange}
/>
</FormControl>
<FormDescription>
The password used to encrypt this repository. It will be stored securely.

View file

@ -8,6 +8,7 @@ import {
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
@ -52,7 +53,7 @@ export const AzureRepositoryForm = ({ form }: Props) => {
<FormItem>
<FormLabel>Account Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Azure Storage account key for authentication.</FormDescription>
<FormMessage />

View file

@ -8,6 +8,7 @@ import {
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
@ -68,7 +69,7 @@ export const R2RepositoryForm = ({ form }: Props) => {
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
<FormMessage />

View file

@ -8,6 +8,7 @@ import {
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
@ -66,7 +67,7 @@ export const RestRepositoryForm = ({ form }: Props) => {
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for REST server authentication.</FormDescription>
<FormMessage />

View file

@ -8,6 +8,7 @@ import {
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
@ -66,7 +67,7 @@ export const S3RepositoryForm = ({ form }: Props) => {
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>S3 secret access key for authentication.</FormDescription>
<FormMessage />

View file

@ -9,6 +9,7 @@ import {
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
type Props = {
@ -67,7 +68,7 @@ export const SMBForm = ({ form }: Props) => {
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for SMB authentication.</FormDescription>
<FormMessage />

View file

@ -9,6 +9,7 @@ import {
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
type Props = {
form: UseFormReturn<FormValues>;
@ -66,7 +67,7 @@ export const WebDAVForm = ({ form }: Props) => {
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for WebDAV authentication (optional).</FormDescription>
<FormMessage />

View file

@ -34,7 +34,7 @@ const mount = async (config: BackendConfig, path: string) => {
const run = async () => {
await fs.mkdir(path, { recursive: true });
const password = await cryptoUtils.decrypt(config.password);
const password = await cryptoUtils.resolveSecret(config.password);
const source = `//${config.server}/${config.share}`;
const options = [

View file

@ -50,7 +50,7 @@ const mount = async (config: BackendConfig, path: string) => {
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
if (config.username && config.password) {
const password = await cryptoUtils.decrypt(config.password);
const password = await cryptoUtils.resolveSecret(config.password);
const secretsFile = "/etc/davfs2/secrets";
const secretsContent = `${source} ${config.username} ${password}\n`;
await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 });

View file

@ -38,42 +38,42 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "email":
return {
...config,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
};
case "slack":
return {
...config,
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
webhookUrl: await cryptoUtils.sealSecret(config.webhookUrl),
};
case "discord":
return {
...config,
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
webhookUrl: await cryptoUtils.sealSecret(config.webhookUrl),
};
case "gotify":
return {
...config,
token: await cryptoUtils.encrypt(config.token),
token: await cryptoUtils.sealSecret(config.token),
};
case "ntfy":
return {
...config,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
};
case "pushover":
return {
...config,
apiToken: await cryptoUtils.encrypt(config.apiToken),
apiToken: await cryptoUtils.sealSecret(config.apiToken),
};
case "telegram":
return {
...config,
botToken: await cryptoUtils.encrypt(config.botToken),
botToken: await cryptoUtils.sealSecret(config.botToken),
};
case "custom":
return {
...config,
shoutrrrUrl: await cryptoUtils.encrypt(config.shoutrrrUrl),
shoutrrrUrl: await cryptoUtils.sealSecret(config.shoutrrrUrl),
};
default:
return config;
@ -85,42 +85,42 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "email":
return {
...config,
password: config.password ? await cryptoUtils.decrypt(config.password) : undefined,
password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined,
};
case "slack":
return {
...config,
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl),
};
case "discord":
return {
...config,
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl),
};
case "gotify":
return {
...config,
token: await cryptoUtils.decrypt(config.token),
token: await cryptoUtils.resolveSecret(config.token),
};
case "ntfy":
return {
...config,
password: config.password ? await cryptoUtils.decrypt(config.password) : undefined,
password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined,
};
case "pushover":
return {
...config,
apiToken: await cryptoUtils.decrypt(config.apiToken),
apiToken: await cryptoUtils.resolveSecret(config.apiToken),
};
case "telegram":
return {
...config,
botToken: await cryptoUtils.decrypt(config.botToken),
botToken: await cryptoUtils.resolveSecret(config.botToken),
};
case "custom":
return {
...config,
shoutrrrUrl: await cryptoUtils.decrypt(config.shoutrrrUrl),
shoutrrrUrl: await cryptoUtils.resolveSecret(config.shoutrrrUrl),
};
default:
return config;

View file

@ -20,31 +20,31 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
const encryptedConfig: Record<string, string | boolean | number> = { ...config };
if (config.customPassword) {
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
encryptedConfig.customPassword = await cryptoUtils.sealSecret(config.customPassword);
}
switch (config.backend) {
case "s3":
case "r2":
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
encryptedConfig.accessKeyId = await cryptoUtils.sealSecret(config.accessKeyId);
encryptedConfig.secretAccessKey = await cryptoUtils.sealSecret(config.secretAccessKey);
break;
case "gcs":
encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson);
encryptedConfig.credentialsJson = await cryptoUtils.sealSecret(config.credentialsJson);
break;
case "azure":
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
encryptedConfig.accountKey = await cryptoUtils.sealSecret(config.accountKey);
break;
case "rest":
if (config.username) {
encryptedConfig.username = await cryptoUtils.encrypt(config.username);
encryptedConfig.username = await cryptoUtils.sealSecret(config.username);
}
if (config.password) {
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
encryptedConfig.password = await cryptoUtils.sealSecret(config.password);
}
break;
case "sftp":
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey);
encryptedConfig.privateKey = await cryptoUtils.sealSecret(config.privateKey);
break;
}

View file

@ -23,12 +23,12 @@ async function encryptSensitiveFields(config: BackendConfig): Promise<BackendCon
case "smb":
return {
...config,
password: await cryptoUtils.encrypt(config.password),
password: await cryptoUtils.sealSecret(config.password),
};
case "webdav":
return {
...config,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
};
default:
return config;

View file

@ -41,11 +41,11 @@ export const hasCompatibleCredentials = async (
(config1.backend === "s3" || config1.backend === "r2") &&
(config2.backend === "s3" || config2.backend === "r2")
) {
const accessKey1 = await cryptoUtils.decrypt(config1.accessKeyId);
const secretKey1 = await cryptoUtils.decrypt(config1.secretAccessKey);
const accessKey1 = await cryptoUtils.resolveSecret(config1.accessKeyId);
const secretKey1 = await cryptoUtils.resolveSecret(config1.secretAccessKey);
const accessKey2 = await cryptoUtils.decrypt(config2.accessKeyId);
const secretKey2 = await cryptoUtils.decrypt(config2.secretAccessKey);
const accessKey2 = await cryptoUtils.resolveSecret(config2.accessKeyId);
const secretKey2 = await cryptoUtils.resolveSecret(config2.secretAccessKey);
return accessKey1 === accessKey2 && secretKey1 === secretKey2;
}
@ -53,8 +53,8 @@ export const hasCompatibleCredentials = async (
}
case "gcs": {
if (config1.backend === "gcs" && config2.backend === "gcs") {
const credentials1 = await cryptoUtils.decrypt(config1.credentialsJson);
const credentials2 = await cryptoUtils.decrypt(config2.credentialsJson);
const credentials1 = await cryptoUtils.resolveSecret(config1.credentialsJson);
const credentials2 = await cryptoUtils.resolveSecret(config2.credentialsJson);
return credentials1 === credentials2 && config1.projectId === config2.projectId;
}
@ -62,8 +62,8 @@ export const hasCompatibleCredentials = async (
}
case "azure": {
if (config1.backend === "azure" && config2.backend === "azure") {
const config1Accountkey = await cryptoUtils.decrypt(config1.accountKey);
const config2Accountkey = await cryptoUtils.decrypt(config2.accountKey);
const config1Accountkey = await cryptoUtils.resolveSecret(config1.accountKey);
const config2Accountkey = await cryptoUtils.resolveSecret(config2.accountKey);
return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey;
}
@ -75,10 +75,10 @@ export const hasCompatibleCredentials = async (
return true;
}
const config1Username = await cryptoUtils.decrypt(config1.username || "");
const config1Password = await cryptoUtils.decrypt(config1.password || "");
const config2Username = await cryptoUtils.decrypt(config2.username || "");
const config2Password = await cryptoUtils.decrypt(config2.password || "");
const config1Username = await cryptoUtils.resolveSecret(config1.username || "");
const config1Password = await cryptoUtils.resolveSecret(config1.password || "");
const config2Username = await cryptoUtils.resolveSecret(config2.username || "");
const config2Password = await cryptoUtils.resolveSecret(config2.password || "");
return config1Username === config2Username && config1Password === config2Password;
}

View file

@ -1,9 +1,15 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { RESTIC_PASS_FILE } from "../core/constants";
import { isNodeJSErrnoException } from "./fs";
const algorithm = "aes-256-gcm" as const;
const keyLength = 32;
const encryptionPrefix = "encv1";
const encryptionPrefix = "encv1:";
const envSecretPrefix = "env://";
const fileSecretPrefix = "file://";
/**
* Checks if a given string is encrypted by looking for the encryption prefix.
@ -12,6 +18,68 @@ const isEncrypted = (val?: string): boolean => {
return typeof val === "string" && val.startsWith(encryptionPrefix);
};
/**
* Checks if a string looks like a supported secret reference.
* - env://VAR_NAME -> reads process.env.VAR_NAME
* - file://name -> reads a file /run/secrets/name
*/
const isSecretReference = (val?: string): boolean => {
return typeof val === "string" && (val.startsWith(envSecretPrefix) || val.startsWith(fileSecretPrefix));
};
/**
* Resolves an environment variable secret reference.
*/
const resolveEnvSecret = (ref: string): string => {
const name = ref.slice(envSecretPrefix.length);
if (!name) {
throw new Error("env:// reference is missing variable name");
}
const value = process.env[name];
if (value === undefined) {
throw new Error(`Environment variable not set: ${name}`);
}
return value;
};
/**
* Resolves a file-based secret reference.
* Reads the secret from /run/secrets/{name}
*/
const resolveFileSecret = async (ref: string): Promise<string> => {
const secretName = ref.slice(fileSecretPrefix.length);
if (!secretName) {
throw new Error("file:// reference is missing secret name");
}
const normalizedName = secretName.replace(/^\/+/, "");
if (!normalizedName) {
throw new Error("file:// reference is missing secret name");
}
if (normalizedName.includes("\0") || normalizedName.includes("/") || normalizedName.includes("\\")) {
throw new Error("Invalid secret reference: secret name must be a single path segment");
}
const resolvedPath = path.join("/run/secrets", normalizedName);
try {
const content = await fs.readFile(resolvedPath, "utf-8");
return content.trimEnd();
} catch (error) {
if (isNodeJSErrnoException(error)) {
if (error.code === "ENOENT") {
throw new Error(`Secret file not found: ${resolvedPath}`);
}
if (error.code === "EACCES") {
throw new Error(`Permission denied reading secret file: ${resolvedPath}`);
}
}
throw new Error(`Failed to read secret file ${resolvedPath}: ${(error as Error).message}`);
}
};
/**
* Given a string, encrypts it using a randomly generated salt.
* Returns the input unchanged if it's empty or already encrypted.
@ -35,7 +103,7 @@ const encrypt = async (data: string) => {
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
const tag = cipher.getAuthTag();
return `${encryptionPrefix}:${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`;
return `${encryptionPrefix}${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`;
};
/**
@ -68,8 +136,54 @@ const decrypt = async (encryptedData: string) => {
return decrypted.toString();
};
export const cryptoUtils = {
encrypt,
decrypt,
isEncrypted,
/**
* Resolves secret references and encrypted database values.
*
* - encv1:... -> decrypt
* - env://VAR -> read process.env.VAR
* - file://name -> read /run/secrets/name
* - otherwise returns value unchanged
*/
const resolveSecret = async (value: string): Promise<string> => {
if (!value) {
return value;
}
if (isEncrypted(value)) {
return decrypt(value);
}
if (value.startsWith(envSecretPrefix)) {
return resolveEnvSecret(value);
}
if (value.startsWith(fileSecretPrefix)) {
return resolveFileSecret(value);
}
return value;
};
/**
* Prepares a secret value for storage.
*
* - env://... and file://... are stored as-is (references)
* - encv1:... is stored as-is (already encrypted)
* - otherwise encrypt before storing
*/
const sealSecret = async (value: string): Promise<string> => {
if (!value) {
return value;
}
if (isEncrypted(value) || isSecretReference(value)) {
return value;
}
return encrypt(value);
};
export const cryptoUtils = {
resolveSecret,
sealSecret,
};

8
app/server/utils/fs.ts Normal file
View file

@ -0,0 +1,8 @@
export const isNodeJSErrnoException = (error: unknown): error is NodeJS.ErrnoException => {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
typeof (error as NodeJS.ErrnoException).code === "string"
);
};

View file

@ -106,7 +106,7 @@ const buildEnv = async (config: RepositoryConfig) => {
};
if (config.isExistingRepository && config.customPassword) {
const decryptedPassword = await cryptoUtils.decrypt(config.customPassword);
const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword);
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
@ -117,17 +117,17 @@ const buildEnv = async (config: RepositoryConfig) => {
switch (config.backend) {
case "s3":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey);
break;
case "r2":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey);
env.AWS_REGION = "auto";
env.AWS_S3_FORCE_PATH_STYLE = "true";
break;
case "gcs": {
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson);
const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 });
env.GOOGLE_PROJECT_ID = config.projectId;
@ -136,7 +136,7 @@ const buildEnv = async (config: RepositoryConfig) => {
}
case "azure": {
env.AZURE_ACCOUNT_NAME = config.accountName;
env.AZURE_ACCOUNT_KEY = await cryptoUtils.decrypt(config.accountKey);
env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey);
if (config.endpointSuffix) {
env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix;
}
@ -144,15 +144,15 @@ const buildEnv = async (config: RepositoryConfig) => {
}
case "rest": {
if (config.username) {
env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username);
env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username);
}
if (config.password) {
env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password);
env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password);
}
break;
}
case "sftp": {
const decryptedKey = await cryptoUtils.decrypt(config.privateKey);
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");

View file

@ -12,8 +12,11 @@ services:
- SYS_ADMIN
environment:
- NODE_ENV=development
# - SMB_PASSWORD=secret
ports:
- "4096:4096"
# secrets:
# - smb-password
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@ -42,3 +45,7 @@ services:
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock
- ~/.config/rclone:/root/.config/rclone
# secrets:
# smb-password:
# file: ./smb-password.txt