First implementation of vault-plugin
Signed-off-by: ivgo <ivgo@spreadgroup.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './service/router';
|
||||
export * from './service/VaultBuilder';
|
||||
export * from './service/vaultApi';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Config } from '@backstage/config';
|
||||
import { Logger } from 'winston';
|
||||
import express, { Router } from 'express';
|
||||
import { Duration } from 'luxon';
|
||||
import { VaultClient } from './vaultApi';
|
||||
import { runPeriodically } from './runPeriodically';
|
||||
|
||||
export interface VaultEnvironment {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export type VaultBuilderReturn = Promise<{
|
||||
router: express.Router;
|
||||
}>;
|
||||
|
||||
export class VaultBuilder {
|
||||
private vaultTokenRefreshInterval: Duration = Duration.fromObject({
|
||||
minutes: 60,
|
||||
});
|
||||
private vaultClient?: VaultClient;
|
||||
|
||||
static createBuilder(env: VaultEnvironment) {
|
||||
return new VaultBuilder(env);
|
||||
}
|
||||
constructor(protected readonly env: VaultEnvironment) {}
|
||||
|
||||
public async build(): VaultBuilderReturn {
|
||||
const { logger, config } = this.env;
|
||||
|
||||
logger.info('Initializing Vault backend');
|
||||
|
||||
if (!config.has('vault')) {
|
||||
logger.warn(
|
||||
'Failed to initialize Vault backend: vault config is missing',
|
||||
);
|
||||
return {
|
||||
router: Router(),
|
||||
};
|
||||
}
|
||||
|
||||
this.vaultClient = this.vaultClient ?? new VaultClient(this.env);
|
||||
|
||||
const router = this.buildRouter(this.vaultClient);
|
||||
|
||||
await this.renewToken(this.vaultClient);
|
||||
|
||||
return {
|
||||
router: router,
|
||||
};
|
||||
}
|
||||
|
||||
public setVaultClient(vaultClient: VaultClient) {
|
||||
this.vaultClient = vaultClient;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setVaultTokenRefreshInterval(refreshInterval: Duration) {
|
||||
this.vaultTokenRefreshInterval = refreshInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
public enableTokenRenew() {
|
||||
runPeriodically(async () => {
|
||||
this.env.logger.info('Renewing Vault token');
|
||||
const vaultClient = this.vaultClient ?? new VaultClient(this.env);
|
||||
await this.renewToken(vaultClient);
|
||||
}, this.vaultTokenRefreshInterval.toMillis());
|
||||
return this;
|
||||
}
|
||||
|
||||
protected async renewToken(vaultClient: VaultClient) {
|
||||
const result = await vaultClient.renewToken();
|
||||
if (!result) {
|
||||
this.env.logger.warn('Error renewing vault token');
|
||||
} else {
|
||||
this.env.logger.info('Vault token renewed');
|
||||
}
|
||||
}
|
||||
|
||||
protected buildRouter(vaultClient: VaultClient): express.Router {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get('/v1/secrets', async (req, res) => {
|
||||
const path = req.query.path;
|
||||
if (typeof path !== 'string') {
|
||||
res
|
||||
.status(400)
|
||||
.send('Something was unexpected about the path query string');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const secrets = await vaultClient.listSecrets(path);
|
||||
res.json(secrets);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import { Logger } from 'winston';
|
||||
import { VaultBuilder } from './VaultBuilder';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { router } = await VaultBuilder.createBuilder(options).build();
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs a function repeatedly, with a fixed wait between invocations.
|
||||
*
|
||||
* Supports async functions, and silently ignores exceptions and rejections.
|
||||
*
|
||||
* @param fn - The function to run. May return a Promise.
|
||||
* @param delayMs - The delay between a completed function invocation and the
|
||||
* next.
|
||||
* @returns A function that, when called, stops the invocation loop.
|
||||
*/
|
||||
export function runPeriodically(fn: () => any, delayMs: number): () => void {
|
||||
let cancel: () => void;
|
||||
let cancelled = false;
|
||||
const cancellationPromise = new Promise<void>(resolve => {
|
||||
cancel = () => {
|
||||
resolve();
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const startRefresh = async () => {
|
||||
while (!cancelled) {
|
||||
try {
|
||||
await fn();
|
||||
} catch {
|
||||
// ignore intentionally
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
new Promise(resolve => setTimeout(resolve, delayMs)),
|
||||
cancellationPromise,
|
||||
]);
|
||||
}
|
||||
};
|
||||
startRefresh();
|
||||
|
||||
return cancel!;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
requestLoggingHandler,
|
||||
} from '@backstage/backend-common';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
export interface ApplicationOptions {
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function createStandaloneApplication(
|
||||
options: ApplicationOptions,
|
||||
): Promise<express.Application> {
|
||||
const { enableCors, logger } = options;
|
||||
const config = new ConfigReader({});
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
if (enableCors) {
|
||||
app.use(cors());
|
||||
}
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
app.use('/', await createRouter({ logger, config }));
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createStandaloneApplication } from './standaloneApplication';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'vault-backend' });
|
||||
|
||||
logger.debug('Creating application...');
|
||||
const app = await createStandaloneApplication({
|
||||
enableCors: options.enableCors,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.debug('Starting application server...');
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(options.port, (err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Listening on port ${options.port}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import fetch from 'cross-fetch';
|
||||
|
||||
type VaultSecretList = {
|
||||
data: {
|
||||
keys: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type Secret = {
|
||||
name: string;
|
||||
showUrl: string;
|
||||
editUrl: string;
|
||||
};
|
||||
|
||||
type RenewTokenResponse = {
|
||||
auth: {
|
||||
client_token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface VaultApi {
|
||||
getFrontendSecretsUrl(): string;
|
||||
listSecrets(secretPath: string): Promise<Secret[]>;
|
||||
renewToken?(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class VaultClient implements VaultApi {
|
||||
private readonly vaultUrl: string;
|
||||
private vaultToken: string;
|
||||
private readonly kvVersion: number;
|
||||
private readonly secretEngineName: string;
|
||||
|
||||
constructor({ config }: { config: Config }) {
|
||||
this.vaultUrl = config.getString('vault.sourceUrl');
|
||||
this.vaultToken = config.getString('vault.token');
|
||||
this.kvVersion = config.getOptionalNumber('vault.kvVersion') ?? 2;
|
||||
this.secretEngineName =
|
||||
config.getOptionalString('vault.secretEngine') ?? 'secrets';
|
||||
}
|
||||
|
||||
private async callApi<T>(
|
||||
path: string,
|
||||
query: { [key in string]: any },
|
||||
method: string = 'GET',
|
||||
): Promise<T | undefined> {
|
||||
const response = await fetch(
|
||||
`${this.vaultUrl}/${path}?${new URLSearchParams(query).toString()}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Vault-Token': this.vaultToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.status === 200) {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isFolder(secretName: string): boolean {
|
||||
const regex = /^.*\/$/gm;
|
||||
return regex.test(secretName);
|
||||
}
|
||||
|
||||
getFrontendSecretsUrl(): string {
|
||||
return `${this.vaultUrl}/ui/vault/secrets/${this.secretEngineName}`;
|
||||
}
|
||||
|
||||
async listSecrets(secretPath: string): Promise<Secret[]> {
|
||||
const listUrl =
|
||||
this.kvVersion === 2
|
||||
? `v1/${this.secretEngineName}/metadata/${secretPath}`
|
||||
: `v1/${this.secretEngineName}/${secretPath}`;
|
||||
const result = await this.callApi<VaultSecretList>(listUrl, { list: true });
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const secrets: Secret[] = [];
|
||||
await Promise.all(
|
||||
result.data.keys.map(async secret => {
|
||||
if (this.isFolder(secret)) {
|
||||
secrets.push(
|
||||
...(await this.listSecrets(`${secretPath}/${secret.slice(0, -1)}`)),
|
||||
);
|
||||
} else {
|
||||
secrets.push({
|
||||
name: secret,
|
||||
editUrl: `${this.vaultUrl}/ui/vault/secrets/${this.secretEngineName}/edit/${secretPath}/${secret}`,
|
||||
showUrl: `${this.vaultUrl}/ui/vault/secrets/${this.secretEngineName}/show/${secretPath}/${secret}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return secrets;
|
||||
}
|
||||
|
||||
async renewToken(): Promise<boolean> {
|
||||
const result = await this.callApi<RenewTokenResponse>(
|
||||
'v1/auth/token/renew-self',
|
||||
{},
|
||||
'POST',
|
||||
);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.vaultToken = result.auth.client_token;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user