Compare commits

..

17 Commits

Author SHA1 Message Date
Nicolas Meienberger
da489fab24 Merge branch 'tvarohohlavy-volumes-secrets-encryption' 2025-12-06 10:08:20 +01:00
Nicolas Meienberger
e4b8076351 refactor: remove trim on password
The password should be taken as-is. It could potentially contain a space
2025-12-06 10:08:01 +01:00
Nicolas Meienberger
70c72f0f9a refactor: no need to print safe args as it's already sanitized 2025-12-06 10:06:03 +01:00
Nicolas Meienberger
c45b760abc ci: fix docker cache args on wrong step 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
9ba26b7599 feat: add DOCKER_HOST support 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
01127ee9d6 fix: volume data not refreshing when changing selection 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
77f5886110 fix: remove debug logs in production 2025-12-06 09:49:59 +01:00
Nico
6b6338291b feat: custom include patterns (#104)
* feat: add custom include patterns

* feat: add exclude-if-present option
2025-12-06 09:49:59 +01:00
Nico
2c11b7c7de feat: naming backup schedules (#103)
* docs: add agents instructions

* feat: naming backup schedules

* fix: wrong table for filtering
2025-12-06 09:49:59 +01:00
Nicolas Meienberger
a0fa043207 fix: broken migration 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
143701820a chore: update dependencies 2025-12-06 09:49:59 +01:00
Nico
aff875c62f feat: mirror repositories (#95)
* feat: mirror repositories

feat: mirror backup repositories

* chore: pr feedbacks
2025-12-06 09:49:59 +01:00
Nicolas Meienberger
e52c25d87b ci: fix docker cache args on wrong step 2025-12-06 09:39:10 +01:00
Jakub Trávník
9fec6883f6 cryptoUtils.decrypt alligned with encrypt in regards of handling extra space in password file 2025-12-04 08:43:17 +01:00
Jakub Trávník
f4df9e935d crypto.Utils comments updated to reflect behaviour 2025-12-04 08:39:51 +01:00
Jakub Trávník
f326f41599 avoid logging secrets in smb backend 2025-12-04 08:33:55 +01:00
Jakub Trávník
f6b8e7e5a2 feat: implement encryption for sensitive fields in volume backends 2025-12-04 00:04:26 +01:00
5 changed files with 51 additions and 11 deletions

View File

@@ -62,8 +62,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }} type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }}
flavor: | flavor: |
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }} latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
- name: Build and push images - name: Build and push images
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -76,6 +74,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }} APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
publish-release: publish-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,6 +1,7 @@
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 { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@@ -33,10 +34,12 @@ const mount = async (config: BackendConfig, path: string) => {
const run = async () => { const run = async () => {
await fs.mkdir(path, { recursive: true }); await fs.mkdir(path, { recursive: true });
const password = await cryptoUtils.decrypt(config.password);
const source = `//${config.server}/${config.share}`; const source = `//${config.server}/${config.share}`;
const options = [ const options = [
`user=${config.username}`, `user=${config.username}`,
`pass=${config.password}`, `pass=${password}`,
`vers=${config.vers}`, `vers=${config.vers}`,
`port=${config.port}`, `port=${config.port}`,
"uid=1000", "uid=1000",

View File

@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@@ -49,8 +50,9 @@ const mount = async (config: BackendConfig, path: string) => {
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"]; : ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
if (config.username && config.password) { if (config.username && config.password) {
const password = await cryptoUtils.decrypt(config.password);
const secretsFile = "/etc/davfs2/secrets"; const secretsFile = "/etc/davfs2/secrets";
const secretsContent = `${source} ${config.username} ${config.password}\n`; const secretsContent = `${source} ${config.username} ${password}\n`;
await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 }); await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 });
} }

View File

@@ -8,6 +8,7 @@ import slugify from "slugify";
import { getCapabilities, parseDockerHost } from "../../core/capabilities"; import { getCapabilities, parseDockerHost } from "../../core/capabilities";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { volumesTable } from "../../db/schema"; import { volumesTable } from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id"; import { generateShortId } from "../../utils/id";
import { getStatFs, type StatFs } from "../../utils/mountinfo"; import { getStatFs, type StatFs } from "../../utils/mountinfo";
@@ -19,6 +20,23 @@ import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes"; import type { BackendConfig } from "~/schemas/volumes";
async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> {
switch (config.backend) {
case "smb":
return {
...config,
password: await cryptoUtils.encrypt(config.password),
};
case "webdav":
return {
...config,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
};
default:
return config;
}
}
const listVolumes = async () => { const listVolumes = async () => {
const volumes = await db.query.volumesTable.findMany({}); const volumes = await db.query.volumesTable.findMany({});
@@ -37,13 +55,14 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
} }
const shortId = generateShortId(); const shortId = generateShortId();
const encryptedConfig = await encryptSensitiveFields(backendConfig);
const [created] = await db const [created] = await db
.insert(volumesTable) .insert(volumesTable)
.values({ .values({
shortId, shortId,
name: slug, name: slug,
config: backendConfig, config: encryptedConfig,
type: backendConfig.backend, type: backendConfig.backend,
}) })
.returning(); .returning();
@@ -175,11 +194,13 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
await backend.unmount(); await backend.unmount();
} }
const encryptedConfig = volumeData.config ? await encryptSensitiveFields(volumeData.config) : undefined;
const [updated] = await db const [updated] = await db
.update(volumesTable) .update(volumesTable)
.set({ .set({
name: newName, name: newName,
config: volumeData.config, config: encryptedConfig,
type: volumeData.config?.backend, type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount, autoRemount: volumeData.autoRemount,
updatedAt: Date.now(), updatedAt: Date.now(),

View File

@@ -6,18 +6,26 @@ const keyLength = 32;
const encryptionPrefix = "encv1"; const encryptionPrefix = "encv1";
/** /**
* Given a string, encrypts it using a randomly generated salt * Checks if a given string is encrypted by looking for the encryption prefix.
*/
const isEncrypted = (val?: string): boolean => {
return typeof val === "string" && val.startsWith(encryptionPrefix);
};
/**
* Given a string, encrypts it using a randomly generated salt.
* Returns the input unchanged if it's empty or already encrypted.
*/ */
const encrypt = async (data: string) => { const encrypt = async (data: string) => {
if (!data) { if (!data) {
return data; return data;
} }
if (data.startsWith(encryptionPrefix)) { if (isEncrypted(data)) {
return data; return data;
} }
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim(); const secret = await Bun.file(RESTIC_PASS_FILE).text();
const salt = crypto.randomBytes(16); const salt = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256"); const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
@@ -31,10 +39,15 @@ const encrypt = async (data: string) => {
}; };
/** /**
* Given an encrypted string, decrypts it using the salt stored in the string * Given an encrypted string, decrypts it using the salt stored in the string.
* Returns the input unchanged if it's not encrypted (for backward compatibility).
*/ */
const decrypt = async (encryptedData: string) => { const decrypt = async (encryptedData: string) => {
const secret = await Bun.file(RESTIC_PASS_FILE).text(); if (!isEncrypted(encryptedData)) {
return encryptedData;
}
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
const parts = encryptedData.split(":").slice(1); // Remove prefix const parts = encryptedData.split(":").slice(1); // Remove prefix
const saltHex = parts.shift() as string; const saltHex = parts.shift() as string;
@@ -58,4 +71,5 @@ const decrypt = async (encryptedData: string) => {
export const cryptoUtils = { export const cryptoUtils = {
encrypt, encrypt,
decrypt, decrypt,
isEncrypted,
}; };