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) |
|
| `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` | **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` |
|
| `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` |
|
| `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` |
|
| `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 { prettifyError, z } from "zod";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { buildAllowedHosts } from "../lib/auth/base-url";
|
import { buildAllowedHosts } from "../lib/auth/base-url";
|
||||||
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
|
|
||||||
const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2");
|
const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2");
|
||||||
const getResticHostname = () => {
|
const getResticHostname = () => {
|
||||||
|
|
@ -38,56 +39,20 @@ const envSchema = z
|
||||||
TRUSTED_ORIGINS: z.string().optional(),
|
TRUSTED_ORIGINS: z.string().optional(),
|
||||||
TRUST_PROXY: z.string().default("false"),
|
TRUST_PROXY: z.string().default("false"),
|
||||||
DISABLE_RATE_LIMITING: 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(),
|
BASE_URL: z.string(),
|
||||||
ENABLE_DEV_PANEL: z.string().default("false"),
|
ENABLE_DEV_PANEL: z.string().default("false"),
|
||||||
PROVISIONING_PATH: z.string().optional(),
|
PROVISIONING_PATH: z.string().optional(),
|
||||||
})
|
})
|
||||||
.transform((s) => {
|
.transform((s, ctx) => {
|
||||||
const baseUrl = unquote(s.BASE_URL);
|
const baseUrl = unquote(s.BASE_URL);
|
||||||
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl];
|
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl];
|
||||||
const authOrigins = [baseUrl, ...trustedOrigins];
|
const authOrigins = [baseUrl, ...trustedOrigins];
|
||||||
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
|
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
|
||||||
|
let appSecret = s.APP_SECRET;
|
||||||
|
|
||||||
for (const origin of invalidOrigins) {
|
if (!appSecret && !s.APP_SECRET_FILE) {
|
||||||
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) {
|
|
||||||
const errorMessage = [
|
const errorMessage = [
|
||||||
"",
|
"",
|
||||||
"================================================================================",
|
"================================================================================",
|
||||||
|
|
@ -103,16 +68,88 @@ export const parseConfig = (env: unknown) => {
|
||||||
"IMPORTANT: Store this secret securely and back it up. If lost, encrypted data",
|
"IMPORTANT: Store this secret securely and back it up. If lost, encrypted data",
|
||||||
"in the database will be unrecoverable.",
|
"in the database will be unrecoverable.",
|
||||||
"================================================================================",
|
"================================================================================",
|
||||||
"",
|
|
||||||
].join("\n");
|
].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)}`);
|
console.error(`Environment variable validation failed: ${prettifyError(result.error)}`);
|
||||||
process.exit(1);
|
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;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ cp secrets/aws_secret_access_key.example secrets/aws_secret_access_key
|
||||||
|
|
||||||
2. Edit `.env`:
|
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_AWS_ACCESS_KEY_ID`
|
||||||
- Set `ZEROBYTE_WEBDAV_PASSWORD`
|
- Set `ZEROBYTE_WEBDAV_PASSWORD`
|
||||||
- Adjust `BASE_URL` and `TZ` if needed
|
- Adjust `BASE_URL` and `TZ` if needed
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue