this is draft af, i aint even linted this
This commit is contained in:
commit
a0f765131e
11 changed files with 4209 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.nyc_output/
|
||||
**/.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
config.yaml
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal 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
68
README.md
Normal 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
0
config.example.yaml
Normal file
17
docker-compose.yaml
Normal file
17
docker-compose.yaml
Normal 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
20
eslint.config.js
Normal 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
29
package.json
Normal 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
3800
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
70
src/config.ts
Normal file
70
src/config.ts
Normal 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
153
src/main.ts
Normal 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
22
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue