192 lines
7.0 KiB
JavaScript
192 lines
7.0 KiB
JavaScript
import * as dotenv from "dotenv";
|
|
dotenv.config();
|
|
|
|
if (process.env.NODE_ENV != "development") {
|
|
process.env.NODE_ENV = "production";
|
|
console.debug = () => {};
|
|
}
|
|
|
|
if (process.env.DOMAIN_WHITELIST) var DOMAIN_WHITELIST = process.env.DOMAIN_WHITELIST.split(',').map(x=>x.trim().toLowerCase()).filter(x=>x);
|
|
if (process.env.USER_WHITELIST) var USER_WHITELIST = process.env.USER_WHITELIST.split(',').map(x=>x.trim()).filter(x=>x);
|
|
|
|
import express from "express";
|
|
import "express-async-errors";
|
|
import fetch from "node-fetch";
|
|
import Keyv from "keyv";
|
|
import {Sha256Signer, Parser} from "activitypub-http-signatures";
|
|
import * as crypto from "crypto";
|
|
import * as util from "util";
|
|
var generateKeyPair = util.promisify(crypto.generateKeyPair);
|
|
var parser = new Parser();
|
|
|
|
var keystore = new Keyv("sqlite://keys.sqlite");
|
|
|
|
var app = express();
|
|
app.set("trust proxy", true);
|
|
app.listen(process.env.PORT || 80, process.env.BIND_IP);
|
|
|
|
app.use(express.text({
|
|
type: [
|
|
"application/jrd+json",
|
|
"application/activity+json",
|
|
"application/json",
|
|
"text/*"
|
|
]
|
|
}));
|
|
|
|
app.use(async (req, res, next) => {
|
|
console.debug({
|
|
ip: req.ip,
|
|
method: req.method,
|
|
url: req.url,
|
|
headers: req.headers,
|
|
body: req.body
|
|
});
|
|
|
|
if (!["GET","POST"].includes(req.method)) return next();
|
|
if (!req.subdomains[0]) return next();
|
|
|
|
var TARGET_NODE = req.subdomains[0].replaceAll(/(?<!-)-(?!-)/g, '.').replaceAll('--','-');
|
|
var TARGET_REGEXP = new RegExp(`(?<!\\.)${TARGET_NODE.replaceAll('.','\\.')}`, 'gi');
|
|
var TARGET_MASQUERADE = req.hostname;
|
|
var TARGET_URL = `https://${TARGET_NODE}${req.url.replaceAll(TARGET_MASQUERADE, TARGET_NODE)}`;
|
|
if (DOMAIN_WHITELIST && !DOMAIN_WHITELIST.includes(TARGET_NODE)) {
|
|
console.debug(`target ${TARGET_NODE} blocked by whitelist`);
|
|
//res.status(403).send(`target ${TARGET_NODE} is not whitelisted`);
|
|
res.redirect(308, TARGET_URL);
|
|
return;
|
|
}
|
|
|
|
var CLIENT_NODE = req.get("User-Agent").match(/(?<=https:\/\/)[a-z0-9-\.]+/i)?.[0];
|
|
if (CLIENT_NODE) {
|
|
if (DOMAIN_WHITELIST && !DOMAIN_WHITELIST.includes(CLIENT_NODE)) {
|
|
console.debug(`client ${CLIENT_NODE} blocked by whitelist`);
|
|
//res.status(403).send(`client ${CLIENT_NODE} is not whitelisted`);
|
|
res.redirect(308, TARGET_URL);
|
|
return;
|
|
}
|
|
var CLIENT_REGEXP = new RegExp(`(?<!\\.)${CLIENT_NODE.replaceAll('.','\\.')}`, 'gi');
|
|
var CLIENT_MASQUERADE = [CLIENT_NODE.replaceAll('-','--').replaceAll('.','-'), ...req.hostname.split('.').slice(-2)].join('.');
|
|
}
|
|
|
|
var opts = {method: req.method, headers: {
|
|
"host": TARGET_NODE,
|
|
"date": new Date().toUTCString(),
|
|
}};
|
|
|
|
if (req.method == "POST") {
|
|
var signature = parser.parse({url: req.url, method: req.method, headers: req.headers});
|
|
console.debug({signature});
|
|
|
|
var publicKeyPem = await getRemotePubkey(signature.keyId);
|
|
if (!publicKeyPem) return res.status(400).send("could not get pubkey");
|
|
|
|
if (!signature.verify(publicKeyPem)) {
|
|
res.status(400).send("bad signature");
|
|
console.debug("bad signature");
|
|
return;
|
|
}
|
|
|
|
var modifiedPayload = req.body.replaceAll(TARGET_MASQUERADE, TARGET_NODE).replaceAll(CLIENT_REGEXP, CLIENT_MASQUERADE);
|
|
console.debug({CLIENT_NODE, CLIENT_REGEXP, CLIENT_MASQUERADE, original:req.body, modified:modifiedPayload});
|
|
|
|
var digest = crypto.createHash("sha256").update(modifiedPayload, "utf-8").digest("base64");
|
|
opts.headers["digest"] = `sha-256=${digest}`;
|
|
|
|
var clientMasqueradeKeyId = signature.keyId.replaceAll(CLIENT_NODE, CLIENT_MASQUERADE);
|
|
var clientMasqueradePrivateKeyPem = (await getLocalKeypair(clientMasqueradeKeyId))?.privateKey;
|
|
|
|
var signer = new Sha256Signer({
|
|
publicKeyId: clientMasqueradeKeyId,
|
|
privateKey: clientMasqueradePrivateKeyPem,
|
|
headerNames: ['(request-target)', 'host', 'date', 'digest']
|
|
});
|
|
|
|
opts.headers["signature"] = signer.sign({url: TARGET_URL, method: opts.method, headers: opts.headers});
|
|
opts.headers["content-tength"] = modifiedPayload.length;
|
|
if (req.get("Content-Type")) opts.headers["content-type"] = req.get("Content-Type");
|
|
opts.body = modifiedPayload;
|
|
}
|
|
|
|
if (req.get("User-Agent")) opts.headers["user-agent"] = req.get("User-Agent").replaceAll(CLIENT_NODE, CLIENT_MASQUERADE);
|
|
if (req.get("Accept")) opts.headers["accept"] = req.get("Accept");
|
|
|
|
var target_res = await fetch(TARGET_URL, opts);
|
|
var contentType = target_res.headers.get("content-type");
|
|
console.debug(target_res.status, target_res.statusText, contentType);
|
|
|
|
//note this affects html attachments from pleroma
|
|
if (contentType.startsWith("text/html")) {
|
|
//res.status(403).send("html is not allowed");
|
|
res.redirect(308, TARGET_URL);
|
|
return;
|
|
}
|
|
|
|
if ([
|
|
"application/jrd+json",
|
|
"application/activity+json",
|
|
"application/json",
|
|
"application/xrd+xml",
|
|
"application/xml"
|
|
].some(t => contentType?.toLowerCase().startsWith(t))) {
|
|
var originalText = await target_res.text(), modifiedText = originalText;
|
|
console.debug({originalText})
|
|
if (contentType.includes("json")) {
|
|
var json = JSON.parse(originalText);
|
|
if (json.preferredUsername && USER_WHITELIST && !USER_WHITELIST.includes(json.preferredUsername)) {
|
|
console.debug(`user ${json.preferredUsername} blocked by whitelist`);
|
|
//res.status(403).send(`${json.preferredUsername} is not whitelisted`);
|
|
res.redirect(308, TARGET_URL);
|
|
return;
|
|
}
|
|
if (json.publicKey) {
|
|
console.debug("has key");
|
|
await keystore.set(json.publicKey.id, {publicKey: json.publicKey.publicKeyPem});
|
|
var masqueradeKeyId = json.publicKey.id.replaceAll(TARGET_REGEXP, TARGET_MASQUERADE);
|
|
var masqueradeKeyPem = (await getLocalKeypair(masqueradeKeyId)).publicKey;
|
|
json.publicKey.id = masqueradeKeyId;
|
|
json.publicKey.publicKeyPem = masqueradeKeyPem;
|
|
modifiedText = JSON.stringify(json);
|
|
}
|
|
}
|
|
modifiedText = modifiedText.replaceAll(TARGET_REGEXP, TARGET_MASQUERADE);
|
|
console.debug({modifiedText});
|
|
} else console.debug("passthrough");
|
|
if (!target_res.ok && !modifiedText && contentType.startsWith("text/")) console.debug("response:", await target_res.text());
|
|
res.status(target_res.status);
|
|
res.header("Content-Type", contentType);
|
|
if (modifiedText) res.send(modifiedText);
|
|
else target_res.body.pipe(res);
|
|
});
|
|
|
|
|
|
|
|
async function getLocalKeypair(id) {
|
|
var keys = await keystore.get(id);
|
|
if (keys) return keys;
|
|
console.debug("making new masquerade key");
|
|
keys = await generateKeyPair('rsa', {
|
|
publicKeyEncoding: {type:'pkcs1', format: 'pem'},
|
|
privateKeyEncoding: {type:'pkcs1', format: 'pem'},
|
|
modulusLength: 2048
|
|
});
|
|
await keystore.set(id, keys);
|
|
return keys;
|
|
}
|
|
|
|
async function getRemotePubkey(id) {
|
|
var publicKey = (await keystore.get(id))?.publicKey;
|
|
if (publicKey) return publicKey;
|
|
console.debug("fetching public key", id);
|
|
var res = await fetch(id, {headers: {Accept: "application/activity+json"}});
|
|
console.debug(res.status, res.statusText, res.headers.get("content-type"));
|
|
if (!res.ok || !res.headers.get("content-type").includes("json")) {
|
|
console.debug("could not get key");
|
|
return false;
|
|
};
|
|
var json = await res.json();
|
|
console.debug(json);
|
|
var publicKey = json?.publicKey?.publicKeyPem;
|
|
if (publicKey) keystore.set(id, {publicKey});
|
|
return publicKey;
|
|
} |