Read secret from docker secrets

This commit is contained in:
2025-11-01 11:09:59 +07:00
parent 04c48e67b8
commit bec4c0af97
9 changed files with 130 additions and 283 deletions

View File

@@ -1,4 +1,45 @@
{ {
"schemaVersion": 2, "schemaVersion": 2,
"dockerfilePath": "./src/Managing.Web3Proxy/Dockerfile-web3proxy" "dockerfilePath": "./src/Managing.Web3Proxy/Dockerfile-web3proxy",
} "preDeployFunction": function(captainAppObj, dockerUpdateObject) {
// Define the array of all three secrets to be mounted in the container
// File.Name must match the filename that code reads from /run/secrets/
dockerUpdateObject.TaskTemplate.ContainerSpec.Secrets = [
{
// Secret 1: PRIVY_APP_ID
File: {
Name: "PRIVY_APP_ID", // Filename in the container at /run/secrets/PRIVY_APP_ID
UID: '0',
GID: '0',
Mode: 292
},
SecretID: "4kk2gw2945x8hdatyhuhomtk2", // <<-- REPLACE THIS with the actual Secret ID from 'docker secret ls'
SecretName: "privy_app_id"
},
{
// Secret 2: PRIVY_APP_SECRET
File: {
Name: "PRIVY_APP_SECRET", // Filename in the container at /run/secrets/PRIVY_APP_SECRET
UID: '0',
GID: '0',
Mode: 292
},
SecretID: "y81woaa2388zidk35gul7y9n6", // <<-- REPLACE THIS with the actual Secret ID from 'docker secret ls'
SecretName: "privy_app_secret"
},
{
// Secret 3: PRIVY_AUTHORIZATION_KEY
File: {
Name: "PRIVY_AUTHORIZATION_KEY", // Filename in the container at /run/secrets/PRIVY_AUTHORIZATION_KEY
UID: '0',
GID: '0',
Mode: 292
},
SecretID: "spnc1vle5q560jsoy72ng0vtc", // <<-- REPLACE THIS with the actual Secret ID from 'docker secret ls'
SecretName: "privy_auth_key"
}
];
return Promise.resolve();
}
}

View File

@@ -1,38 +1,22 @@
# --- Stage 1: Build & Install Dependencies --- # Use an official Node.js image as the base
FROM node:18-alpine AS builder FROM node:22.14.0-alpine
WORKDIR /app # Set the working directory in the container
WORKDIR /app
# Copy package.json and lock file (if used) from source subdir
COPY /src/Managing.Web3Proxy/package*.json ./ # COPY package*.json ./
COPY /src/Managing.Web3Proxy/package.json ./
ENV NODE_ENV production
# Declaring env
# Install dependencies (development dependencies are fine here) ENV NODE_ENV production
RUN npm ci --no-audit --fund=false
# Install dependencies with the --legacy-peer-deps flag to bypass peer dependency conflicts
# Copy the rest of the source code RUN npm install
COPY src/Managing.Web3Proxy/ .
COPY src/Managing.Web3Proxy/ .
# Run the build script
RUN npm run build RUN npm run build
# --- Stage 2: Production Runtime --- EXPOSE 4111
FROM node:22.14.0-alpine AS production
CMD ["npm", "run", "start"]
WORKDIR /app
# Copy essential files from the builder stage
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules/ ./node_modules/
# Copy the compiled application code (assuming 'dist' is the output folder)
COPY --from=builder /app/dist/ ./dist/
# Set production environment again for the runtime stage
ENV NODE_ENV production
EXPOSE 4111
# Start the application from the compiled output
CMD ["fastify", "start", "-l", "info", "dist/app.js"]

View File

@@ -9,7 +9,6 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bitwarden/sdk-napi": "1.0.0",
"@fastify/autoload": "^6.0.0", "@fastify/autoload": "^6.0.0",
"@fastify/cookie": "^11.0.1", "@fastify/cookie": "^11.0.1",
"@fastify/cors": "^11.0.0", "@fastify/cors": "^11.0.0",
@@ -82,85 +81,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@bitwarden/sdk-napi": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-napi/-/sdk-napi-1.0.0.tgz",
"integrity": "sha512-KrOXiuuQdVBvIM4l7XGrKl8otA9tSEKHjRQr0nF71eyCmIDzOV2kt/gchgLK42exGGuD/JcmmgnXx/j+U6W/+g==",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@bitwarden/sdk-napi-darwin-arm64": "1.0.0",
"@bitwarden/sdk-napi-darwin-x64": "1.0.0",
"@bitwarden/sdk-napi-linux-x64-gnu": "1.0.0",
"@bitwarden/sdk-napi-win32-x64-msvc": "1.0.0"
}
},
"node_modules/@bitwarden/sdk-napi-darwin-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-napi-darwin-arm64/-/sdk-napi-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-0W6P2VByGFqYIf6bkI7qKWlXPGhG9KFibrN14pvllFIkbNRaz3hktMOLJ/JdWZVmdHrz0R5xmAVldbIWNMjFOA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@bitwarden/sdk-napi-darwin-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-napi-darwin-x64/-/sdk-napi-darwin-x64-1.0.0.tgz",
"integrity": "sha512-Esyg8sktE50U+g12lL/xe68hgc5y78jPPV9maNcKTO9EWdde4RIx9u3xEpbYk5fyHaeMFXdo3TK8A8qLD34X5w==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@bitwarden/sdk-napi-linux-x64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-napi-linux-x64-gnu/-/sdk-napi-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-PBGEOq9saYuBBthCq5jAr7eQSyEmQylB+kwsuT0j666VXT124a3YjDGgobPYaR9KN/IRDQ5h2Tysuy1pojx10A==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@bitwarden/sdk-napi-win32-x64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-napi-win32-x64-msvc/-/sdk-napi-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-cu92XYjsOWEVECyohGtLxiXcMJMVui+nDJtyNg52iGDeAzWQ7QZh0/bcjkymTCuGoqRD3sT0cCs+5PbW66K2XQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",

View File

@@ -29,7 +29,6 @@
"author": "Oda", "author": "Oda",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bitwarden/sdk-napi": "1.0.0",
"@fastify/autoload": "^6.0.0", "@fastify/autoload": "^6.0.0",
"@fastify/cookie": "^11.0.1", "@fastify/cookie": "^11.0.1",
"@fastify/cors": "^11.0.0", "@fastify/cors": "^11.0.0",

View File

@@ -1,5 +1,5 @@
import fp from 'fastify-plugin' import fp from 'fastify-plugin'
import {getSecret} from '../../utils/bitwarden.js' import fs from 'fs'
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -11,24 +11,53 @@ declare module 'fastify' {
} }
} }
// Docker secrets are mounted at /run/secrets/ by default in CapRover
const readSecretFile = (secretName: string): string | undefined => {
const secretPath = `/run/secrets/${secretName}`
if (fs.existsSync(secretPath)) {
try {
return fs.readFileSync(secretPath, 'utf8').trim()
} catch (error) {
console.error(`Failed to read secret file ${secretPath}:`, error)
return undefined
}
}
return undefined
}
export default fp(async function (fastify) { export default fp(async function (fastify) {
const isProd = process.env.NODE_ENV === 'production' const isProd = process.env.NODE_ENV === 'production'
// Resolve secrets let appId: string
const [authKeyBw, appIdBw, appSecretBw] = isProd let appSecret: string
? await Promise.all([ let authKey: string
getSecret('PRIVY_AUTHORIZATION_KEY'),
getSecret('PRIVY_APP_ID'),
getSecret('PRIVY_APP_SECRET')
])
: [undefined, undefined, undefined]
const appId = appIdBw || process.env.PRIVY_APP_ID || '' if (isProd) {
const appSecret = appSecretBw || process.env.PRIVY_APP_SECRET || '' // In production, read from Docker secrets (mounted files)
const authKey = authKeyBw || process.env.PRIVY_AUTHORIZATION_KEY || '' appId = readSecretFile('PRIVY_APP_ID') || process.env.PRIVY_APP_ID || ''
appSecret = readSecretFile('PRIVY_APP_SECRET') || process.env.PRIVY_APP_SECRET || ''
authKey = readSecretFile('PRIVY_AUTHORIZATION_KEY') || process.env.PRIVY_AUTHORIZATION_KEY || ''
} else {
// In non-production, use env vars or file paths
const readMaybeFile = (envKey: string, fileKey: string): string | undefined => {
const filePath = process.env[fileKey]
if (filePath && fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf8').trim()
return process.env[envKey]
}
appId = readMaybeFile('PRIVY_APP_ID', 'PRIVY_APP_ID_FILE') || ''
appSecret = readMaybeFile('PRIVY_APP_SECRET', 'PRIVY_APP_SECRET_FILE') || ''
authKey = readMaybeFile('PRIVY_AUTHORIZATION_KEY', 'PRIVY_AUTHORIZATION_KEY_FILE') || ''
}
if (!appId || !appSecret || !authKey) { if (!appId || !appSecret || !authKey) {
fastify.log.warn('Privy secrets not fully resolved at plugin load.') fastify.log.warn('Privy secrets not fully resolved at plugin load.')
fastify.log.warn({
appId: !!appId,
appSecret: !!appSecret,
authKey: !!authKey,
isProd
}, 'Privy secrets status')
} }
fastify.decorate('privySecrets', { fastify.decorate('privySecrets', {
@@ -39,5 +68,3 @@ export default fp(async function (fastify) {
fastify.log.info('Privy secrets decorated on Fastify instance') fastify.log.info('Privy secrets decorated on Fastify instance')
}, { name: 'privy-secrets' }) }, { name: 'privy-secrets' })

View File

@@ -7,7 +7,6 @@ import {PrivyClient} from '@privy-io/server-auth'
import {ethers} from 'ethers' import {ethers} from 'ethers'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import fs from 'fs' import fs from 'fs'
import {PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_AUTHORIZATION_KEY} from '../../utils/privy-secrets.js'
import Token from '../../generated/gmxsdk/abis/Token.json' with {type: 'json'} import Token from '../../generated/gmxsdk/abis/Token.json' with {type: 'json'}
import {ARBITRUM} from '../../generated/gmxsdk/configs/chains.js' import {ARBITRUM} from '../../generated/gmxsdk/configs/chains.js'
import {TOKENS} from '../../generated/gmxsdk/configs/tokens.js' import {TOKENS} from '../../generated/gmxsdk/configs/tokens.js'
@@ -96,7 +95,7 @@ export async function getAuthorizationSignature({url, body}: {url: string; body:
url, url,
body, body,
headers: { headers: {
'privy-app-id': process.env.NODE_ENV === 'production' ? (PRIVY_APP_ID ?? process.env.PRIVY_APP_ID) : process.env.PRIVY_APP_ID 'privy-app-id': process.env.PRIVY_APP_ID || ''
} }
}; };
@@ -105,11 +104,18 @@ export async function getAuthorizationSignature({url, body}: {url: string; body:
const serializedPayload = canonicalize(payload) as string; const serializedPayload = canonicalize(payload) as string;
const serializedPayloadBuffer = Buffer.from(serializedPayload); const serializedPayloadBuffer = Buffer.from(serializedPayload);
// Resolve authorization key: use constant in production, file/env in non-production // Resolve authorization key: Docker secrets in production, file/env in non-production
let resolvedKey: string | undefined let resolvedKey: string | undefined
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
resolvedKey = PRIVY_AUTHORIZATION_KEY // In production, read from Docker secrets mounted at /run/secrets/
const secretPath = '/run/secrets/PRIVY_AUTHORIZATION_KEY'
if (fs.existsSync(secretPath)) {
resolvedKey = fs.readFileSync(secretPath, 'utf8').trim()
} else {
resolvedKey = process.env.PRIVY_AUTHORIZATION_KEY
}
} else { } else {
// Non-production: allow file fallback
const filePath = process.env.PRIVY_AUTHORIZATION_KEY_FILE const filePath = process.env.PRIVY_AUTHORIZATION_KEY_FILE
const fromFile = filePath && fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8').trim() : undefined const fromFile = filePath && fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8').trim() : undefined
resolvedKey = fromFile || process.env.PRIVY_AUTHORIZATION_KEY || undefined resolvedKey = fromFile || process.env.PRIVY_AUTHORIZATION_KEY || undefined
@@ -146,13 +152,21 @@ export const makePrivyRequest = async <T>(
method: 'GET' | 'POST' = 'POST' method: 'GET' | 'POST' = 'POST'
): Promise<T> => { ): Promise<T> => {
try { try {
// Use constants in production, env in non-production // Resolve secrets: Docker secrets in production, env in non-production
const appId = process.env.NODE_ENV === 'production' let appId: string
? (PRIVY_APP_ID ?? process.env.PRIVY_APP_ID ?? "") let appSecret: string
: (process.env.PRIVY_APP_ID ?? "")
const appSecret = process.env.NODE_ENV === 'production' if (process.env.NODE_ENV === 'production') {
? (PRIVY_APP_SECRET ?? process.env.PRIVY_APP_SECRET ?? "") // In production, read from Docker secrets mounted at /run/secrets/
: (process.env.PRIVY_APP_SECRET ?? "") const appIdPath = '/run/secrets/PRIVY_APP_ID'
const appSecretPath = '/run/secrets/PRIVY_APP_SECRET'
appId = (fs.existsSync(appIdPath) ? fs.readFileSync(appIdPath, 'utf8').trim() : undefined) || process.env.PRIVY_APP_ID || ''
appSecret = (fs.existsSync(appSecretPath) ? fs.readFileSync(appSecretPath, 'utf8').trim() : undefined) || process.env.PRIVY_APP_SECRET || ''
} else {
// Non-production: use env vars
appId = process.env.PRIVY_APP_ID || ''
appSecret = process.env.PRIVY_APP_SECRET || ''
}
let headers: Record<string, string> = { let headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -6,7 +6,6 @@
*/ */
import Fastify from 'fastify' import Fastify from 'fastify'
import './bootstrap/privy-secrets.js'
import fp from 'fastify-plugin' import fp from 'fastify-plugin'
import {createClient, RedisClientType} from 'redis' import {createClient, RedisClientType} from 'redis'
@@ -15,8 +14,6 @@ import closeWithGrace from 'close-with-grace'
// Import your application as a normal plugin. // Import your application as a normal plugin.
import serviceApp from './app.js' import serviceApp from './app.js'
// Import Privy secrets initialization
import {initializePrivySecrets} from './utils/privy-secrets.js'
// Idempotency storage using Redis // Idempotency storage using Redis
interface IdempotencyEntry { interface IdempotencyEntry {
@@ -169,9 +166,6 @@ const app = Fastify({
}) })
async function init () { async function init () {
// Initialize Privy secrets from Bitwarden (production) or env (non-production)
await initializePrivySecrets()
// Initialize Redis connection // Initialize Redis connection
await initializeRedis() await initializeRedis()

View File

@@ -1,89 +0,0 @@
import {BitwardenClient, ClientSettings, DeviceType} from '@bitwarden/sdk-napi'
type SecretFetcher = (nameOrId: string) => Promise<string | undefined>
let clientPromise: Promise<BitwardenClient> | null = null
const secretCache = new Map<string, string>()
function getClient(): Promise<BitwardenClient> {
if (clientPromise) return clientPromise
clientPromise = (async () => {
const accessToken = process.env.BITWARDEN_ACCESS_TOKEN
if (!accessToken) throw new Error('BITWARDEN_ACCESS_TOKEN is not set')
const settings: ClientSettings = {
apiUrl: process.env.BITWARDEN_API_URL || 'https://vault.bitwarden.com/api',
identityUrl: process.env.BITWARDEN_IDENTITY_URL || 'https://vault.bitwarden.com/identity',
userAgent: 'Managing-Web3Proxy',
deviceType: DeviceType.SDK,
}
const client = new BitwardenClient(settings)
const stateFile = process.env.BITWARDEN_STATE_FILE || '/tmp/bw-sdk-state.json'
await client.auth().loginAccessToken(accessToken, stateFile)
return client
})()
return clientPromise
}
export const getSecret: SecretFetcher = async (nameOrId: string) => {
if (secretCache.has(nameOrId)) return secretCache.get(nameOrId)
const client = await getClient()
const organizationId = process.env.BITWARDEN_ORGANIZATION_ID
// Try fetch by ID first
try {
const byId = await client.secrets().get(nameOrId)
if (byId?.value) {
secretCache.set(nameOrId, byId.value)
return byId.value
}
} catch {}
// Optional: fetch by configured secret id env
const envKey = process.env[`BITWARDEN_SECRET_ID_${nameOrId}`]
if (envKey) {
try {
const res = await client.secrets().get(envKey)
if (res?.value) {
secretCache.set(nameOrId, res.value)
return res.value
}
} catch {}
}
// If we have organization_id, try listing secrets and finding by name/key
if (organizationId) {
try {
const secretsList = await client.secrets().list(organizationId)
if (secretsList?.data) {
// Find secret by key (name) matching nameOrId
const found = secretsList.data.find((s: any) => s.key === nameOrId || s.id === nameOrId)
if (found) {
// Fetch the full secret by ID to get the value
try {
const secret = await client.secrets().get(found.id)
if (secret?.value) {
secretCache.set(nameOrId, secret.value)
return secret.value
}
} catch {}
}
}
} catch (error) {
console.error(`Failed to list secrets for organization ${organizationId}:`, error)
}
}
return undefined
}
export function clearSecretCache() {
secretCache.clear()
}

View File

@@ -1,43 +0,0 @@
import {getSecret} from './bitwarden.js'
let PRIVY_APP_ID: string | undefined
let PRIVY_APP_SECRET: string | undefined
let PRIVY_AUTHORIZATION_KEY: string | undefined
let initialized = false
// Initialize secrets from Bitwarden in production
export async function initializePrivySecrets(): Promise<void> {
if (initialized) return
if (process.env.NODE_ENV === 'production') {
try {
const [authKey, appId, appSecret] = await Promise.all([
getSecret('PRIVY_AUTHORIZATION_KEY'),
getSecret('PRIVY_APP_ID'),
getSecret('PRIVY_APP_SECRET')
])
console.log('appId', appId);
PRIVY_AUTHORIZATION_KEY = authKey || process.env.PRIVY_AUTHORIZATION_KEY
PRIVY_APP_ID = appId || process.env.PRIVY_APP_ID
PRIVY_APP_SECRET = appSecret || process.env.PRIVY_APP_SECRET
} catch (error) {
console.error('Failed to load Privy secrets from Bitwarden:', error)
// Fallback to env vars
PRIVY_AUTHORIZATION_KEY = process.env.PRIVY_AUTHORIZATION_KEY
PRIVY_APP_ID = process.env.PRIVY_APP_ID
PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET
}
} else {
// In non-production, use env vars directly
PRIVY_AUTHORIZATION_KEY = process.env.PRIVY_AUTHORIZATION_KEY
PRIVY_APP_ID = process.env.PRIVY_APP_ID
PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET
}
initialized = true
}
export { PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_AUTHORIZATION_KEY }