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
19 changes: 19 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ To be released.
14 adds post-quantum ML-DSA passkey support on runtimes that provide the
algorithms. \[[GHSA-2g3p-m8c9-hhwh], [GHSA-j3h4-m3m2-7p7j]]

- Changed the HTTP server to avoid buffering entire uploads and incoming
activities before handling them behind a reverse proxy. Shutdown now waits
for active HTTP requests alongside background workers within the existing
30-second deadline.

Responses now avoid creating a web stream for strings and byte buffers
until one is needed.

Static files now support conditional requests with `ETag` and
`Last-Modified` validators. Hidden files and symlinks pointing outside the
configured storage directory are no longer served.

Proxy mode now uses the first nonempty forwarded host and protocol in a
comma-separated list, and ignores invalid forwarded hosts in favor of the
original `Host` header. Trusted proxies must overwrite client-supplied
forwarded headers. [[#672], [#673]]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

[FEP-044f]: https://w3id.org/fep/044f
[FEP-c0e0]: https://w3id.org/fep/c0e0
[FEP-f228]: https://w3id.org/fep/f228
Expand Down Expand Up @@ -175,6 +192,8 @@ To be released.
[#661]: https://github.com/fedify-dev/hollo/issues/661
[#663]: https://github.com/fedify-dev/hollo/pull/663
[#664]: https://github.com/fedify-dev/hollo/pull/664
[#672]: https://github.com/fedify-dev/hollo/issues/672
[#673]: https://github.com/fedify-dev/hollo/pull/673


Version 0.9.24
Expand Down
187 changes: 99 additions & 88 deletions bin/server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { isIP } from "node:net";

import type { ServerType } from "@hono/node-server";
import { serve } from "@hono/node-server";
import { behindProxy } from "x-forwarded-fetch";

import "../src/logging";
import { checkHandleHostConsistency } from "../src/handle-host-check";
import { configureSentry } from "../src/sentry";
import { createServerLifecycle } from "../src/server-lifecycle";

// oxlint-disable-next-line typescript/dot-notation
configureSentry(process.env["SENTRY_DSN"]);
Expand Down Expand Up @@ -42,95 +39,109 @@ if (!["all", "web", "worker"].includes(NODE_TYPE)) {
process.exit(1);
}

// Warn if the configured HANDLE_HOST disagrees with an existing account.
await checkHandleHostConsistency();

let webServer: ServerType | undefined;

// Start web server if running as web or all node
if (NODE_TYPE === "web" || NODE_TYPE === "all") {
const { default: app } = await import("../src/index");
webServer = serve(
{
fetch: BEHIND_PROXY
? behindProxy(app.fetch.bind(app))
: app.fetch.bind(app),
const lifecycle = createServerLifecycle([
async () => {
// Warn if HANDLE_HOST disagrees with an existing account.
await checkHandleHostConsistency();
},
async (registerStop) => {
if (NODE_TYPE === "worker") return;
// Initialize Sentry before loading the HTTP adapter and application.
const { createHttpServer, enableFastResponse } =
await import("../src/http-server");
enableFastResponse();
const { default: app } = await import("../src/index");
if (lifecycle.stopping) return;
const server = createHttpServer(app.fetch.bind(app), {
port: PORT,
hostname: BIND,
},
(info) => {
let host = info.address;
// We override it here to show localhost instead of what it resolves to:
if (BIND === "localhost") {
host = "localhost";
} else if (info.family === "IPv6") {
host = `[${info.address}]`;
behindProxy: BEHIND_PROXY,
});
registerStop(() => server.close());
await server.ready();
if (lifecycle.stopping) return;
const url = new URL(server.url!);
if (BIND === "localhost") url.hostname = "localhost";
console.log(`Listening on ${url.href}`);
},
async (registerStop) => {
if (NODE_TYPE === "web") return;
const [
{ federation },
{ backgroundJobs, replyScrapes, pollNotifications },
{ itemLeases },
{ WEB_ORIGIN },
] = await Promise.all([
import("../src/federation"),
import("../src/federation/federation"),
import("../src/background/lease"),
import("../src/env"),
]);
if (lifecycle.stopping) return;

const controller = new AbortController();
let queue: Promise<void> | undefined;
let stopRecovery: (() => Promise<void>) | undefined;
// Publish cleanup before any fallible worker initialization.
registerStop(async () => {
controller.abort();
try {
await stopRecovery?.();
await queue;
} finally {
await itemLeases.close();
}

console.log(`Listening on http://${host}:${info.port}/`);
},
);
}

// Start workers if running as worker or all node
let stopWorkers: (() => Promise<void>) | undefined;
if (NODE_TYPE === "worker" || NODE_TYPE === "all") {
const [
{ federation },
{ backgroundJobs, replyScrapes, pollNotifications },
{ itemLeases },
] = await Promise.all([
import("../src/federation"),
import("../src/federation/federation"),
import("../src/background/lease"),
]);

// Start the Fedify message queue
const controller = new AbortController();
backgroundJobs.setSignal(controller.signal);
replyScrapes.setSignal(controller.signal);
pollNotifications.setSignal(controller.signal);
const queue = federation
.startQueue(undefined, { signal: controller.signal })
.catch((error) => {
console.error("Error starting Fedify queue:", error);
process.exit(1);
});

// Start the workers for background job processing
const { WEB_ORIGIN } = await import("../src/env");
const stopRecovery = backgroundJobs.startRecovery(
federation.createContext(
new URL(WEB_ORIGIN ?? "http://localhost"),
undefined,
),
controller.signal,
);

stopWorkers = async () => {
controller.abort();
await stopRecovery();
await queue;
await itemLeases.close();
};
console.log("Worker started (Fedify activity/task queues + Job recovery)");
}

// Graceful shutdown handling
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
backgroundJobs.setSignal(controller.signal);
replyScrapes.setSignal(controller.signal);
pollNotifications.setSignal(controller.signal);
queue = federation
.startQueue(undefined, { signal: controller.signal })
.catch((error) => {
console.error("Error starting Fedify queue:", error);
void shutdown(1);
});
stopRecovery = backgroundJobs.startRecovery(
federation.createContext(
new URL(WEB_ORIGIN ?? "http://localhost"),
undefined,
),
controller.signal,
);
console.log("Worker started (Fedify activity/task queues + Job recovery)");
},
]);

let exitCode = 0;
let shutdownPromise: Promise<void> | undefined;
/** Stop acquired resources once, forcing exit if cleanup exceeds 30 seconds. */
function shutdown(code = 0): Promise<void> {
exitCode = Math.max(exitCode, code);
if (shutdownPromise) return shutdownPromise;
// Forced termination releases DB locks; durable unfinished items recover on
// restart. Do not reuse a slot while its external operation is still alive.
const deadline = setTimeout(() => process.exit(0), 30_000);
const deadline = setTimeout(() => process.exit(exitCode), 30_000);
deadline.unref();
webServer?.close();
await stopWorkers?.();
// The legacy federation queue does not drain its handlers. Keep the main
// client available until process exit rather than rejecting their queries.
process.exit(0);
};
shutdownPromise = lifecycle.stop().then(
() => {
// The legacy federation queue does not drain its handlers. Keep the main
// client available until process exit rather than rejecting their queries.
process.exit(exitCode);
},
(error) => {
console.error("Error stopping server:", error);
process.exit(1);
},
);
return shutdownPromise;
}

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// Handle signals even during asynchronous startup.
process.on("SIGTERM", () => void shutdown());
process.on("SIGINT", () => void shutdown());
try {
await lifecycle.start();
} catch (error) {
console.error("Error starting server:", error);
await shutdown(1);
}
21 changes: 21 additions & 0 deletions changes.d/streaming-http-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
links:
'#672': https://github.com/fedify-dev/hollo/issues/672
'#673': https://github.com/fedify-dev/hollo/pull/673
---
- Changed the HTTP server to avoid buffering entire uploads and incoming
activities before handling them behind a reverse proxy. Shutdown now waits
for active HTTP requests alongside background workers within the existing
30-second deadline.

Responses now avoid creating a web stream for strings and byte buffers
until one is needed.

Static files now support conditional requests with `ETag` and
`Last-Modified` validators. Hidden files and symlinks pointing outside the
configured storage directory are no longer served.

Proxy mode now uses the first nonempty forwarded host and protocol in a
comma-separated list, and ignores invalid forwarded hosts in favor of the
original `Host` header. Trusted proxies must overwrite client-supplied
forwarded headers. [[#672], [#673]]
6 changes: 6 additions & 0 deletions docs/src/content/docs/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ Turned off by default.
security reasons.
</Aside>

When enabled, Hollo uses the first nonempty value in comma-separated
`X-Forwarded-Host` and `X-Forwarded-Proto` headers. Invalid forwarded hosts
are ignored in favor of the original `Host` header. Configure your proxy to
replace client-supplied forwarded headers and allow only trusted proxies to
connect directly to Hollo.

### `HANDLE_HOST` <Badge text="Optional" />

The fediverse handle domain when running a [split-domain WebFinger
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/ja/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ HolloがL7ロードバランサーの後ろにある場合(通常はそうす
この動作はセキュリティ上注意が必要です。
</Aside>

有効にすると、カンマ区切りの `X-Forwarded-Host` と `X-Forwarded-Proto`
ヘッダーの最初の空でない値を使用します。無効な転送ホストは無視され、元の
`Host` ヘッダーが使用されます。クライアントが送信した転送ヘッダーをプロキシで
上書きし、信頼できるプロキシだけが Hollo に直接接続できるようにしてください。

### `HANDLE_HOST` <Badge text="オプション" />

[ドメイン分離 WebFinger 設定][split-domain]で使用するフェディバースの
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/ko/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ Hollo가 L7 로드 밸런서 뒤에 위치할 경우 (일반적으로 그래야
이 동작은 보안상 주의를 기울여야 합니다.
</Aside>

이 옵션을 켜면, 쉼표로 구분된 `X-Forwarded-Host`와 `X-Forwarded-Proto`
헤더에서 첫 번째 비어 있지 않은 값을 사용합니다. 유효하지 않은 전달 호스트는
무시하고 원래 `Host` 헤더를 사용합니다. 프록시가 클라이언트가 보낸 전달 헤더를
덮어쓰도록 설정하고, 신뢰할 수 있는 프록시만 Hollo에 직접 연결할 수 있게 하세요.

### `HANDLE_HOST` <Badge text="선택" />

[도메인 분리 WebFinger 설정][split-domain]에서 사용할 페디버스 핸들
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/zh-cn/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ openssl rand -hex 32
启用此选项后,Hollo将信任来自反向代理的`X-Forwarded-For`、`X-Forwarded-Proto`和`X-Forwarded-Host`头。这对于安全来说非常重要。
</Aside>

启用后,Hollo 使用逗号分隔的 `X-Forwarded-Host` 和 `X-Forwarded-Proto`
头中的第一个非空值。无效的转发主机将被忽略,并使用原始 `Host` 头。
请配置代理以覆盖客户端提供的转发头,并仅允许受信任的代理直接连接到 Hollo。

### `HANDLE_HOST` <Badge text="可选" />

在[分域 WebFinger 配置][split-domain]中用作联邦宇宙账号句柄的域名。
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/zh-tw/install/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ openssl rand -hex 32
啟用此選項後,Hollo 將信任來自反向代理的 `X-Forwarded-For`、`X-Forwarded-Proto` 和 `X-Forwarded-Host` 標頭。這對於安全來說非常重要。
</Aside>

啟用後,Hollo 使用以逗號分隔的 `X-Forwarded-Host` 和 `X-Forwarded-Proto`
標頭中的第一個非空值。無效的轉送主機會被忽略,並使用原始 `Host` 標頭。
請設定代理以覆寫用戶端提供的轉送標頭,並只允許受信任的代理直接連線至 Hollo。

### `HANDLE_HOST` <Badge text="可選" />

在[分域 WebFinger 設定][split-domain]中用作聯邦宇宙帳號句柄的網域名稱。
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
"@gukhanmun/opendict-fst": "^0.2.0",
"@gukhanmun/stdict-fst": "^0.2.0",
"@hexagon/base64": "^2.0.4",
"@hono/node-server": "^2.1.4",
"@hono/zod-validator": "^0.9.1",
"@js-temporal/polyfill": "^0.5.0",
"@logtape/drizzle-orm": "~2.3.10",
Expand Down Expand Up @@ -84,10 +83,10 @@
"qrcode": "^1.5.4",
"semver": "^7.8.1",
"sharp": "^0.35.5",
"srvx": "^1.0.6",
"ssrfcheck": "^1.4.0",
"tsx": "^4.23.15",
"uuidv7-js": "^1.1.4",
"x-forwarded-fetch": "^0.2.0",
"xss": "^1.0.15",
"zod": "^4.6.5"
},
Expand Down
Loading
Loading