Skip to content

Instantly share code, notes, and snippets.

@kissifrot
Created August 12, 2022 12:16
Show Gist options
  • Save kissifrot/ead2e6bb54f1a9a1bc82ed71b649fb45 to your computer and use it in GitHub Desktop.
Save kissifrot/ead2e6bb54f1a9a1bc82ed71b649fb45 to your computer and use it in GitHub Desktop.
Basic nodejs ALB lambda with added Gateway IP information
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