Browse Source

Feat: Prometheus (#1655)

* check metrics password

* rewrite prometheus and json metric endpoints

* move metrics to general

metrics is not per interface

* change metrics settings in admin panel

* add i18n keys
pull/1657/head
Bernd Storath 6 months ago
committed by GitHub
parent
commit
48b1413957
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      src/app/components/ClientCard/Avatar.vue
  2. 2
      src/app/components/form/DateField.vue
  3. 26
      src/app/components/form/NullTextField.vue
  4. 1
      src/app/pages/admin.vue
  5. 26
      src/app/pages/admin/index.vue
  6. 3
      src/app/pages/admin/metrics.vue
  7. 23
      src/app/pages/me.vue
  8. 10
      src/app/pages/setup/1.vue
  9. 15
      src/i18n/locales/en.json
  10. 6
      src/server/api/admin/general.get.ts
  11. 11
      src/server/database/migrations/0000_short_skin.sql
  12. 4
      src/server/database/migrations/0001_classy_the_stranger.sql
  13. 77
      src/server/database/migrations/meta/0000_snapshot.json
  14. 79
      src/server/database/migrations/meta/0001_snapshot.json
  15. 4
      src/server/database/migrations/meta/_journal.json
  16. 7
      src/server/database/repositories/general/schema.ts
  17. 91
      src/server/database/repositories/general/service.ts
  18. 9
      src/server/database/repositories/general/types.ts
  19. 6
      src/server/database/repositories/interface/schema.ts
  20. 21
      src/server/database/repositories/metrics/schema.ts
  21. 31
      src/server/database/repositories/metrics/service.ts
  22. 4
      src/server/database/repositories/metrics/types.ts
  23. 1
      src/server/database/schema.ts
  24. 3
      src/server/database/sqlite.ts
  25. 14
      src/server/routes/metrics/index.get.ts
  26. 44
      src/server/routes/metrics/json.get.ts
  27. 67
      src/server/routes/metrics/prometheus.get.ts
  28. 60
      src/server/utils/handler.ts
  29. 74
      src/server/utils/metrics.ts
  30. 10
      src/shared/utils/time.ts

8
src/app/components/ClientCard/Avatar.vue

@ -6,9 +6,11 @@
<div <div
v-if=" v-if="
client.latestHandshakeAt && isPeerConnected({
new Date().getTime() - new Date(client.latestHandshakeAt).getTime() < latestHandshakeAt: client.latestHandshakeAt
1000 * 60 * 10 ? new Date(client.latestHandshakeAt)
: null,
})
" "
> >
<div <div

2
src/app/components/form/DateField.vue

@ -14,7 +14,7 @@
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ id: string; label: string }>(); defineProps<{ id: string; label: string }>();
const [data] = defineModel<string | null>({ const data = defineModel<string | null>({
set(value) { set(value) {
const temp = value?.trim() ?? null; const temp = value?.trim() ?? null;
if (temp === '') { if (temp === '') {

26
src/app/components/form/NullTextField.vue

@ -0,0 +1,26 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model.trim="data"
:name="id"
type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
const data = defineModel<string | null>({
set(value) {
const temp = value?.trim() ?? null;
if (temp === '') {
return null;
}
return temp;
},
});
</script>

1
src/app/pages/admin.vue

@ -45,7 +45,6 @@ const menuItems = [
{ id: 'config', name: 'Config' }, { id: 'config', name: 'Config' },
{ id: 'interface', name: 'Interface' }, { id: 'interface', name: 'Interface' },
{ id: 'hooks', name: 'Hooks' }, { id: 'hooks', name: 'Hooks' },
{ id: 'metrics', name: 'Metrics' },
]; ];
const activeMenuItem = computed(() => { const activeMenuItem = computed(() => {

26
src/app/pages/admin/index.vue

@ -5,13 +5,31 @@
<FormNumberField <FormNumberField
id="session" id="session"
v-model="data.sessionTimeout" v-model="data.sessionTimeout"
label="Session Timeout" :label="$t('general.sessionTimeout')"
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormHeading>Actions</FormHeading> <FormHeading>{{ $t('general.metrics') }}</FormHeading>
<FormActionField type="submit" label="Save" /> <FormNullTextField
<FormActionField label="Revert" @click="revert" /> id="password"
v-model="data.metricsPassword"
:label="$t('passsword')"
/>
<FormSwitchField
id="prometheus"
v-model="data.metricsPrometheus"
:label="$t('general.prometheus')"
/>
<FormSwitchField
id="json"
v-model="data.metricsJson"
:label="$t('general.json')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormActionField type="submit" :label="$t('form.save')" />
<FormActionField :label="$t('form.revert')" @click="revert" />
</FormGroup> </FormGroup>
</FormElement> </FormElement>
</main> </main>

3
src/app/pages/admin/metrics.vue

@ -1,3 +0,0 @@
<template><div></div></template>
<script lang="ts" setup></script>

23
src/app/pages/me.vue

@ -9,7 +9,11 @@
<FormGroup> <FormGroup>
<FormHeading>{{ $t('me.sectionGeneral') }}</FormHeading> <FormHeading>{{ $t('me.sectionGeneral') }}</FormHeading>
<FormTextField id="name" v-model="name" :label="$t('name')" /> <FormTextField id="name" v-model="name" :label="$t('name')" />
<FormTextField id="email" v-model="email" :label="$t('email')" /> <FormNullTextField
id="email"
v-model="email"
:label="$t('email')"
/>
<FormActionField type="submit" :label="$t('save')" /> <FormActionField type="submit" :label="$t('save')" />
</FormGroup> </FormGroup>
</FormElement> </FormElement>
@ -49,20 +53,7 @@ authStore.update();
const toast = useToast(); const toast = useToast();
const name = ref(authStore.userData?.name); const name = ref(authStore.userData?.name);
const email = ref(authStore.userData?.email);
const rawEmail = ref(authStore.userData?.email);
const email = computed({
get: () => rawEmail.value ?? undefined,
set: (value) => {
const temp = value?.trim() ?? null;
if (temp === '') {
rawEmail.value = null;
return;
}
rawEmail.value = temp;
return;
},
});
async function submit() { async function submit() {
try { try {
@ -70,7 +61,7 @@ async function submit() {
method: 'post', method: 'post',
body: { body: {
name: name.value, name: name.value,
email: rawEmail.value, email: email.value,
}, },
}); });
toast.showToast({ toast.showToast({

10
src/app/pages/setup/1.vue

@ -6,7 +6,9 @@
<div class="mb-8 flex justify-center"> <div class="mb-8 flex justify-center">
<UiChooseLang /> <UiChooseLang />
</div> </div>
<div><BaseButton @click="nextStep">Continue</BaseButton></div> <div>
<NuxtLink to="/setup/2"><BaseButton>Continue</BaseButton></NuxtLink>
</div>
</div> </div>
</template> </template>
@ -17,10 +19,4 @@ definePageMeta({
const setupStore = useSetupStore(); const setupStore = useSetupStore();
setupStore.setStep(1); setupStore.setStep(1);
const router = useRouter();
async function nextStep() {
router.push('/setup/2');
}
</script> </script>

15
src/i18n/locales/en.json

@ -107,7 +107,6 @@
}, },
"name": "Name", "name": "Name",
"username": "Username", "username": "Username",
"password": "Password",
"signIn": "Sign In", "signIn": "Sign In",
"logout": "Logout", "logout": "Logout",
"updateAvailable": "There is an update available!", "updateAvailable": "There is an update available!",
@ -151,5 +150,17 @@
"error": { "error": {
"clear": "Clear", "clear": "Clear",
"login": "Log in error" "login": "Log in error"
} },
"general": {
"sessionTimeout": "Session Timeout",
"metrics": "Metrics",
"prometheus": "Prometheus",
"json": "JSON"
},
"form": {
"actions": "Actions",
"save": "Save",
"revert": "Revert"
},
"password": "Password"
} }

6
src/server/api/admin/general.get.ts

@ -1,6 +1,4 @@
export default definePermissionEventHandler(actions.ADMIN, async () => { export default definePermissionEventHandler(actions.ADMIN, async () => {
const sessionConfig = await Database.general.getSessionConfig(); const generalConfig = await Database.general.getConfig();
return { return generalConfig;
sessionTimeout: sessionConfig.sessionTimeout,
};
}); });

11
src/server/database/migrations/0000_short_skin.sql

@ -24,6 +24,9 @@ CREATE TABLE `general_table` (
`setupStep` integer NOT NULL, `setupStep` integer NOT NULL,
`session_password` text NOT NULL, `session_password` text NOT NULL,
`session_timeout` integer NOT NULL, `session_timeout` integer NOT NULL,
`metricsPrometheus` integer NOT NULL,
`metricsJson` integer NOT NULL,
`metricsPassword` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
); );
@ -54,14 +57,6 @@ CREATE TABLE `interfaces_table` (
); );
--> statement-breakpoint --> statement-breakpoint
CREATE UNIQUE INDEX `interfaces_table_port_unique` ON `interfaces_table` (`port`);--> statement-breakpoint CREATE UNIQUE INDEX `interfaces_table_port_unique` ON `interfaces_table` (`port`);--> statement-breakpoint
CREATE TABLE `prometheus_table` (
`id` text PRIMARY KEY NOT NULL,
`password` text NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
FOREIGN KEY (`id`) REFERENCES `interfaces_table`(`name`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `one_time_links_table` ( CREATE TABLE `one_time_links_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`one_time_link` text NOT NULL, `one_time_link` text NOT NULL,

4
src/server/database/migrations/0001_classy_the_stranger.sql

@ -1,6 +1,6 @@
PRAGMA journal_mode=WAL;--> statement-breakpoint PRAGMA journal_mode=WAL;--> statement-breakpoint
INSERT INTO `general_table` (`setupStep`, `session_password`, `session_timeout`) INSERT INTO `general_table` (`setupStep`, `session_password`, `session_timeout`, `metricsPrometheus`, `metricsJson`)
VALUES (1, hex(randomblob(256)), 3600); VALUES (1, hex(randomblob(256)), 3600, 0, 0);
--> statement-breakpoint --> statement-breakpoint
INSERT INTO `interfaces_table` (`name`, `device`, `port`, `private_key`, `public_key`, `ipv4_cidr`, `ipv6_cidr`, `mtu`, `enabled`) INSERT INTO `interfaces_table` (`name`, `device`, `port`, `private_key`, `public_key`, `ipv4_cidr`, `ipv6_cidr`, `mtu`, `enabled`)
VALUES ('wg0', 'eth0', 51820, '---default---', '---default---', '10.8.0.0/24', 'fdcc:ad94:bacf:61a4::cafe:0/112', 1420, 1); VALUES ('wg0', 'eth0', 51820, '---default---', '---default---', '10.8.0.0/24', 'fdcc:ad94:bacf:61a4::cafe:0/112', 1420, 1);

77
src/server/database/migrations/meta/0000_snapshot.json

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "25907c5f-be21-4ae6-88c4-1a72b2f335e7", "id": "2c4694af-5916-430f-96d3-55aac2653e7e",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"clients_table": { "clients_table": {
@ -175,6 +175,27 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"metricsPrometheus": {
"name": "metricsPrometheus",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metricsJson": {
"name": "metricsJson",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metricsPassword": {
"name": "metricsPassword",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "text", "type": "text",
@ -370,60 +391,6 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"prometheus_table": {
"name": "prometheus_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {
"prometheus_table_id_interfaces_table_name_fk": {
"name": "prometheus_table_id_interfaces_table_name_fk",
"tableFrom": "prometheus_table",
"tableTo": "interfaces_table",
"columnsFrom": [
"id"
],
"columnsTo": [
"name"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"one_time_links_table": { "one_time_links_table": {
"name": "one_time_links_table", "name": "one_time_links_table",
"columns": { "columns": {

79
src/server/database/migrations/meta/0001_snapshot.json

@ -1,6 +1,6 @@
{ {
"id": "60af732f-adc0-405d-96cc-2f818585f593", "id": "91d39ed5-2c45-4af6-ba39-4cd72ba71f6a",
"prevId": "25907c5f-be21-4ae6-88c4-1a72b2f335e7", "prevId": "2c4694af-5916-430f-96d3-55aac2653e7e",
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"tables": { "tables": {
@ -175,6 +175,27 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"metricsPrometheus": {
"name": "metricsPrometheus",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metricsJson": {
"name": "metricsJson",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metricsPassword": {
"name": "metricsPassword",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "text", "type": "text",
@ -370,60 +391,6 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"prometheus_table": {
"name": "prometheus_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {
"prometheus_table_id_interfaces_table_name_fk": {
"name": "prometheus_table_id_interfaces_table_name_fk",
"tableFrom": "prometheus_table",
"columnsFrom": [
"id"
],
"tableTo": "interfaces_table",
"columnsTo": [
"name"
],
"onUpdate": "cascade",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"one_time_links_table": { "one_time_links_table": {
"name": "one_time_links_table", "name": "one_time_links_table",
"columns": { "columns": {

4
src/server/database/migrations/meta/_journal.json

@ -5,14 +5,14 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1737122352401, "when": 1739191645161,
"tag": "0000_short_skin", "tag": "0000_short_skin",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "6", "version": "6",
"when": 1737122356601, "when": 1739191678456,
"tag": "0001_classy_the_stranger", "tag": "0001_classy_the_stranger",
"breakpoints": true "breakpoints": true
} }

7
src/server/database/repositories/general/schema.ts

@ -3,9 +3,16 @@ import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core';
export const general = sqliteTable('general_table', { export const general = sqliteTable('general_table', {
id: int().primaryKey({ autoIncrement: false }).default(1), id: int().primaryKey({ autoIncrement: false }).default(1),
setupStep: int().notNull(), setupStep: int().notNull(),
sessionPassword: text('session_password').notNull(), sessionPassword: text('session_password').notNull(),
sessionTimeout: int('session_timeout').notNull(), sessionTimeout: int('session_timeout').notNull(),
metricsPrometheus: int({ mode: 'boolean' }).notNull(),
metricsJson: int({ mode: 'boolean' }).notNull(),
metricsPassword: text(),
createdAt: text('created_at') createdAt: text('created_at')
.notNull() .notNull()
.default(sql`(CURRENT_TIMESTAMP)`), .default(sql`(CURRENT_TIMESTAMP)`),

91
src/server/database/repositories/general/service.ts

@ -5,45 +5,68 @@ import type { GeneralUpdateType } from './types';
function createPreparedStatement(db: DBType) { function createPreparedStatement(db: DBType) {
return { return {
find: db.query.general.findFirst().prepare(), getSetupStep: db.query.general
updateSetupStep: db .findFirst({
.update(general) columns: {
.set({ setupStep: true,
setupStep: sql.placeholder('setupStep') as never as number, },
})
.prepare(),
getSessionConfig: db.query.general
.findFirst({
columns: {
sessionPassword: true,
sessionTimeout: true,
},
}) })
.prepare(), .prepare(),
update: db getMetricsConfig: db.query.general
.findFirst({
columns: {
metricsPrometheus: true,
metricsJson: true,
metricsPassword: true,
},
})
.prepare(),
getConfig: db.query.general
.findFirst({
columns: {
sessionTimeout: true,
metricsPrometheus: true,
metricsJson: true,
metricsPassword: true,
},
})
.prepare(),
updateSetupStep: db
.update(general) .update(general)
.set({ .set({
sessionTimeout: sql.placeholder('sessionTimeout') as never as number, setupStep: sql.placeholder('setupStep') as never as number,
}) })
.prepare(), .prepare(),
}; };
} }
export class GeneralService { export class GeneralService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>; #statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) { constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db); this.#statements = createPreparedStatement(db);
} }
/** /**
* @throws * @throws
*/ */
private async get() { async getSetupStep() {
const result = await this.#statements.find.execute(); const result = await this.#statements.getSetupStep.execute();
if (!result) { if (!result) {
throw new Error('General Config not found'); throw new Error('General Config not found');
} }
return result;
}
/**
* @throws
*/
async getSetupStep() {
const result = await this.get();
return { step: result.setupStep, done: result.setupStep === 0 }; return { step: result.setupStep, done: result.setupStep === 0 };
} }
@ -55,14 +78,46 @@ export class GeneralService {
* @throws * @throws
*/ */
async getSessionConfig() { async getSessionConfig() {
const result = await this.get(); const result = await this.#statements.getSessionConfig.execute();
if (!result) {
throw new Error('General Config not found');
}
return { return {
sessionPassword: result.sessionPassword, sessionPassword: result.sessionPassword,
sessionTimeout: result.sessionTimeout, sessionTimeout: result.sessionTimeout,
}; };
} }
/**
* @throws
*/
async getMetricsConfig() {
const result = await this.#statements.getMetricsConfig.execute();
if (!result) {
throw new Error('General Config not found');
}
return {
prometheus: result.metricsPrometheus,
json: result.metricsJson,
password: result.metricsPassword,
};
}
update(data: GeneralUpdateType) { update(data: GeneralUpdateType) {
return this.#statements.update.execute(data); return this.#db.update(general).set(data).execute();
}
async getConfig() {
const result = await this.#statements.getConfig.execute();
if (!result) {
throw new Error('General Config not found');
}
return result;
} }
} }

9
src/server/database/repositories/general/types.ts

@ -5,9 +5,18 @@ import z from 'zod';
export type GeneralType = InferSelectModel<typeof general>; export type GeneralType = InferSelectModel<typeof general>;
const sessionTimeout = z.number({ message: 'zod.general.sessionTimeout' }); const sessionTimeout = z.number({ message: 'zod.general.sessionTimeout' });
const metricsEnabled = z.boolean({ message: 'zod.general.metricsEnabled' });
const metricsPassword = z
.string({ message: 'zod.general.metricsPassword' })
.min(1, { message: 'zod.general.metricsPasswordMin' })
// TODO: validate argon2 regex?
.nullable();
export const GeneralUpdateSchema = z.object({ export const GeneralUpdateSchema = z.object({
sessionTimeout: sessionTimeout, sessionTimeout: sessionTimeout,
metricsPrometheus: metricsEnabled,
metricsJson: metricsEnabled,
metricsPassword: metricsPassword,
}); });
export type GeneralUpdateType = z.infer<typeof GeneralUpdateSchema>; export type GeneralUpdateType = z.infer<typeof GeneralUpdateSchema>;

6
src/server/database/repositories/interface/schema.ts

@ -1,7 +1,7 @@
import { sql, relations } from 'drizzle-orm'; import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { userConfig, hooks, prometheus } from '../../schema'; import { userConfig, hooks } from '../../schema';
// maybe support multiple interfaces in the future // maybe support multiple interfaces in the future
export const wgInterface = sqliteTable('interfaces_table', { export const wgInterface = sqliteTable('interfaces_table', {
@ -28,10 +28,6 @@ export const wgInterfaceRelations = relations(wgInterface, ({ one }) => ({
fields: [wgInterface.name], fields: [wgInterface.name],
references: [hooks.id], references: [hooks.id],
}), }),
prometheus: one(prometheus, {
fields: [wgInterface.name],
references: [prometheus.id],
}),
userConfig: one(userConfig, { userConfig: one(userConfig, {
fields: [wgInterface.name], fields: [wgInterface.name],
references: [userConfig.id], references: [userConfig.id],

21
src/server/database/repositories/metrics/schema.ts

@ -1,21 +0,0 @@
import { sql } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { wgInterface } from '../../schema';
export const prometheus = sqliteTable('prometheus_table', {
id: text()
.primaryKey()
.references(() => wgInterface.name, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),
password: text().notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});

31
src/server/database/repositories/metrics/service.ts

@ -1,31 +0,0 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { prometheus } from './schema';
function createPreparedStatement(db: DBType) {
return {
get: db.query.prometheus
.findFirst({ where: eq(prometheus.id, sql.placeholder('interface')) })
.prepare(),
};
}
export class PrometheusService {
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#statements = createPreparedStatement(db);
}
get(infName: string) {
return this.#statements.get.execute({ interface: infName });
}
}
export class MetricsService {
prometheus: PrometheusService;
constructor(db: DBType) {
this.prometheus = new PrometheusService(db);
}
}

4
src/server/database/repositories/metrics/types.ts

@ -1,4 +0,0 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { prometheus } from './schema';
export type PrometheusType = InferSelectModel<typeof prometheus>;

1
src/server/database/schema.ts

@ -3,7 +3,6 @@ export * from './repositories/client/schema';
export * from './repositories/general/schema'; export * from './repositories/general/schema';
export * from './repositories/hooks/schema'; export * from './repositories/hooks/schema';
export * from './repositories/interface/schema'; export * from './repositories/interface/schema';
export * from './repositories/metrics/schema';
export * from './repositories/oneTimeLink/schema'; export * from './repositories/oneTimeLink/schema';
export * from './repositories/user/schema'; export * from './repositories/user/schema';
export * from './repositories/userConfig/schema'; export * from './repositories/userConfig/schema';

3
src/server/database/sqlite.ts

@ -11,7 +11,6 @@ import { UserConfigService } from './repositories/userConfig/service';
import { InterfaceService } from './repositories/interface/service'; import { InterfaceService } from './repositories/interface/service';
import { HooksService } from './repositories/hooks/service'; import { HooksService } from './repositories/hooks/service';
import { OneTimeLinkService } from './repositories/oneTimeLink/service'; import { OneTimeLinkService } from './repositories/oneTimeLink/service';
import { MetricsService } from './repositories/metrics/service';
const DB_DEBUG = debug('Database'); const DB_DEBUG = debug('Database');
@ -31,7 +30,6 @@ class DBService {
interfaces: InterfaceService; interfaces: InterfaceService;
hooks: HooksService; hooks: HooksService;
oneTimeLinks: OneTimeLinkService; oneTimeLinks: OneTimeLinkService;
metrics: MetricsService;
constructor(db: DBType) { constructor(db: DBType) {
this.clients = new ClientService(db); this.clients = new ClientService(db);
@ -41,7 +39,6 @@ class DBService {
this.interfaces = new InterfaceService(db); this.interfaces = new InterfaceService(db);
this.hooks = new HooksService(db); this.hooks = new HooksService(db);
this.oneTimeLinks = new OneTimeLinkService(db); this.oneTimeLinks = new OneTimeLinkService(db);
this.metrics = new MetricsService(db);
} }
} }

14
src/server/routes/metrics/index.get.ts

@ -1,14 +0,0 @@
export default defineEventHandler(async (event) => {
// TODO: check password
const prometheus = await Database.metrics.prometheus.get('wg0');
if (!prometheus) {
throw createError({
statusCode: 400,
message: 'Prometheus metrics are not enabled',
});
}
setHeader(event, 'Content-Type', 'text/plain');
return getPrometheusResponse();
});

44
src/server/routes/metrics/json.get.ts

@ -1,13 +1,35 @@
export default defineEventHandler(async () => { export default defineMetricsHandler('json', async () => {
// TODO: check password
const prometheus = await Database.metrics.prometheus.get('wg0');
if (!prometheus) {
throw createError({
statusCode: 400,
message: 'Prometheus metrics are not enabled',
});
}
return getMetricsJSON(); return getMetricsJSON();
}); });
async function getMetricsJSON() {
const clients = await WireGuard.getClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
for (const client of clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (isPeerConnected(client)) {
wireguardConnectedPeersCount++;
}
}
return {
wireguard_configured_peers: wireguardPeerCount,
wireguard_enabled_peers: wireguardEnabledPeersCount,
wireguard_connected_peers: wireguardConnectedPeersCount,
clients: clients.map((client) => ({
name: client.name,
enabled: client.enabled,
ipv4Address: client.ipv4Address,
ipv6Address: client.ipv6Address,
publicKey: client.publicKey,
endpoint: client.endpoint,
latestHandshakeAt: client.latestHandshakeAt,
transferRx: client.transferRx,
transferTx: client.transferTx,
})),
};
}

67
src/server/routes/metrics/prometheus.get.ts

@ -0,0 +1,67 @@
export default defineMetricsHandler('prometheus', async ({ event }) => {
setHeader(event, 'Content-Type', 'text/plain');
return getPrometheusResponse();
});
async function getPrometheusResponse() {
const clients = await WireGuard.getClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
const wireguardSentBytes = [];
const wireguardReceivedBytes = [];
const wireguardLatestHandshakeSeconds = [];
for (const client of clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (isPeerConnected(client)) {
wireguardConnectedPeersCount++;
}
const id = `interface="wg0",enabled="${client.enabled}",ipv4Address="${client.ipv4Address}",ipv6Address="${client.ipv6Address}",name="${client.name}"`;
wireguardSentBytes.push(
`wireguard_sent_bytes{${id}} ${client.transferTx ?? 0}`
);
wireguardReceivedBytes.push(
`wireguard_received_bytes{${id}} ${client.transferRx ?? 0}`
);
// TODO: if latestHandshakeAt is null this would result in client showing as online?
wireguardLatestHandshakeSeconds.push(
`wireguard_latest_handshake_seconds{${id}} ${client.latestHandshakeAt ? (Date.now() - client.latestHandshakeAt.getTime()) / 1000 : 0}`
);
}
const returnText = [
'# HELP wg-easy and wireguard metrics',
'',
'# HELP wireguard_configured_peers',
'# TYPE wireguard_configured_peers gauge',
`wireguard_configured_peers{interface="wg0"} ${wireguardPeerCount}`,
'',
'# HELP wireguard_enabled_peers',
'# TYPE wireguard_enabled_peers gauge',
`wireguard_enabled_peers{interface="wg0"} ${wireguardEnabledPeersCount}`,
'',
'# HELP wireguard_connected_peers',
'# TYPE wireguard_connected_peers gauge',
`wireguard_connected_peers{interface="wg0"} ${wireguardConnectedPeersCount}`,
'',
'# HELP wireguard_sent_bytes Bytes sent to the peer',
'# TYPE wireguard_sent_bytes counter',
`${wireguardSentBytes.join('\n')}`,
'',
'# HELP wireguard_received_bytes Bytes received from the peer',
'# TYPE wireguard_received_bytes counter',
`${wireguardReceivedBytes.join('\n')}`,
'',
'# HELP wireguard_latest_handshake_seconds UNIX timestamp seconds of the last handshake',
'# TYPE wireguard_latest_handshake_seconds gauge',
`${wireguardLatestHandshakeSeconds.join('\n')}`,
];
return returnText.join('\n');
}

60
src/server/utils/handler.ts

@ -57,3 +57,63 @@ export const defineSetupEventHandler = <
return await handler({ event, setup }); return await handler({ event, setup });
}); });
}; };
type Metrics = 'prometheus' | 'json';
type MetricsHandler<
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
> = { (params: { event: H3Event<TReq> }): TRes };
/**
* check if the metrics are enabled and the token is correct
*/
export const defineMetricsHandler = <
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
>(
type: Metrics,
handler: MetricsHandler<TReq, TRes>
) => {
return defineEventHandler(async (event) => {
const auth = getHeader(event, 'Authorization');
if (!auth) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const [method, value] = auth.split(' ');
if (method !== 'Bearer' || !value) {
throw createError({
statusCode: 401,
statusMessage: 'Bearer Auth required',
});
}
const metricsConfig = await Database.general.getMetricsConfig();
if (metricsConfig[type] !== true) {
throw createError({
statusCode: 400,
statusMessage: 'Metrics not enabled',
});
}
if (metricsConfig.password) {
const tokenValid = await isPasswordValid(value, metricsConfig.password);
if (!tokenValid) {
throw createError({
statusCode: 401,
statusMessage: 'Incorrect token',
});
}
}
return await handler({ event });
});
};

74
src/server/utils/metrics.ts

@ -1,74 +0,0 @@
// TODO: rewrite
export async function getPrometheusResponse() {
const clients = await WireGuard.getClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
let wireguardSentBytes = '';
let wireguardReceivedBytes = '';
let wireguardLatestHandshakeSeconds = '';
for (const client of clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (client.endpoint !== null) {
wireguardConnectedPeersCount++;
}
wireguardSentBytes += `wireguard_sent_bytes{interface="wg0",enabled="${client.enabled}",ipv4Address="${client.ipv4Address}",ipv6Address="${client.ipv6Address}",name="${client.name}"} ${Number(client.transferTx)}\n`;
wireguardReceivedBytes += `wireguard_received_bytes{interface="wg0",enabled="${client.enabled}",ipv4Address="${client.ipv4Address}",ipv6Address="${client.ipv6Address}",name="${client.name}"} ${Number(client.transferRx)}\n`;
wireguardLatestHandshakeSeconds += `wireguard_latest_handshake_seconds{interface="wg0",enabled="${client.enabled}",ipv4Address="${client.ipv4Address}",ipv6Address="${client.ipv6Address}",name="${client.name}"} ${client.latestHandshakeAt ? (new Date().getTime() - new Date(client.latestHandshakeAt).getTime()) / 1000 : 0}\n`;
}
let returnText = '# HELP wg-easy and wireguard metrics\n';
returnText += '\n# HELP wireguard_configured_peers\n';
returnText += '# TYPE wireguard_configured_peers gauge\n';
returnText += `wireguard_configured_peers{interface="wg0"} ${wireguardPeerCount}\n`;
returnText += '\n# HELP wireguard_enabled_peers\n';
returnText += '# TYPE wireguard_enabled_peers gauge\n';
returnText += `wireguard_enabled_peers{interface="wg0"} ${wireguardEnabledPeersCount}\n`;
returnText += '\n# HELP wireguard_connected_peers\n';
returnText += '# TYPE wireguard_connected_peers gauge\n';
returnText += `wireguard_connected_peers{interface="wg0"} ${wireguardConnectedPeersCount}\n`;
returnText += '\n# HELP wireguard_sent_bytes Bytes sent to the peer\n';
returnText += '# TYPE wireguard_sent_bytes counter\n';
returnText += `${wireguardSentBytes}`;
returnText +=
'\n# HELP wireguard_received_bytes Bytes received from the peer\n';
returnText += '# TYPE wireguard_received_bytes counter\n';
returnText += `${wireguardReceivedBytes}`;
returnText +=
'\n# HELP wireguard_latest_handshake_seconds UNIX timestamp seconds of the last handshake\n';
returnText += '# TYPE wireguard_latest_handshake_seconds gauge\n';
returnText += `${wireguardLatestHandshakeSeconds}`;
return returnText;
}
export async function getMetricsJSON() {
const clients = await WireGuard.getClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
for (const client of clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (client.endpoint !== null) {
wireguardConnectedPeersCount++;
}
}
return {
wireguard_configured_peers: wireguardPeerCount,
wireguard_enabled_peers: wireguardEnabledPeersCount,
wireguard_connected_peers: wireguardConnectedPeersCount,
};
}

10
src/shared/utils/time.ts

@ -0,0 +1,10 @@
export function isPeerConnected(client: { latestHandshakeAt: Date | null }) {
if (!client.latestHandshakeAt) {
return false;
}
const lastHandshakeMs = Date.now() - client.latestHandshakeAt.getTime();
// connected if last handshake was less than 10 minutes ago
return lastHandshakeMs < 1000 * 60 * 10;
}
Loading…
Cancel
Save