feat: Add github proxy (#4418)

### Description
This PR adds github-proxy CloudFlare Worker project which will attach an
api key to github requests to increase read capacity. It is mostly a
passthrough proxy that has a simple allowlist.

This project is created and deployed using CloudFlare's [cloudflare
create
CLI](https://developers.cloudflare.com/workers/get-started/guide/)

It is deployed using `yarn deploy` (aka wrangler)

### Drive-by changes
- `yarn up chai@4.5.0`
- `yarn up typescript@5.5.2`
- `yarn up yaml@2.4.5`

### Related issues
Fixes: https://github.com/hyperlane-xyz/hyperlane-registry/issues/163

### Backward compatibility
Yes

### Testing
Manual/Unit Tests
- Use a script to ddos github, then try cli command `hyperlane warp
read`
- Unit tests for the Worker, and GithubRegistry changes
pull/4482/head
Lee 2 months ago committed by GitHub
parent 1ab8751afb
commit 0e2f94ba10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/famous-ants-tan.md
  2. 1
      Dockerfile
  3. 2
      solidity/package.json
  4. 2
      typescript/cli/cli.ts
  5. 8
      typescript/cli/package.json
  6. 7
      typescript/cli/src/commands/options.ts
  7. 1
      typescript/cli/src/consts.ts
  8. 23
      typescript/cli/src/context/context.ts
  9. 1
      typescript/cli/src/context/types.ts
  10. 1
      typescript/cli/src/tests/commands/core.ts
  11. 3
      typescript/cli/src/tests/commands/warp.ts
  12. 54
      typescript/cli/src/tests/warp-read.e2e-test.ts
  13. 1
      typescript/github-proxy/.dev.vars.example
  14. 172
      typescript/github-proxy/.gitignore
  15. 35
      typescript/github-proxy/package.json
  16. 1
      typescript/github-proxy/src/errors.ts
  17. 26
      typescript/github-proxy/src/index.ts
  18. 34
      typescript/github-proxy/test/index.spec.ts
  19. 11
      typescript/github-proxy/test/tsconfig.json
  20. 15
      typescript/github-proxy/tsconfig.json
  21. 11
      typescript/github-proxy/vitest.config.ts
  22. 6
      typescript/github-proxy/worker-configuration.d.ts
  23. 24
      typescript/github-proxy/wrangler.toml
  24. 4
      typescript/helloworld/package.json
  25. 6
      typescript/infra/package.json
  26. 4
      typescript/sdk/package.json
  27. 4
      typescript/utils/package.json
  28. 2
      typescript/widgets/package.json
  29. 1292
      yarn.lock

@ -0,0 +1,5 @@
---
'github-proxy': major
---
Add github proxy to reduce github API load

@ -18,6 +18,7 @@ COPY typescript/cli/package.json ./typescript/cli/
COPY typescript/infra/package.json ./typescript/infra/
COPY typescript/ccip-server/package.json ./typescript/ccip-server/
COPY typescript/widgets/package.json ./typescript/widgets/
COPY typescript/github-proxy/package.json ./typescript/github-proxy/
COPY solidity/package.json ./solidity/
RUN yarn install && yarn cache clean

@ -19,7 +19,7 @@
"@typechain/ethers-v6": "^0.5.1",
"@typechain/hardhat": "^9.1.0",
"@types/node": "^18.14.5",
"chai": "^4.3.6",
"chai": "4.5.0",
"ethereum-waffle": "^4.0.10",
"ethers": "^5.7.2",
"hardhat": "^2.22.2",

@ -12,6 +12,7 @@ import { deployCommand } from './src/commands/deploy.js';
import { hookCommand } from './src/commands/hook.js';
import { ismCommand } from './src/commands/ism.js';
import {
disableProxyCommandOption,
keyCommandOption,
logFormatCommandOption,
logLevelCommandOption,
@ -46,6 +47,7 @@ try {
.option('registry', registryUriCommandOption)
.option('overrides', overrideRegistryUriCommandOption)
.option('key', keyCommandOption)
.option('disableProxy', disableProxyCommandOption)
.option('yes', skipConfirmationOption)
.global(['log', 'verbosity', 'registry', 'overrides', 'yes'])
.middleware([

@ -5,7 +5,7 @@
"dependencies": {
"@aws-sdk/client-kms": "^3.577.0",
"@aws-sdk/client-s3": "^3.577.0",
"@hyperlane-xyz/registry": "2.5.0",
"@hyperlane-xyz/registry": "4.0.0",
"@hyperlane-xyz/sdk": "5.1.0",
"@hyperlane-xyz/utils": "5.1.0",
"@inquirer/prompts": "^3.0.0",
@ -16,7 +16,7 @@
"latest-version": "^8.0.0",
"terminal-link": "^3.0.0",
"tsx": "^4.7.1",
"yaml": "^2.4.1",
"yaml": "2.4.5",
"yargs": "^17.7.2",
"zod": "^3.21.2",
"zod-validation-error": "^3.3.0",
@ -30,12 +30,12 @@
"@types/yargs": "^17.0.24",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"chai": "^4.3.6",
"chai": "4.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"mocha": "^10.2.0",
"prettier": "^2.8.8",
"typescript": "^5.1.6"
"typescript": "5.3.3"
},
"scripts": {
"hyperlane": "node ./dist/cli.js",

@ -54,6 +54,13 @@ export const keyCommandOption: Options = {
defaultDescription: 'process.env.HYP_KEY',
};
export const disableProxyCommandOption: Options = {
type: 'boolean',
description:
'Disable routing of Github API requests through the Hyperlane registry proxy.',
default: false,
};
/* Command-specific options */
export const coreTargetsCommandOption: Options = {

@ -2,3 +2,4 @@ export const MINIMUM_CORE_DEPLOY_GAS = (1e8).toString();
export const MINIMUM_WARP_DEPLOY_GAS = (6e8).toString(); // Rough calculation through deployments to testnets with 2x buffer
export const MINIMUM_TEST_SEND_GAS = (3e5).toString();
export const MINIMUM_AVS_GAS = (3e6).toString();
export const PROXY_DEPLOYED_URL = 'https://proxy.hyperlane.xyz';

@ -2,6 +2,7 @@ import { confirm } from '@inquirer/prompts';
import { ethers } from 'ethers';
import {
DEFAULT_GITHUB_REGISTRY,
GithubRegistry,
IRegistry,
MergedRegistry,
@ -16,6 +17,7 @@ import {
import { isHttpsUrl, isNullish, rootLogger } from '@hyperlane-xyz/utils';
import { isSignCommand } from '../commands/signCommands.js';
import { PROXY_DEPLOYED_URL } from '../consts.js';
import { forkNetworkToMultiProvider, verifyAnvil } from '../deploy/dry-run.js';
import { logBlue } from '../logger.js';
import { runSingleChainSelectionStep } from '../utils/chains.js';
@ -37,6 +39,7 @@ export async function contextMiddleware(argv: Record<string, any>) {
key: argv.key,
fromAddress: argv.fromAddress,
requiresKey,
disableProxy: argv.disableProxy,
skipConfirmation: argv.yes,
};
if (!isDryRun && settings.fromAddress)
@ -59,8 +62,9 @@ export async function getContext({
key,
requiresKey,
skipConfirmation,
disableProxy = false,
}: ContextSettings): Promise<CommandContext> {
const registry = getRegistry(registryUri, registryOverrideUri);
const registry = getRegistry(registryUri, registryOverrideUri, !disableProxy);
let signer: ethers.Wallet | undefined = undefined;
if (key || requiresKey) {
@ -89,10 +93,11 @@ export async function getDryRunContext(
key,
fromAddress,
skipConfirmation,
disableProxy = false,
}: ContextSettings,
chain?: ChainName,
): Promise<CommandContext> {
const registry = getRegistry(registryUri, registryOverrideUri);
const registry = getRegistry(registryUri, registryOverrideUri, !disableProxy);
const chainMetadata = await registry.getMetadata();
if (!chain) {
@ -137,6 +142,7 @@ export async function getDryRunContext(
function getRegistry(
primaryRegistryUri: string,
overrideRegistryUri: string,
enableProxy: boolean,
): IRegistry {
const logger = rootLogger.child({ module: 'MergedRegistry' });
const registries = [primaryRegistryUri, overrideRegistryUri]
@ -145,7 +151,14 @@ function getRegistry(
.map((uri, index) => {
const childLogger = logger.child({ uri, index });
if (isHttpsUrl(uri)) {
return new GithubRegistry({ uri, logger: childLogger });
return new GithubRegistry({
uri,
logger: childLogger,
proxyUrl:
enableProxy && isCanonicalRepoUrl(uri)
? PROXY_DEPLOYED_URL
: undefined,
});
} else {
return new FileSystemRegistry({
uri,
@ -159,6 +172,10 @@ function getRegistry(
});
}
function isCanonicalRepoUrl(url: string) {
return url === DEFAULT_GITHUB_REGISTRY;
}
/**
* Retrieves a new MultiProvider based on all known chain metadata & custom user chains
* @param customChains Custom chains specified by the user

@ -14,6 +14,7 @@ export interface ContextSettings {
key?: string;
fromAddress?: string;
requiresKey?: boolean;
disableProxy?: boolean;
skipConfirmation?: boolean;
}

@ -11,7 +11,6 @@ export async function hyperlaneCoreDeploy(
) {
return $`yarn workspace @hyperlane-xyz/cli run hyperlane core deploy \
--registry ${REGISTRY_PATH} \
--overrides " " \
--config ${coreInputPath} \
--chain ${chain} \
--key ${ANVIL_KEY} \

@ -14,7 +14,6 @@ $.verbose = true;
export async function hyperlaneWarpDeploy(warpCorePath: string) {
return $`yarn workspace @hyperlane-xyz/cli run hyperlane warp deploy \
--registry ${REGISTRY_PATH} \
--overrides " " \
--config ${warpCorePath} \
--key ${ANVIL_KEY} \
--verbosity debug \
@ -30,7 +29,6 @@ export async function hyperlaneWarpApply(
) {
return $`yarn workspace @hyperlane-xyz/cli run hyperlane warp apply \
--registry ${REGISTRY_PATH} \
--overrides " " \
--config ${warpDeployPath} \
--warp ${warpCorePath} \
--key ${ANVIL_KEY} \
@ -45,7 +43,6 @@ export async function hyperlaneWarpRead(
) {
return $`yarn workspace @hyperlane-xyz/cli run hyperlane warp read \
--registry ${REGISTRY_PATH} \
--overrides " " \
--address ${warpAddress} \
--chain ${chain} \
--key ${ANVIL_KEY} \

@ -0,0 +1,54 @@
import { expect } from 'chai';
import { WarpRouteDeployConfig } from '@hyperlane-xyz/sdk';
import { readYamlOrJson, writeYamlOrJson } from '../utils/files.js';
import {
ANVIL_KEY,
REGISTRY_PATH,
deployOrUseExistingCore,
} from './commands/helpers.js';
import { hyperlaneWarpDeploy, readWarpConfig } from './commands/warp.js';
const CHAIN_NAME_2 = 'anvil2';
const EXAMPLES_PATH = './examples';
const CORE_CONFIG_PATH = `${EXAMPLES_PATH}/core-config.yaml`;
const WARP_CONFIG_PATH_EXAMPLE = `${EXAMPLES_PATH}/warp-route-deployment.yaml`;
const TEMP_PATH = '/tmp'; // /temp gets removed at the end of all-test.sh
const WARP_CONFIG_PATH_2 = `${TEMP_PATH}/anvil2/warp-route-deployment-anvil2.yaml`;
const WARP_CORE_CONFIG_PATH_2 = `${REGISTRY_PATH}/deployments/warp_routes/ETH/anvil2-config.yaml`;
const TEST_TIMEOUT = 60_000; // Long timeout since these tests can take a while
describe('WarpRead e2e tests', async function () {
let anvil2Config: WarpRouteDeployConfig;
this.timeout(TEST_TIMEOUT);
before(async function () {
await deployOrUseExistingCore(CHAIN_NAME_2, CORE_CONFIG_PATH, ANVIL_KEY);
// Create a new warp config using the example
const exampleWarpConfig: WarpRouteDeployConfig = readYamlOrJson(
WARP_CONFIG_PATH_EXAMPLE,
);
anvil2Config = { anvil2: { ...exampleWarpConfig.anvil1 } };
writeYamlOrJson(WARP_CONFIG_PATH_2, anvil2Config);
});
beforeEach(async function () {
await hyperlaneWarpDeploy(WARP_CONFIG_PATH_2);
});
it('should be able to read a warp route', async function () {
const warpConfigPath = `${TEMP_PATH}/warp-route-deployment-2.yaml`;
const warpConfig = await readWarpConfig(
CHAIN_NAME_2,
WARP_CORE_CONFIG_PATH_2,
warpConfigPath,
);
expect(warpConfig[CHAIN_NAME_2].type).to.be.equal(
anvil2Config[CHAIN_NAME_2].type,
);
});
});

@ -0,0 +1,172 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/

@ -0,0 +1,35 @@
{
"name": "@hyperlane-xyz/github-proxy",
"description": "Github proxy that adds the API key to requests",
"version": "5.1.0",
"scripts": {
"deploy": "wrangler deploy",
"deploy:staging": "wrangler deploy --env staging",
"deploy:key": "wrangler secret put GITHUB_API_KEY",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"prettier": "prettier --write ./src ./test",
"cf-typegen": "wrangler types"
},
"type": "module",
"homepage": "https://www.hyperlane.xyz",
"repository": "https://github.com/hyperlane-xyz/hyperlane-monorepo",
"keywords": [
"Hyperlane",
"Github",
"Proxy",
"Typescript"
],
"license": "Apache-2.0",
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.4.5",
"@cloudflare/workers-types": "^4.20240821.1",
"@faker-js/faker": "^8.4.1",
"chai": "4.5.0",
"prettier": "^2.8.8",
"typescript": "5.3.3",
"vitest": "1.4.0",
"wrangler": "^3.74.0"
}
}

@ -0,0 +1 @@
export const DISALLOWED_URL_MSG = 'Origin not allowed';

@ -0,0 +1,26 @@
import { DISALLOWED_URL_MSG } from './errors.js';
const GITHUB_API_ALLOWLIST = [
'/repos/hyperlane-xyz/hyperlane-registry/git/trees/main',
];
const GITPUB_API_HOST = 'https://api.github.com';
export default {
async fetch(request, env, _ctx): Promise<Response> {
const apiUrlPath = new URL(request.url).pathname;
const isAllowed = GITHUB_API_ALLOWLIST.includes(apiUrlPath);
if (!isAllowed) {
return new Response(DISALLOWED_URL_MSG, { status: 401 });
}
const apiUrl = new URL(`${GITPUB_API_HOST}${apiUrlPath}`);
return fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Hyperlane-Github-Proxy',
'X-GitHub-Api-Version': '2022-11-28',
Authorization: `Bearer ${env.GITHUB_API_KEY}`,
},
});
},
} satisfies ExportedHandler<Env>;

@ -0,0 +1,34 @@
import { faker } from '@faker-js/faker';
import { SELF } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';
import { DISALLOWED_URL_MSG } from '../src/errors.js';
describe('Hello World worker', () => {
it('returns empty response if pathname provided is not a valid api url', async () => {
const results = await SELF.fetch('https://example.com/favicon.ico');
expect(results.status).toBe(401);
expect(await results.text()).toBe(DISALLOWED_URL_MSG);
});
it('returns empty response if origin is not on allowlist', async () => {
const results = await SELF.fetch(
'https://example.com/repos/custom-hyperlane-xyz/hyperlane-registry/git/trees/main?recursive=true',
);
expect(results.status).toBe(401);
expect(await results.text()).toBe(DISALLOWED_URL_MSG);
});
it('returns empty response if origin is not on allowlist (with faker 200 tests)', async () => {
for (let i = 0; i < 200; i++) {
const results = await SELF.fetch(
`https://example.com/${faker.internet.url}`,
);
expect(results.status).toBe(401);
expect(await results.text()).toBe(DISALLOWED_URL_MSG);
}
});
});

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"],
"exclude": []
}

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types"
] /* Specify type package names to be included without being referenced in a source file. */,
"noEmit": true /* Disable emitting files from a compilation. */,
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */
},
"exclude": ["test"],
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
}

@ -0,0 +1,11 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.toml' },
},
},
},
});

@ -0,0 +1,6 @@
// Generated by Wrangler on Tue Sep 03 2024 19:01:13 GMT-0400 (Eastern Daylight Time)
// by running `wrangler types`
interface Env {
GITHUB_API_KEY: string;
}

@ -0,0 +1,24 @@
#:schema node_modules/wrangler/config-schema.json
name = "github-proxy-prod"
main = "src/index.ts"
compatibility_date = "2024-08-21"
compatibility_flags = ["nodejs_compat"]
workers_dev = false
routes = [
{ pattern = "proxy.hyperlane.xyz", custom_domain = true }
]
# CPU limit - 10ms keeps us within the free tier. As of 9/6/2024, the median CPU time is ~1ms
[limits]
cpu_ms = 10
# Automatically place your workloads in an optimal location to minimize latency.
# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure
# rather than the end user may result in better performance.
# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
[placement]
mode = "smart"
[env.staging]
name = "github-proxy-staging"
workers_dev = true

@ -4,7 +4,7 @@
"version": "5.1.0",
"dependencies": {
"@hyperlane-xyz/core": "5.1.0",
"@hyperlane-xyz/registry": "2.5.0",
"@hyperlane-xyz/registry": "4.0.0",
"@hyperlane-xyz/sdk": "5.1.0",
"@openzeppelin/contracts-upgradeable": "^4.9.3",
"ethers": "^5.7.2"
@ -18,7 +18,7 @@
"@typechain/hardhat": "^9.1.0",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"chai": "^4.3.6",
"chai": "4.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"ethereum-waffle": "^4.0.10",

@ -14,7 +14,7 @@
"@ethersproject/providers": "^5.7.2",
"@google-cloud/secret-manager": "^5.5.0",
"@hyperlane-xyz/helloworld": "5.1.0",
"@hyperlane-xyz/registry": "2.5.0",
"@hyperlane-xyz/registry": "4.0.0",
"@hyperlane-xyz/sdk": "5.1.0",
"@hyperlane-xyz/utils": "5.1.0",
"@inquirer/prompts": "^5.3.8",
@ -30,7 +30,7 @@
"json-stable-stringify": "^1.1.1",
"prom-client": "^14.0.1",
"prompts": "^2.4.2",
"yaml": "^2.4.5",
"yaml": "2.4.5",
"yargs": "^17.7.2"
},
"devDependencies": {
@ -43,7 +43,7 @@
"@types/prompts": "^2.0.14",
"@types/sinon-chai": "^3.2.12",
"@types/yargs": "^17.0.24",
"chai": "^4.3.6",
"chai": "4.5.0",
"ethereum-waffle": "^4.0.10",
"ethers": "^5.7.2",
"hardhat": "^2.22.2",

@ -33,7 +33,7 @@
"@types/sinon": "^17.0.1",
"@types/sinon-chai": "^3.2.12",
"@types/ws": "^8.5.5",
"chai": "^4.3.6",
"chai": "4.5.0",
"dotenv": "^10.0.0",
"eslint": "^8.57.0",
"ethereum-waffle": "^4.0.10",
@ -44,7 +44,7 @@
"ts-node": "^10.8.0",
"tsx": "^4.7.1",
"typescript": "5.3.3",
"yaml": "^2.4.1"
"yaml": "2.4.5"
},
"type": "module",
"exports": {

@ -9,12 +9,12 @@
"ethers": "^5.7.2",
"lodash-es": "^4.17.21",
"pino": "^8.19.0",
"yaml": "^2.4.1"
"yaml": "2.4.5"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/mocha": "^10.0.1",
"chai": "^4.3.6",
"chai": "4.5.0",
"mocha": "^10.2.0",
"prettier": "^2.8.8",
"typescript": "5.3.3"

@ -7,7 +7,7 @@
"react-dom": "^18"
},
"dependencies": {
"@hyperlane-xyz/registry": "2.5.0",
"@hyperlane-xyz/registry": "4.0.0",
"@hyperlane-xyz/sdk": "5.1.0"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save