mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(server): honor X-Forwarded-Proto so daemon web UI auto-connects behind HTTPS reverse proxy (#1739)
* fix(server): honor forwarded proto for daemon web UI * fix(server): configure trusted daemon proxies * docs: document daemon web UI reverse proxy setup * docs: consolidate web UI proxy guidance
This commit is contained in:
@@ -150,6 +150,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
cors: { allowedOrigins: string[] },
|
||||
|
||||
@@ -67,6 +67,19 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
|
||||
|
||||
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
|
||||
|
||||
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"daemon": {
|
||||
"trustedProxies": ["loopback", "172.16.0.0/12"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers.
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
@@ -77,6 +90,7 @@ server {
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
138
packages/server/src/server/bootstrap-web-ui.test.ts
Normal file
138
packages/server/src/server/bootstrap-web-ui.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
|
||||
interface InitialDaemonConnectionHint {
|
||||
listen: string;
|
||||
useTls: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function fetchDaemonWebUi(options: {
|
||||
port: number;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: options.port,
|
||||
path: "/",
|
||||
headers: {
|
||||
host: `daemon.example.test:${options.port}`,
|
||||
...options.headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Expected 200 from web UI, got ${res.statusCode ?? 0}`));
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.concat(chunks).toString("utf-8"));
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readInjectedConnectionHint(html: string): InitialDaemonConnectionHint {
|
||||
const match = html.match(
|
||||
/window\.__PASEO_INITIAL_DAEMON_CONNECTION__=(?<json>\{[^<]+})<\/script>/,
|
||||
);
|
||||
if (!match?.groups?.json) {
|
||||
throw new Error("Missing initial daemon connection hint");
|
||||
}
|
||||
return JSON.parse(match.groups.json) as InitialDaemonConnectionHint;
|
||||
}
|
||||
|
||||
describe("daemon web UI bootstrap", () => {
|
||||
let tempRoot: string | null = null;
|
||||
let daemonHandle: TestPaseoDaemon | null = null;
|
||||
|
||||
async function createWebUiDist(): Promise<string> {
|
||||
tempRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-web-ui-"));
|
||||
const distDir = path.join(tempRoot, "dist");
|
||||
await mkdir(distDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(distDir, "index.html"),
|
||||
"<!DOCTYPE html><html><head></head><body>app</body></html>",
|
||||
);
|
||||
return distDir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await daemonHandle?.close();
|
||||
daemonHandle = null;
|
||||
if (tempRoot) {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
tempRoot = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("injects a TLS initial connection hint only for HTTPS forwarded by a trusted proxy", async () => {
|
||||
const distDir = await createWebUiDist();
|
||||
|
||||
daemonHandle = await createTestPaseoDaemon({
|
||||
mcpEnabled: false,
|
||||
webUi: {
|
||||
enabled: true,
|
||||
distDir,
|
||||
},
|
||||
});
|
||||
|
||||
const httpHint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({ port: daemonHandle.port }),
|
||||
);
|
||||
const httpsHint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({
|
||||
port: daemonHandle.port,
|
||||
headers: { "x-forwarded-proto": "https" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(httpHint).toEqual({
|
||||
listen: `daemon.example.test:${daemonHandle.port}`,
|
||||
useTls: false,
|
||||
label: os.hostname(),
|
||||
});
|
||||
expect(httpsHint).toEqual({
|
||||
listen: `daemon.example.test:${daemonHandle.port}`,
|
||||
useTls: true,
|
||||
label: os.hostname(),
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores forwarded HTTPS when proxy trust is disabled", async () => {
|
||||
const distDir = await createWebUiDist();
|
||||
|
||||
daemonHandle = await createTestPaseoDaemon({
|
||||
mcpEnabled: false,
|
||||
trustedProxies: [],
|
||||
webUi: {
|
||||
enabled: true,
|
||||
distDir,
|
||||
},
|
||||
});
|
||||
|
||||
const httpsHint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({
|
||||
port: daemonHandle.port,
|
||||
headers: { "x-forwarded-proto": "https" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(httpsHint).toEqual({
|
||||
listen: `daemon.example.test:${daemonHandle.port}`,
|
||||
useTls: false,
|
||||
label: os.hostname(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -326,6 +326,7 @@ export interface PaseoDaemonConfig {
|
||||
corsAllowedOrigins: string[];
|
||||
allowedHosts?: HostnamesConfig;
|
||||
hostnames?: HostnamesConfig;
|
||||
trustedProxies?: true | string[];
|
||||
mcpEnabled?: boolean;
|
||||
mcpInjectIntoAgents?: boolean;
|
||||
autoArchiveAfterMerge?: boolean;
|
||||
@@ -423,6 +424,10 @@ function mountWebUi(app: express.Application, config: PaseoDaemonConfig, logger:
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | string[] {
|
||||
return config.trustedProxies ?? ["loopback"];
|
||||
}
|
||||
|
||||
export async function createPaseoDaemon(
|
||||
config: PaseoDaemonConfig,
|
||||
rootLogger: Logger,
|
||||
@@ -487,6 +492,7 @@ export async function createPaseoDaemon(
|
||||
const listenTarget = parseListenString(config.listen);
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", resolveExpressTrustProxySetting(config));
|
||||
let boundListenTarget: ListenTarget | null = null;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
|
||||
const terminalManager = createConfiguredTerminalManager({
|
||||
|
||||
@@ -150,6 +150,56 @@ describe("daemon service proxy config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon trusted proxy config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("trusts loopback proxies by default", async () => {
|
||||
const home = await createPaseoHome({ version: 1 });
|
||||
|
||||
expect(loadConfig(home, { env: {} }).trustedProxies).toEqual(["loopback"]);
|
||||
});
|
||||
|
||||
test("loads trusted proxies from persisted config", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
trustedProxies: ["loopback", "10.0.0.0/8"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadConfig(home, { env: {} }).trustedProxies).toEqual(["loopback", "10.0.0.0/8"]);
|
||||
});
|
||||
|
||||
test("PASEO_TRUSTED_PROXIES overrides persisted config", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
trustedProxies: ["loopback"],
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadConfig(home, {
|
||||
env: { PASEO_TRUSTED_PROXIES: "loopback,172.16.0.0/12" },
|
||||
});
|
||||
|
||||
expect(config.trustedProxies).toEqual(["loopback", "172.16.0.0/12"]);
|
||||
});
|
||||
|
||||
test("PASEO_TRUSTED_PROXIES supports explicit trust-all and trust-none modes", async () => {
|
||||
const trustAllHome = await createPaseoHome({ version: 1 });
|
||||
expect(
|
||||
loadConfig(trustAllHome, { env: { PASEO_TRUSTED_PROXIES: "true" } }).trustedProxies,
|
||||
).toBe(true);
|
||||
|
||||
const trustNoneHome = await createPaseoHome({ version: 1 });
|
||||
expect(
|
||||
loadConfig(trustNoneHome, { env: { PASEO_TRUSTED_PROXIES: "false" } }).trustedProxies,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon worktree root config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
|
||||
@@ -25,6 +25,7 @@ import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostn
|
||||
const DEFAULT_PORT = 6767;
|
||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
|
||||
const DEFAULT_TRUSTED_PROXIES = ["loopback"];
|
||||
|
||||
export function resolveBundledWebUiDistDir(moduleUrl: string | URL = import.meta.url): string {
|
||||
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
|
||||
@@ -72,6 +73,8 @@ export type CliConfigOverrides = Partial<{
|
||||
hostnames: HostnamesConfig;
|
||||
}>;
|
||||
|
||||
type TrustedProxiesConfig = true | string[];
|
||||
|
||||
function resolveLogConfigFromEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
@@ -307,6 +310,37 @@ function resolveCorsAllowedOrigins(
|
||||
);
|
||||
}
|
||||
|
||||
function parseTrustedProxiesEnv(value: string | undefined): TrustedProxiesConfig | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(",")
|
||||
.map((proxy) => proxy.trim())
|
||||
.filter((proxy) => proxy.length > 0);
|
||||
}
|
||||
|
||||
function resolveTrustedProxiesConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): TrustedProxiesConfig {
|
||||
return (
|
||||
parseTrustedProxiesEnv(env.PASEO_TRUSTED_PROXIES) ??
|
||||
persisted.daemon?.trustedProxies ??
|
||||
DEFAULT_TRUSTED_PROXIES
|
||||
);
|
||||
}
|
||||
|
||||
// PASEO_LISTEN can be:
|
||||
// - host:port (TCP)
|
||||
// - /path/to/socket (Unix socket)
|
||||
@@ -374,6 +408,7 @@ function resolveStaticLoadConfigSettings(
|
||||
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||
cli?.hostnames,
|
||||
]),
|
||||
trustedProxies: resolveTrustedProxiesConfig(env, persisted),
|
||||
appBaseUrl: env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL,
|
||||
};
|
||||
}
|
||||
@@ -396,6 +431,7 @@ export function loadConfig(
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
appBaseUrl,
|
||||
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
|
||||
|
||||
@@ -425,6 +461,7 @@ export function loadConfig(
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
autoArchiveAfterMerge,
|
||||
|
||||
@@ -63,6 +63,28 @@ describe("PersistedConfigSchema daemon relay config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon trusted proxy config", () => {
|
||||
test("accepts optional trusted proxy ranges", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
daemon: {
|
||||
trustedProxies: ["loopback", "172.16.0.0/12"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.daemon?.trustedProxies).toEqual(["loopback", "172.16.0.0/12"]);
|
||||
});
|
||||
|
||||
test("accepts explicit trust-all proxy config", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
daemon: {
|
||||
trustedProxies: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.daemon?.trustedProxies).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon web UI feature config", () => {
|
||||
test("accepts optional web UI enable flag and dist dir", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
|
||||
@@ -231,6 +231,7 @@ export const PersistedConfigSchema = z
|
||||
listen: z.string().optional(),
|
||||
hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||
trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||
mcp: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -37,6 +37,8 @@ interface TestPaseoDaemonOptions {
|
||||
auth?: PaseoDaemonConfig["auth"];
|
||||
pushNotificationSender?: PushNotificationSender;
|
||||
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
|
||||
webUi?: PaseoDaemonConfig["webUi"];
|
||||
trustedProxies?: PaseoDaemonConfig["trustedProxies"];
|
||||
}
|
||||
|
||||
export interface TestPaseoDaemon {
|
||||
@@ -168,6 +170,8 @@ async function prepareTestDaemonConfig(
|
||||
auth: options.auth,
|
||||
pushNotificationSender: options.pushNotificationSender,
|
||||
serviceProxy: options.serviceProxy,
|
||||
webUi: options.webUi,
|
||||
trustedProxies: options.trustedProxies,
|
||||
openai: options.openai,
|
||||
speech: options.speech,
|
||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||
|
||||
@@ -45,6 +45,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"trustedProxies": {
|
||||
"description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"const": true
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -388,6 +403,19 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"webUi": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"distDir": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -164,6 +164,9 @@ In the mobile app, enter the password in the direct connection setup screen.
|
||||
- `PASEO_LISTEN`, override `daemon.listen`
|
||||
- `PASEO_HOSTNAMES`, override/extend `daemon.hostnames`
|
||||
- `PASEO_ALLOWED_HOSTS`, deprecated alias for `PASEO_HOSTNAMES`
|
||||
- `PASEO_WEB_UI_ENABLED`, enable or disable the daemon-served web UI
|
||||
- `PASEO_WEB_UI_DIST_DIR`, override the daemon web UI build directory
|
||||
- `PASEO_TRUSTED_PROXIES`, configure trusted reverse proxy ranges for `X-Forwarded-*` headers
|
||||
- `PASEO_LOG_CONSOLE_LEVEL`, override `log.console.level`
|
||||
- `PASEO_LOG_FILE_LEVEL`, override `log.file.level`
|
||||
- `PASEO_LOG_FILE_PATH`, override `log.file.path`
|
||||
|
||||
@@ -164,9 +164,27 @@ That's the whole config. Caddy provisions a certificate automatically and preser
|
||||
|
||||
Terminate TLS at the proxy (or tunnel) and forward to the daemon over plain HTTP on localhost, that's what the configs above do. When the page is served over HTTPS and the proxy passes `X-Forwarded-Proto: https`, the app automatically connects back over `wss://`. You don't configure the scheme anywhere; it follows the edge.
|
||||
|
||||
The daemon honors the forwarded scheme only from a proxy on the **same host** (a loopback connection), which is what all the setups above do, the proxy or tunnel forwards to `127.0.0.1:6767`. Keep the proxy co-located with the daemon. If you must run the proxy on a different machine, the daemon won't trust its forwarded scheme; in that case add the daemon as a host manually in the UI with TLS enabled.
|
||||
The daemon trusts forwarded headers from loopback proxies by default, which is what all the setups above do, the proxy or tunnel forwards to `127.0.0.1:6767`.
|
||||
|
||||
If you serve the UI over HTTPS but the app tries to connect over `ws://` (and the browser blocks it as mixed content), your proxy isn't on the daemon's host or isn't forwarding `X-Forwarded-Proto`. Fix whichever applies.
|
||||
If your proxy reaches the daemon from another address, as in some Docker, LAN, or load-balancer setups, configure the trusted proxy ranges:
|
||||
|
||||
```json
|
||||
{
|
||||
"daemon": {
|
||||
"trustedProxies": ["loopback", "172.16.0.0/12"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values:
|
||||
|
||||
```bash
|
||||
PASEO_TRUSTED_PROXIES=loopback,172.16.0.0/12 paseo daemon start --web-ui
|
||||
```
|
||||
|
||||
Only use `trustedProxies: true` when your final trusted proxy overwrites client-supplied `X-Forwarded-*` headers. Otherwise a client could spoof forwarded header values.
|
||||
|
||||
If you serve the UI over HTTPS but the app tries to connect over `ws://` (and the browser blocks it as mixed content), your proxy isn't forwarding `X-Forwarded-Proto` or the daemon doesn't trust the proxy address. Fix whichever applies.
|
||||
|
||||
For the remote/relay path (driving a daemon through the Paseo relay rather than a reverse proxy), the relay has its own public-vs-internal TLS settings, see [Security](/docs/security).
|
||||
|
||||
@@ -206,7 +224,7 @@ For the full threat model, relay encryption, and DNS-rebinding details, see [Sec
|
||||
- **Blank page or 404 at `/`.** The web UI isn't enabled. Start the daemon with `--web-ui` and confirm with `paseo daemon status` that it's the daemon you're hitting.
|
||||
- **Page loads but never connects.** The proxy isn't forwarding the WebSocket upgrade, or it's stripping the `Host` header. Check the upgrade headers in your proxy config.
|
||||
- **Connects, then output freezes.** Response buffering is on, or read timeouts are too short. Disable buffering and raise the timeouts.
|
||||
- **"Mixed content" / connection blocked over HTTPS.** The app fell back to `ws://`. Either the proxy isn't sending `X-Forwarded-Proto: https`, or it isn't running on the daemon's host (the daemon only trusts the forwarded scheme from a loopback proxy). Co-locate the proxy with the daemon and forward the header.
|
||||
- **"Mixed content" / connection blocked over HTTPS.** The app fell back to `ws://`. Either the proxy isn't sending `X-Forwarded-Proto: https`, or the daemon doesn't trust the proxy address. Forward the header and configure `daemon.trustedProxies` if the proxy is not loopback.
|
||||
- **`403 Invalid Host header`.** Your domain isn't in the allowlist. Add it with `--hostnames` or `daemon.hostnames`, see [DNS rebinding protection](/docs/security#dns-rebinding-protection).
|
||||
- **Large prompts or uploads fail.** Raise the proxy's max body size (`client_max_body_size` in Nginx).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user