commit 13d90df20c1ca78516e33c29205555806a41b422 Author: Vasili Sviridov Date: Wed Jun 24 16:28:45 2026 -0700 feat: initial import diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4439830 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "sqlite3-inet"] + path = sqlite3-inet + url = https://github.com/AlexeyPechnikov/sqlite3-inet.git diff --git a/as_ban_policy_server.service b/as_ban_policy_server.service new file mode 100644 index 0000000..3637f3a --- /dev/null +++ b/as_ban_policy_server.service @@ -0,0 +1,14 @@ +[Unit] +Description=Postfix policy server that bans clients by their upstream Autonomous System +Wants=postfix.service +After=postfix.service + +[Service] +Type=simple +WorkingDirectory=/opt/as_ban_policy_server +ExecStart=/usr/bin/node /opt/as_ban_policy_server/index.mjs +User=postfix +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db.mjs b/db.mjs new file mode 100644 index 0000000..6a7c954 --- /dev/null +++ b/db.mjs @@ -0,0 +1,75 @@ +import { DatabaseSync } from "node:sqlite"; +import { resolve } from "node:path"; + +const MAX_TTL = 1 * 60 * 60 * 1000; + +const dbPath = resolve(import.meta.dirname, "data", "service.db"); +const extPath = resolve(import.meta.dirname, "data", "libsqliteipv4.so"); +console.log(`Database at ${dbPath}`); +const db = new DatabaseSync(dbPath, { readonly: false, allowExtension: true }); + +db.loadExtension(extPath); + +const schema = ` +CREATE TABLE IF NOT EXISTS "banned_as" ( + "as" TEXT NOT NULL PRIMARY KEY, + "is_active" BOOLEAN NOT NULL, + "comment" TEXT + "reason" TEXT +); + +CREATE TABLE IF NOT EXISTS "as_stats" ( + "as" TEXT NOT NULL PRIMARY KEY, + "accepted" INT, + "rejected" INT +); + +CREATE TABLE IF NOT EXISTS "cidr_cache" ( + "range" TEXT NOT NULL PRIMARY KEY, + "as" TEXT NOT NULL, + "description" TEXT, + "date_created" TEXT NOT NULL +); +`; + +db.exec(schema); + +const getASFromCacheQuery = db.prepare(`SELECT range, "as", date_created FROM cidr_cache WHERE ISINNET(?, range) LIMIT 1`); +const deleteRangeFromCacheQuery = db.prepare(`DELETE FROM cidr_cache WHERE range = ?`); + +export function getASFromCache(ip) { + const data = getASFromCacheQuery.get(ip); + + if(!data) { return null }; + + const { range, as, date_created } = data; + + if(new Date(date_created).getTime() - new Date().getTime() > MAX_TTL) { + console.log(`AS Cache for ${range} is stale. Removing`); + deleteRangeFromCacheQuery.run(range); + return null; + } + + return as; +} + +const isASBannedQuery = db.prepare(`SELECT reason FROM banned_as WHERE "as" = ? AND is_active = true;`); +export function isASBanned(as) { + return isASBannedQuery.get(as); +}; + +const updateASQuery = db.prepare(`INSERT INTO cidr_cache VALUES (?, ?, ?, datetime('now', 'localtime'));`); +export function updateAS(as, range, description) { + updateASQuery.run(range, as, description); +} + +const incrementAcceptedQuery = db.prepare(`INSERT INTO as_stats ("as", accepted) VALUES (?, 1) ON CONFLICT("as") DO UPDATE SET accepted = accepted + 1;`) +const incrementRejectedQuery = db.prepare(`INSERT INTO as_stats ("as", rejected) VALUES (?, 1) ON CONFLICT("as") DO UPDATE SET rejected = rejected + 1;`) + +export function incrementAccepted(as) { + incrementAcceptedQuery.run(as); +} + +export function incrementRejected(as) { + incrementRejectedQuery.run(as); +} diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..28642b8 --- /dev/null +++ b/index.mjs @@ -0,0 +1,89 @@ +import net from "node:net"; +import IpToAsn from "ip-to-asn"; + +import { + getASFromCache, + updateAS, + isASBanned, + incrementRejected, + incrementAccepted, +} from "./db.mjs"; + +const asClient = new IpToAsn(); + +// Create a server +const server = net.createServer((socket) => { + // console.log(`Policy server connection established`); + + socket.on("data", async (data) => { + const lines = data.toString().trim(); + + let response = "dunno"; // Default response + + try { + const command = lines.split(/[\r\n]/).reduce((acc, i) => { + const [key, value] = i.split("="); + return { ...acc, [key]: value }; + }, {}); + + const { client_address, client_name, sender, recipient } = command; + + console.log( + `Incoming email from "${client_name}[${client_address}]. ${sender} -> ${recipient}`, + ); + + // check if IP belongs to previously cached AS records + let as = getASFromCache(client_address); + + if (!as) { + // console.log(`Fetching AS for ${client_address}`); + const result = await query(client_address); + if (result) { + const { range, ASN, description } = result; + as = ASN; + updateAS(as, range, description); + } + } + + const reason = isASBanned(as); + if (reason) { + // console.log(`AS ${as} is banned`); + response = `reject ${reason.reason}`; + incrementRejected(as); + } else { + incrementAccepted(as); + } + // Check if AS is in the ban list + } catch (err) { + console.error("Error processing command:", err); + } + + console.log(`Verdict: ${response}`); + socket.write(`${response}\r\n`); + }); + + socket.on("end", () => { + console.log("Policy server connection ended"); + }); + + socket.on("error", (err) => { + console.error("Policy server socket error:", err); + }); +}); + +// Start the server +server.listen(12346, "127.0.0.1", () => { + console.log(`Policy server running`); +}); + +function query(...ip) { + return new Promise((resolve, reject) => { + asClient.query(ip, (err, result) => { + if (err) { + return reject(err); + } + + resolve(result[ip[0]]); + }); + }); +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ece9a04 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "as_ban_policy_server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "as_ban_policy_server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ip-to-asn": "github:vsviridov/ip-to-asn#0779e4c247bcf736f7adc56b730bea3dbe69500a" + } + }, + "node_modules/ip-to-asn": { + "version": "1.0.1", + "resolved": "git+ssh://git@github.com/vsviridov/ip-to-asn.git#0779e4c247bcf736f7adc56b730bea3dbe69500a", + "integrity": "sha512-K986ggPV8Akw58l13tDmYoa9uB01t0ojq60qBY8q6wugsyyw3Sa9UVNN0YX7eq5z6H8CR74FvomDE4zXIQXgWQ==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d6cba09 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "as_ban_policy_server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "ip-to-asn": "github:vsviridov/ip-to-asn#0779e4c247bcf736f7adc56b730bea3dbe69500a" + } +} diff --git a/sqlite3-inet b/sqlite3-inet new file mode 160000 index 0000000..3aac8af --- /dev/null +++ b/sqlite3-inet @@ -0,0 +1 @@ +Subproject commit 3aac8aff9a318c5723fb6b45f1eb9d7a67c43a09