From 1cbcdea9d783b4e25d8e500ebc09b9192c375059 Mon Sep 17 00:00:00 2001 From: Alexander Shirokii Date: Wed, 18 Mar 2026 12:17:16 +0300 Subject: [PATCH] Fix metrics auth and enable fork CI smoke runs --- .github/workflows/deploy-pr.yml | 4 +- .github/workflows/lint.yml | 3 +- Dockerfile | 3 +- .../database/repositories/general/types.ts | 8 +++ .../repositories/interface/service.ts | 16 +++-- src/server/utils/cache.ts | 18 +++-- src/server/utils/handler.ts | 65 ++++++++++--------- src/test/unit/cache.spec.ts | 26 ++++++++ src/test/unit/general.spec.ts | 43 ++++++++++++ 9 files changed, 144 insertions(+), 42 deletions(-) create mode 100644 src/test/unit/cache.spec.ts create mode 100644 src/test/unit/general.spec.ts diff --git a/.github/workflows/deploy-pr.yml b/.github/workflows/deploy-pr.yml index ca8710fc..2e06bfa5 100644 --- a/.github/workflows/deploy-pr.yml +++ b/.github/workflows/deploy-pr.yml @@ -2,6 +2,9 @@ name: Pull Request on: workflow_dispatch: + push: + branches: + - ci/** pull_request: concurrency: @@ -12,7 +15,6 @@ jobs: docker: name: Build Docker runs-on: ${{ matrix.arch.os }} - if: github.repository_owner == 'wg-easy' strategy: fail-fast: false matrix: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 366e5440..4716f38e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,13 +4,13 @@ on: push: branches: - master + - ci/** pull_request: jobs: docs: name: Check Docs runs-on: ubuntu-latest - if: github.repository_owner == 'wg-easy' steps: - name: Checkout repository @@ -37,7 +37,6 @@ jobs: name: Lint runs-on: ubuntu-latest needs: docs - if: github.repository_owner == 'wg-easy' strategy: fail-fast: false diff --git a/Dockerfile b/Dockerfile index 9cd8b311..83c783a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,8 @@ RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \ "https://github.com/go-acme/lego/releases/download/${LEGO_VERSION}/lego_${LEGO_VERSION}_linux_${ARCH}.tar.gz" && \ wget -qO /tmp/lego.tar.gz.sha256 \ "https://github.com/go-acme/lego/releases/download/${LEGO_VERSION}/lego_${LEGO_VERSION#v}_checksums.txt" && \ - grep "lego_${LEGO_VERSION}_linux_${ARCH}.tar.gz" /tmp/lego.tar.gz.sha256 | sha256sum -c - && \ + grep "lego_${LEGO_VERSION}_linux_${ARCH}.tar.gz" /tmp/lego.tar.gz.sha256 | \ + awk '{ print $1 " /tmp/lego.tar.gz" }' | sha256sum -c - && \ tar -xzf /tmp/lego.tar.gz -C /tmp lego && \ mv /tmp/lego /usr/local/bin/lego && \ chmod +x /usr/local/bin/lego && \ diff --git a/src/server/database/repositories/general/types.ts b/src/server/database/repositories/general/types.ts index e16bc4c6..bb986531 100644 --- a/src/server/database/repositories/general/types.ts +++ b/src/server/database/repositories/general/types.ts @@ -18,6 +18,14 @@ export const GeneralUpdateSchema = z.object({ metricsPrometheus: metricsEnabled, metricsJson: metricsEnabled, metricsPassword: metricsPassword, +}).superRefine((data, ctx) => { + if ((data.metricsPrometheus || data.metricsJson) && !data.metricsPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['metricsPassword'], + message: t('zod.general.metricsPassword'), + }); + } }); export type GeneralUpdateType = z.infer; diff --git a/src/server/database/repositories/interface/service.ts b/src/server/database/repositories/interface/service.ts index 4c0d0b42..a551c9a8 100644 --- a/src/server/database/repositories/interface/service.ts +++ b/src/server/database/repositories/interface/service.ts @@ -92,19 +92,19 @@ export class InterfaceService { const clients = await tx.query.client.findMany().execute(); for (const client of clients) { - // TODO: optimize - const clients = await tx.query.client.findMany().execute(); + const index = clients.findIndex(({ id }) => id === client.id); + const nextClients = clients.slice(); // only calculate ip if cidr has changed let nextIpv4 = client.ipv4Address; if (data.ipv4Cidr !== oldCidr.ipv4Cidr) { - nextIpv4 = nextIP(4, parseCidr(data.ipv4Cidr), clients); + nextIpv4 = nextIP(4, parseCidr(data.ipv4Cidr), nextClients); } let nextIpv6 = client.ipv6Address; if (data.ipv6Cidr !== oldCidr.ipv6Cidr) { - nextIpv6 = nextIP(6, parseCidr(data.ipv6Cidr), clients); + nextIpv6 = nextIP(6, parseCidr(data.ipv6Cidr), nextClients); } await tx @@ -115,6 +115,14 @@ export class InterfaceService { }) .where(eq(clientSchema.id, client.id)) .execute(); + + if (index !== -1) { + clients[index] = { + ...client, + ipv4Address: nextIpv4, + ipv6Address: nextIpv6, + }; + } } }); } diff --git a/src/server/utils/cache.ts b/src/server/utils/cache.ts index 96ea8c2d..55fb7df4 100644 --- a/src/server/utils/cache.ts +++ b/src/server/utils/cache.ts @@ -8,17 +8,27 @@ type Opts = { /** * Cache function for 1 hour */ -export function cacheFunction(fn: () => T, { expiry }: Opts): () => T { - let cache: { value: T; expiry: number } | null = null; +export function cacheFunction( + fn: () => Promise, + { expiry }: Opts +): () => Promise { + let cache: { value: Promise; expiry: number } | null = null; - return (): T => { + return (): Promise => { const now = Date.now(); if (cache && cache.expiry > now) { return cache.value; } - const result = fn(); + const result = fn().catch((error) => { + if (cache?.value === result) { + cache = null; + } + + throw error; + }); + cache = { value: result, expiry: now + expiry, diff --git a/src/server/utils/handler.ts b/src/server/utils/handler.ts index 5f0baa08..9e72d80d 100644 --- a/src/server/utils/handler.ts +++ b/src/server/utils/handler.ts @@ -136,39 +136,10 @@ export const defineMetricsHandler = < >( type: Metrics, handler: MetricsHandler -) => { + ) => { return defineEventHandler(async (event) => { const metricsConfig = await Database.general.getMetricsConfig(); - if (metricsConfig.password) { - 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 tokenValid = await isPasswordValid(value, metricsConfig.password); - - if (!tokenValid) { - throw createError({ - statusCode: 401, - statusMessage: 'Incorrect token', - }); - } - } - if (metricsConfig[type] !== true) { throw createError({ statusCode: 400, @@ -176,6 +147,40 @@ export const defineMetricsHandler = < }); } + if (!metricsConfig.password) { + throw createError({ + statusCode: 503, + statusMessage: 'Metrics password is not configured', + }); + } + + 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 tokenValid = await isPasswordValid(value, metricsConfig.password); + + if (!tokenValid) { + throw createError({ + statusCode: 401, + statusMessage: 'Incorrect token', + }); + } + return await handler({ event }); }); }; diff --git a/src/test/unit/cache.spec.ts b/src/test/unit/cache.spec.ts new file mode 100644 index 00000000..26df9e72 --- /dev/null +++ b/src/test/unit/cache.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, test, vi } from 'vitest'; +import { cacheFunction } from '../../server/utils/cache'; + +describe('cacheFunction', () => { + test('retries after a rejected async result instead of caching the failure', async () => { + const fn = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce('ok'); + + const cached = cacheFunction(fn, { expiry: 60_000 }); + + await expect(cached()).rejects.toThrow('boom'); + await expect(cached()).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + test('reuses successful async results until expiry', async () => { + const fn = vi.fn<() => Promise>().mockResolvedValue('ok'); + const cached = cacheFunction(fn, { expiry: 60_000 }); + + await expect(cached()).resolves.toBe('ok'); + await expect(cached()).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/unit/general.spec.ts b/src/test/unit/general.spec.ts new file mode 100644 index 00000000..2e47c8ba --- /dev/null +++ b/src/test/unit/general.spec.ts @@ -0,0 +1,43 @@ +import { beforeAll, describe, expect, test } from 'vitest'; + +declare global { + var t: (value: string) => string; +} + +let GeneralUpdateSchema: typeof import('../../server/database/repositories/general/types').GeneralUpdateSchema; + +beforeAll(async () => { + globalThis.t = (value: string) => value; + ({ GeneralUpdateSchema } = await import( + '../../server/database/repositories/general/types' + )); +}); + +describe('GeneralUpdateSchema', () => { + test('requires a metrics password when metrics are enabled', async () => { + await expect( + GeneralUpdateSchema.parseAsync({ + sessionTimeout: 60, + metricsPrometheus: true, + metricsJson: false, + metricsPassword: null, + }) + ).rejects.toThrow(); + }); + + test('allows disabling metrics without a password', async () => { + await expect( + GeneralUpdateSchema.parseAsync({ + sessionTimeout: 60, + metricsPrometheus: false, + metricsJson: false, + metricsPassword: null, + }) + ).resolves.toEqual({ + sessionTimeout: 60, + metricsPrometheus: false, + metricsJson: false, + metricsPassword: null, + }); + }); +});