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
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: "1.25"
cache: false
- run: go vet ./...

Expand All @@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: "1.25"
cache: false
- run: CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tmp/httphq ./src

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: "1.25"
cache: false
- run: go test ./...
e2e:
Expand All @@ -28,11 +28,11 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: "1.25"
cache: false
- uses: actions/setup-node@v6
with:
node-version: '22'
node-version: "22"
- name: Build server
run: CGO_ENABLED=0 go build -o ./bin/httphq ./src
working-directory: .
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,45 @@

[Scripts](docs/scripts.md)

## Configuration

httphq is configured entirely through environment variables.

| Variable | Default | Description |
| ----------------- | ------------- | ----------------------------------------------------------------------------------------------------------- |
| `APPLICATION_ENV` | `development` | Set to `production` to bind all interfaces, raise the rate limit, and default logging to `info`. |
| `LOG_LEVEL` | env-dependent | Overrides the log level: `debug`, `info`, `warn`, or `error`. |
| `PLATFORM` | `direct` | The hosting platform in front of httphq — selects which header the real client IP is read from (see below). |

### `PLATFORM`

httphq derives the client IP (used for rate limiting and shown on captured
requests) from the header your hosting platform sets. Declare your platform
and httphq picks the right strategy:

| `PLATFORM` | Client IP source |
| ---------------- | ----------------------------------------------------------------------- |
| unset / `direct` | The TCP connection peer (no proxy). |
| `cloudflare` | `CF-Connecting-IP` header. |
| `fly` | `Fly-Client-IP` header. |
| `heroku` | `X-Forwarded-For` (leftmost). |
| `render` | `X-Forwarded-For` (leftmost). |
| `proxy` | `X-Forwarded-For` (leftmost) — generic nginx / Traefik / load balancer. |

An unrecognised value falls back to `direct`.

Declaring the platform also strips its vendor-specific headers (e.g.
Cloudflare's `Cf-*`) from captured requests, so users inspect their own traffic
without the noise of whatever provider sits in front of httphq.

> **Security note.** Setting `PLATFORM` trusts that platform's client-IP
> header unconditionally — httphq cannot tell a real platform header from one
> a client forged. You must ensure inbound traffic cannot reach httphq
> bypassing the platform (e.g. lock your origin to the platform's IP ranges),
> or a client can spoof its IP and evade rate limiting. Conversely, if you run
> behind a proxy but leave `PLATFORM` unset, every request appears to come
> from the proxy and rate limiting becomes global.

## Logging

httphq logs to stdout as structured JSON via the standard library's `log/slog` — one JSON object per line, with no log files or shipping built in, so any collector can pick the logs up. Field names follow OpenTelemetry conventions (`service.name`, `http.request.method`, `url.path`, `http.response.status_code`, ...). Every request gets a correlation `request_id` (a valid inbound `X-Request-Id` is reused, otherwise one is minted) that is echoed back on the response header and stamped onto every log line emitted while handling that request. Each request produces one access-log line; headers and bodies are never logged, paths are logged without their query string, and a denylist masks sensitive keys as a backstop. The level defaults to `info` in production (`debug` elsewhere) and is overridable with `LOG_LEVEL`; Kubernetes probe traffic to `/api/health` logs at `debug` so it stays out of production logs.
Expand Down
42 changes: 13 additions & 29 deletions e2e/tests/endpoint-screen.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
test,
expect,
type APIRequestContext,
} from "@playwright/test";
import { test, expect, type APIRequestContext } from "@playwright/test";

const randomId = () => Math.random().toString(36).slice(2, 7);

Expand Down Expand Up @@ -40,13 +36,11 @@ test.describe("Endpoint screen", () => {

test("copy-url button writes the URL to the clipboard", async ({ page }) => {
await page.locator('[data-test="copy-url"]').click();
const clipboard = await page.evaluate(() =>
navigator.clipboard.readText(),
);
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboard).toBe(endpointUrl);
await expect(
page.locator('[data-test="copy-url-label"]'),
).toContainText("Copied!");
await expect(page.locator('[data-test="copy-url-label"]')).toContainText(
"Copied!",
);
});

test("disclaimer about 4-hour retention is visible", async ({ page }) => {
Expand All @@ -60,15 +54,11 @@ test.describe("Endpoint screen", () => {
page,
}) => {
await page.locator('[data-test="send-toggle"]').click();
await page
.locator('[data-test="send-method"]')
.selectOption("PUT");
await page.locator('[data-test="send-method"]').selectOption("PUT");
await page
.locator('[data-test="send-headers"]')
.fill("X-Source: panel\nContent-Type: application/json");
await page
.locator('[data-test="send-body"]')
.fill('{"hello":"panel"}');
await page.locator('[data-test="send-body"]').fill('{"hello":"panel"}');
await page.locator('[data-test="send-submit"]').click();

const card = page.locator('[data-test="request"]').first();
Expand Down Expand Up @@ -123,9 +113,9 @@ test.describe("Endpoint screen", () => {
await expect(details).toContainText("127.0.0.1");
await expect(details).toContainText(endpointPath);
await expect(card).toContainText("POST");
await expect(
card.locator('[data-test="request-headers"]'),
).toContainText("Content-Type");
await expect(card.locator('[data-test="request-headers"]')).toContainText(
"Content-Type",
);
await expect(card.locator('[data-test="request-body"]')).toContainText(
"Hello, World!",
);
Expand All @@ -145,9 +135,7 @@ test.describe("Endpoint screen", () => {
expect(text).toContain('"hello": "world"');
expect(text).toContain("\n ");
// Highlight.js wraps tokens in <span class="hljs-…"> elements.
const tokenCount = await body
.locator("pre span.hljs-string")
.count();
const tokenCount = await body.locator("pre span.hljs-string").count();
expect(tokenCount).toBeGreaterThan(0);
});

Expand Down Expand Up @@ -254,17 +242,13 @@ test.describe("Endpoint screen", () => {
"3 results",
);

await page
.locator('[data-test="method-filter"]')
.selectOption("POST");
await page.locator('[data-test="method-filter"]').selectOption("POST");
await expect(page.locator('[data-test="search-results"]')).toContainText(
"1 result",
);
await expect(page.locator('[data-test="request"]')).toHaveCount(1);

await page
.locator('[data-test="method-filter"]')
.selectOption("");
await page.locator('[data-test="method-filter"]').selectOption("");
await expect(page.locator('[data-test="search-results"]')).toContainText(
"3 results",
);
Expand Down
4 changes: 3 additions & 1 deletion e2e/tests/home-screen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ test.describe("Home screen", () => {
});

test("create endpoint button is visible", async ({ page }) => {
await expect(page.locator('button[data-test="create-endpoint"]')).toBeVisible();
await expect(
page.locator('button[data-test="create-endpoint"]'),
).toBeVisible();
});

test("create endpoint button redirects to the endpoint screen", async ({
Expand Down
7 changes: 3 additions & 4 deletions public/endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,9 @@
},

deleteRequest(uuid) {
return fetch(
`/api/endpoints/${this.endpointId}/requests/${uuid}`,
{ method: "DELETE" },
)
return fetch(`/api/endpoints/${this.endpointId}/requests/${uuid}`, {
method: "DELETE",
})
.then(() => {
this.requests = this.requests.filter((r) => r.uuid !== uuid);
})
Expand Down
6 changes: 5 additions & 1 deletion public/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ window.renderBody = function (body, headers) {
}
// Heuristic: looks like XML/HTML if it starts with '<'
const trimmed = body.trimStart();
if (trimmed.startsWith("<") && window.hljs && window.hljs.getLanguage("xml")) {
if (
trimmed.startsWith("<") &&
window.hljs &&
window.hljs.getLanguage("xml")
) {
return window.hljs.highlight(body, { language: "xml" }).value;
}
return window.htmlEscape(body);
Expand Down
Loading
Loading