diff --git a/.env.example b/.env.example index 7ce4dab1..8303e218 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ SERVER_IP=localhost DATABASE_URL=./data/zerobyte.db -RESTIC_PASS_FILE=./data/restic.pass RESTIC_CACHE_DIR=./data/restic/cache ZEROBYTE_REPOSITORIES_DIR=./data/repositories ZEROBYTE_VOLUMES_DIR=./data/volumes +APP_SECRET= diff --git a/.env.test b/.env.test index 6d03fac1..04a34a89 100644 --- a/.env.test +++ b/.env.test @@ -1,2 +1,2 @@ DATABASE_URL=:memory: -ZEROBYTE_APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69 +APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69 diff --git a/AGENTS.md b/AGENTS.md index 62a639f6..2e9509f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,7 +185,6 @@ Zerobyte is a wrapper around Restic for backup operations. Key integration point - `buildRepoUrl()` - Constructs repository URLs for different backends - `buildEnv()` - Sets environment variables (credentials, cache dir) -- `ensurePassfile()` - Manages encryption password file - Type-safe parsing of Restic JSON output using ArkType schemas **Rclone Integration** (`app/server/modules/repositories/`): diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 56b5f99a..121d2e91 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -13,7 +13,7 @@ import { eq } from "drizzle-orm"; import { config } from "../server/core/config"; import { db } from "../server/db/db"; import { cryptoUtils } from "../server/utils/crypto"; -import { organization as organizationTable, member } from "../server/db/schema"; +import { organization as organizationTable, member, usersTable } from "../server/db/schema"; import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user"; export type AuthMiddlewareContext = MiddlewareContext>; @@ -55,25 +55,31 @@ const createBetterAuth = (secret: string) => resticPassword: await cryptoUtils.sealSecret(resticPassword), }; - await db.transaction(async (tx) => { - const orgId = Bun.randomUUIDv7(); + try { + await db.transaction(async (tx) => { + const orgId = Bun.randomUUIDv7(); - await tx.insert(organizationTable).values({ - name: `${user.name}'s Workspace`, - slug: slug, - id: orgId, - createdAt: new Date(), - metadata, - }); + await tx.insert(organizationTable).values({ + name: `${user.name}'s Workspace`, + slug: slug, + id: orgId, + createdAt: new Date(), + metadata, + }); - await tx.insert(member).values({ - id: Bun.randomUUIDv7(), - userId: user.id, - role: "owner", - organizationId: orgId, - createdAt: new Date(), + await tx.insert(member).values({ + id: Bun.randomUUIDv7(), + userId: user.id, + role: "owner", + organizationId: orgId, + createdAt: new Date(), + }); }); - }); + } catch { + await db.delete(usersTable).where(eq(usersTable.id, user.id)); + + throw new Error(`Failed to create organization for user ${user.id}`); + } }, }, }, diff --git a/app/server/core/config.ts b/app/server/core/config.ts index a7b0895e..27721a9a 100644 --- a/app/server/core/config.ts +++ b/app/server/core/config.ts @@ -34,7 +34,7 @@ const envSchema = type({ APP_VERSION: "string = 'dev'", TRUSTED_ORIGINS: "string?", DISABLE_RATE_LIMITING: 'string = "false"', - ZEROBYTE_APP_SECRET: "32 <= string <= 256", + APP_SECRET: "32 <= string <= 256", }).pipe((s) => ({ __prod__: s.NODE_ENV === "production", environment: s.NODE_ENV, @@ -46,25 +46,25 @@ const envSchema = type({ appVersion: s.APP_VERSION, trustedOrigins: s.TRUSTED_ORIGINS?.split(",").map((origin) => origin.trim()), disableRateLimiting: s.DISABLE_RATE_LIMITING === "true", - appSecret: s.ZEROBYTE_APP_SECRET, + appSecret: s.APP_SECRET, })); const parseConfig = (env: unknown) => { const result = envSchema(env); if (result instanceof type.errors) { - if (!process.env.ZEROBYTE_APP_SECRET) { + if (!process.env.APP_SECRET) { const errorMessage = [ "", "================================================================================", - "ZEROBYTE_APP_SECRET is not configured.", + "APP_SECRET is not configured.", "", "This secret is required for encrypting sensitive data in the database.", "", "To generate a new secret, run:", " openssl rand -hex 32", "", - "Then set the ZEROBYTE_APP_SECRET environment variable with the generated value.", + "Then set the APP_SECRET environment variable with the generated value.", "", "IMPORTANT: Store this secret securely and back it up. If lost, encrypted data", "in the database will be unrecoverable.", diff --git a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts index 25561796..1bdf01cd 100644 --- a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts +++ b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts @@ -15,7 +15,7 @@ import { RESTIC_PASS_FILE } from "~/server/core/constants"; * * This migration performs two critical tasks: * 1. Assigns unique restic passwords to each organization (using the legacy password for existing orgs) - * 2. Re-keys all encrypted secrets from the legacy restic passfile to use the new ZEROBYTE_APP_SECRET + * 2. Re-keys all encrypted secrets from the legacy restic passfile to use the new APP_SECRET * * This allows per-organization encryption key isolation while ensuring * database encryption is decoupled from restic repository passwords. diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index ae143cc9..39a36731 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -3,7 +3,6 @@ import { and, eq, or } from "drizzle-orm"; import { db } from "../../db/db"; import { backupSchedulesTable, volumesTable } from "../../db/schema"; import { logger } from "../../utils/logger"; -import { restic } from "../../utils/restic"; import { volumeService } from "../volumes/volume.service"; import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling"; import { VolumeHealthCheckJob } from "../../jobs/healthchecks"; @@ -55,10 +54,6 @@ export const startup = async () => { await Scheduler.start(); await Scheduler.clear(); - await restic.ensurePassfile().catch((err) => { - logger.error(`Error ensuring restic passfile exists: ${err.message}`); - }); - await initAuth().catch((err) => { logger.error(`Error initializing auth: ${toMessage(err)}`); throw err; diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index c1a704e2..5a26add4 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -129,7 +129,9 @@ const createRepository = async (name: string, config: RepositoryConfig, compress } const errorMessage = toMessage(error); - await db.delete(repositoriesTable).where(and(eq(repositoriesTable.id, id), eq(repositoriesTable.organizationId, organizationId))); + await db + .delete(repositoriesTable) + .where(and(eq(repositoriesTable.id, id), eq(repositoriesTable.organizationId, organizationId))); throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`); }; @@ -155,7 +157,9 @@ const deleteRepository = async (id: string) => { await db .delete(repositoriesTable) - .where(and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId))); + .where( + and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)), + ); cache.delByPrefix(`snapshots:${repository.id}:`); cache.delByPrefix(`ls:${repository.id}:`); @@ -330,7 +334,9 @@ const checkHealth = async (repositoryId: string) => { lastChecked: Date.now(), lastError: error, }) - .where(and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId))); + .where( + and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)), + ); return { lastError: error }; } finally { @@ -420,7 +426,9 @@ const doctorRepository = async (id: string) => { lastChecked: Date.now(), lastError: doctorError, }) - .where(and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId))); + .where( + and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)), + ); return { success: doctorSucceeded, @@ -466,7 +474,11 @@ const deleteSnapshots = async (id: string, snapshotIds: string[]) => { } }; -const tagSnapshots = async (id: string, snapshotIds: string[], tags: { add?: string[]; remove?: string[]; set?: string[] }) => { +const tagSnapshots = async ( + id: string, + snapshotIds: string[], + tags: { add?: string[]; remove?: string[]; set?: string[] }, +) => { const organizationId = getOrganizationId(); const repository = await findRepository(id); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index d4ac1f72..34707ac0 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -64,17 +64,6 @@ const snapshotInfoSchema = type({ }).optional(), }); -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 }); - } -}; - export const buildRepoUrl = (config: RepositoryConfig): string => { switch (config.backend) { case "local": @@ -239,8 +228,6 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string) }; const init = async (config: RepositoryConfig, organizationId: string) => { - await ensurePassfile(); - const repoUrl = buildRepoUrl(config); logger.info(`Initializing restic repository at ${repoUrl}...`); @@ -956,7 +943,6 @@ export const addCommonArgs = ( }; export const restic = { - ensurePassfile, init, backup, restore, diff --git a/docker-compose.yml b/docker-compose.yml index 424f5d3a..ca96bb2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - SYS_ADMIN environment: - NODE_ENV=development + - APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b ports: - "4096:4096" volumes: @@ -21,6 +22,7 @@ services: - ~/.config/rclone:/root/.config/rclone zerobyte-prod: + # image: ghcr.io/nicotsx/zerobyte:v0.22.0 build: context: . dockerfile: Dockerfile @@ -35,6 +37,8 @@ services: - SYS_ADMIN ports: - "4096:4096" + environment: + - APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte @@ -49,6 +53,7 @@ services: restart: unless-stopped environment: - DISABLE_RATE_LIMITING=true + - APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b devices: - /dev/fuse:/dev/fuse cap_add: