O painel respondia "API endpoint not found" e o logotipo devolvia a pagina de 404 do Next. As rotas estavam registadas no servico protocols, mas o proxy do webmail decide o upstream por uma lista propria, e eu nao acrescentei a "bimi" -- exactamente o erro contra o qual o comentario dessa lista avisa. Ficam ambas cobertas por testes: cada area administrativa que o protocols possui, e o caminho well-known do logotipo. Esquecer isto nao volta a ser silencioso. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
/**
|
|
* Proxies /api/v1/* to the auth service.
|
|
*
|
|
* The browser cannot reach http://auth:3001 - that name only resolves inside
|
|
* the compose network - so every call the pages make used to 404 against the
|
|
* Next server. Routing them through here keeps the session cookie first-party
|
|
* (same origin, so SameSite=Strict still applies) and keeps the auth service
|
|
* off the public network.
|
|
*/
|
|
|
|
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? "http://auth:3001";
|
|
// The mail screens are served by the protocols service, which owns the folder,
|
|
// UID, flag and blob logic. Both verify the same session token, so the browser
|
|
// sees one origin and one cookie either way.
|
|
const PROTOCOLS_SERVICE_URL = process.env.PROTOCOLS_SERVICE_URL ?? "http://protocols:8080";
|
|
|
|
/**
|
|
* Admin areas the protocols service owns rather than the auth service.
|
|
*
|
|
* Each is here because that process holds something the auth service does not:
|
|
* the DKIM vault, the certificate watcher, and the DNS resolver plus the
|
|
* expected-record spec.
|
|
*
|
|
* Adding a route to protocols without adding it here sends the request to the
|
|
* auth service, which answers 401 from its admin guard - indistinguishable from
|
|
* a session problem, and the reason to keep this list beside the services it
|
|
* names.
|
|
*/
|
|
const PROTOCOLS_ADMIN_AREAS = new Set(["dkim", "tls", "dns", "bimi"]);
|
|
|
|
function upstreamFor(path: string[]): string {
|
|
if (path[0] === "mail") return PROTOCOLS_SERVICE_URL;
|
|
if (path[0] === "admin" && PROTOCOLS_ADMIN_AREAS.has(path[1])) return PROTOCOLS_SERVICE_URL;
|
|
return AUTH_SERVICE_URL;
|
|
}
|
|
|
|
// Hop-by-hop headers must not be forwarded (RFC 9110 section 7.6.1), and the
|
|
// upstream sets its own content-length.
|
|
const STRIPPED = new Set([
|
|
"host",
|
|
"connection",
|
|
"keep-alive",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"proxy-authorization",
|
|
"proxy-authenticate",
|
|
"te",
|
|
"trailer",
|
|
"content-length",
|
|
]);
|
|
|
|
function filterHeaders(source: Headers): Headers {
|
|
const out = new Headers();
|
|
source.forEach((value, key) => {
|
|
if (!STRIPPED.has(key.toLowerCase())) out.append(key, value);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
async function forward(req: NextRequest, path: string[]): Promise<Response> {
|
|
const target = `${upstreamFor(path)}/api/v1/${path.join("/")}${req.nextUrl.search}`;
|
|
const headers = filterHeaders(req.headers);
|
|
|
|
// The client's address is what the auth service records on sessions and
|
|
// recovery-code use; without this every login looks like it came from the
|
|
// web container.
|
|
const forwardedFor = req.headers.get("x-forwarded-for");
|
|
if (forwardedFor) headers.set("x-forwarded-for", forwardedFor);
|
|
|
|
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
|
|
let upstream: Response;
|
|
try {
|
|
upstream = await fetch(target, {
|
|
method: req.method,
|
|
headers,
|
|
body: hasBody ? await req.arrayBuffer() : undefined,
|
|
redirect: "manual",
|
|
cache: "no-store",
|
|
});
|
|
} catch {
|
|
// A dead upstream is a gateway problem, not a 404 - the difference decides
|
|
// whether an operator looks at the auth service or at this route.
|
|
return NextResponse.json(
|
|
{ error: "UPSTREAM_UNAVAILABLE", message: "Upstream service is unreachable" },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
|
|
const responseHeaders = new Headers();
|
|
upstream.headers.forEach((value, key) => {
|
|
if (!STRIPPED.has(key.toLowerCase())) responseHeaders.append(key, value);
|
|
});
|
|
// Set-Cookie can legitimately appear more than once (logout clears both
|
|
// surfaces); getSetCookie preserves them where forEach would fold them into
|
|
// one malformed header.
|
|
responseHeaders.delete("set-cookie");
|
|
for (const cookie of upstream.headers.getSetCookie?.() ?? []) {
|
|
responseHeaders.append("set-cookie", cookie);
|
|
}
|
|
|
|
return new NextResponse(upstream.body, {
|
|
status: upstream.status,
|
|
headers: responseHeaders,
|
|
});
|
|
}
|
|
|
|
type Context = { params: Promise<{ path: string[] }> };
|
|
|
|
async function handler(req: NextRequest, ctx: Context): Promise<Response> {
|
|
const { path } = await ctx.params;
|
|
return forward(req, path ?? []);
|
|
}
|
|
|
|
export const GET = handler;
|
|
export const POST = handler;
|
|
export const PUT = handler;
|
|
export const PATCH = handler;
|
|
export const DELETE = handler;
|
|
|
|
// Sessions are per-request; nothing here may be cached or prerendered.
|
|
export const dynamic = "force-dynamic";
|