Browse Source

Fix metrics auth and enable fork CI smoke runs

pull/2552/head
Alexander Shirokii 4 months ago
parent
commit
1cbcdea9d7
  1. 4
      .github/workflows/deploy-pr.yml
  2. 3
      .github/workflows/lint.yml
  3. 3
      Dockerfile
  4. 8
      src/server/database/repositories/general/types.ts
  5. 16
      src/server/database/repositories/interface/service.ts
  6. 18
      src/server/utils/cache.ts
  7. 65
      src/server/utils/handler.ts
  8. 26
      src/test/unit/cache.spec.ts
  9. 43
      src/test/unit/general.spec.ts

4
.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:

3
.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

3
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 && \

8
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<typeof GeneralUpdateSchema>;

16
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,
};
}
}
});
}

18
src/server/utils/cache.ts

@ -8,17 +8,27 @@ type Opts = {
/**
* Cache function for 1 hour
*/
export function cacheFunction<T>(fn: () => T, { expiry }: Opts): () => T {
let cache: { value: T; expiry: number } | null = null;
export function cacheFunction<T>(
fn: () => Promise<T>,
{ expiry }: Opts
): () => Promise<T> {
let cache: { value: Promise<T>; expiry: number } | null = null;
return (): T => {
return (): Promise<T> => {
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,

65
src/server/utils/handler.ts

@ -136,39 +136,10 @@ export const defineMetricsHandler = <
>(
type: Metrics,
handler: MetricsHandler<TReq, TRes>
) => {
) => {
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 });
});
};

26
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<string>>()
.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<string>>().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);
});
});

43
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,
});
});
});
Loading…
Cancel
Save