diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md index 3de9e990feb..30ccf4aa54b 100644 --- a/packages/grafana-toolkit/README.md +++ b/packages/grafana-toolkit/README.md @@ -61,6 +61,7 @@ With grafana-toolkit, we give you a CLI that addresses common tasks performed wh - `grafana-toolkit plugin:dev` - `grafana-toolkit plugin:test` - `grafana-toolkit plugin:build` +- `grafana-toolkit plugin:sign` ### Create your plugin @@ -105,6 +106,19 @@ Available options: - `--coverage` - Reports code coverage after the test step of the build. +### Sign your plugin + +`grafana-toolkit plugin:sign` + +This command creates a signed MANIFEST.txt file which Grafana uses to validate the integrity of the plugin. + +Available options: + +- `--signatureType` - The [type of Signature](https://grafana.com/legal/plugins/) you are generating: `private`, `community` or `commercial` +- `--rootUrls` - For private signatures, a list of the Grafana instance URLs that the plugin will be used on + +To generate a signature, you will need to sign up for a free account on https://grafana.com, create an API key with the Plugin Publisher role, and pass that in the `GRAFANA_API_KEY` environment variable. + ## FAQ ### Which version of grafana-toolkit should I use? diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index 6ce928a80e8..b87f06191e5 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -18,6 +18,7 @@ import { pluginUpdateTask } from './tasks/plugin.update'; import { ciBuildPluginDocsTask, ciBuildPluginTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci'; import { buildPackageTask } from './tasks/package.build'; import { pluginCreateTask } from './tasks/plugin.create'; +import { pluginSignTask } from './tasks/plugin.sign'; import { bundleManagedTask } from './tasks/plugin/bundle.managed'; import { componentCreateTask } from './tasks/component.create'; @@ -179,6 +180,19 @@ export const run = (includeInternalScripts = false) => { }); }); + program + .command('plugin:sign') + .option('--signatureType ', 'Signature Type') + .option('--rootUrls ', 'Root URLs') + .description('Create a plugin signature') + .action(async cmd => { + await execTask(pluginSignTask)({ + signatureType: cmd.signatureType, + rootUrls: cmd.rootUrls, + silent: true, + }); + }); + program .command('plugin:ci-build') .option('--finish', 'move all results to the jobs folder', false) @@ -200,11 +214,14 @@ export const run = (includeInternalScripts = false) => { program .command('plugin:ci-package') - .option('--signing-admin', 'Use the admin API endpoint for signing the manifest.', false) + .option('--signatureType ', 'Signature Type') + .option('--rootUrls ', 'Root URLs') + .option('--signing-admin', 'Use the admin API endpoint for signing the manifest. (deprecated)', false) .description('Create a zip packages for the plugin') .action(async cmd => { await execTask(ciPackagePluginTask)({ - signingAdmin: cmd.signingAdmin, + signatureType: cmd.signatureType, + rootUrls: cmd.rootUrls, }); }); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts index 17a4eedeacb..c774349d669 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts @@ -8,6 +8,7 @@ import execa = require('execa'); import path = require('path'); import fs from 'fs-extra'; import { getPackageDetails, getGrafanaVersions, readGitLog } from '../../plugins/utils'; +import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest'; import { getJobFolder, writeJobStats, @@ -25,7 +26,8 @@ const rimraf = promisify(rimrafCallback); export interface PluginCIOptions { finish?: boolean; upload?: boolean; - signingAdmin?: boolean; + signatureType?: string; + rootUrls?: string[]; maxJestWorkers?: string; } @@ -107,7 +109,7 @@ export const ciBuildPluginDocsTask = new Task('Build Plugin Doc * 2. zip it into packages in `~/ci/packages` * 3. prepare grafana environment in: `~/ci/grafana-test-env` */ -const packagePluginRunner: TaskRunner = async ({ signingAdmin }) => { +const packagePluginRunner: TaskRunner = async ({ signatureType, rootUrls }) => { const start = Date.now(); const ciDir = getCiFolder(); const packagesDir = path.resolve(ciDir, 'packages'); @@ -163,11 +165,16 @@ const packagePluginRunner: TaskRunner = async ({ signingAdmin } }); // Write a MANIFEST.txt file in the dist folder - // By using the --signing-admin flag the plugin doesn't need to be in the plugins database to be signed, - // however it requires an Admin API key. try { - const grabplCommandFlags = signingAdmin ? ['build-plugin-manifest', '--signing-admin'] : ['build-plugin-manifest']; - await execa('grabpl', [...grabplCommandFlags, distContentDir]); + const manifest = await buildManifest(distContentDir); + if (signatureType) { + manifest.signatureType = signatureType; + } + if (rootUrls) { + manifest.rootUrls = rootUrls; + } + const signedManifest = await signManifest(manifest); + await saveManifest(distContentDir, signedManifest); } catch (err) { console.warn(`Error signing manifest: ${distContentDir}`, err); } diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts new file mode 100644 index 00000000000..1a9d2a5b925 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts @@ -0,0 +1,38 @@ +import path from 'path'; +import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest'; +import { Task, TaskRunner } from './task'; + +interface PluginSignOptions { + signatureType?: string; + rootUrls?: string[]; +} + +const pluginSignRunner: TaskRunner = async ({ signatureType, rootUrls }) => { + const distContentDir = path.resolve('dist'); + + try { + console.log('Building manifest...'); + const manifest = await buildManifest(distContentDir); + // console.log(manifest); + + console.log('Signing manifest...'); + if (signatureType) { + manifest.signatureType = signatureType; + } + if (rootUrls) { + manifest.rootUrls = rootUrls; + } + + const signedManifest = await signManifest(manifest); + // console.log(signedManifest); + + console.log('Saving signed manifest...'); + await saveManifest(distContentDir, signedManifest); + + console.log('Signed successfully'); + } catch (err) { + console.warn(err); + } +}; + +export const pluginSignTask = new Task('plugin:sign task', pluginSignRunner); diff --git a/packages/grafana-toolkit/src/plugins/manifest.ts b/packages/grafana-toolkit/src/plugins/manifest.ts new file mode 100644 index 00000000000..dd4c455d27a --- /dev/null +++ b/packages/grafana-toolkit/src/plugins/manifest.ts @@ -0,0 +1,83 @@ +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { ManifestInfo } from './types'; + +const MANIFEST_FILE = 'MANIFEST.txt'; + +async function* walk(dir: string, baseDir: string): AsyncGenerator { + for await (const d of await (fs.promises as any).opendir(dir)) { + const entry = path.join(dir, d.name); + if (d.isDirectory()) { + yield* await walk(entry, baseDir); + } else if (d.isFile()) { + yield path.relative(baseDir, entry); + } else if (d.isSymbolicLink()) { + const realPath = fs.realpathSync(entry); + if (!realPath.startsWith(baseDir)) { + throw new Error( + `symbolic link ${path.relative(baseDir, entry)} targets a file outside of the base directory: ${baseDir}` + ); + } + yield path.relative(baseDir, entry); + } + } +} + +export async function buildManifest(dir: string): Promise { + const pluginJson = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), { encoding: 'utf8' })); + + const manifest = { + plugin: pluginJson.id, + version: pluginJson.info.version, + files: {}, + } as ManifestInfo; + + for await (const p of await walk(dir, dir)) { + if (p === MANIFEST_FILE) { + continue; + } + + manifest.files[p] = crypto + .createHash('sha256') + .update(fs.readFileSync(path.join(dir, p))) + .digest('hex'); + } + + return manifest; +} + +export async function signManifest(manifest: ManifestInfo): Promise { + const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY; + if (!GRAFANA_API_KEY) { + throw new Error('You must enter a GRAFANA_API_KEY to sign the plugin manifest'); + } + + const GRAFANA_COM_URL = process.env.GRAFANA_COM_URL || 'https://grafana.com/api'; + const url = GRAFANA_COM_URL + '/plugins/ci/sign'; + + const axios = require('axios'); + + try { + const info = await axios.post(url, manifest, { + headers: { Authorization: 'Bearer ' + GRAFANA_API_KEY }, + }); + if (info.status !== 200) { + console.warn('Error: ', info); + throw new Error('Error signing manifest'); + } + + return info.data; + } catch (err) { + if ((err.response && err.response.data) || err.response.data.message) { + throw new Error('Error signing manifest: ' + err.response.data.message); + } + + throw new Error('Error signing manifest: ' + err.message); + } +} + +export async function saveManifest(dir: string, signedManifest: string): Promise { + fs.writeFileSync(path.join(dir, MANIFEST_FILE), signedManifest); + return true; +} diff --git a/packages/grafana-toolkit/src/plugins/types.ts b/packages/grafana-toolkit/src/plugins/types.ts index bf8d5020120..792fd09e4fd 100644 --- a/packages/grafana-toolkit/src/plugins/types.ts +++ b/packages/grafana-toolkit/src/plugins/types.ts @@ -91,6 +91,10 @@ export interface GitLogInfo { export interface ManifestInfo { // time: number; << filled in by the server // keyId: string; << filled in by the server + // signedByOrg: string; << filled in by the server + // signedByOrgName: string; << filled in by the server + signatureType?: string; // filled in by the server if not specified + rootUrls?: string[]; // for private signatures plugin: string; version: string; files: Record;