mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-25 14:32:17 +00:00
feat: integrate vault provider management and environment variable resolution
- Added support for managing various vault providers (HashiCorp, AWS, Azure, Doppler, Infisical) in the dashboard. - Implemented environment variable resolution using vault references, allowing seamless integration of secrets into application environments. - Enhanced UI components to display and manage vault providers effectively. - Introduced tests for vault reference resolution and environment variable preparation. - Updated related components to utilize new vault management features.
This commit is contained in:
@@ -92,4 +92,5 @@ export type AuditResourceType =
|
||||
| "mount"
|
||||
| "application"
|
||||
| "compose"
|
||||
| "network";
|
||||
| "network"
|
||||
| "vaultProvider";
|
||||
|
||||
@@ -42,5 +42,6 @@ export * from "./sso";
|
||||
export * from "./tag";
|
||||
export * from "./user";
|
||||
export * from "./utils";
|
||||
export * from "./vault-provider";
|
||||
export * from "./volume-backups";
|
||||
export * from "./web-server-settings";
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const vaultProviderType = pgEnum("VaultProviderType", [
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"doppler",
|
||||
"azure",
|
||||
]);
|
||||
|
||||
export const hashicorpVaultConfigSchema = z.object({
|
||||
providerType: z.literal("hashicorp"),
|
||||
url: z.string().url(),
|
||||
token: z.string().min(1),
|
||||
namespace: z.string().optional(),
|
||||
mount: z.string().min(1).default("secret"),
|
||||
});
|
||||
|
||||
export const infisicalVaultConfigSchema = z.object({
|
||||
providerType: z.literal("infisical"),
|
||||
siteUrl: z.string().url().default("https://app.infisical.com"),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
projectId: z.string().min(1),
|
||||
environmentSlug: z.string().min(1),
|
||||
secretPath: z.string().default("/"),
|
||||
});
|
||||
|
||||
export const awsVaultConfigSchema = z.object({
|
||||
providerType: z.literal("aws"),
|
||||
region: z.string().min(1),
|
||||
accessKeyId: z.string().min(1),
|
||||
secretAccessKey: z.string().min(1),
|
||||
endpoint: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const dopplerVaultConfigSchema = z.object({
|
||||
providerType: z.literal("doppler"),
|
||||
serviceToken: z.string().min(1),
|
||||
project: z.string().optional(),
|
||||
config: z.string().optional(),
|
||||
});
|
||||
|
||||
export const azureVaultConfigSchema = z.object({
|
||||
providerType: z.literal("azure"),
|
||||
vaultUri: z.string().url(),
|
||||
tenantId: z.string().min(1),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
});
|
||||
|
||||
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
hashicorpVaultConfigSchema,
|
||||
infisicalVaultConfigSchema,
|
||||
awsVaultConfigSchema,
|
||||
dopplerVaultConfigSchema,
|
||||
azureVaultConfigSchema,
|
||||
]);
|
||||
|
||||
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
|
||||
|
||||
export const vaultProvider = pgTable(
|
||||
"vault_provider",
|
||||
{
|
||||
vaultProviderId: text("vaultProviderId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
providerType: vaultProviderType("providerType").notNull(),
|
||||
config: jsonb("config").$type<VaultProviderConfig>().notNull(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("vault_provider_org_name_idx").on(
|
||||
table.organizationId,
|
||||
table.name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const vaultProviderNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Name can only contain letters, numbers, dashes and underscores",
|
||||
);
|
||||
|
||||
const createSchema = createInsertSchema(vaultProvider);
|
||||
|
||||
export const apiCreateVaultProvider = createSchema.pick({}).extend({
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
});
|
||||
|
||||
export const apiUpdateVaultProvider = createSchema.pick({}).extend({
|
||||
vaultProviderId: z.string().min(1),
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
});
|
||||
|
||||
export const apiFindOneVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTestVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1).optional(),
|
||||
config: vaultProviderConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export const apiListVaultSecretNames = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
@@ -51,6 +51,7 @@ export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
export * from "./services/user";
|
||||
export * from "./services/vault-provider";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
@@ -102,6 +103,7 @@ export * from "./utils/docker/compose/volume";
|
||||
export * from "./utils/docker/domain";
|
||||
export * from "./utils/docker/types";
|
||||
export * from "./utils/docker/utils";
|
||||
export * from "./utils/vault";
|
||||
export * from "./utils/filesystem/directory";
|
||||
export * from "./utils/filesystem/ssh";
|
||||
export * from "./utils/git-branch-validation";
|
||||
|
||||
@@ -48,6 +48,7 @@ export const statements = {
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -74,6 +75,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@@ -113,6 +115,7 @@ export const ownerRole = ac.newRole({
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -150,6 +153,7 @@ export const adminRole = ac.newRole({
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -192,4 +196,6 @@ export const memberRole = ac.newRole({
|
||||
notification: [],
|
||||
tag: ["read"],
|
||||
auditLog: [],
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../utils/docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../utils/vault";
|
||||
import { type Application, findApplicationById } from "./application";
|
||||
import { findDeploymentById } from "./deployment";
|
||||
import type { Environment } from "./environment";
|
||||
@@ -215,7 +216,9 @@ const rollbackApplication = async (
|
||||
throw new Error("Full context is required for rollback");
|
||||
}
|
||||
|
||||
const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
|
||||
const resolvedContext = await withResolvedVaultRefs(fullContext);
|
||||
|
||||
const rollbackRegistry = resolvedContext.rollbackRegistry ?? undefined;
|
||||
|
||||
// Ensure Docker daemon is authenticated with the rollback registry
|
||||
// before updating the swarm service. The authconfig in CreateServiceOptions
|
||||
@@ -236,7 +239,7 @@ const rollbackApplication = async (
|
||||
cpuReservation,
|
||||
command,
|
||||
ports,
|
||||
} = fullContext;
|
||||
} = resolvedContext;
|
||||
|
||||
const resources = calculateResources({
|
||||
memoryLimit,
|
||||
@@ -248,7 +251,7 @@ const rollbackApplication = async (
|
||||
const volumesMount = generateVolumeMounts(mounts);
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(
|
||||
fullContext as Parameters<typeof resolveServiceNetworks>[0],
|
||||
resolvedContext as Parameters<typeof resolveServiceNetworks>[0],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -261,14 +264,14 @@ const rollbackApplication = async (
|
||||
UpdateConfig,
|
||||
Ulimits,
|
||||
} = generateConfigContainer(
|
||||
fullContext as Parameters<typeof generateConfigContainer>[0],
|
||||
resolvedContext as Parameters<typeof generateConfigContainer>[0],
|
||||
);
|
||||
|
||||
const bindsMount = generateBindMounts(mounts);
|
||||
const envVariables = prepareEnvironmentVariables(
|
||||
env,
|
||||
fullContext.environment.project.env,
|
||||
fullContext.environment.env,
|
||||
resolvedContext.environment.project.env,
|
||||
resolvedContext.environment.env,
|
||||
);
|
||||
|
||||
let rollbackImage = image;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateVaultProvider,
|
||||
type VaultProviderConfig,
|
||||
vaultProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { getVaultClient } from "@dokploy/server/utils/vault";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type VaultProvider = typeof vaultProvider.$inferSelect;
|
||||
|
||||
export const VAULT_SECRET_MASK = "********";
|
||||
|
||||
const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
|
||||
{
|
||||
hashicorp: ["token"],
|
||||
infisical: ["clientSecret"],
|
||||
aws: ["secretAccessKey"],
|
||||
doppler: ["serviceToken"],
|
||||
azure: ["clientSecret"],
|
||||
};
|
||||
|
||||
export const maskVaultProviderConfig = (
|
||||
config: VaultProviderConfig,
|
||||
): VaultProviderConfig => {
|
||||
const masked: Record<string, unknown> = { ...config };
|
||||
for (const field of SENSITIVE_FIELDS[config.providerType]) {
|
||||
if (masked[field]) {
|
||||
masked[field] = VAULT_SECRET_MASK;
|
||||
}
|
||||
}
|
||||
return masked as VaultProviderConfig;
|
||||
};
|
||||
|
||||
export const mergeVaultProviderConfig = (
|
||||
incoming: VaultProviderConfig,
|
||||
existing: VaultProviderConfig,
|
||||
): VaultProviderConfig => {
|
||||
const merged: Record<string, unknown> = { ...incoming };
|
||||
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
|
||||
if (merged[field] === VAULT_SECRET_MASK) {
|
||||
if (incoming.providerType !== existing.providerType) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Credentials must be re-entered when changing the provider type",
|
||||
});
|
||||
}
|
||||
merged[field] = (existing as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
return merged as VaultProviderConfig;
|
||||
};
|
||||
|
||||
const isUniqueNameViolation = (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message.includes("vault_provider_org_name_idx");
|
||||
|
||||
export const createVaultProvider = async (
|
||||
input: z.infer<typeof apiCreateVaultProvider>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
try {
|
||||
const newProvider = await db
|
||||
.insert(vaultProvider)
|
||||
.values({
|
||||
name: input.name,
|
||||
providerType: input.config.providerType,
|
||||
config: input.config,
|
||||
organizationId,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newProvider) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the vault provider",
|
||||
});
|
||||
}
|
||||
return newProvider;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A vault provider named "${input.name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const findVaultProviderById = async (vaultProviderId: string) => {
|
||||
const provider = await db.query.vaultProvider.findFirst({
|
||||
where: eq(vaultProvider.vaultProviderId, vaultProviderId),
|
||||
});
|
||||
if (!provider) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Vault provider not found",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findVaultProvidersByOrganizationId = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
return await db.query.vaultProvider.findMany({
|
||||
where: eq(vaultProvider.organizationId, organizationId),
|
||||
orderBy: (providers, { asc }) => [asc(providers.name)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateVaultProvider = async (
|
||||
vaultProviderId: string,
|
||||
name: string,
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const existing = await findVaultProviderById(vaultProviderId);
|
||||
const mergedConfig = mergeVaultProviderConfig(config, existing.config);
|
||||
|
||||
try {
|
||||
const updated = await db
|
||||
.update(vaultProvider)
|
||||
.set({
|
||||
name,
|
||||
providerType: mergedConfig.providerType,
|
||||
config: mergedConfig,
|
||||
})
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error updating the vault provider",
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A vault provider named "${name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeVaultProvider = async (vaultProviderId: string) => {
|
||||
const removed = await db
|
||||
.delete(vaultProvider)
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!removed) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Vault provider not found",
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
export const testVaultProviderConnection = async (
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const client = getVaultClient(config.providerType);
|
||||
try {
|
||||
await client.testConnection(config);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error connecting to the vault provider",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listVaultProviderSecretNames = async (
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const client = getVaultClient(config.providerType);
|
||||
if (!client.listSecretNames) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await client.listSecretNames(config);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
getEnvironmentVariablesObject,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
} from "../docker/utils";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type ComposeNested = InferResultType<
|
||||
"compose",
|
||||
{ environment: { with: { project: true } }; mounts: true; domains: true }
|
||||
>;
|
||||
|
||||
export const getBuildComposeCommand = async (compose: ComposeNested) => {
|
||||
export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
|
||||
const compose = await withResolvedVaultRefs(rawCompose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const { sourceType, appName, mounts, composeType, domains } = compose;
|
||||
const command = createCommand(compose);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
import { getDockerCommand } from "./docker-file";
|
||||
import { getHerokuCommand } from "./heroku";
|
||||
import { getNixpacksCommand } from "./nixpacks";
|
||||
@@ -38,7 +39,8 @@ export type ApplicationNested = InferResultType<
|
||||
}
|
||||
>;
|
||||
|
||||
export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
export const getBuildCommand = async (rawApplication: ApplicationNested) => {
|
||||
const application = await withResolvedVaultRefs(rawApplication);
|
||||
let command = "";
|
||||
|
||||
if (application.sourceType !== "docker") {
|
||||
@@ -77,8 +79,9 @@ export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
};
|
||||
|
||||
export const mechanizeDockerContainer = async (
|
||||
application: ApplicationNested,
|
||||
rawApplication: ApplicationNested,
|
||||
) => {
|
||||
const application = await withResolvedVaultRefs(rawApplication);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type LibsqlNested = InferResultType<
|
||||
"libsql",
|
||||
@@ -18,7 +19,8 @@ export type LibsqlNested = InferResultType<
|
||||
environment: { with: { project: true } };
|
||||
}
|
||||
>;
|
||||
export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
export const buildLibsql = async (rawLibsql: LibsqlNested) => {
|
||||
const libsql = await withResolvedVaultRefs(rawLibsql);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MariadbNested = InferResultType<
|
||||
"mariadb",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
export const buildMariadb = async (rawMariadb: MariadbNested) => {
|
||||
const mariadb = await withResolvedVaultRefs(rawMariadb);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MongoNested = InferResultType<
|
||||
"mongo",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
|
||||
export const buildMongo = async (mongo: MongoNested) => {
|
||||
export const buildMongo = async (rawMongo: MongoNested) => {
|
||||
const mongo = await withResolvedVaultRefs(rawMongo);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MysqlNested = InferResultType<
|
||||
"mysql",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
|
||||
export const buildMysql = async (mysql: MysqlNested) => {
|
||||
export const buildMysql = async (rawMysql: MysqlNested) => {
|
||||
const mysql = await withResolvedVaultRefs(rawMysql);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type PostgresNested = InferResultType<
|
||||
"postgres",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
export const buildPostgres = async (rawPostgres: PostgresNested) => {
|
||||
const postgres = await withResolvedVaultRefs(rawPostgres);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type RedisNested = InferResultType<
|
||||
"redis",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildRedis = async (redis: RedisNested) => {
|
||||
export const buildRedis = async (rawRedis: RedisNested) => {
|
||||
const redis = await withResolvedVaultRefs(rawRedis);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@@ -401,6 +401,13 @@ export const prepareEnvironmentVariables = (
|
||||
projectEnv?: string | null,
|
||||
environmentEnv?: string | null,
|
||||
) => {
|
||||
for (const source of [serviceEnv, projectEnv, environmentEnv]) {
|
||||
if (source?.includes("${{vault.")) {
|
||||
throw new Error(
|
||||
"Unresolved vault reference: call withResolvedVaultRefs() on the entity before preparing environment variables",
|
||||
);
|
||||
}
|
||||
}
|
||||
const projectVars = parse(projectEnv ?? "");
|
||||
const environmentVars = parse(environmentEnv ?? "");
|
||||
const serviceVars = parse(serviceEnv ?? "");
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
GetSecretValueCommand,
|
||||
ListSecretsCommand,
|
||||
SecretsManagerClient,
|
||||
} from "@aws-sdk/client-secrets-manager";
|
||||
import type { awsVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import type { VaultClient } from "./types";
|
||||
|
||||
type AwsConfig = z.infer<typeof awsVaultConfigSchema>;
|
||||
|
||||
const parseRef = (ref: string) => {
|
||||
if (ref.startsWith("arn:")) {
|
||||
throw new Error(
|
||||
`Invalid AWS Secrets Manager reference "${ref}": use the secret name, not the ARN`,
|
||||
);
|
||||
}
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return { secretId: ref, field: null };
|
||||
}
|
||||
return {
|
||||
secretId: ref.slice(0, separatorIndex),
|
||||
field: ref.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const createClient = (config: AwsConfig) =>
|
||||
new SecretsManagerClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
...(config.endpoint && { endpoint: config.endpoint }),
|
||||
});
|
||||
|
||||
export const awsClient: VaultClient<AwsConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const client = createClient(config);
|
||||
const secretIds = [...new Set(refs.map((ref) => parseRef(ref).secretId))];
|
||||
|
||||
const secretStrings = new Map<string, string>();
|
||||
await Promise.all(
|
||||
secretIds.map(async (secretId) => {
|
||||
const response = await client.send(
|
||||
new GetSecretValueCommand({ SecretId: secretId }),
|
||||
);
|
||||
if (response.SecretString === undefined) {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: secret "${secretId}" has no string value (binary secrets are not supported)`,
|
||||
);
|
||||
}
|
||||
secretStrings.set(secretId, response.SecretString);
|
||||
}),
|
||||
);
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
const { secretId, field } = parseRef(ref);
|
||||
const secretString = secretStrings.get(secretId) as string;
|
||||
if (field === null) {
|
||||
result[ref] = secretString;
|
||||
continue;
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(secretString);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: secret "${secretId}" is not JSON, cannot extract field "${field}"`,
|
||||
);
|
||||
}
|
||||
const value = parsed[field];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: field "${field}" not found in secret "${secretId}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const client = createClient(config);
|
||||
await client.send(new ListSecretsCommand({ MaxResults: 1 }));
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const client = createClient(config);
|
||||
const names: string[] = [];
|
||||
let nextToken: string | undefined;
|
||||
do {
|
||||
const response = await client.send(
|
||||
new ListSecretsCommand({ MaxResults: 100, NextToken: nextToken }),
|
||||
);
|
||||
for (const secret of response.SecretList ?? []) {
|
||||
if (secret.Name) {
|
||||
names.push(secret.Name);
|
||||
}
|
||||
}
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken && names.length < 500);
|
||||
return names;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { azureVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type AzureConfig = z.infer<typeof azureVaultConfigSchema>;
|
||||
|
||||
const API_VERSION = "7.4";
|
||||
|
||||
const baseUrl = (config: AzureConfig) => config.vaultUri.replace(/\/+$/, "");
|
||||
|
||||
const getAccessToken = async (config: AzureConfig) => {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
scope: "https://vault.azure.net/.default",
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`https://login.microsoftonline.com/${encodeURIComponent(config.tenantId)}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { error_description?: string };
|
||||
detail = (body.error_description ?? "").split("\n")[0] ?? "";
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Azure Key Vault: authentication failed (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { access_token?: string };
|
||||
if (!data.access_token) {
|
||||
throw new Error("Azure Key Vault: no access token returned");
|
||||
}
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
const readSecret = async (
|
||||
config: AzureConfig,
|
||||
token: string,
|
||||
name: string,
|
||||
) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets/${encodeURIComponent(name)}?api-version=${API_VERSION}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Azure Key Vault: secret "${name}" not found`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Azure Key Vault: failed to read secret "${name}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { value?: string };
|
||||
if (data.value === undefined) {
|
||||
throw new Error(`Azure Key Vault: secret "${name}" has no value`);
|
||||
}
|
||||
return data.value;
|
||||
};
|
||||
|
||||
export const azureClient: VaultClient<AzureConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const token = await getAccessToken(config);
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
refs.map(async (ref) => {
|
||||
result[ref] = await readSecret(config, token, ref);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const token = await getAccessToken(config);
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=1`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Azure Key Vault: cannot list secrets (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const token = await getAccessToken(config);
|
||||
const names: string[] = [];
|
||||
let url: string | null =
|
||||
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=25`;
|
||||
|
||||
while (url && names.length < 200) {
|
||||
const response = await vaultFetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
break;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
value?: { id: string }[];
|
||||
nextLink?: string | null;
|
||||
};
|
||||
for (const item of data.value ?? []) {
|
||||
const name = item.id.split("/").pop();
|
||||
if (name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
url = data.nextLink ?? null;
|
||||
}
|
||||
return names.slice(0, 200);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { dopplerVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type DopplerConfig = z.infer<typeof dopplerVaultConfigSchema>;
|
||||
|
||||
const downloadUrl = (config: DopplerConfig) => {
|
||||
const params = new URLSearchParams({ format: "json" });
|
||||
if (config.project) {
|
||||
params.set("project", config.project);
|
||||
}
|
||||
if (config.config) {
|
||||
params.set("config", config.config);
|
||||
}
|
||||
return `https://api.doppler.com/v3/configs/config/secrets/download?${params.toString()}`;
|
||||
};
|
||||
|
||||
const downloadSecrets = async (config: DopplerConfig) => {
|
||||
const response = await vaultFetch(downloadUrl(config), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.serviceToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { messages?: string[] };
|
||||
detail = (body.messages ?? []).join(", ");
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Doppler: failed to fetch secrets (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as Record<string, string>;
|
||||
};
|
||||
|
||||
export const dopplerClient: VaultClient<DopplerConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await downloadSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(`Doppler: secret "${ref}" not found in this config`);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await downloadSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await downloadSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { hashicorpVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type HashicorpConfig = z.infer<typeof hashicorpVaultConfigSchema>;
|
||||
|
||||
const parseRef = (ref: string) => {
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex <= 0 || separatorIndex === ref.length - 1) {
|
||||
throw new Error(
|
||||
`Invalid HashiCorp Vault reference "${ref}": expected format <path>:<field> (e.g. myapp/prod:DB_PASSWORD)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: ref.slice(0, separatorIndex),
|
||||
field: ref.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const buildHeaders = (config: HashicorpConfig) => {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Vault-Token": config.token,
|
||||
};
|
||||
if (config.namespace) {
|
||||
headers["X-Vault-Namespace"] = config.namespace;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const baseUrl = (config: HashicorpConfig) => config.url.replace(/\/+$/, "");
|
||||
|
||||
const encodePath = (path: string) =>
|
||||
path.split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
const readSecret = async (config: HashicorpConfig, path: string) => {
|
||||
const url = `${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/data/${encodePath(path)}`;
|
||||
const response = await vaultFetch(url, { headers: buildHeaders(config) });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: failed to read secret at "${path}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data?: { data?: Record<string, unknown> };
|
||||
};
|
||||
return body.data?.data ?? {};
|
||||
};
|
||||
|
||||
export const hashicorpClient: VaultClient<HashicorpConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const byPath = new Map<string, string[]>();
|
||||
for (const ref of refs) {
|
||||
const { path } = parseRef(ref);
|
||||
byPath.set(path, [...(byPath.get(path) ?? []), ref]);
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
[...byPath.entries()].map(async ([path, pathRefs]) => {
|
||||
const data = await readSecret(config, path);
|
||||
for (const ref of pathRefs) {
|
||||
const { field } = parseRef(ref);
|
||||
const value = data[field];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: field "${field}" not found in secret "${path}"`,
|
||||
);
|
||||
}
|
||||
result[ref] =
|
||||
typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/v1/auth/token/lookup-self`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: token validation failed (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const MAX_DEPTH = 4;
|
||||
const MAX_ENTRIES = 200;
|
||||
const names: string[] = [];
|
||||
|
||||
const listKeys = async (path: string) => {
|
||||
const cleanPath = path.replace(/\/+$/, "");
|
||||
const suffix = cleanPath ? `/${encodePath(cleanPath)}` : "";
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/metadata${suffix}?list=true`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const body = (await response.json()) as { data?: { keys?: string[] } };
|
||||
return body.data?.keys ?? [];
|
||||
};
|
||||
|
||||
const walk = async (path: string, depth: number) => {
|
||||
if (depth > MAX_DEPTH || names.length >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
for (const key of await listKeys(path)) {
|
||||
if (names.length >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
const fullPath = `${path}${key}`;
|
||||
if (key.endsWith("/")) {
|
||||
await walk(fullPath, depth + 1);
|
||||
continue;
|
||||
}
|
||||
const fields = await readSecret(config, fullPath).catch(
|
||||
() => ({}) as Record<string, unknown>,
|
||||
);
|
||||
const fieldNames = Object.keys(fields);
|
||||
if (fieldNames.length === 0) {
|
||||
names.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
for (const field of fieldNames) {
|
||||
names.push(`${fullPath}:${field}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk("", 0);
|
||||
return names.slice(0, MAX_ENTRIES);
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
import type { infisicalVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type InfisicalConfig = z.infer<typeof infisicalVaultConfigSchema>;
|
||||
|
||||
const baseUrl = (config: InfisicalConfig) => config.siteUrl.replace(/\/+$/, "");
|
||||
|
||||
const login = async (config: InfisicalConfig) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v1/auth/universal-auth/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: authentication failed (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { accessToken?: string };
|
||||
if (!body.accessToken) {
|
||||
throw new Error("Infisical: no access token returned");
|
||||
}
|
||||
return body.accessToken;
|
||||
};
|
||||
|
||||
const fetchSecrets = async (config: InfisicalConfig) => {
|
||||
const accessToken = await login(config);
|
||||
const params = new URLSearchParams({
|
||||
workspaceId: config.projectId,
|
||||
environment: config.environmentSlug,
|
||||
secretPath: config.secretPath,
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: failed to fetch secrets (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
secrets?: { secretKey: string; secretValue: string }[];
|
||||
};
|
||||
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const secret of body.secrets ?? []) {
|
||||
secrets[secret.secretKey] = secret.secretValue;
|
||||
}
|
||||
return secrets;
|
||||
};
|
||||
|
||||
export const infisicalClient: VaultClient<InfisicalConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(
|
||||
`Infisical: secret "${ref}" not found in environment "${config.environmentSlug}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await fetchSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { VaultProviderConfig } from "@dokploy/server/db/schema";
|
||||
|
||||
export interface VaultClient<
|
||||
C extends VaultProviderConfig = VaultProviderConfig,
|
||||
> {
|
||||
getSecrets(config: C, refs: string[]): Promise<Record<string, string>>;
|
||||
testConnection(config: C): Promise<void>;
|
||||
listSecretNames?(config: C): Promise<string[]>;
|
||||
}
|
||||
|
||||
export const VAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const vaultFetch = async (url: string, init: RequestInit = {}) => {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(VAULT_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user