diff --git a/api/src/admin-schema.gql b/api/src/admin-schema.gql index 0370ed2..722f73f 100644 --- a/api/src/admin-schema.gql +++ b/api/src/admin-schema.gql @@ -168,6 +168,7 @@ type CheckEventModel { error: String id: ID! responseTimeMs: Int + sourceIp: String status: String! statusCode: Int timestamp: DateTime! diff --git a/api/src/checks/check.model.ts b/api/src/checks/check.model.ts index 7470e49..e001d29 100644 --- a/api/src/checks/check.model.ts +++ b/api/src/checks/check.model.ts @@ -8,6 +8,7 @@ export class CheckEventModel { @Field({ nullable: true }) error?: string; @Field(() => Int, { nullable: true }) responseTimeMs?: number; @Field(() => Int, { nullable: true }) statusCode?: number; + @Field({ nullable: true }) sourceIp?: string; } @ObjectType() diff --git a/api/src/main.ts b/api/src/main.ts index 0f61e07..699a6a3 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -190,7 +190,7 @@ export async function startApplicationLifecycle( export async function buildApp(): Promise { const app = await NestFactory.create( AppModule, - new FastifyAdapter(), + new FastifyAdapter({ trustProxy: true }), { rawBody: true }, ); // Register helmet on the underlying Fastify instance diff --git a/api/src/ping/client-ip.spec.ts b/api/src/ping/client-ip.spec.ts new file mode 100644 index 0000000..60519de --- /dev/null +++ b/api/src/ping/client-ip.spec.ts @@ -0,0 +1,22 @@ +import { normalizeClientIp } from './client-ip'; + +describe('normalizeClientIp', () => { + it('keeps a public IPv4 address', () => { + expect(normalizeClientIp('203.0.113.40')).toBe('203.0.113.40'); + }); + + it('keeps a public IPv6 address', () => { + expect(normalizeClientIp('2001:db8::1')).toBe('2001:db8::1'); + }); + + it('unwraps IPv4-mapped IPv6 so the timeline shows the v4 origin', () => { + expect(normalizeClientIp('::ffff:198.51.100.20')).toBe('198.51.100.20'); + }); + + it('rejects empty, spoofed lists, and non-IP values', () => { + expect(normalizeClientIp(undefined)).toBeNull(); + expect(normalizeClientIp('')).toBeNull(); + expect(normalizeClientIp('not-an-ip')).toBeNull(); + expect(normalizeClientIp('203.0.113.40, 192.0.2.1')).toBeNull(); + }); +}); diff --git a/api/src/ping/client-ip.ts b/api/src/ping/client-ip.ts new file mode 100644 index 0000000..7f62f74 --- /dev/null +++ b/api/src/ping/client-ip.ts @@ -0,0 +1,9 @@ +import { isIP } from 'node:net'; + +export function normalizeClientIp( + value: string | undefined | null, +): string | null { + if (!value) return null; + const mapped = value.replace(/^::ffff:/i, ''); + return isIP(mapped) ? mapped : null; +} diff --git a/api/src/ping/ping.controller.ts b/api/src/ping/ping.controller.ts index a0a23c8..1fb4f45 100644 --- a/api/src/ping/ping.controller.ts +++ b/api/src/ping/ping.controller.ts @@ -1,4 +1,6 @@ -import { Controller, Get, HttpCode, Param, Post } from '@nestjs/common'; +import { Controller, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import { normalizeClientIp } from './client-ip'; import { PingService } from './ping.service'; @Controller('ping') @@ -7,15 +9,21 @@ export class PingController { @Get(':slug') @HttpCode(200) - async getPing(@Param('slug') slug: string): Promise { - await this.pingService.recordPing(slug); + async getPing( + @Param('slug') slug: string, + @Req() req: FastifyRequest, + ): Promise { + await this.pingService.recordPing(slug, normalizeClientIp(req.ip)); return 'OK'; } @Post(':slug') @HttpCode(200) - async postPing(@Param('slug') slug: string): Promise { - await this.pingService.recordPing(slug); + async postPing( + @Param('slug') slug: string, + @Req() req: FastifyRequest, + ): Promise { + await this.pingService.recordPing(slug, normalizeClientIp(req.ip)); return 'OK'; } } diff --git a/api/src/ping/ping.service.spec.ts b/api/src/ping/ping.service.spec.ts index 1739ab7..4089780 100644 --- a/api/src/ping/ping.service.spec.ts +++ b/api/src/ping/ping.service.spec.ts @@ -151,11 +151,36 @@ describe('PingService recordPing', () => { }); expect(h.tx.checkEvent.create).toHaveBeenCalledTimes(1); + expect(h.tx.checkEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + checkId: initialCheck.id, + status: 'UP', + sourceIp: null, + }) as object, + }); expect(h.tx.check.update).toHaveBeenCalledTimes(1); expect(h.tx.notificationChannel.findMany).not.toHaveBeenCalled(); expect(h.alertQueue.enqueue).not.toHaveBeenCalled(); }); + it('stores the heartbeat origin IP on the UP event', async () => { + const h = harness(); + h.tx.check.findUnique.mockResolvedValue({ + ...h.freshCheck, + status: 'UP', + }); + + await h.service.recordPing(initialCheck.pingSlug, '203.0.113.40'); + + expect(h.tx.checkEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + checkId: initialCheck.id, + status: 'UP', + sourceIp: '203.0.113.40', + }) as object, + }); + }); + it.each([ ['deleted', null], ['converted', { ...initialCheck, type: 'HTTP' }], diff --git a/api/src/ping/ping.service.ts b/api/src/ping/ping.service.ts index 1bca69f..f3ef6ff 100644 --- a/api/src/ping/ping.service.ts +++ b/api/src/ping/ping.service.ts @@ -19,7 +19,10 @@ export class PingService { private readonly alertQueue: AlertQueueService, ) {} - async recordPing(slug: string): Promise { + async recordPing( + slug: string, + sourceIp: string | null = null, + ): Promise { const check = await this.prisma.check.findUnique({ where: { pingSlug: slug }, }); @@ -55,6 +58,7 @@ export class PingService { checkId: lockedCheck.id, timestamp: now, status: 'UP', + sourceIp, }, }); await tx.check.update({ diff --git a/api/src/schema.gql b/api/src/schema.gql index cbc1e4b..f859f8c 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -137,6 +137,7 @@ type CheckEventModel { error: String id: ID! responseTimeMs: Int + sourceIp: String status: String! statusCode: Int timestamp: DateTime! diff --git a/api/test/ping.e2e-spec.ts b/api/test/ping.e2e-spec.ts index 37af800..980c4ff 100644 --- a/api/test/ping.e2e-spec.ts +++ b/api/test/ping.e2e-spec.ts @@ -44,7 +44,12 @@ interface CheckShape { status: string; pingSlug: string; lastEventAt: string | null; - events: Array<{ id: string; status: string; timestamp: string }>; + events: Array<{ + id: string; + status: string; + timestamp: string; + sourceIp: string | null; + }>; } interface GqlCreateCheckResponse { @@ -127,7 +132,7 @@ describe('ping (e2e)', () => { `query($id: ID!) { check(id: $id) { id status lastEventAt - events { id status timestamp } + events { id status timestamp sourceIp } } }`, { id: checkId }, @@ -141,6 +146,32 @@ describe('ping (e2e)', () => { expect(check.events.some((e) => e.status === 'UP')).toBe(true); }); + it('records the forwarded client IP on a heartbeat ping', async () => { + const r = await app.inject({ + method: 'GET', + url: `/ping/${pingSlug}`, + remoteAddress: '127.0.0.1', + headers: { 'x-forwarded-for': '203.0.113.40' }, + }); + expect(r.statusCode).toBe(200); + + const res = (await gql( + app, + token, + `query($id: ID!) { + check(id: $id) { + events { status sourceIp } + } + }`, + { id: checkId }, + )) as GqlCheckResponse; + + expect(res.errors).toBeUndefined(); + expect(res.data!.check.events[0]).toEqual( + expect.objectContaining({ status: 'UP', sourceIp: '203.0.113.40' }), + ); + }); + it('POST /ping/:slug also returns 200 "OK"', async () => { const r = await app.inject({ method: 'POST', url: `/ping/${pingSlug}` }); expect(r.statusCode).toBe(200); diff --git a/database/prisma/migrations/20260902120000_check_event_source_ip/migration.sql b/database/prisma/migrations/20260902120000_check_event_source_ip/migration.sql new file mode 100644 index 0000000..ad9c721 --- /dev/null +++ b/database/prisma/migrations/20260902120000_check_event_source_ip/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "check_events" ADD COLUMN "source_ip" TEXT; diff --git a/database/prisma/schema.prisma b/database/prisma/schema.prisma index fdc5f8b..0fbec3e 100644 --- a/database/prisma/schema.prisma +++ b/database/prisma/schema.prisma @@ -253,6 +253,7 @@ model CheckEvent { responseTimeMs Int? @map("response_time_ms") statusCode Int? @map("status_code") error String? + sourceIp String? @map("source_ip") @@index([checkId, timestamp]) @@map("check_events") diff --git a/frontend/components/app/event-timeline.tsx b/frontend/components/app/event-timeline.tsx index 5916a71..d71c183 100644 --- a/frontend/components/app/event-timeline.tsx +++ b/frontend/components/app/event-timeline.tsx @@ -9,6 +9,7 @@ export interface TimelineEvent { error: string | null; responseTimeMs: number | null; statusCode: number | null; + sourceIp: string | null; } const EVENT_STATUS_LABELS: Record = { @@ -69,6 +70,9 @@ export function EventTimeline({ events }: { events: TimelineEvent[] }) { {new Date(event.timestamp).toLocaleString()} + {event.sourceIp && ( +

{event.sourceIp}

+ )} {event.statusCode !== null && (

HTTP {event.statusCode}

)} diff --git a/frontend/lib/legacy-queries.ts b/frontend/lib/legacy-queries.ts index 0f02981..ed305ef 100644 --- a/frontend/lib/legacy-queries.ts +++ b/frontend/lib/legacy-queries.ts @@ -41,6 +41,7 @@ export const CHECK_BY_SLUG = gql` error responseTimeMs statusCode + sourceIp } } } diff --git a/frontend/lib/queries.ts b/frontend/lib/queries.ts index 5398995..eeb8302 100644 --- a/frontend/lib/queries.ts +++ b/frontend/lib/queries.ts @@ -54,6 +54,7 @@ export const CHECK = gql` error responseTimeMs statusCode + sourceIp } } } @@ -87,6 +88,7 @@ export const CHECK_BY_ORGANIZATION_SLUG = gql` error responseTimeMs statusCode + sourceIp } } } diff --git a/frontend/test/check-detail.test.tsx b/frontend/test/check-detail.test.tsx index 726c5f9..801d3aa 100644 --- a/frontend/test/check-detail.test.tsx +++ b/frontend/test/check-detail.test.tsx @@ -69,6 +69,7 @@ const CHECK_DATA: CheckDetailData = { error: null, responseTimeMs: null, statusCode: null, + sourceIp: "203.0.113.40", }, { id: "e2", @@ -77,6 +78,7 @@ const CHECK_DATA: CheckDetailData = { error: "missed heartbeat", responseTimeMs: null, statusCode: null, + sourceIp: null, }, ], }; @@ -162,6 +164,7 @@ describe("CheckDetail", () => { expect(screen.getByText("Nightly backup")).toBeInTheDocument(); expect(screen.getByRole("status", { name: "UP" })).toBeInTheDocument(); + expect(screen.getByText("203.0.113.40")).toBeInTheDocument(); expect(screen.getByText("missed heartbeat")).toBeInTheDocument(); }); @@ -372,6 +375,7 @@ describe("CheckDetail", () => { error: null, responseTimeMs: null, statusCode: null, + sourceIp: "198.51.100.12", }, { id: "outage", @@ -380,6 +384,7 @@ describe("CheckDetail", () => { error: "missed heartbeat", responseTimeMs: null, statusCode: null, + sourceIp: null, }, ], }, diff --git a/frontend/test/event-timeline.test.tsx b/frontend/test/event-timeline.test.tsx index cdfff69..f68aa6e 100644 --- a/frontend/test/event-timeline.test.tsx +++ b/frontend/test/event-timeline.test.tsx @@ -10,6 +10,7 @@ function event(id: string, timestamp: string, overrides: Partial error: null, responseTimeMs: null, statusCode: null, + sourceIp: null, ...overrides, }; } @@ -82,4 +83,20 @@ describe("EventTimeline", () => { render(); expect(screen.getByText("missed heartbeat")).toBeInTheDocument(); }); + + it("shows the heartbeat origin IP on received pings", () => { + render( + , + ); + + expect(screen.getByText("203.0.113.40")).toBeInTheDocument(); + }); + + it("does not invent an origin IP for a missed heartbeat", () => { + render(); + + expect(screen.queryByText(/^\d{1,3}(?:\.\d{1,3}){3}$/)).not.toBeInTheDocument(); + }); }); diff --git a/integrations/mcp/src/tools.ts b/integrations/mcp/src/tools.ts index 558df0d..2de949a 100644 --- a/integrations/mcp/src/tools.ts +++ b/integrations/mcp/src/tools.ts @@ -138,6 +138,7 @@ interface CheckEvent { responseTimeMs: number | null; error: string | null; statusCode: number | null; + sourceIp: string | null; } interface CheckDetail { @@ -467,6 +468,7 @@ const getCheck: ToolDef = { responseTimeMs error statusCode + sourceIp } } }`, @@ -501,8 +503,9 @@ const getCheck: ToolDef = { const eventLines = check.events.map((e) => { const rt = e.responseTimeMs != null ? ` ${e.responseTimeMs}ms` : ""; const sc = e.statusCode != null ? ` [${e.statusCode}]` : ""; + const ip = e.sourceIp ? ` from ${e.sourceIp}` : ""; const err = e.error ? ` error: ${e.error}` : ""; - return ` [${e.timestamp}] ${e.status}${sc}${rt}${err}`; + return ` [${e.timestamp}] ${e.status}${sc}${rt}${ip}${err}`; }); return text([header, ...eventLines].join("\n")); diff --git a/integrations/mcp/test/tools.test.ts b/integrations/mcp/test/tools.test.ts index 7a744fa..cccdc38 100644 --- a/integrations/mcp/test/tools.test.ts +++ b/integrations/mcp/test/tools.test.ts @@ -383,8 +383,8 @@ describe("get_check", () => { intervalSeconds: 60, notificationChannelIds: ["channel-email", "channel-webhook"], events: [ - { id: "e1", status: "UP", timestamp: "2024-01-01T00:01:00Z", responseTimeMs: 120, error: null, statusCode: 200 }, - { id: "e2", status: "DOWN", timestamp: "2024-01-01T00:00:00Z", responseTimeMs: null, error: "timeout", statusCode: null }, + { id: "e1", status: "UP", timestamp: "2024-01-01T00:01:00Z", responseTimeMs: 120, error: null, statusCode: 200, sourceIp: "203.0.113.40" }, + { id: "e2", status: "DOWN", timestamp: "2024-01-01T00:00:00Z", responseTimeMs: null, error: "timeout", statusCode: null, sourceIp: null }, ], }, }; @@ -396,6 +396,7 @@ describe("get_check", () => { expect(calls[0].variables).toEqual({ id: "c1" }); expect(calls[0].query).toContain("check"); expect(calls[0].query).toContain("notificationChannelIds"); + expect(calls[0].query).toContain("sourceIp"); expect(result.content[0].text).toContain("UP"); expect(result.content[0].text).toContain( "Notification channels: channel-email, channel-webhook", @@ -404,6 +405,7 @@ describe("get_check", () => { const text = result.content[0].text; expect(text).toContain("2024-01-01T00:01:00Z"); expect(text).toContain("2024-01-01T00:00:00Z"); + expect(text).toContain("203.0.113.40"); }); it("accurately exposes an empty notification channel selection", async () => {