Created
August 12, 2022 12:16
-
-
Save kissifrot/ead2e6bb54f1a9a1bc82ed71b649fb45 to your computer and use it in GitHub Desktop.
Basic nodejs ALB lambda with added Gateway IP information
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const http = require('http'); | |
const https = require('https'); | |
const swIgnored = ["/css/", "/fonts/", "/images/", "/js/", "/scss/", ".bmp", ".css", ".csv", ".doc", ".docx", ".eot", ".gif", ".ico", ".ief", ".jpe", ".jpeg", ".jpg", ".js", ".json", ".less", ".map", ".m1v", ".mov", ".mp2", ".mp3", ".mp4", ".mpa", ".mpe", ".mpeg", ".mpg", ".otf", ".ott", ".ogv", ".pbm", ".pdf", ".pgm", ".png", ".ppm", ".pnm", ".ppt", ".pps", ".ps", ".qt", ".ras", ".rdf", ".rgb", ".rss", ".svg", ".swf", ".tiff", ".tif", ".tsv", ".ttf", ".txt", ".vcf", ".wav", ".webm", ".woff", ".woff2", ".xbm", ".xlm", ".xls", ".xml", ".xpdl", ".xpm", ".xwd"]; // in lower case | |
const swStatusHeader = "x-sw-status"; | |
const swFallbackHeader = "x-sw-fallback"; | |
const swClientIpHeader = "x-sw-client-ip"; | |
// Read env vars | |
var swDomain = process.env.SW_DOMAIN; // Speedworkers domain name, something like test.speedworkers.com | |
var swWebsiteId = process.env.SW_WEBSITE_ID; // ID provided by botify | |
var swToken = process.env.SW_TOKEN; // Token provided by Botify | |
var swTimeout = parseInt(process.env.SW_TIMEOUT); // Delay in ms before considering SW is down | |
var fallbackDomain = process.env.FALLBACK_DOMAIN; // ALB DNS name, like test-1234567890.us-east-1.elb.amazonaws.com | |
var fallbackPort = process.env.FALLBACK_PORT; // ALB port | |
var fallbackProtocol = process.env.FALLBACK_PROTOCOL; // Protocol to use to call ALB (either HTTP or HTTPS) | |
var fallbackTimeout = parseInt(process.env.FALLBACK_TIMEOUT); // Delay in ms before considering the request failed | |
var fallbackSecret = process.env.FALLBACK_SECRET; // Secret value to match ALB rule and make sure this lambda is not called | |
var originDomain = process.env.ORIGIN_DOMAIN; // Origin domain to rebuild the requested URL (https://xxxx.com) | |
var lambdaGatewayIp = process.env.LAMBDA_GATEWAY_IP; // Lambda IP Gateway used | |
if (swTimeout === undefined || isNaN(swTimeout)) { | |
swTimeout = 2000; | |
} | |
if (fallbackTimeout === undefined || isNaN(fallbackTimeout)) { | |
fallbackTimeout = 15000; | |
} | |
// Don't forget the limits! Lambda response can't exceed 1 MB | |
// Don't forget to enable multi value headers in the lambda target group | |
// Don't forget to allow the lambda in the ALB security group | |
exports.handler = function (event, context, callback) { | |
// Ignore resources that are never cached | |
if (ignorePath(event.path)) { | |
redirectToFallback(event, context, callback); | |
return; | |
} | |
callSpeedWorkers(event, context, callback); | |
}; | |
function redirectToFallback(event, context, callback) { | |
console.log("Falling back"); | |
let options = { | |
host: fallbackDomain, | |
port: fallbackPort, | |
path: event.path + queryParamsToString(event), | |
method: event.httpMethod, | |
headers: { ...event.multiValueHeaders } || {}, | |
timeout: fallbackTimeout, | |
rejectUnauthorized: false | |
}; | |
// In the ALB rule, check if this header is set and target the usual target group (not the lambda) | |
options.headers[swFallbackHeader] = fallbackSecret; | |
options.headers["host"] = originDomain.replace(new RegExp("https?://", "i"), ''); | |
let body = []; | |
function fallbackCallback(res) { | |
res.on("data", function (chunk) { | |
body.push(chunk); | |
}); | |
res.on("end", function () { | |
console.log("Fallback status code: " + res.statusCode); | |
let response = { | |
statusCode: res.statusCode, | |
statusDescription: "" + res.statusCode + " " + res.statusMessage, | |
isBase64Encoded: true, | |
multiValueHeaders: {}, | |
body: Buffer.concat(body).toString("base64") | |
}; | |
for (const header in res.headers) { | |
if (Array.isArray(res.headers[header])) { | |
response.multiValueHeaders[header] = res.headers[header]; | |
} else { | |
response.multiValueHeaders[header] = [res.headers[header]]; | |
} | |
} | |
// just for debug, remove it in prod | |
response.multiValueHeaders[swStatusHeader] = ["fallback"]; | |
callback(null, response); | |
}); | |
res.on("error", function (e) { | |
console.log("Error on fallback response: " + e); | |
callback(e, null); | |
}); | |
} | |
options['headers']['x-forwarded-for'][0] = options['headers']['x-forwarded-for'][0] + ', ' + lambdaGatewayIp; | |
// Call the fallback | |
let req = null; | |
if (fallbackProtocol === "HTTPS" || fallbackProtocol === "https") { | |
req = https.request(options, fallbackCallback); | |
} else { | |
req = http.request(options, fallbackCallback); | |
} | |
req.on("timeout", function() { | |
console.log("Timeout on fallback, aborting"); | |
req.abort(); | |
}); | |
req.on("error", function(e) { | |
console.log("Error on fallback request: " + e); | |
callback(e, null); | |
}); | |
req.end(); | |
} | |
function callSpeedWorkers(event, context, callback) { | |
// Rebuild the original URL | |
let targetUri = originDomain + event.path; | |
// Add query parameters | |
targetUri += queryParamsToString(event); | |
console.log("Requesting to SW: " + targetUri); | |
// Build path for SpeedWorkers | |
let path = "/page?"; | |
path += "uri=" + encodeURIComponent(targetUri); | |
path += "&"; | |
path += "website_id=" + swWebsiteId; | |
path += "&"; | |
path += "token=" + swToken; | |
let options = { | |
host: swDomain, | |
port: 443, | |
path: path, | |
method: event.httpMethod, | |
headers: { ...event.multiValueHeaders } || {}, | |
timeout: swTimeout, | |
}; | |
options.headers["host"] = swDomain; | |
options.headers[swClientIpHeader] = getCallerIp(event); | |
// Ensure IMS header is not filtered by a third party | |
if (event.multiValueHeaders["if-modified-since"] !== undefined) { | |
options.headers["x-sw-if-modified-since"] = event.multiValueHeaders["if-modified-since"]; | |
} | |
// Call SpeedWorkers | |
let body = []; | |
let req = https.request(options, function(res) { | |
res.on("data", function (chunk) { | |
body.push(chunk); | |
}); | |
res.on("end", function () { | |
console.log("SW status code: " + res.statusCode); | |
console.log("SW result: " + res.headers[swStatusHeader]); | |
if (res.headers[swStatusHeader] === undefined) { | |
console.log("Missing SW status"); | |
redirectToFallback(event, context, callback); | |
return; | |
} | |
if (res.headers[swStatusHeader] !== "success") | |
{ | |
console.log("SW is unable to deliver the page"); | |
redirectToFallback(event, context, callback); | |
return; | |
} | |
// Make sure there is no cache | |
res.headers["cache-control"] = "max-age=0"; | |
let response = { | |
statusCode: res.statusCode, | |
statusDescription: "" + res.statusCode + " " + res.statusMessage, | |
isBase64Encoded: true, | |
multiValueHeaders: {}, | |
body: Buffer.concat(body).toString("base64") | |
}; | |
for (const header in res.headers) { | |
if (Array.isArray(res.headers[header])) { | |
response.multiValueHeaders[header] = res.headers[header]; | |
} else { | |
response.multiValueHeaders[header] = [res.headers[header]]; | |
} | |
} | |
callback(null, response); | |
}); | |
res.on("error", function (e) { | |
console.log("Error on SW response: " + e); | |
redirectToFallback(event, context, callback); | |
}); | |
}); | |
req.on("timeout", function() { | |
console.log("SW timeout, aborting"); | |
req.abort(); | |
}); | |
req.on("error", function(e) { | |
console.log("Error on SW request: " + e); | |
redirectToFallback(event, context, callback); | |
}); | |
req.end(); | |
} | |
// The ALB set the caller IP address in the x-forwarded-for header | |
function getCallerIp(event) { | |
let xForwardedForArray = event.multiValueHeaders["x-forwarded-for"]; | |
if (xForwardedForArray === undefined) { | |
return ""; | |
} | |
if (!Array.isArray(xForwardedForArray)) { | |
return ""; | |
} | |
let longest = xForwardedForArray.reduce( | |
function (a, b) { | |
return a.length > b.length ? a : b; | |
} | |
); | |
let ips = longest.split(", "); | |
return ips[ips.length - 1]; | |
} | |
function queryParamsToString(event) { | |
if (event.multiValueQueryStringParameters === undefined) { | |
return ""; | |
} | |
let str = "?"; | |
for (const [key, values] of Object.entries(event.multiValueQueryStringParameters)) { | |
for (const value of values) { | |
str += `${key}=${value}`; | |
str += "&"; | |
} | |
} | |
str = str.slice(0, -1); | |
return str; | |
} | |
// returns true if the url contains a path sw should ignore | |
function ignorePath(url) { | |
for (let i = 0; i < swIgnored.length; i++) | |
{ | |
if (url.toLowerCase().includes(swIgnored[i])) | |
return true; | |
} | |
return false; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment