this is draft af, i aint even linted this

This commit is contained in:
soffee 2025-04-09 02:27:22 +03:00
commit a0f765131e
11 changed files with 4209 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules/
dist/
.nyc_output/
**/.DS_Store
.idea
.vscode
*.log
*.tsbuildinfo
config.yaml

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM node:22-alpine AS build
WORKDIR /build
RUN apk add python3 make g++ && corepack enable
COPY package*.json pnpm*.yaml tsconfig.json ./
RUN pnpm install --frozen-lockfile
COPY src ./src
RUN pnpm run build && pnpm prune --prod
FROM node:22-alpine AS final
USER node
WORKDIR /app
COPY --from=build /build/package*.json ./
COPY --from=build /build/dist ./dist
COPY --from=build /build/node_modules ./node_modules
ENTRYPOINT [ "docker-entrypoint.sh", "node", "dist/main.js" ]

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# mtproto_exporter
mtcute powered Prometheus metrics exporter
*this exporter is mostly useful only with userbot*
## Available Metrics
`messenger_dialog_info{peerId, peerType, displayName}`
Dialog information exposed as labels
`messenger_dialog_messages_count{peerId}`
Messages count since exporter startup
`messenger_dialog_unread_messages_count{peerId}`
Number of unread messages in dialogs
`messenger_dialog_keywords_count{peerId}`
Number of keywords found in messages since exporter startup
`messenger_dialog_words_count{peerId}`
Number of words in messages since exporter startup
This metric is disabled by default because it will produce a lot of unique time series (more info [here](https://prometheus.io/docs/practices/naming/#labels))
This will expose each **word** in each **message** in each **chat** as unique metric.
This metric can be enabled with command line flag `--words-counter`
## CLI Options
`--bind-host`, `-b` - ip address where http server will be listening on
`--port`, `-p` - port where http server will be listening on
`--words-counter` - enable each word counting metric
`--keywords-file`, `-k` - path to yaml file with keywords and patterns (see [keywords.yml.example](./keywords.yml.example))
`--watch-file`, `-w` - watch for keywords file updates and reload keywords configuration in runtime
`--include-peers`, `-i` - comma-separated list of `peer.id`s to gather metrics from.
if set, only specified peers will be exposed in metrics.
can be specified multiple times. can not be used along with `--exclude-peers`
`--exclude-peers` `x` - comma-separated list of `peer.id`s to exclude from metrics.
if set, specified peers will not be exposed in metrics.
can be specified multiple times. can not be used along with `--include-peers`
## Environment Variables
`API_ID` - Telegram api id used for mtproto connection (see [mtcute.dev](https://mtcute.dev/guide/intro/sign-in.html))
`API_HASH` - Telegram api hash used for mtproto connection (see [mtcute.dev](https://mtcute.dev/guide/intro/sign-in.html))
## Development
```bash
pnpm install --frozen-lockfile
cp .env.example .env
# edit .env
pnpm start
```
*generated with @mtcute/create-bot*

0
config.example.yaml Normal file
View file

17
docker-compose.yaml Normal file
View file

@ -0,0 +1,17 @@
version: "3.9"
services:
bot:
build:
context: .
restart: always
env_file:
- .env
volumes:
- ./bot-data:/app/bot-data
- ./keywords.yml:/app/keywords.yml
command:
- "--keywords-file"
- "/app/keywords.yml"
- "--watch-file"
ports:
- 127.0.0.1:9669:9669

20
eslint.config.js Normal file
View file

@ -0,0 +1,20 @@
import antfu from "@antfu/eslint-config";
export default antfu({
stylistic: {
indent: 4,
semi: true,
quotes: "double",
},
typescript: true,
yaml: false,
rules: {
"curly": ["error", "multi-line"],
"style/brace-style": ["error", "1tbs", { allowSingleLine: true }],
// "import/order": ["error", { "newlines-between": "always" }], this shit breaks eslint
"antfu/if-newline": "off",
"style/max-statements-per-line": ["error", { max: 2 }],
"no-console": "off",
"antfu/no-top-level-await": "off",
},
});

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "prometheus_policy_proxy",
"type": "module",
"version": "1.0.0",
"packageManager": "pnpm@10.6.5",
"license": "MIT",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"start": "dotenv tsx ./src/main.ts --",
"build": "tsc"
},
"dependencies": {
"@tinyhttp/app": "^2.5.2",
"command-line-args": "^6.0.1",
"dotenv-cli": "^8.0.0",
"js-yaml": "^4.1.0",
"node-fetch": "^3.3.2",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@antfu/eslint-config": "^4.11.0",
"@types/command-line-args": "^5.2.3",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.14.0",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}

3800
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

70
src/config.ts Normal file
View file

@ -0,0 +1,70 @@
import type { OptionDefinition } from "command-line-args";
import { readFile } from "node:fs/promises";
import fs from "node:fs";
import cmdline from "command-line-args";
import yaml from "js-yaml";
export interface Configuration {
bindHost: string;
port: number;
configFile: string;
watchFile: boolean;
prometheusURL: string;
permit_query: string[];
}
const optionDefinitions: OptionDefinition[] = [
{ name: "bind-host", alias: "b", type: String, defaultValue: "0.0.0.0" },
{ name: "port", alias: "p", type: Number, defaultValue: 9091 },
{ name: "config-file", alias: "c", type: String },
{ name: "watch-file", alias: "w", type: Boolean, defaultValue: false },
{ name: "prometheus-url", alias: "u", type: String },
];
const cli = cmdline(optionDefinitions);
if (!cli["prometheus-url"]) {
throw new Error("--prometheus-url is required.");
}
const config: Configuration = {
bindHost: cli["bind-host"],
port: cli.port,
configFile: cli["config-file"],
watchFile: cli["watch-file"],
prometheusURL: cli["prometheus-url"],
permit_query: [],
};
export async function loadConfigFile(filePath: string): Promise<void> {
const doc = yaml.load(await readFile(filePath, "utf8")) as { permit_query?: any[] };
if (typeof doc.permit_query === "object" && doc.permit_query.constructor.name === "Array") {
for (const item of doc.permit_query) {
if (typeof item === "string") {
config.permit_query.push(item);
// } else if (typeof item === "object") {
}
}
} else {
throw new Error("Config file format error: no 'permit_query' property, or not an array.");
}
}
await loadConfigFile(config.configFile);
if (config.watchFile) {
fs.watchFile(config.configFile, async (curr, prev) => {
if (curr.mtimeMs === prev.mtimeMs) {
return;
}
console.log("[watch-file] Config file was updated. Re-reading configuration...");
try {
await loadConfigFile(config.configFile);
} catch (e) {
console.error("Failed to read config file", config.configFile, e);
}
});
}
export { config };

153
src/main.ts Normal file
View file

@ -0,0 +1,153 @@
import { collectDefaultMetrics, Counter, Registry } from "prom-client";
import { App, Request, Response } from '@tinyhttp/app'
import { config } from "./config.js";
import fetch from "node-fetch";
const app = new App()
const registry = new Registry();
collectDefaultMetrics({ register: registry });
let rejectedCounter = new Counter({
name: "prom_policy_requests_rejected_count",
help: "Number of rejected requests to datasource",
labelNames: ["endpoint"],
});
let badRequestCounter = new Counter({
name: "prom_policy_bad_requests_count",
help: "Number of bad requests to datasource",
labelNames: ["endpoint"],
});
let requestsServedCounter = new Counter({
name: "prom_policy_requests_served_count",
help: "Number of successfully served requests to datasource",
labelNames: ["endpoint"],
});
registry.registerMetric(rejectedCounter);
registry.registerMetric(badRequestCounter);
registry.registerMetric(requestsServedCounter);
function readParams(req: Request, params: string[]) {
let data: { [key: string]: string } = {};
if (req.headers["content-type"] === "application/x-www-form-urlencoded") {
const bodyData = new URLSearchParams(String(req.read()));
for(const p of params) {
const v = bodyData.get(p);
if(v) {
data[p] = v;
}
}
} else {
function first<T>(strOrArr: T | T[] | undefined) {
if(Array.isArray(strOrArr)) {
return strOrArr[0];
}
return strOrArr;
}
for(const p of params) {
const v = first(req.query[p]);
if(v) {
data[p] = v;
}
}
}
return data;
}
function validateQuery(query: string, req: Request, res: Response) {
if(!query) {
badRequestCounter.inc({ endpoint: req.path });
res.statusCode = 400;
res.json({
status: "error",
errorType: "bad_data",
error: "unknown position: parse error: no expression found in input",
});
return false;
}
if(!config.permit_query.includes(query)) {
rejectedCounter.inc({ endpoint: req.path });
res.statusCode = 403;
res.json({
status: "error",
errorType: "access_denied",
error: "you are not allowed to perform this query. (bonk!)",
});
return false;
}
return true;
}
app.all("/api/v1/query", async (req, res) => {
const data = readParams(req, ["query", "time", "timeout", "limit"]);
console.log(data);
if(!validateQuery(data.query, req, res)) {
console.log("query was rejected.");
return;
}
let promRes = await fetch(config.prometheusURL + "/api/v1/query", {
method: 'POST',
headers:{
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(data),
});
let promData = await promRes.text();
console.log("prometheus response:", promData);
res.send(promData);
requestsServedCounter.inc({ endpoint: req.path });
});
app.all("/api/v1/query_range", async (req, res) => {
const data = readParams(req, ["query", "start", "end", "step", "timeout", "limit"]);
console.log(data);
if(!validateQuery(data.query, req, res)) {
console.log("query was rejected.");
return;
}
let promRes = await fetch(config.prometheusURL + "/api/v1/query_range", {
method: 'POST',
headers:{
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(data),
});
let promData = await promRes.text();
console.log("prometheus response:", promData);
res.send(promData);
requestsServedCounter.inc({ endpoint: req.path });
});
app.get("/api/v1/series", (req, res) => {});
app.post("/api/v1/series", (req, res) => {});
app.get("/api/v1/labels", (req, res) => {});
app.post("/api/v1/labels", (req, res) => {});
app.get("/api/v1/label/<label_name>/values", (req, res) => {});
app.get('/metrics', async (req, res) => {
const metrics = await registry.metrics();
res.statusCode = 200;
res.setHeader("Content-Type", registry.contentType);
res.send(metrics);
});
app.listen(
config.port,
() => console.log(`HTTP server started on ${config.bindHost}:${config.port}`),
config.bindHost,
);

22
tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"incremental": true,
"target": "es2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowJs": true,
"strict": true,
"inlineSources": true,
"outDir": "./dist",
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"./src"
],
"exclude": [
"**/node_modules"
]
}