feat(config): add support for APP_SECRET as a file (#769)
* feat(config): add support for APP_SECRET as a file * fix(tsc): ensure appSecret is set in type system * fix: pr feedback
This commit is contained in:
parent
595a29056d
commit
70c7de1efc
6 changed files with 238 additions and 44 deletions
|
|
@ -97,6 +97,7 @@ Zerobyte can be customized using environment variables. Below are the available
|
|||
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
||||
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
|
||||
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
|
||||
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
|
||||
| `PORT` | The port the web interface and API will listen on. | `4096` |
|
||||
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
|
||||
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
|
||||
|
|
|
|||
154
app/server/core/__tests__/config.test.ts
Normal file
154
app/server/core/__tests__/config.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseConfig } from "../config";
|
||||
|
||||
const validAppSecret = "a".repeat(32);
|
||||
const fileAppSecret = "b".repeat(32);
|
||||
const appSecretFixturePath = fileURLToPath(new URL("./fixtures/app-secret.txt", import.meta.url));
|
||||
const shortAppSecretFixturePath = fileURLToPath(new URL("./fixtures/short-app-secret.txt", import.meta.url));
|
||||
|
||||
const createEnv = (overrides: Record<string, string | undefined> = {}) => {
|
||||
const env = {
|
||||
APP_SECRET: validAppSecret,
|
||||
BASE_URL: "http://example.com",
|
||||
RESTIC_HOSTNAME: "configured-restic-host",
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined));
|
||||
};
|
||||
|
||||
const expectParseConfigToExit = (env: Record<string, string | undefined>, message: string) => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code: string | number | null | undefined) => {
|
||||
throw new Error(`process.exit:${code}`);
|
||||
});
|
||||
|
||||
expect(() => parseConfig(env)).toThrow("process.exit:1");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining(message));
|
||||
};
|
||||
|
||||
describe("parseConfig", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("parses quoted origins and derives runtime flags", () => {
|
||||
const config = parseConfig(
|
||||
createEnv({
|
||||
NODE_ENV: "development",
|
||||
BASE_URL: '"https://example.com"',
|
||||
TRUSTED_ORIGINS: '"https://admin.example.com", "http://localhost:3000"',
|
||||
TRUST_PROXY: "true",
|
||||
DISABLE_RATE_LIMITING: "true",
|
||||
ENABLE_DEV_PANEL: "true",
|
||||
SERVER_IP: "0.0.0.0",
|
||||
SERVER_IDLE_TIMEOUT: "120",
|
||||
PORT: "8080",
|
||||
APP_VERSION: "1.2.3",
|
||||
MIGRATIONS_PATH: "/tmp/migrations",
|
||||
PROVISIONING_PATH: "/tmp/provisioning",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config).toEqual({
|
||||
__prod__: false,
|
||||
environment: "development",
|
||||
serverIp: "0.0.0.0",
|
||||
serverIdleTimeout: 120,
|
||||
resticHostname: "configured-restic-host",
|
||||
port: 8080,
|
||||
migrationsPath: "/tmp/migrations",
|
||||
appVersion: "1.2.3",
|
||||
trustedOrigins: ["https://admin.example.com", "http://localhost:3000", "https://example.com"],
|
||||
trustProxy: true,
|
||||
disableRateLimiting: true,
|
||||
appSecret: validAppSecret,
|
||||
baseUrl: "https://example.com",
|
||||
isSecure: true,
|
||||
enableDevPanel: true,
|
||||
provisioningPath: "/tmp/provisioning",
|
||||
allowedHosts: ["example.com", "admin.example.com", "localhost:3000"],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the configured RESTIC_HOSTNAME when present", () => {
|
||||
const config = parseConfig(
|
||||
createEnv({
|
||||
RESTIC_HOSTNAME: "manual-restic-host",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.resticHostname).toBe("manual-restic-host");
|
||||
});
|
||||
|
||||
test("reads APP_SECRET from APP_SECRET_FILE", () => {
|
||||
const config = parseConfig(
|
||||
createEnv({
|
||||
APP_SECRET: undefined,
|
||||
APP_SECRET_FILE: appSecretFixturePath,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.appSecret).toBe(fileAppSecret);
|
||||
});
|
||||
|
||||
test("reads APP_SECRET from APP_SECRET_FILE when APP_SECRET is empty", () => {
|
||||
const config = parseConfig(
|
||||
createEnv({
|
||||
APP_SECRET: "",
|
||||
APP_SECRET_FILE: appSecretFixturePath,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.appSecret).toBe(fileAppSecret);
|
||||
});
|
||||
|
||||
test("exits when APP_SECRET is missing", () => {
|
||||
expectParseConfigToExit(
|
||||
createEnv({
|
||||
APP_SECRET: undefined,
|
||||
}),
|
||||
"APP_SECRET is not configured.",
|
||||
);
|
||||
});
|
||||
|
||||
test("exits when both APP_SECRET and APP_SECRET_FILE are set", () => {
|
||||
expectParseConfigToExit(
|
||||
createEnv({
|
||||
APP_SECRET_FILE: "/run/secrets/app-secret",
|
||||
}),
|
||||
"Both APP_SECRET and APP_SECRET_FILE are set. Please set only one of these.",
|
||||
);
|
||||
});
|
||||
|
||||
test("exits when APP_SECRET_FILE cannot be read", () => {
|
||||
expectParseConfigToExit(
|
||||
createEnv({
|
||||
APP_SECRET: undefined,
|
||||
APP_SECRET_FILE: "/path/that/does/not/exist",
|
||||
}),
|
||||
"Failed to read APP_SECRET from file:",
|
||||
);
|
||||
});
|
||||
|
||||
test("exits when APP_SECRET_FILE contains a secret with an invalid length", () => {
|
||||
expectParseConfigToExit(
|
||||
createEnv({
|
||||
APP_SECRET: undefined,
|
||||
APP_SECRET_FILE: shortAppSecretFixturePath,
|
||||
}),
|
||||
"The secret read from APP_SECRET_FILE must be between 32 and 256 characters long.",
|
||||
);
|
||||
});
|
||||
|
||||
test("exits when no valid trusted origins are provided", () => {
|
||||
expectParseConfigToExit(
|
||||
createEnv({
|
||||
BASE_URL: "notaurl",
|
||||
}),
|
||||
"No valid trusted origins provided. Please check the BASE_URL and TRUSTED_ORIGINS environment variables.",
|
||||
);
|
||||
});
|
||||
});
|
||||
1
app/server/core/__tests__/fixtures/app-secret.txt
Normal file
1
app/server/core/__tests__/fixtures/app-secret.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
1
app/server/core/__tests__/fixtures/short-app-secret.txt
Normal file
1
app/server/core/__tests__/fixtures/short-app-secret.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
too-short
|
||||
|
|
@ -3,6 +3,7 @@ import os from "node:os";
|
|||
import { prettifyError, z } from "zod";
|
||||
import "dotenv/config";
|
||||
import { buildAllowedHosts } from "../lib/auth/base-url";
|
||||
import { toMessage } from "@zerobyte/core/utils";
|
||||
|
||||
const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2");
|
||||
const getResticHostname = () => {
|
||||
|
|
@ -38,56 +39,20 @@ const envSchema = z
|
|||
TRUSTED_ORIGINS: z.string().optional(),
|
||||
TRUST_PROXY: z.string().default("false"),
|
||||
DISABLE_RATE_LIMITING: z.string().default("false"),
|
||||
APP_SECRET: z.string().min(32).max(256),
|
||||
APP_SECRET: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(32).max(256).optional()),
|
||||
APP_SECRET_FILE: z.string().optional(),
|
||||
BASE_URL: z.string(),
|
||||
ENABLE_DEV_PANEL: z.string().default("false"),
|
||||
PROVISIONING_PATH: z.string().optional(),
|
||||
})
|
||||
.transform((s) => {
|
||||
.transform((s, ctx) => {
|
||||
const baseUrl = unquote(s.BASE_URL);
|
||||
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl];
|
||||
const authOrigins = [baseUrl, ...trustedOrigins];
|
||||
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
|
||||
let appSecret = s.APP_SECRET;
|
||||
|
||||
for (const origin of invalidOrigins) {
|
||||
console.warn(
|
||||
`Ignoring invalid origin in configuration: ${origin}. Make sure it is a valid URL with a protocol (e.g. https://example.com)`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
__prod__: s.NODE_ENV === "production",
|
||||
environment: s.NODE_ENV,
|
||||
serverIp: s.SERVER_IP,
|
||||
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
||||
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
|
||||
port: s.PORT,
|
||||
migrationsPath: s.MIGRATIONS_PATH,
|
||||
appVersion: s.APP_VERSION,
|
||||
trustedOrigins: trustedOrigins,
|
||||
trustProxy: s.TRUST_PROXY === "true",
|
||||
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true" || s.NODE_ENV === "test",
|
||||
appSecret: s.APP_SECRET,
|
||||
baseUrl,
|
||||
isSecure: baseUrl.startsWith("https://"),
|
||||
enableDevPanel: s.ENABLE_DEV_PANEL === "true",
|
||||
provisioningPath: s.PROVISIONING_PATH,
|
||||
allowedHosts,
|
||||
};
|
||||
});
|
||||
|
||||
export const parseConfig = (env: unknown) => {
|
||||
const result = envSchema.safeParse(env);
|
||||
|
||||
if (result.success && result.data.allowedHosts.length === 0) {
|
||||
console.error(
|
||||
`Configuration error: No valid trusted origins provided. Please check the BASE_URL and TRUSTED_ORIGINS environment variables.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
if (!process.env.APP_SECRET) {
|
||||
if (!appSecret && !s.APP_SECRET_FILE) {
|
||||
const errorMessage = [
|
||||
"",
|
||||
"================================================================================",
|
||||
|
|
@ -103,16 +68,88 @@ export const parseConfig = (env: unknown) => {
|
|||
"IMPORTANT: Store this secret securely and back it up. If lost, encrypted data",
|
||||
"in the database will be unrecoverable.",
|
||||
"================================================================================",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
console.error(errorMessage);
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (s.APP_SECRET && s.APP_SECRET_FILE) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Both APP_SECRET and APP_SECRET_FILE are set. Please set only one of these.",
|
||||
});
|
||||
}
|
||||
|
||||
if (s.APP_SECRET_FILE) {
|
||||
try {
|
||||
appSecret = readFileSync(s.APP_SECRET_FILE, "utf-8").trim();
|
||||
if (appSecret.length < 32 || appSecret.length > 256) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "The secret read from APP_SECRET_FILE must be between 32 and 256 characters long.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Failed to read APP_SECRET from file: ${toMessage(err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const origin of invalidOrigins) {
|
||||
console.warn(
|
||||
`Ignoring invalid origin in configuration: ${origin}. Make sure it is a valid URL with a protocol (e.g. https://example.com)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (allowedHosts.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"No valid trusted origins provided. Please check the BASE_URL and TRUSTED_ORIGINS environment variables.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
__prod__: s.NODE_ENV === "production",
|
||||
environment: s.NODE_ENV,
|
||||
serverIp: s.SERVER_IP,
|
||||
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
||||
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
|
||||
port: s.PORT,
|
||||
migrationsPath: s.MIGRATIONS_PATH,
|
||||
appVersion: s.APP_VERSION,
|
||||
trustedOrigins: trustedOrigins,
|
||||
trustProxy: s.TRUST_PROXY === "true",
|
||||
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true" || s.NODE_ENV === "test",
|
||||
appSecret: appSecret ?? "",
|
||||
baseUrl,
|
||||
isSecure: baseUrl.startsWith("https://"),
|
||||
enableDevPanel: s.ENABLE_DEV_PANEL === "true",
|
||||
provisioningPath: s.PROVISIONING_PATH,
|
||||
allowedHosts,
|
||||
};
|
||||
});
|
||||
|
||||
export const parseConfig = (env: unknown) => {
|
||||
const result = envSchema.safeParse(env);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`Environment variable validation failed: ${prettifyError(result.error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!result.data.appSecret) {
|
||||
console.error(
|
||||
"APP_SECRET is required but was not provided. Please set the APP_SECRET environment variable or provide a file with APP_SECRET_FILE.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ cp secrets/aws_secret_access_key.example secrets/aws_secret_access_key
|
|||
|
||||
2. Edit `.env`:
|
||||
|
||||
- Set `APP_SECRET` to a real secret, for example `openssl rand -hex 32`
|
||||
- Set `APP_SECRET` to a real secret, for example `openssl rand -hex 32`, or set `APP_SECRET_FILE` to a mounted secret file
|
||||
- Set `ZEROBYTE_AWS_ACCESS_KEY_ID`
|
||||
- Set `ZEROBYTE_WEBDAV_PASSWORD`
|
||||
- Adjust `BASE_URL` and `TZ` if needed
|
||||
|
|
|
|||
Loading…
Reference in a new issue