Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/src/admin-schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ type CheckEventModel {
error: String
id: ID!
responseTimeMs: Int
sourceIp: String
status: String!
statusCode: Int
timestamp: DateTime!
Expand Down
1 change: 1 addition & 0 deletions api/src/checks/check.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export async function startApplicationLifecycle(
export async function buildApp(): Promise<NestFastifyApplication> {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
new FastifyAdapter({ trustProxy: true }),
{ rawBody: true },
);
// Register helmet on the underlying Fastify instance
Expand Down
22 changes: 22 additions & 0 deletions api/src/ping/client-ip.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 9 additions & 0 deletions api/src/ping/client-ip.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 13 additions & 5 deletions api/src/ping/ping.controller.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -7,15 +9,21 @@ export class PingController {

@Get(':slug')
@HttpCode(200)
async getPing(@Param('slug') slug: string): Promise<string> {
await this.pingService.recordPing(slug);
async getPing(
@Param('slug') slug: string,
@Req() req: FastifyRequest,
): Promise<string> {
await this.pingService.recordPing(slug, normalizeClientIp(req.ip));
return 'OK';
}

@Post(':slug')
@HttpCode(200)
async postPing(@Param('slug') slug: string): Promise<string> {
await this.pingService.recordPing(slug);
async postPing(
@Param('slug') slug: string,
@Req() req: FastifyRequest,
): Promise<string> {
await this.pingService.recordPing(slug, normalizeClientIp(req.ip));
return 'OK';
}
}
25 changes: 25 additions & 0 deletions api/src/ping/ping.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand Down
6 changes: 5 additions & 1 deletion api/src/ping/ping.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export class PingService {
private readonly alertQueue: AlertQueueService,
) {}

async recordPing(slug: string): Promise<PingResult> {
async recordPing(
slug: string,
sourceIp: string | null = null,
): Promise<PingResult> {
const check = await this.prisma.check.findUnique({
where: { pingSlug: slug },
});
Expand Down Expand Up @@ -55,6 +58,7 @@ export class PingService {
checkId: lockedCheck.id,
timestamp: now,
status: 'UP',
sourceIp,
},
});
await tx.check.update({
Expand Down
1 change: 1 addition & 0 deletions api/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ type CheckEventModel {
error: String
id: ID!
responseTimeMs: Int
sourceIp: String
status: String!
statusCode: Int
timestamp: DateTime!
Expand Down
35 changes: 33 additions & 2 deletions api/test/ping.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 },
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "check_events" ADD COLUMN "source_ip" TEXT;
1 change: 1 addition & 0 deletions database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions frontend/components/app/event-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface TimelineEvent {
error: string | null;
responseTimeMs: number | null;
statusCode: number | null;
sourceIp: string | null;
}

const EVENT_STATUS_LABELS: Record<EventStatus, string> = {
Expand Down Expand Up @@ -69,6 +70,9 @@ export function EventTimeline({ events }: { events: TimelineEvent[] }) {
{new Date(event.timestamp).toLocaleString()}
</span>
</div>
{event.sourceIp && (
<p className="text-xs font-mono text-muted-foreground mt-0.5">{event.sourceIp}</p>
)}
{event.statusCode !== null && (
<p className="text-xs text-muted-foreground mt-0.5">HTTP {event.statusCode}</p>
)}
Expand Down
1 change: 1 addition & 0 deletions frontend/lib/legacy-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const CHECK_BY_SLUG = gql`
error
responseTimeMs
statusCode
sourceIp
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const CHECK = gql`
error
responseTimeMs
statusCode
sourceIp
}
}
}
Expand Down Expand Up @@ -87,6 +88,7 @@ export const CHECK_BY_ORGANIZATION_SLUG = gql`
error
responseTimeMs
statusCode
sourceIp
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions frontend/test/check-detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const CHECK_DATA: CheckDetailData = {
error: null,
responseTimeMs: null,
statusCode: null,
sourceIp: "203.0.113.40",
},
{
id: "e2",
Expand All @@ -77,6 +78,7 @@ const CHECK_DATA: CheckDetailData = {
error: "missed heartbeat",
responseTimeMs: null,
statusCode: null,
sourceIp: null,
},
],
};
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -372,6 +375,7 @@ describe("CheckDetail", () => {
error: null,
responseTimeMs: null,
statusCode: null,
sourceIp: "198.51.100.12",
},
{
id: "outage",
Expand All @@ -380,6 +384,7 @@ describe("CheckDetail", () => {
error: "missed heartbeat",
responseTimeMs: null,
statusCode: null,
sourceIp: null,
},
],
},
Expand Down
17 changes: 17 additions & 0 deletions frontend/test/event-timeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function event(id: string, timestamp: string, overrides: Partial<TimelineEvent>
error: null,
responseTimeMs: null,
statusCode: null,
sourceIp: null,
...overrides,
};
}
Expand Down Expand Up @@ -82,4 +83,20 @@ describe("EventTimeline", () => {
render(<EventTimeline events={EVENTS} />);
expect(screen.getByText("missed heartbeat")).toBeInTheDocument();
});

it("shows the heartbeat origin IP on received pings", () => {
render(
<EventTimeline
events={[event("up", "2026-09-01T16:21:04.000Z", { sourceIp: "203.0.113.40" })]}
/>,
);

expect(screen.getByText("203.0.113.40")).toBeInTheDocument();
});

it("does not invent an origin IP for a missed heartbeat", () => {
render(<EventTimeline events={[EVENTS[0]]} />);

expect(screen.queryByText(/^\d{1,3}(?:\.\d{1,3}){3}$/)).not.toBeInTheDocument();
});
});
5 changes: 4 additions & 1 deletion integrations/mcp/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ interface CheckEvent {
responseTimeMs: number | null;
error: string | null;
statusCode: number | null;
sourceIp: string | null;
}

interface CheckDetail {
Expand Down Expand Up @@ -467,6 +468,7 @@ const getCheck: ToolDef = {
responseTimeMs
error
statusCode
sourceIp
}
}
}`,
Expand Down Expand Up @@ -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"));
Expand Down
6 changes: 4 additions & 2 deletions integrations/mcp/test/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
},
};
Expand All @@ -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",
Expand All @@ -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 () => {
Expand Down
Loading