├── assets ├── error.png ├── configurator.gif ├── forward-proxy.gif ├── forward-proxy-bypass.gif └── forward-proxy-server.gif ├── tslint.json ├── tools ├── tsconfig.json └── configure.ts ├── tsconfig.test.json ├── src ├── helpers │ ├── handled-error.ts │ ├── context.ts │ ├── request.ts │ └── response.ts ├── handlers │ ├── origin-response.ts │ └── origin-request.ts ├── views │ ├── error.ts │ └── error.html └── config.ts ├── tsconfig.json ├── .gitignore ├── LICENSE ├── anyproxy.rule.js ├── package.json ├── serverless.yml └── README.md /assets/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyoul/proxyfront/HEAD/assets/error.png -------------------------------------------------------------------------------- /assets/configurator.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyoul/proxyfront/HEAD/assets/configurator.gif -------------------------------------------------------------------------------- /assets/forward-proxy.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyoul/proxyfront/HEAD/assets/forward-proxy.gif -------------------------------------------------------------------------------- /assets/forward-proxy-bypass.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyoul/proxyfront/HEAD/assets/forward-proxy-bypass.gif -------------------------------------------------------------------------------- /assets/forward-proxy-server.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyoul/proxyfront/HEAD/assets/forward-proxy-server.gif -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "object-literal-sort-keys": false, 9 | "interface-name": [ 10 | true, 11 | "never-prefix" 12 | ], 13 | "trailing-comma": false, 14 | "arrow-parens": false 15 | }, 16 | "rulesDirectory": [] 17 | } -------------------------------------------------------------------------------- /tools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "sourceMap": true, 7 | "rootDir": "src", 8 | "outDir": "dst", 9 | "strict": false, 10 | "strictNullChecks": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "lib": [ 14 | "esnext", 15 | "esnext.asynciterable" 16 | ] 17 | }, 18 | "include": [ 19 | "./*.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2017", 5 | "noImplicitAny": true, 6 | "sourceMap": true, 7 | "rootDir": "src", 8 | "outDir": "dst", 9 | "strict": false, 10 | "strictNullChecks": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "lib": [ 14 | "esnext", 15 | "esnext.asynciterable" 16 | ] 17 | }, 18 | "include": [ 19 | "src/**/*" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/handled-error.ts: -------------------------------------------------------------------------------- 1 | import { URL } from "url"; 2 | 3 | export class HandledError extends Error { 4 | public status: number; 5 | public reason?: string; 6 | public underlyingError?: Error; 7 | public origin?: URL; 8 | 9 | constructor(status: number, metadata: { 10 | message: string; 11 | reason?: string; 12 | underlyingError?: Error; 13 | origin?: URL; 14 | }) { 15 | super(metadata.message); 16 | this.status = status; 17 | this.reason = metadata.reason; 18 | this.underlyingError = metadata.underlyingError; 19 | this.origin = metadata.origin; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2017", 5 | "noImplicitAny": true, 6 | "sourceMap": true, 7 | "rootDir": "src", 8 | "outDir": "dst", 9 | "strict": false, 10 | "strictNullChecks": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "lib": [ 14 | "esnext", 15 | "esnext.asynciterable" 16 | ] 17 | }, 18 | "exclude": [ 19 | "**/__test__/" 20 | ], 21 | "include": [ 22 | "src/**/*" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dst 2 | dst.zip 3 | 4 | .env 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # node-waf configuration 29 | .lock-wscript 30 | 31 | # Compiled binary addons (http://nodejs.org/api/addons.html) 32 | build/Release 33 | 34 | # Dependency directories 35 | node_modules 36 | jspm_packages 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional REPL history 42 | .node_repl_history 43 | 44 | .serverless 45 | 46 | # IDE Configurations 47 | .vscode 48 | .idea 49 | 50 | # ProxyFront Configurations 51 | config.*.yml 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | Copyright © 2019 MooYeol Prescott Lee, http://debug.so 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the “Software”), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /anyproxy.rule.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const DISALLOWED_REQUEST_HEADERS = [ 4 | 'host', 'proxy-connection', 5 | ].reduce((hash, v) => { 6 | hash[v] = true; 7 | return hash; 8 | }, {}); 9 | 10 | if (!process.env.PROXYFRONT_HOST) { 11 | throw new Error("`PROXYFRONT_HOST` was not found in environment variable."); 12 | } 13 | 14 | module.exports = { 15 | *beforeSendRequest(requestDetail) { 16 | const { requestOptions } = requestDetail; 17 | const requestedUrl = requestDetail.url; 18 | const safeHeaders = Object.keys(requestOptions.headers).reduce((hash, k) => { 19 | if (!DISALLOWED_REQUEST_HEADERS[k.toLowerCase()]) { 20 | hash[k] = requestOptions.headers[k]; 21 | } 22 | 23 | return hash; 24 | }, {}); 25 | 26 | requestOptions.hostname = process.env.PROXYFRONT_HOST; 27 | requestOptions.port = 443; 28 | requestOptions.path = `/${requestedUrl}`; 29 | requestOptions.headers = { 30 | ...safeHeaders, 31 | Host: process.env.PROXYFRONT_HOST, 32 | }; 33 | 34 | return { 35 | protocol: 'https', 36 | requestOptions, 37 | }; 38 | }, 39 | *beforeDealHttpsRequest(requestDetail) { 40 | return true; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/handlers/origin-response.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CloudFrontResponseEvent, 3 | CloudFrontResultResponse, 4 | } from "aws-lambda"; 5 | 6 | import * as status from "statuses"; 7 | 8 | import { Config } from "../config"; 9 | import { ResponseContext } from "../helpers/response"; 10 | import { render as renderErrorPage } from "../views/error"; 11 | 12 | export async function handler(event: CloudFrontResponseEvent): Promise { 13 | const context = new ResponseContext(event); 14 | 15 | if (Config.debug && context.query.has("PROXYFRONT_SIMULATE_ORIGIN_RESPONSE")) { 16 | return context.json(200, { 17 | event, 18 | response: context.passthrough(), 19 | }, { 20 | "Cache-Control": "public, no-cache", 21 | }); 22 | } 23 | 24 | const corsHeaders = (() => { 25 | const origin = context.request.headers.origin && context.request.headers.origin.length > 0 ? 26 | context.request.headers.origin[0].value : 27 | null; 28 | 29 | if (!origin) { 30 | return {}; 31 | } 32 | 33 | return { 34 | "Access-Control-Allow-Origin": origin, 35 | "Access-Control-Max-Age": `${Config.cors && Config.cors.maxAge || 300}`, 36 | }; 37 | })() as { [key: string]: string }; 38 | 39 | const responseStatus = parseInt(context.response.status, 10); 40 | if (responseStatus >= 500) { 41 | return context.html(responseStatus, await renderErrorPage({ 42 | requestIp: context.request.clientIp, 43 | status: { 44 | code: responseStatus, 45 | description: context.response.statusDescription || status[responseStatus]! || "Unknown Error", 46 | }, 47 | origin: { 48 | hostname: context.request.origin!.custom!.domainName, 49 | }, 50 | }), { 51 | "Cache-Control": "public, no-cache", 52 | ...corsHeaders, 53 | }); 54 | } 55 | 56 | return context.passthrough(corsHeaders); 57 | } 58 | -------------------------------------------------------------------------------- /src/helpers/context.ts: -------------------------------------------------------------------------------- 1 | import { CloudFrontHeaders, CloudFrontRequest, CloudFrontResultResponse } from "aws-lambda"; 2 | import * as qs from "querystring"; 3 | 4 | export interface ResponseHeader { 5 | [key: string]: string | string[]; 6 | } 7 | 8 | export interface Response { 9 | statusCode: number | string; 10 | statusText?: string; 11 | headers?: ResponseHeader; 12 | body: any; 13 | } 14 | 15 | export abstract class Context { 16 | public abstract request: CloudFrontRequest; 17 | 18 | private cachedQuery: Map; 19 | 20 | // lazily parse query parameters 21 | public get query() { 22 | if (!this.cachedQuery) { 23 | const parsed = qs.parse(this.request.querystring); 24 | 25 | this.cachedQuery = new Map( 26 | Object.keys(parsed).map((k) => [k, parsed[k]] as [string, string[]]) 27 | ); 28 | } 29 | 30 | return this.cachedQuery; 31 | } 32 | 33 | public json(status: number | string, body: any, headers: ResponseHeader = {}) { 34 | return this.reply({ 35 | statusCode: status, 36 | headers: { 37 | "Content-Type": "application/json; charset=utf-8", 38 | ...headers, 39 | }, 40 | body: JSON.stringify(body), 41 | }); 42 | } 43 | 44 | public html(status: number | string, body: string, headers: ResponseHeader = {}) { 45 | return this.reply({ 46 | statusCode: status, 47 | headers: { 48 | "Content-Type": "text/html; charset=utf-8", 49 | ...headers, 50 | }, 51 | body, 52 | }); 53 | } 54 | 55 | public abstract reply(response: Response): CloudFrontResultResponse; 56 | 57 | protected transformHeaders(header: ResponseHeader): CloudFrontHeaders { 58 | return Object.keys(header).reduce((hash, k) => { 59 | const values = (Array.isArray(header[k]) ? header[k] : [header[k]]) as string[]; 60 | 61 | hash[k.toLowerCase()] = values.map((v) => ({ 62 | key: k, 63 | value: v, 64 | })); 65 | 66 | return hash; 67 | }, {} as CloudFrontHeaders); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proxyfront", 3 | "version": "1.0.0", 4 | "description": "Turn Cloudfront as dynamic forward proxy server", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/mooyoul/proxyfront.git" 9 | }, 10 | "scripts": { 11 | "build": "check-engine && rm -Rf dst && tsc && cd src && find . \\( -name '*.json' -o -name '*.html' \\) -type f -exec cp {} ../dst/{} \\; && cd ..", 12 | "pack": "rm -f dst.zip && cp package.json dst/package.json && (cp package-lock.json dst/ || true) && (cp .npmrc dst/.npmrc || true) && cd dst && npm install --cache=../.npm --production && npm ls && zip -rqy ../dst.zip . && cd ..", 13 | "pretest": "check-engine && rm -Rf dst && tsc -p ./tsconfig.test.json && cd src && find . -name '*.json' -type f -exec cp {} ../dst/{} \\; && cd ..", 14 | "test": "mocha --exit -t 20000 dst/**/__test__/**/*.js", 15 | "lint": "tslint -c tslint.json 'src/**/*.ts'", 16 | "deploy": "npm run build && npm run pack && serverless deploy --verbose", 17 | "deploy:stage": "npm run deploy -- -s stage", 18 | "deploy:prod": "npm run deploy -- -s prod", 19 | "client": "npx anyproxy --rule anyproxy.rule.js", 20 | "configure": "ts-node tools/configure.ts", 21 | "info:stage": "sls info -s stage", 22 | "info:prod": "sls info -s prod" 23 | }, 24 | "engines": { 25 | "node": "^8.10.0", 26 | "npm": ">= 5.6.0" 27 | }, 28 | "author": "MooYeol Prescott Lee ", 29 | "license": "MIT", 30 | "devDependencies": { 31 | "@types/aws-lambda": "^8.10.18", 32 | "@types/chai": "^4.1.7", 33 | "@types/common-tags": "^1.8.0", 34 | "@types/inquirer": "0.0.43", 35 | "@types/js-yaml": "^3.12.0", 36 | "@types/lodash.template": "^4.4.4", 37 | "@types/mocha": "^5.2.5", 38 | "@types/node": "^8.10.39", 39 | "@types/statuses": "^1.5.0", 40 | "@types/tldjs": "^2.3.0", 41 | "@vingle/serverless-tag-plugin": "^1.1.2", 42 | "chai": "^4.2.0", 43 | "check-engine": "^1.7.0", 44 | "cli-highlight": "^2.0.0", 45 | "common-tags": "^1.8.0", 46 | "inquirer": "^6.2.2", 47 | "js-yaml": "^3.12.1", 48 | "mocha": "^5.2.0", 49 | "ow": "^0.12.0", 50 | "serverless": "^1.35.1", 51 | "serverless-lambda-version": "^0.1.2", 52 | "serverless-prune-plugin": "^1.3.2", 53 | "tldjs": "^2.3.1", 54 | "ts-node": "^7.0.1", 55 | "tslint": "^5.12.0", 56 | "typescript": "^3.2.2" 57 | }, 58 | "dependencies": { 59 | "lodash.template": "^4.4.0", 60 | "statuses": "^1.5.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/views/error.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import template = require("lodash.template"); // tslint:disable-line 3 | import * as path from "path"; 4 | 5 | const AWS_REGION_LOCATION_MAP = new Map([ 6 | ["us-east-2", "US East\n(Ohio)"], 7 | ["us-east-1", "US East\n(N. Virginia)"], 8 | ["us-west-1", "US West\n(N. California)"], 9 | ["us-west-2", "US West\n(Oregon)"], 10 | ["ap-south-1", "Asia Pacific\n(Mumbai)"], 11 | ["ap-northeast-2", "Asia Pacific\n(Seoul)"], 12 | ["ap-southeast-1", "Asia Pacific\n(Singapore)"], 13 | ["ap-southeast-2", "Asia Pacific\n(Sydney)"], 14 | ["ap-northeast-1", "Asia Pacific\n(Tokyo)"], 15 | ["ca-central-1", "Canada\n(Central)"], 16 | ["cn-north-1", "China\n(Beijing)"], 17 | ["cn-northwest-1", "China\n(Ningxia)"], 18 | ["eu-central-1", "EU\n(Frankfurt)"], 19 | ["eu-west-1", "EU\n(Ireland)"], 20 | ["eu-west-2", "EU\n(London)"], 21 | ["eu-west-3", "EU\n(Paris)"], 22 | ["eu-north-1", "EU\n(Stockholm)"], 23 | ["sa-east-1", "South America\n(São Paulo)"], 24 | ["us-gov-east-1", "AWS GovCloud\n(US-East)"], 25 | ["us-gov-west-1", "AWS GovCloud\n(US)"], 26 | ]); 27 | 28 | const CURRENT_LOCATION = AWS_REGION_LOCATION_MAP.get(process.env.AWS_REGION!) || process.env.AWS_REGION! || "Unknown"; 29 | 30 | let renderTemplate: ReturnType; 31 | export interface ErrorTemplateInput { 32 | requestIp: string; 33 | status: { 34 | code: number; 35 | description: string; 36 | }; 37 | reason?: string; 38 | origin?: { 39 | hostname: string; 40 | }; 41 | } 42 | 43 | export async function render(input: ErrorTemplateInput): Promise { 44 | if (!renderTemplate) { 45 | const templateContent = await new Promise((resolve, reject) => { 46 | fs.readFile(path.join(__dirname, "error.html"), { encoding: "utf8" }, (e, data) => { 47 | if (e) { return reject(e); } 48 | 49 | resolve(data); 50 | }); 51 | }); 52 | 53 | renderTemplate = template(templateContent, { variable: "data" }); 54 | } 55 | 56 | return renderTemplate({ 57 | ...input, 58 | location: CURRENT_LOCATION.replace(/\n/g, "
"), 59 | time: getUTCString(), 60 | }); 61 | } 62 | 63 | function getUTCString() { 64 | const now = new Date(); 65 | 66 | const yyyy = now.getUTCFullYear(); 67 | const mm = `00${now.getUTCMonth() + 1}`.slice(-2); 68 | const dd = `00${now.getUTCDate()}`.slice(-2); 69 | const hh = `00${now.getUTCHours()}`.slice(-2); 70 | const ii = `00${now.getUTCMinutes()}`.slice(-2); 71 | const ss = `00${now.getUTCSeconds()}`.slice(-2); 72 | 73 | return `${yyyy}-${mm}-${dd} ${hh}:${ii}:${ss} UTC`; 74 | } 75 | -------------------------------------------------------------------------------- /src/helpers/request.ts: -------------------------------------------------------------------------------- 1 | import { CloudFrontHeaders, CloudFrontRequest, CloudFrontRequestEvent, CloudFrontResultResponse } from "aws-lambda"; 2 | import * as status from "statuses"; 3 | import { URL } from "url"; 4 | 5 | import { Config } from "../config"; 6 | import { Context, Response } from "./context"; 7 | 8 | const DEFAULT_PORT = new Map<"http:" | "https:", number>([ 9 | ["http:", 80], 10 | ["https:", 443], 11 | ]); 12 | 13 | // tslint:disable-next-line 14 | const READONLY_HEADER_NAMES = new Map([ 15 | "accept-encoding", 16 | "content-length", 17 | "if-modified-since", 18 | "if-none-match", 19 | "if-range", 20 | "if-unmodified-since", 21 | "range", 22 | "transfer-encoding", 23 | "via" 24 | ].map((k) => [k, true] as [typeof k, true])); 25 | 26 | const BLACKLISTED_HEADER_NAMES = new Map([ 27 | ].map((k) => [k, true] as [typeof k, true])); 28 | 29 | export class RequestContext extends Context { 30 | public readonly request: CloudFrontRequest; 31 | 32 | constructor(event: CloudFrontRequestEvent) { 33 | super(); 34 | 35 | const { request } = event.Records[0].cf; 36 | 37 | this.request = request; 38 | } 39 | 40 | public reply(response: Response): CloudFrontResultResponse { 41 | return { 42 | status: response.statusCode.toString(), 43 | statusDescription: response.statusText || status[response.statusCode] as string, 44 | headers: this.transformHeaders(response.headers || {}), 45 | body: response.body, 46 | }; 47 | } 48 | 49 | public proxy(remote: URL): CloudFrontRequest { 50 | const headers = Object.keys(this.request.headers) 51 | .reduce((hash, k) => { 52 | if (READONLY_HEADER_NAMES.has(k) || !BLACKLISTED_HEADER_NAMES.has(k)) { 53 | // this is non-removable, non-modifiable field. 54 | hash[k] = this.request.headers[k]; 55 | } 56 | 57 | return hash; 58 | }, {} as CloudFrontHeaders); 59 | 60 | if (Config.origin.userAgent) { 61 | headers["user-agent"] = [{ key: "User-Agent", value: Config.origin.userAgent }]; 62 | } 63 | 64 | headers.host = [{ key: "Host", value: remote.hostname }]; 65 | 66 | return { 67 | ...this.request, 68 | uri: remote.pathname, 69 | headers, 70 | origin: { 71 | custom: { 72 | domainName: remote.hostname, 73 | port: parseInt(remote.port, 10) || DEFAULT_PORT.get(remote.protocol as "http:" | "https:")!, 74 | protocol: remote.protocol.slice(0, -1) as "http" | "https", 75 | path: "", 76 | sslProtocols: ["SSLv3", "TLSv1", "TLSv1.1", "TLSv1.2"], 77 | readTimeout: Config.origin.readTimeout, 78 | keepaliveTimeout: Config.origin.keepAliveTimeout, 79 | customHeaders: {}, 80 | }, 81 | }, 82 | }; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/helpers/response.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CloudFrontHeaders, 3 | CloudFrontRequest, 4 | CloudFrontResponse, 5 | CloudFrontResponseEvent, 6 | CloudFrontResultResponse, 7 | } from "aws-lambda"; 8 | 9 | import * as status from "statuses"; 10 | 11 | import { Context, Response } from "./context"; 12 | 13 | export type ResponseEventType = "origin-response" | "viewer-response"; 14 | 15 | const READONLY_HEADER_NAMES = new Map([ 16 | ["origin-response", ["via", "transfer-encoding"]], 17 | ["viewer-response", ["content-encoding", "content-length", "transfer-encoding", "warning", "via"]], 18 | ]); 19 | 20 | export class ResponseContext extends Context { 21 | public readonly type: "origin-response" | "viewer-response"; 22 | public readonly request: CloudFrontRequest; 23 | public readonly response: CloudFrontResponse; 24 | 25 | private readonly READONLY_HEADERS: string[]; 26 | 27 | constructor(event: CloudFrontResponseEvent) { 28 | super(); 29 | 30 | const { config, request, response } = event.Records[0].cf; 31 | 32 | this.type = config.eventType as ResponseEventType; 33 | this.READONLY_HEADERS = READONLY_HEADER_NAMES.get(this.type) || []; 34 | this.request = request; 35 | this.response = response; 36 | } 37 | 38 | public reply(response: Response): CloudFrontResultResponse { 39 | // @note there exists some reserved headers, so we need to copy them 40 | // tslint:disable-next-line 41 | // @see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html#lambda-read-only-headers-origin-response-events 42 | const readonlyHeaders = this.READONLY_HEADERS 43 | .reduce((hash, k) => { 44 | const hasField = Object.prototype.hasOwnProperty.call(this.response.headers, k); 45 | 46 | if (hasField) { 47 | hash[k] = this.response.headers[k]; 48 | } 49 | 50 | return hash; 51 | }, {} as CloudFrontHeaders); 52 | 53 | return { 54 | ...this.response, 55 | status: response.statusCode.toString(), 56 | statusDescription: response.statusText || status[response.statusCode] as string, 57 | headers: { 58 | ...(this.transformHeaders(response.headers || {})), 59 | ...readonlyHeaders, 60 | }, 61 | body: response.body, 62 | }; 63 | } 64 | 65 | public passthrough(additionalHeaders?: { [key: string]: string }) { 66 | const merged = { 67 | ...this.response.headers, 68 | ...(additionalHeaders ? this.transformHeaders(additionalHeaders) : {}), 69 | }; 70 | 71 | const headers = Object.keys(merged) 72 | .reduce((hash, k) => { 73 | if (k.toLowerCase() !== "cache-control") { 74 | hash[k] = merged[k]; 75 | } 76 | 77 | return hash; 78 | }, {} as CloudFrontHeaders); 79 | 80 | return { 81 | ...this.response, 82 | headers, 83 | }; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-line-length 2 | interface Config { 3 | // If enabled, reserved query parameters like PROXYFRONT_SIMULATE_ORIGIN_REQUEST will work. 4 | debug: boolean; 5 | 6 | // Origin related configurations 7 | origin: { 8 | // If enabled, ProxyFront will check origin connectivity before passing request to CloudFront. 9 | // It's recommended to enable this flag since Cloudfront does not provide detailed failure reason 10 | // and first-byte latency can be extremely high if connection issue occurs. 11 | checkConnectivity: boolean; 12 | 13 | // Origin Read Timeout 14 | // @see https://aws.amazon.com/about-aws/whats-new/2017/03/announcing-configure-read-timeout-and-keep-alive-timeout-values-for-your-amazon-cloudfront-custom-origins/ 15 | // @see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#request-custom-request-timeout 16 | readTimeout: number; // in seconds 17 | 18 | // Keep-Alive idle timeout 19 | // @see https://aws.amazon.com/about-aws/whats-new/2017/03/announcing-configure-read-timeout-and-keep-alive-timeout-values-for-your-amazon-cloudfront-custom-origins/ 20 | // @see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#request-custom-persistent-connections 21 | keepAliveTimeout: number; // in seconds 22 | 23 | // Origin Request User-Agent 24 | // if not specified, default user agent will be forwarded. 25 | // Default user agent will be: 26 | // Forward User-Agent header: Client's User-Agent 27 | // Not forward User-Agent header: Amazon CloudFront 28 | userAgent?: string; 29 | 30 | // Origin Whitelist 31 | // If you want to allow only specific origin, configure whitelist 32 | // 1. Specify allowed hostname 33 | // 2. Specify regexp that matches allowed hostname 34 | // 3. Specify custom async matcher function that returns boolean. 35 | whitelist?: Array Promise)>; 36 | }; 37 | 38 | // CORS related configurations 39 | // If not specified, CORS middleware will be disabled. 40 | // For example: 41 | // 1) CORS pre-flight request won't be accepted 42 | // 2) Response won't include CORS related headers (e.g. `Access-Control-Allow-Origin`) 43 | cors?: { 44 | whitelist?: Array Promise)>; 45 | maxAge?: string; 46 | }; 47 | } 48 | // tslint:enable:max-line-length 49 | 50 | // Below are default configuration. Feel free to edit! 51 | // @todo Refactor to support better multi-stage deployment 52 | export const Config: Config = { 53 | debug: false, 54 | origin: { 55 | checkConnectivity: true, 56 | readTimeout: 15, 57 | keepAliveTimeout: 30, 58 | // tslint:disable-next-line 59 | userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36", 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: proxyfront 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs8.10 6 | stage: ${file(./config.${opt:stage}.yml):STAGE} 7 | 8 | plugins: 9 | - serverless-lambda-version 10 | - serverless-prune-plugin 11 | - "@vingle/serverless-tag-plugin" 12 | 13 | custom: 14 | prune: 15 | automatic: true 16 | number: 5 17 | 18 | package: 19 | artifact: dst.zip 20 | 21 | functions: 22 | originRequest: 23 | handler: handlers/origin-request.handler 24 | memorySize: 128 25 | timeout: 15 26 | role: LambdaEdgeRole 27 | originResponse: 28 | handler: handlers/origin-response.handler 29 | memorySize: 128 30 | timeout: 1 31 | role: LambdaEdgeRole 32 | 33 | resources: 34 | Conditions: 35 | CreateRoute53Records: 36 | Fn::Equals: 37 | - ${file(./config.${opt:stage}.yml):CREATE_ROUTE53_RECORDS} 38 | - "true" 39 | 40 | Resources: 41 | S3Bucket: 42 | Type: AWS::S3::Bucket 43 | Properties: 44 | BucketName: ${file(./config.${opt:stage}.yml):BUCKET_NAME} 45 | 46 | CloudfrontDistribution: 47 | DependsOn: S3Bucket 48 | Type: AWS::CloudFront::Distribution 49 | Properties: 50 | DistributionConfig: 51 | Aliases: ${file(./config.${opt:stage}.yml):CLOUDFRONT_CUSTOM_DOMAIN_NAMES} 52 | Origins: 53 | # This is dummy domain name. ProxyFront never sends actual requests to this domain! 54 | - DomainName: aws.amazon.com 55 | Id: DummyOrigin 56 | CustomOriginConfig: 57 | OriginProtocolPolicy: http-only 58 | Enabled: true 59 | Comment: proxyfront-${opt:stage} 60 | IPV6Enabled: true 61 | DefaultCacheBehavior: 62 | AllowedMethods: ${file(./config.${opt:stage}.yml):CLOUDFRONT_ALLOWED_METHODS} 63 | TargetOriginId: DummyOrigin 64 | DefaultTTL: ${file(./config.${opt:stage}.yml):CLOUDFRONT_DEFAULT_TTL} 65 | MinTTL: ${file(./config.${opt:stage}.yml):CLOUDFRONT_MIN_TTL} 66 | MaxTTL: ${file(./config.${opt:stage}.yml):CLOUDFRONT_MAX_TTL} 67 | ForwardedValues: 68 | QueryString: true 69 | Cookies: 70 | Forward: ${file(./config.${opt:stage}.yml):CLOUDFRONT_FORWARD_COOKIE} 71 | Headers: ${file(./config.${opt:stage}.yml):CLOUDFRONT_FORWARD_HEADER} 72 | ViewerProtocolPolicy: redirect-to-https 73 | LambdaFunctionAssociations: 74 | - EventType: origin-request 75 | LambdaFunctionARN: OriginRequestLambdaFunction 76 | - EventType: origin-response 77 | LambdaFunctionARN: OriginResponseLambdaFunction 78 | CustomErrorResponses: 79 | - ErrorCode: 400 80 | ErrorCachingMinTTL: 0 81 | - ErrorCode: 403 82 | ErrorCachingMinTTL: 0 83 | - ErrorCode: 404 84 | ErrorCachingMinTTL: 0 85 | - ErrorCode: 405 86 | ErrorCachingMinTTL: 0 87 | - ErrorCode: 414 88 | ErrorCachingMinTTL: 0 89 | - ErrorCode: 500 90 | ErrorCachingMinTTL: 0 91 | - ErrorCode: 501 92 | ErrorCachingMinTTL: 0 93 | - ErrorCode: 502 94 | ErrorCachingMinTTL: 0 95 | - ErrorCode: 503 96 | ErrorCachingMinTTL: 0 97 | - ErrorCode: 504 98 | ErrorCachingMinTTL: 0 99 | HttpVersion: http2 100 | PriceClass: PriceClass_All 101 | ViewerCertificate: ${file(./config.${opt:stage}.yml):CLOUDFRONT_VIEWER_CERTIFICATE} 102 | Logging: 103 | Bucket: 104 | Fn::GetAtt: 105 | - S3Bucket 106 | - DomainName 107 | Prefix: logs/cloudfront/raw/ 108 | 109 | CloudfrontRoute53Record: 110 | Condition: CreateRoute53Records 111 | Type: AWS::Route53::RecordSet 112 | Properties: 113 | Comment: proxyfront Cloudfront ${opt:stage} A (IPv4) Record 114 | HostedZoneId: ${file(./config.${opt:stage}.yml):ROUTE53_HOSTED_ZONE_ID} 115 | Name: ${file(./config.${opt:stage}.yml):ROUTE53_DOMAIN_NAME} 116 | Type: A 117 | AliasTarget: 118 | # @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html#cfn-route53-aliastarget-hostedzoneid 119 | HostedZoneId: Z2FDTNDATAQYW2 # This value is AWS-defined constant value. 120 | DNSName: 121 | Fn::Join: 122 | - "." 123 | - - Fn::GetAtt: [CloudfrontDistribution, DomainName] 124 | - "" 125 | 126 | CloudfrontRoute53IPv6Record: 127 | Condition: CreateRoute53Records 128 | Type: AWS::Route53::RecordSet 129 | Properties: 130 | Comment: proxyfront Cloudfront ${opt:stage} AAAA (IPv6) Record 131 | HostedZoneId: ${file(./config.${opt:stage}.yml):ROUTE53_HOSTED_ZONE_ID} 132 | Name: ${file(./config.${opt:stage}.yml):ROUTE53_DOMAIN_NAME} 133 | Type: AAAA 134 | AliasTarget: 135 | # @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html#cfn-route53-aliastarget-hostedzoneid 136 | HostedZoneId: Z2FDTNDATAQYW2 # This value is AWS-defined constant value. 137 | DNSName: 138 | Fn::Join: 139 | - "." 140 | - - Fn::GetAtt: [CloudfrontDistribution, DomainName] 141 | - "" 142 | 143 | LambdaEdgeRole: 144 | Type: AWS::IAM::Role 145 | Properties: 146 | AssumeRolePolicyDocument: 147 | Version: '2012-10-17' 148 | Statement: 149 | - Effect: Allow 150 | Principal: 151 | Service: 152 | - edgelambda.amazonaws.com 153 | - lambda.amazonaws.com 154 | Action: 155 | - sts:AssumeRole 156 | ManagedPolicyArns: 157 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 158 | -------------------------------------------------------------------------------- /src/handlers/origin-request.ts: -------------------------------------------------------------------------------- 1 | import * as net from "net"; 2 | import * as tls from "tls"; 3 | import { URL } from "url"; 4 | 5 | import { 6 | CloudFrontRequest, 7 | CloudFrontRequestEvent, 8 | CloudFrontResultResponse, 9 | } from "aws-lambda"; 10 | 11 | import { Config } from "../config"; 12 | import { HandledError } from "../helpers/handled-error"; 13 | import { RequestContext } from "../helpers/request"; 14 | import { render as renderErrorPage } from "../views/error"; 15 | 16 | export async function handler(event: CloudFrontRequestEvent): Promise { 17 | const context = new RequestContext(event); 18 | 19 | try { 20 | if (context.request.method.toUpperCase() === "OPTIONS") { 21 | const origin = context.request.headers.origin && context.request.headers.origin.length > 0 ? 22 | context.request.headers.origin[0].value : 23 | ""; 24 | 25 | await checkCORSOrigin(origin, Config.cors ? Config.cors.whitelist : []); 26 | 27 | return context.reply({ 28 | statusCode: 204, 29 | headers: { 30 | "Access-Control-Allow-Methods": "GET, PUT, POST, DELETE, HEAD", 31 | "Access-Control-Allow-Origin": origin, 32 | "Access-Control-Max-Age": `${Config.cors && Config.cors.maxAge || 300}`, 33 | }, 34 | body: "", 35 | }); 36 | } 37 | 38 | const remote = extractUrl(context); 39 | 40 | await checkOriginHostname(remote.hostname, Config.origin.whitelist); 41 | 42 | if (Config.debug && context.query.has("PROXYFRONT_SIMULATE_ORIGIN_REQUEST")) { 43 | return context.json(200, { 44 | event, 45 | request: context.proxy(remote), 46 | }, { 47 | "Cache-Control": "public, no-cache", 48 | }); 49 | } 50 | 51 | if (Config.origin.checkConnectivity) { 52 | await checkOriginConnectivity(remote, 5000); 53 | } 54 | 55 | return context.proxy(remote); 56 | } catch (e) { 57 | console.error(e.stack); // tslint:disable-line 58 | 59 | if (e instanceof HandledError) { 60 | return context.html(e.status, await renderErrorPage({ 61 | requestIp: context.request.clientIp, 62 | status: { 63 | code: e.status, 64 | description: e.message, 65 | }, 66 | reason: e.reason, 67 | origin: e.origin, 68 | }), { 69 | "Cache-Control": "public, no-cache", 70 | }); 71 | } else { 72 | return context.html(400, await renderErrorPage({ 73 | requestIp: context.request.clientIp, 74 | status: { 75 | code: 500, 76 | description: `Failed to process request`, 77 | }, 78 | reason: e.message, 79 | }), { 80 | "Cache-Control": "public, no-cache", 81 | }); 82 | } 83 | } 84 | } 85 | 86 | function extractUrl(context: RequestContext): URL { 87 | const candidates = [ 88 | context.request.uri, 89 | context.request.uri.slice(1), 90 | ]; 91 | 92 | const matched = candidates.find((candidate) => /^https?:/.test(candidate)); 93 | 94 | if (!matched) { 95 | throw new HandledError(400, { 96 | message: "Invalid URL" , 97 | reason: "URL input is missing, or invalid. Check your URL and try again.", 98 | }); 99 | } 100 | 101 | try { 102 | let built = matched; 103 | if (context.query.size > 0) { 104 | built += `?${context.request.querystring}`; 105 | } 106 | 107 | return new URL(built); 108 | } catch (e) { 109 | throw new HandledError(400, { 110 | message: "Malformed URL", 111 | reason: "URL input is malformed. Check your URL format and try again.", 112 | underlyingError: e, 113 | }); 114 | } 115 | } 116 | 117 | async function isWhitelisted( 118 | value: string, 119 | matchers: Array Promise)>, 120 | ): Promise { 121 | for (const matcher of matchers) { 122 | if (typeof matcher === "string") { 123 | if (value === matcher) { 124 | return true; 125 | } 126 | } else if (matcher instanceof RegExp) { 127 | matcher.lastIndex = 0; 128 | if (matcher.test(value)) { 129 | return true; 130 | } 131 | } else { 132 | if (await matcher(value)) { 133 | return true; 134 | } 135 | } 136 | } 137 | 138 | return false; 139 | } 140 | 141 | export async function checkOriginHostname( 142 | hostname: string, 143 | whitelist?: Array Promise)>, 144 | ): Promise { 145 | if (!whitelist || whitelist.length === 0) { 146 | return; 147 | } 148 | 149 | const isAllowed = await isWhitelisted(hostname, whitelist); 150 | if (!isAllowed) { 151 | throw new HandledError(403, { 152 | message: "Forbidden", 153 | reason: `Access Denied. The requested origin \`${hostname}\` is not allowed.`, 154 | }); 155 | } 156 | } 157 | 158 | async function checkCORSOrigin( 159 | origin?: string, 160 | whitelist: Array Promise)> = [], 161 | ): Promise { 162 | if (!origin) { 163 | throw new HandledError(400, { 164 | message: "Bad Request", 165 | reason: "Origin header was not found on request", 166 | }); 167 | } 168 | 169 | const isAllowed = await isWhitelisted(origin, whitelist); 170 | if (!isAllowed) { 171 | throw new HandledError(403, { 172 | message: "Forbidden", 173 | reason: `Access Denied. The requested origin \`${origin}\` is not allowed.`, 174 | }); 175 | } 176 | } 177 | 178 | function checkOriginConnectivity(remoteUrl: URL, connectionTimeoutMs: number) { 179 | return new Promise((resolve, reject) => { 180 | const socket = remoteUrl.protocol === "http:" ? 181 | net.connect({ 182 | host: remoteUrl.hostname, 183 | port: remoteUrl.port || 80, 184 | } as net.NetConnectOpts) : 185 | tls.connect({ 186 | host: remoteUrl.hostname, 187 | port: remoteUrl.port || 443, 188 | servername: remoteUrl.hostname, 189 | } as tls.ConnectionOptions); 190 | 191 | const origin = remoteUrl; 192 | const CONNECT_EVENT = socket instanceof tls.TLSSocket ? 193 | "secureConnect" : 194 | "connect"; 195 | 196 | socket.setTimeout(connectionTimeoutMs) 197 | .once("timeout", onTimeout) 198 | .once("error", onError) 199 | .once(CONNECT_EVENT, onConnect); 200 | 201 | function onConnect() { 202 | socket 203 | .removeListener("timeout", onTimeout) 204 | .removeListener("error", onError); 205 | 206 | if (socket instanceof tls.TLSSocket) { 207 | if (socket.authorizationError) { 208 | return reject(new HandledError(502, { 209 | message: "Bad Gateway", 210 | reason: socket.authorizationError.message, 211 | underlyingError: socket.authorizationError, 212 | origin, 213 | })); 214 | } 215 | 216 | if (!socket.authorized) { 217 | return reject(new HandledError(502, { 218 | message: "Bad Gateway", 219 | reason: "Unauthorized origin TLS connection detected.", 220 | origin, 221 | })); 222 | } 223 | } 224 | 225 | resolve(); 226 | } 227 | 228 | function onTimeout() { 229 | socket 230 | .removeListener(CONNECT_EVENT, onConnect) 231 | .removeListener("error", onError); 232 | 233 | reject(new HandledError(502, { 234 | message: "Bad Gateway", 235 | reason: "Origin connection timeout", 236 | origin, 237 | })); 238 | } 239 | 240 | function onError(e: Error) { 241 | socket 242 | .removeListener("timeout", onTimeout) 243 | .removeListener(CONNECT_EVENT, onConnect); 244 | 245 | reject(new HandledError(502, { 246 | message: "Bad Gateway", 247 | reason: e.message, 248 | underlyingError: e, 249 | origin, 250 | })); 251 | } 252 | }); 253 | } 254 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # proxyfront 2 | Turn CloudFront as dynamic forward proxy server 3 | 4 | ## Why? 5 | 6 | Sometimes you may want to proxy remote resources, which are not owned by you. 7 | 8 | For example, Let's suppose that you want to show 'open graph image' from your website. but sadly, given open graph image is hosted at a small-sized server. It means sending requests from your website can be a potential DoS attack. 9 | 10 | also, Remote web server can block 'Hot Linking'. In this case, you must proxy the resource to display images. 11 | 12 | You can implement your own proxy server by using Lambda with API Gateway. but this method has a critical limitation: The response size limit. 13 | 14 | In Lambda, you can send a response up to 6MB. but, Response body should be encoded with base64 if you are sending binary data. In theory, base64 has additional storage overhead. so actually, you can send a response up to 4.5MB binary data. Also, Using Lambda with API Gateway does not support response streaming, nor ranged requests (e.g. sending a partial response). It should cause slower first-byte latency time. 15 | 16 | But now, We can use Lambda@Edge with CloudFront. We can modify origin dynamically! Since Lambda@Edge does not handle response directly, We can eliminate the response size issue! 17 | With Lambda@Edge, we can send a response up to 20GByte. 18 | 19 | For another example, You should use the HTTPS protocol to resources if you are running your website on the HTTPS protocol. (For further details, Please refer Google Developers - Mixed Content) but if remote does not support HTTPS, you should proxy the resource to secure resource transfer. 20 | 21 | Also, ProxyFront can act as the 'Forward Proxy' server! 22 | 23 | ## Examples 24 | 25 | - Proxying remote resource: https://proxy.aws.debug.so/https://i.ytimg.com/vi/IWJUPY-2EIM/hqdefault.jpg 26 | - Proxying 5.2MB PDF: https://proxy.aws.debug.so/https://docs.aws.amazon.com/en_us/lambda/latest/dg/lambda-dg.pdf 27 | - Proxying 397MB Video: https://proxy.aws.debug.so/https://download.blender.org/peach/bigbuckbunny_movies/big_buck_bunny_720p_h264.mov 28 | - Securing insecure remote resource: https://proxy.aws.debug.so/http://b.vimeocdn.com/ts/345/113/345113661_640.jpg 29 | - Checking origin connectivity: https://proxy.aws.debug.so/http://not-exists-subdomain.debug.so 30 | - Checking origin TLS handshake: https://proxy.aws.debug.so/https://debug.so 31 | - Checking allowed origin (Origin Whitelist): https://proxy.aws.debug.so/https://not-allowed-host.com/foobarbaz 32 | 33 | ### Screenshots 34 | 35 | #### Forward Proxy 36 | 37 | ##### The `X-Forwarded-For` header shows IP Address of CloudFront Edge PoP 38 | 39 | ![Forward Proxy](/assets/forward-proxy.gif) 40 | 41 | ##### Bypass Internet Censorship System through CloudFront 42 | 43 | ![Bypass Internet Censorship](/assets/forward-proxy-bypass.gif) 44 | 45 | 46 | #### Customized Error Pages 47 | 48 | ![Customized Error Pages](/assets/error.png) 49 | 50 | 51 | ## Features 52 | 53 | - Dynamic Origin Selection using Request URL 54 | - Check origin connectivity to prevent slow first-byte response 55 | - Check SSL related issues to prevent CloudFront failure 56 | - Built-in CORS support 57 | - Cloudflare-like customized error page 58 | - Whitelisting Origin 59 | - Forward Proxy Server 60 | 61 | ### Two proxy modes 62 | 63 | - Static remote resource proxy server 64 | - Only OPTIONS, HEAD, GET methods are allowed 65 | - Remote resource will be cached to edge by default 66 | - Forward proxy server 67 | - All CloudFront supported HTTP methods are allowed (OPTIONS, HEAD, GET, POST, PUT, DELETE, PATCH) 68 | - All responses won't be cached to edge by default 69 | 70 | ## Getting Started 71 | 72 | Deployment is super easy. ProxyFront provides CLI configurator too! 73 | 74 | ![Configurator Demo](/assets/configurator.gif) 75 | 76 | ```bash 77 | $ git clone https://github.com/mooyoul/proxyfront.git 78 | $ cd proxyfront 79 | $ npm i 80 | $ npm run configure # Configure required resources like Custom Domain, Route 53 Record... 81 | $ vi src/config.ts # Configure advanced configuration 82 | $ npm run deploy:prod # or npm run deploy:stage 83 | ``` 84 | 85 | That's it! Initial Deployment will take up to 1 hour. 86 | 87 | ### Running Forward Proxy Server 88 | 89 | ![Forward Proxy Server](/assets/forward-proxy-server.gif) 90 | 91 | Since CloudFront does not support `CONNECT` method, You'll need to use custom proxy software to translate these proxy client requests. 92 | Simply run `env PROXYFRONT_HOST=my-proxy-front.example.com npm run client` to start forward proxy. 93 | You'll need to create (e.g. `npx run anyproxy-ca --genrate`) & trust created custom Root CA from target devices if you need proxy HTTPS requests. 94 | For further details, Please refer to [anyproxy repository](https://github.com/alibaba/anyproxy). 95 | 96 | ## Configuration 97 | 98 | There are two kind of configurations: 99 | 100 | ### Deployment/Resource related Configurations (config.*.yml) 101 | 102 | This configuration file should not be edited by manually. 103 | Please re-run `npm run configure` if you need to reconfigure your stack. 104 | 105 | ### Proxy related Configurations (src/config.ts) 106 | 107 | ```typescript 108 | interface Config { 109 | // If enabled, reserved query parameters like PROXYFRONT_SIMULATE_ORIGIN_REQUEST will work. 110 | debug: boolean; 111 | 112 | // Origin related configurations 113 | origin: { 114 | // If enabled, ProxyFront will check origin connectivity before passing request to CloudFront. 115 | // It's recommended to enable this flag since Cloudfront does not provide detailed failure reason 116 | // and first-byte latency can be extremely high if connection issue occurs. 117 | checkConnectivity: boolean; 118 | 119 | // Origin Read Timeout 120 | // @see https://aws.amazon.com/about-aws/whats-new/2017/03/announcing-configure-read-timeout-and-keep-alive-timeout-values-for-your-amazon-cloudfront-custom-origins/ 121 | // @see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#request-custom-request-timeout 122 | readTimeout: number; // in seconds 123 | 124 | // Keep-Alive idle timeout 125 | // @see https://aws.amazon.com/about-aws/whats-new/2017/03/announcing-configure-read-timeout-and-keep-alive-timeout-values-for-your-amazon-cloudfront-custom-origins/ 126 | // @see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#request-custom-persistent-connections 127 | keepAliveTimeout: number; // in seconds 128 | 129 | // Origin Request User-Agent 130 | // if not specified, default user agent will be forwarded. 131 | // Default user agent will be: 132 | // Forward User-Agent header: Client's User-Agent 133 | // Not forward User-Agent header: Amazon CloudFront 134 | userAgent?: string; 135 | 136 | // Origin Whitelist 137 | // If you want to allow only specific origin, configure whitelist 138 | // 1. Specify allowed hostname 139 | // 2. Specify regexp that matches allowed hostname 140 | // 3. Specify custom async matcher function that returns boolean. 141 | whitelist?: Array Promise)>; 142 | }; 143 | 144 | // CORS related configurations 145 | // If not specified, CORS middleware will be disabled. 146 | // For example: 147 | // 1) CORS pre-flight request won't be accepted 148 | // 2) Response won't include CORS related headers (e.g. `Access-Control-Allow-Origin`) 149 | cors?: { 150 | whitelist?: Array Promise)>; 151 | maxAge?: string; 152 | }; 153 | } 154 | ``` 155 | 156 | ## Limitations 157 | 158 | - Max number of CloudFront loops 159 | - (to be updated) 160 | 161 | ## Debugging 162 | 163 | If debugging mode is enabled, You can use following query parameters to inspect internal Lambda@Edge request/responses: 164 | 165 | - `PROXYFRONT_SIMULATE_ORIGIN_REQUEST` 166 | - `PROXYFRONT_SIMULATE_ORIGIN_RESPONSE` 167 | 168 | 169 | ## License 170 | 171 | [MIT](LICENSE) 172 | 173 | See full license on [mooyoul.mit-license.org](http://mooyoul.mit-license.org/) 174 | -------------------------------------------------------------------------------- /tools/configure.ts: -------------------------------------------------------------------------------- 1 | import { highlight } from "cli-highlight"; 2 | import { stripIndent } from "common-tags"; 3 | import * as crypto from "crypto"; 4 | import * as fs from "fs"; 5 | import * as inquirer from "inquirer"; 6 | import * as yml from "js-yaml"; 7 | import ow from "ow"; 8 | import * as path from "path"; 9 | import * as tldjs from "tldjs"; 10 | 11 | interface Input { 12 | stage: string; 13 | s3: { 14 | bucketName: string; 15 | }; 16 | hasCustomDomainName: boolean; 17 | customDomain?: { 18 | hostname: string; 19 | certificateArn: string; 20 | hasRoute53Record: boolean; 21 | route53Record?: { 22 | zoneId: string; 23 | } 24 | }; 25 | proxy: { 26 | type: "static-resource-proxy" | "forward-proxy"; 27 | ttl?: number; 28 | }; 29 | } 30 | 31 | // tslint:disable:no-console 32 | (async () => { 33 | const { stage } = await inquirer.prompt<{ stage: "prod" | "stage" }>({ 34 | type: "list", 35 | name: "stage", 36 | message: "Which stage do you want to configure?", 37 | default: "prod", 38 | choices: [ 39 | "prod", 40 | "stage", 41 | ], 42 | validate(value) { 43 | try { 44 | ow(value, ow.string.lowercase.alphabetical.minLength(3).maxLength(16)); 45 | return true; 46 | } catch (e) { 47 | return e.message; 48 | } 49 | }, 50 | }); 51 | 52 | const CONFIG_FILE_PATH = path.join(__dirname, `../config.${stage}.yml`); 53 | 54 | const hasConfig = await new Promise((resolve, reject) => { 55 | fs.stat(CONFIG_FILE_PATH, (e) => { 56 | if (e) { 57 | if (e.code === "ENOENT") { 58 | return resolve(false); 59 | } 60 | 61 | reject(e); 62 | } 63 | 64 | resolve(true); 65 | }); 66 | }); 67 | 68 | if (hasConfig) { 69 | const { overwrite } = await inquirer.prompt<{ overwrite: boolean; }>([{ 70 | type: "confirm", 71 | name: "overwrite", 72 | message: stripIndent` 73 | Previous configuration was found. 74 | Are you sure to continue? You'll lose previous configuration. 75 | `, 76 | default: false, 77 | }]); 78 | 79 | if (!overwrite) { 80 | console.log("\nAborted."); 81 | return; 82 | } 83 | } 84 | 85 | const inputs = await inquirer.prompt([{ 86 | type: "input", 87 | name: "s3.bucketName", 88 | message: stripIndent` 89 | What's the name of S3 Bucket? 90 | Created s3 bucket will be used for saving CloudFront Access logs. 91 | `, 92 | default() { 93 | return `proxyfront-${stage}-${crypto.randomBytes(8).toString("hex")}`; 94 | }, 95 | validate(value) { 96 | try { 97 | ow(value, ow.string.lowercase.minLength(3).maxLength(63)); 98 | 99 | if (!/^[a-z0-9\-]+$/i.test(value)) { 100 | return `Expected string to be alphanumeric or dash (-), got \`${value}\``; 101 | } 102 | 103 | if (!/^[a-z0-9]/.test(value)) { 104 | return `Expected first character to be alphanumeric, got \`${value}\``; 105 | } 106 | 107 | return true; 108 | } catch (e) { 109 | return e.message; 110 | } 111 | } 112 | }, { 113 | type: "confirm", 114 | name: "hasCustomDomainName", 115 | message: "Do you want to configure custom domain name?", 116 | default: true, 117 | }, { 118 | type: "input", 119 | name: "customDomain.hostname", 120 | when(input: Partial) { 121 | return input.hasCustomDomainName!; 122 | }, 123 | message: "Please input custom domain name (e.g. proxy.example.com)", 124 | validate(value) { 125 | try { 126 | const hostname = tldjs.parse(value); 127 | if (!hostname.isValid || hostname.hostname !== value) { 128 | return `Expected valid hostname, got \`${value}\``; 129 | } 130 | 131 | if (!hostname.tldExists) { 132 | return `Expected valid TLD, got \`${hostname.publicSuffix}\``; 133 | } 134 | 135 | return true; 136 | } catch (e) { 137 | return e.message; 138 | } 139 | }, 140 | }, { 141 | type: "input", 142 | name: "customDomain.certificateArn", 143 | when(input: Partial) { 144 | return input.hasCustomDomainName!; 145 | }, 146 | message(input: Partial) { 147 | return stripIndent` 148 | Please input ACM Certificate ARN for \`${input.customDomain!.hostname}\` 149 | (e.g. arn:aws:acm:us-east-1:123456789012:certificate/00112233-4455-6677-8899-aabbccddeeff) 150 | `; 151 | }, 152 | validate(value) { 153 | if (!/arn:aws:acm:[a-z0-9\-]+:[0-9]+:certificate\/[a-z0-9\-]/.test(value)) { 154 | return "Invalid ARN format. Please check ARN input and try again."; 155 | } 156 | 157 | const [ , , , region ] = value.split(":"); 158 | if (region !== "us-east-1") { 159 | return "ACM Certificate for CloudFront distribution must be issued from us-east-1 (N. Virginia) region."; 160 | } 161 | 162 | return true; 163 | }, 164 | }, { 165 | type: "confirm", 166 | name: "customDomain.hasRoute53Record", 167 | when(input: Partial) { 168 | return input.hasCustomDomainName!; 169 | }, 170 | message(input: Partial) { 171 | return `Do you want to configure route 53 record for \`${input.customDomain!.hostname}\`?`; 172 | }, 173 | default: true, 174 | }, { 175 | type: "input", 176 | name: "customDomain.route53Record.zoneId", 177 | when(input: Partial) { 178 | return input.hasCustomDomainName! && input.customDomain!.hasRoute53Record; 179 | }, 180 | message(input: Partial) { 181 | return `Please input Route 53 Hosted Zone Id for \`${input.customDomain!.hostname}\``; 182 | }, 183 | validate(value) { 184 | try { 185 | ow(value, ow.string.uppercase.alphanumeric.minLength(2).maxLength(32)); 186 | 187 | return true; 188 | } catch (e) { 189 | return e.message; 190 | } 191 | }, 192 | }, { 193 | type: "list", 194 | name: "proxy.type", 195 | message: "Which purpose of ProxyFront?", 196 | default: "static-resource-proxy", 197 | choices: [{ 198 | name: "for remote static resource proxy (e.g. to cache remote static resources)", 199 | value: "static-resource-proxy", 200 | }, { 201 | name: "for forward proxy server", 202 | value: "forward-proxy", 203 | }], 204 | }, { 205 | type: "input", 206 | name: "proxy.ttl", 207 | when(input: Partial) { 208 | return input.proxy!.type === "static-resource-proxy"; 209 | }, 210 | message: stripIndent` 211 | How long cached resource stay in CloudFront? 212 | Specify Default TTL in seconds unit. (Default: 30 days) 213 | `, 214 | filter: Number, 215 | default: 2592000, 216 | validate(value) { 217 | try { 218 | ow(value, ow.number.greaterThanOrEqual(0).lessThanOrEqual(3153600000)); 219 | return true; 220 | } catch (e) { 221 | return e.message; 222 | } 223 | }, 224 | }]); 225 | 226 | const config = createConfigYaml({ stage, ...inputs }); 227 | 228 | console.log("Generated configuration file contents: \n"); 229 | console.log(highlight(config, { language: "yaml" })); 230 | 231 | const { hasConfirmed } = await inquirer.prompt<{ hasConfirmed: boolean; }>([{ 232 | type: "confirm", 233 | name: "hasConfirmed", 234 | message: "Does it looks good?", 235 | default: true, 236 | }]); 237 | 238 | if (!hasConfirmed) { 239 | console.log("\nAborted."); 240 | return; 241 | } 242 | 243 | await new Promise(((resolve, reject) => { 244 | fs.writeFile(CONFIG_FILE_PATH, config, (e) => { 245 | if (e) { return reject(e); } 246 | 247 | resolve(); 248 | }); 249 | })); 250 | 251 | // tslint:disable:max-line-length 252 | console.log("\n"); 253 | console.log(stripIndent` 254 | Stack configuration file was saved to \`${CONFIG_FILE_PATH}\`. 255 | Now you can deploy your own ProxyFront service by calling \`${highlight("npm run deploy:prod", { language: "bash" })}\` or \`${highlight("npm run deploy:stage", { language: "bash" })}\` 256 | from your terminal! 257 | 258 | If you want to configure remote origin whitelist or CORS support, 259 | Feel free to edit \`src/config.ts\`. 260 | `); 261 | 262 | if (inputs.proxy.type === "forward-proxy") { 263 | console.log("\n"); 264 | console.log(stripIndent` 265 | also, you've selected forward proxy feature. 266 | you can run proxy client by calling \`${highlight("env PROXYFRONT_HOST=proxyfront.example.com npm run client", { language: "bash" })}\`! 267 | `); 268 | } 269 | 270 | console.log("\n🎉 Done. "); 271 | // tslint:enable:max-line-length 272 | })().catch(console.error); 273 | 274 | function createConfigYaml(input: Input): string { 275 | const isStaticProxy = input.proxy.type === "static-resource-proxy"; 276 | const hasRoute53Record = input.hasCustomDomainName && input.customDomain!.hasRoute53Record; 277 | 278 | const config = { 279 | STAGE: input.stage, 280 | BUCKET_NAME: input.s3.bucketName, 281 | CLOUDFRONT_VIEWER_CERTIFICATE: input.hasCustomDomainName ? 282 | { 283 | AcmCertificateArn: input.customDomain!.certificateArn, 284 | SslSupportMethod: "sni-only", 285 | // For backward compatibility with older devices such Android 4.1.1 (ICS) 286 | MinimumProtocolVersion: "TLSv1", 287 | } : 288 | { CloudFrontDefaultCertificate: true }, 289 | CLOUDFRONT_CUSTOM_DOMAIN_NAMES: input.hasCustomDomainName ? 290 | [input.customDomain!.hostname] : 291 | { Ref: "AWS::NoValue" }, 292 | CLOUDFRONT_ALLOWED_METHODS: isStaticProxy ? 293 | ["GET", "HEAD", "OPTIONS"] : 294 | ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"], 295 | CLOUDFRONT_MIN_TTL: 0, 296 | CLOUDFRONT_DEFAULT_TTL: isStaticProxy ? 297 | input.proxy.ttl! : 298 | 0, 299 | CLOUDFRONT_MAX_TTL: isStaticProxy ? 300 | 3153600000 : 301 | 0, 302 | CLOUDFRONT_FORWARD_COOKIE: isStaticProxy ? 303 | "none" : 304 | "all", 305 | CLOUDFRONT_FORWARD_HEADER: isStaticProxy ? 306 | ["Origin"] : 307 | ["*"], 308 | CREATE_ROUTE53_RECORDS: hasRoute53Record ? 309 | "true" : 310 | "false", 311 | ROUTE53_HOSTED_ZONE_ID: hasRoute53Record ? 312 | input.customDomain!.route53Record!.zoneId : 313 | { Ref: "AWS::NoValue" }, 314 | ROUTE53_DOMAIN_NAME: hasRoute53Record ? 315 | `${input.customDomain!.hostname}.` : 316 | { Ref: "AWS::NoValue" }, 317 | } as any; 318 | 319 | return yml.safeDump(config); 320 | } 321 | 322 | // tslint:enable:no-console 323 | -------------------------------------------------------------------------------- /src/views/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ProxyFront Error 6 | 7 | 8 | 9 | 215 | 216 | 217 |
218 |

219 | 220 | Error <%= data.status.code %> 221 | 222 | 223 | <%= data.time %> 224 | 225 |

226 |

227 | <%- data.status.description %> 228 |

229 | <% if (data.reason) { %> 230 |

231 | <%- data.reason %> 232 |

233 | <% } %> 234 |
235 |
236 |
237 | AWS-General_Client_dark-bg 238 |

239 | You 240 |

241 |
242 |
243 |
244 | Browser 245 |
246 |

Working

247 |
248 |
249 |
250 | Amazon-CloudFront 251 |

252 | <%= data.location %> 253 |

254 |
255 |
256 | Amazon
257 | CloudFront 258 |
259 |

<%= data.origin ? 'Working' : 'Error' %>

260 |
261 |
262 |
263 | AWS-General_Traditional-Server_dark-bg 264 |

265 | <%= data.origin ? data.origin.hostname : 'Unknown' %> 266 |

267 |
268 |
269 |
270 | Host 271 |
272 |

<%= data.origin ? 'Error' : 'Unknown' %>

273 |
274 |
275 | 284 |
285 | 286 | 287 | --------------------------------------------------------------------------------