mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 02:06:37 +08:00
Merge branch 'feat/origin-error-page'
This commit is contained in:
@@ -24,6 +24,7 @@ sidebar: false
|
|||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
|
- 新增全局源站错误页:可在「网站管理 → 错误页」配置开关、触发状态码(支持 `500-599` 区间与单码)与自定义 HTML;默认启用 Cloudflare 风格页面并保持真实 HTTP 状态码,修改后随配置版本发布下发到边缘,关闭后恢复透传。
|
||||||
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
|
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -259,6 +259,16 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
|
|||||||
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
|
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
|
||||||
| `openresty_main_config_template` | `string` | 允许用户完全重写整个 OpenResty nginx.conf 的底层结构大骨架模板 | 空 (内置缺省骨架) |
|
| `openresty_main_config_template` | `string` | 允许用户完全重写整个 OpenResty nginx.conf 的底层结构大骨架模板 | 空 (内置缺省骨架) |
|
||||||
|
|
||||||
|
### 7. 源站错误页 (Origin Error Page)
|
||||||
|
|
||||||
|
全局源站错误页配置,写入配置版本快照后随发布/回滚下发到边缘 Agent。仅作用于**反代**路由;Pages 静态路由不受影响。管理端入口:「网站管理 → 错误页」。设计说明见 [源站错误页设计](../design/origin-error-page.md)。
|
||||||
|
|
||||||
|
| 配置键 (Key) | 数据类型 | 作用说明 | 默认值 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `origin_error_page_enabled` | `bool` | 是否启用全局源站错误页。开启后,源站或网关返回的匹配状态码由自定义/默认 HTML 替换,**HTTP 状态码保持原值**;关闭后不生成相关指令,恢复透传。修改后需发布配置版本生效 | `true` |
|
||||||
|
| `origin_error_page_status_codes` | `json` | 触发错误页的状态码标签 JSON 数组。支持单码(如 `522`)与闭区间(如 `500-599`);单码与区间两端均须在 **400–599**,且 `lo ≤ hi`。启用时展开结果不能为空 | `["500-599"]` |
|
||||||
|
| `origin_error_page_html` | `string` | 错误页自定义 HTML。空字符串表示使用内置 Cloudflare 风格默认模板;支持占位符 `{{status}}`(与 HTTP 状态码一致)、`{{host}}`(请求 Host)。最大 **256 KiB**(按字节)。勿嵌入不可信第三方脚本 | 空 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 前端构建环境变量
|
## 前端构建环境变量
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
FileWarning,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { useAuth } from '@/components/providers/auth-provider';
|
||||||
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { TagsInput } from '@/components/ui/tags-input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
DEFAULT_ORIGIN_ERROR_PAGE_HTML,
|
||||||
|
ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,
|
||||||
|
previewOriginErrorPageHTML,
|
||||||
|
} from '@/lib/openflare/default-origin-error-page-html';
|
||||||
|
import {
|
||||||
|
DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS,
|
||||||
|
parseStatusCodeTagsJSON,
|
||||||
|
validateStatusCodeTagMessage,
|
||||||
|
validateStatusCodeTags,
|
||||||
|
} from '@/lib/openflare/status-code-tags';
|
||||||
|
import { OptionService } from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
const optionsQueryKey = ['openflare', 'options'] as const;
|
||||||
|
|
||||||
|
const KEY_ENABLED = 'origin_error_page_enabled';
|
||||||
|
const KEY_STATUS_CODES = 'origin_error_page_status_codes';
|
||||||
|
const KEY_HTML = 'origin_error_page_html';
|
||||||
|
|
||||||
|
type ErrorPageFields = {
|
||||||
|
enabled: boolean;
|
||||||
|
statusCodes: string[];
|
||||||
|
html: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultFields: ErrorPageFields = {
|
||||||
|
enabled: true,
|
||||||
|
statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS],
|
||||||
|
html: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function optionsToMap(options: Array<{ key: string; value: string }>) {
|
||||||
|
return options.reduce<Record<string, string>>((acc, option) => {
|
||||||
|
acc[option.key] = option.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOptionsToFields(
|
||||||
|
optionMap: Record<string, string>,
|
||||||
|
): ErrorPageFields {
|
||||||
|
const enabledRaw = optionMap[KEY_ENABLED];
|
||||||
|
return {
|
||||||
|
enabled: enabledRaw === undefined ? true : enabledRaw === 'true',
|
||||||
|
statusCodes: parseStatusCodeTagsJSON(optionMap[KEY_STATUS_CODES]),
|
||||||
|
html: optionMap[KEY_HTML] ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFields(fields: ErrorPageFields) {
|
||||||
|
validateStatusCodeTags(fields.statusCodes);
|
||||||
|
|
||||||
|
const htmlBytes = new TextEncoder().encode(fields.html).length;
|
||||||
|
if (htmlBytes > ORIGIN_ERROR_PAGE_HTML_MAX_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`HTML 超过最大长度限制(${ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorPagesPage() {
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [fields, setFields] = useState<ErrorPageFields>(defaultFields);
|
||||||
|
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||||
|
const [tagError, setTagError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const optionsQuery = useQuery({
|
||||||
|
queryKey: optionsQueryKey,
|
||||||
|
queryFn: () => OptionService.list(),
|
||||||
|
enabled: !!user?.is_admin,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!optionsQuery.data) return;
|
||||||
|
setFields(mapOptionsToFields(optionsToMap(optionsQuery.data)));
|
||||||
|
setTagError(null);
|
||||||
|
}, [optionsQuery.data]);
|
||||||
|
|
||||||
|
const previewSrcDoc = useMemo(
|
||||||
|
() => previewOriginErrorPageHTML(fields.html),
|
||||||
|
[fields.html],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
validateFields(fields);
|
||||||
|
await OptionService.updateBatch([
|
||||||
|
{ key: KEY_ENABLED, value: String(fields.enabled) },
|
||||||
|
{
|
||||||
|
key: KEY_STATUS_CODES,
|
||||||
|
value: JSON.stringify(fields.statusCodes),
|
||||||
|
},
|
||||||
|
{ key: KEY_HTML, value: fields.html },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('源站错误页已保存,请前往版本发布使配置生效');
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: optionsQueryKey }),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['openflare', 'config-preview'],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['openflare', 'config-versions'],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleStatusCodesChange = (statusCodes: string[]) => {
|
||||||
|
setFields((prev) => ({ ...prev, statusCodes }));
|
||||||
|
setTagError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidateTag = (tag: string) => {
|
||||||
|
const message = validateStatusCodeTagMessage(tag);
|
||||||
|
if (message) {
|
||||||
|
setTagError(message);
|
||||||
|
toast.error(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
setTagError(null);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDefaultTemplate = () => {
|
||||||
|
setFields((prev) => ({
|
||||||
|
...prev,
|
||||||
|
html: DEFAULT_ORIGIN_ERROR_PAGE_HTML,
|
||||||
|
}));
|
||||||
|
toast.success('已加载默认 HTML 模板到编辑器');
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreDefault = () => {
|
||||||
|
setFields((prev) => ({
|
||||||
|
...prev,
|
||||||
|
html: '',
|
||||||
|
statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS],
|
||||||
|
}));
|
||||||
|
setRestoreOpen(false);
|
||||||
|
setTagError(null);
|
||||||
|
toast.success('已恢复默认:状态码 500-599,HTML 使用服务端内置模板');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className='py-6 px-1'>
|
||||||
|
<LoadingStateWithBorder
|
||||||
|
icon={FileWarning}
|
||||||
|
description='加载权限信息...'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.is_admin) {
|
||||||
|
return (
|
||||||
|
<div className='py-6 px-1'>
|
||||||
|
<EmptyStateWithBorder
|
||||||
|
icon={FileWarning}
|
||||||
|
title='权限不足'
|
||||||
|
description='只有管理员可以访问源站错误页设置。'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className='py-6 px-1'>
|
||||||
|
<LoadingStateWithBorder
|
||||||
|
icon={FileWarning}
|
||||||
|
description='加载错误页配置...'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className='py-6 px-1'>
|
||||||
|
<ErrorInline
|
||||||
|
message={
|
||||||
|
optionsQuery.error instanceof Error
|
||||||
|
? optionsQuery.error.message
|
||||||
|
: '加载失败'
|
||||||
|
}
|
||||||
|
onRetry={() => void optionsQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-6 py-6 px-1'>
|
||||||
|
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<FileWarning className='size-5 text-primary' />
|
||||||
|
<div>
|
||||||
|
<h1 className='text-2xl font-semibold tracking-tight'>错误页</h1>
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
配置源站/网关错误响应时的统一 HTML
|
||||||
|
页面。保存后需发布配置版本后生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<Button variant='outline' size='sm' asChild>
|
||||||
|
<Link href='/config-versions'>
|
||||||
|
<ExternalLink className='size-3.5' />
|
||||||
|
查看配置预览
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className='size-3.5 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Save className='size-3.5' />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<CardTitle className='text-base'>启用源站错误页</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
关闭后透传源站或 Nginx 默认错误响应,不注入 error_page 指令。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={fields.enabled}
|
||||||
|
onCheckedChange={(enabled) =>
|
||||||
|
setFields((prev) => ({ ...prev, enabled }))
|
||||||
|
}
|
||||||
|
aria-label='启用源站错误页'
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-base'>触发状态码</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
支持单码(如 502)或闭区间(如 500-599),范围 400–599。默认
|
||||||
|
500-599。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='flex flex-col gap-2'>
|
||||||
|
<Label htmlFor='origin-error-status-codes' className='sr-only'>
|
||||||
|
状态码标签
|
||||||
|
</Label>
|
||||||
|
<TagsInput
|
||||||
|
id='origin-error-status-codes'
|
||||||
|
value={fields.statusCodes}
|
||||||
|
onChange={handleStatusCodesChange}
|
||||||
|
validateTag={handleValidateTag}
|
||||||
|
placeholder='例如 502 或 500-599,回车添加'
|
||||||
|
aria-invalid={!!tagError}
|
||||||
|
/>
|
||||||
|
{tagError ? (
|
||||||
|
<p className='text-xs text-destructive'>{tagError}</p>
|
||||||
|
) : (
|
||||||
|
<p className='text-xs text-muted-foreground'>
|
||||||
|
输入后按 Enter 或逗号添加;Backspace 可删除最后一个标签。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-2'>
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
|
<div>
|
||||||
|
<CardTitle className='text-base'>HTML 模板</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
支持占位符 {'{{status}}'} 与 {'{{host}}'}
|
||||||
|
。留空则使用服务端内置默认页。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={loadDefaultTemplate}
|
||||||
|
>
|
||||||
|
<Sparkles className='size-3.5' />
|
||||||
|
加载默认模板
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setRestoreOpen(true)}
|
||||||
|
>
|
||||||
|
<RotateCcw className='size-3.5' />
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
id='origin-error-html'
|
||||||
|
value={fields.html}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFields((prev) => ({ ...prev, html: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder='留空使用内置默认模板…'
|
||||||
|
className='min-h-[28rem] font-mono text-xs leading-relaxed'
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className='mt-2 text-xs text-muted-foreground'>
|
||||||
|
当前 {new TextEncoder().encode(fields.html).length} /{' '}
|
||||||
|
{ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节
|
||||||
|
{fields.html.trim() === '' ? '(使用内置默认)' : ''}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-base'>预览</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
客户端预览:{'{{status}}'}→502,{'{{host}}'}→example.com
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='overflow-hidden rounded-md border bg-muted/30'>
|
||||||
|
<iframe
|
||||||
|
title='源站错误页预览'
|
||||||
|
sandbox=''
|
||||||
|
srcDoc={previewSrcDoc}
|
||||||
|
className='h-[32rem] w-full bg-background'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={restoreOpen} onOpenChange={setRestoreOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>恢复默认配置?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
将状态码重置为 500-599,并将 HTML
|
||||||
|
清空为「使用服务端内置默认模板」。此操作不会自动保存,仍需点击保存并发布配置版本。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={restoreDefault}>
|
||||||
|
确认恢复
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type TagsInputProps = {
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
/** Called when a tag fails a custom validator; return true if accepted. */
|
||||||
|
validateTag?: (tag: string) => string | null;
|
||||||
|
'aria-invalid'?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeTag(raw: string) {
|
||||||
|
return raw.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagsInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = '输入后按 Enter 添加',
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
validateTag,
|
||||||
|
'aria-invalid': ariaInvalid,
|
||||||
|
}: TagsInputProps) {
|
||||||
|
const [draft, setDraft] = React.useState('');
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const commit = (raw: string) => {
|
||||||
|
const tag = normalizeTag(raw);
|
||||||
|
if (!tag) {
|
||||||
|
setDraft('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateTag) {
|
||||||
|
const error = validateTag(tag);
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes(tag)) {
|
||||||
|
setDraft('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange([...value, tag]);
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAt = (index: number) => {
|
||||||
|
onChange(value.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (event.key === 'Enter' || event.key === ',' || event.key === 'Tab') {
|
||||||
|
if (draft.trim()) {
|
||||||
|
event.preventDefault();
|
||||||
|
commit(draft);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
removeAt(value.length - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
const text = event.clipboardData.getData('text');
|
||||||
|
if (!text || (!text.includes(',') && !text.includes('\n'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const parts = text
|
||||||
|
.split(/[,\n]+/)
|
||||||
|
.map(normalizeTag)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length === 0) return;
|
||||||
|
|
||||||
|
const next = [...value];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (validateTag) {
|
||||||
|
const error = validateTag(part);
|
||||||
|
if (error) continue;
|
||||||
|
}
|
||||||
|
if (!next.includes(part)) {
|
||||||
|
next.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='tags-input'
|
||||||
|
className={cn(
|
||||||
|
'border-input dark:bg-input/30 flex min-h-8 w-full flex-wrap items-center gap-1.5 rounded-md border bg-transparent px-2 py-1 text-xs transition-[color,box-shadow] outline-none',
|
||||||
|
'focus-within:border-ring focus-within:ring-ring/30 focus-within:ring-[2px]',
|
||||||
|
ariaInvalid &&
|
||||||
|
'ring-destructive/20 dark:ring-destructive/40 border-destructive',
|
||||||
|
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
{value.map((tag, index) => (
|
||||||
|
<Badge
|
||||||
|
key={`${tag}-${index}`}
|
||||||
|
variant='secondary'
|
||||||
|
className='gap-1 pr-1 font-normal'
|
||||||
|
>
|
||||||
|
<span className='max-w-[12rem] truncate'>{tag}</span>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='hover:bg-muted rounded-sm p-0.5 outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
||||||
|
aria-label={`移除 ${tag}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
removeAt(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className='size-3' />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
value={draft}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={ariaInvalid}
|
||||||
|
placeholder={value.length === 0 ? placeholder : undefined}
|
||||||
|
className='placeholder:text-muted-foreground min-w-[8rem] flex-1 bg-transparent py-0.5 text-xs outline-none'
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onBlur={() => {
|
||||||
|
if (draft.trim()) {
|
||||||
|
commit(draft);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TagsInput };
|
||||||
@@ -60,6 +60,7 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
|
|||||||
childUrls: ['/cloudflare/settings'],
|
childUrls: ['/cloudflare/settings'],
|
||||||
},
|
},
|
||||||
{ title: '源站地址', url: '/origins', childUrls: ['/origins/detail'] },
|
{ title: '源站地址', url: '/origins', childUrls: ['/origins/detail'] },
|
||||||
|
{ title: '错误页', url: '/error-pages' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ export const openflareWebsiteSubNav = [
|
|||||||
{ title: '证书', url: '/certificates' },
|
{ title: '证书', url: '/certificates' },
|
||||||
{ title: 'DNS 账号', url: '/dns-accounts' },
|
{ title: 'DNS 账号', url: '/dns-accounts' },
|
||||||
{ title: 'Cloudflare', url: '/cloudflare' },
|
{ title: 'Cloudflare', url: '/cloudflare' },
|
||||||
|
{ title: '错误页', url: '/error-pages' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const nonConsoleRoutePrefixes = [
|
const nonConsoleRoutePrefixes = [
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Built-in Cloudflare-style origin error page.
|
||||||
|
* Keep in sync with Go DefaultOriginErrorPageHTML in
|
||||||
|
* pkg/render/openresty/origin_error_page.go
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ORIGIN_ERROR_PAGE_HTML = `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{status}} | 源站暂时无法提供服务</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||||
|
background: #f6f7f9;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: #0f1419; color: #e5e7eb; }
|
||||||
|
.card { background: #1a2332; border-color: #2d3a4d; box-shadow: none; }
|
||||||
|
.status { color: #f3f4f6; }
|
||||||
|
.muted { color: #9ca3af; }
|
||||||
|
.host { background: #243044; color: #d1d5db; }
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(32rem, calc(100% - 2rem));
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(3.5rem, 12vw, 5.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p { margin: 0.4rem 0; line-height: 1.6; }
|
||||||
|
.muted { color: #6b7280; font-size: 0.95rem; }
|
||||||
|
.host {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card" role="main">
|
||||||
|
<p class="status" aria-label="HTTP status">{{status}}</p>
|
||||||
|
<h1>源站暂时无法提供服务</h1>
|
||||||
|
<p class="muted">网关已拦截源站错误响应。请稍后重试;若问题持续,请联系站点管理员。</p>
|
||||||
|
<p class="host">{{host}}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Max HTML size in bytes (aligned with backend 256 KiB). */
|
||||||
|
export const ORIGIN_ERROR_PAGE_HTML_MAX_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
export function effectiveOriginErrorPageHTML(html: string): string {
|
||||||
|
return html.trim() === '' ? DEFAULT_ORIGIN_ERROR_PAGE_HTML : html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewOriginErrorPageHTML(
|
||||||
|
html: string,
|
||||||
|
status = '502',
|
||||||
|
host = 'example.com',
|
||||||
|
): string {
|
||||||
|
return effectiveOriginErrorPageHTML(html)
|
||||||
|
.replaceAll('{{status}}', status)
|
||||||
|
.replaceAll('{{host}}', host);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/** Matches pkg/render/openresty StatusCodeMin / StatusCodeMax. */
|
||||||
|
export const STATUS_CODE_MIN = 400;
|
||||||
|
export const STATUS_CODE_MAX = 599;
|
||||||
|
|
||||||
|
export const DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS = ['500-599'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single tag such as "502" or "500-599".
|
||||||
|
* Bounds must fall within 400–599 inclusive (aligned with Go ParseStatusCodeTag).
|
||||||
|
*/
|
||||||
|
export function parseStatusCodeTag(tag: string): {
|
||||||
|
lo: number;
|
||||||
|
hi: number;
|
||||||
|
} {
|
||||||
|
const trimmed = tag.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('状态码标签不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lo: number;
|
||||||
|
let hi: number;
|
||||||
|
|
||||||
|
const dash = trimmed.indexOf('-');
|
||||||
|
if (dash >= 0) {
|
||||||
|
lo = Number.parseInt(trimmed.slice(0, dash), 10);
|
||||||
|
hi = Number.parseInt(trimmed.slice(dash + 1), 10);
|
||||||
|
if (Number.isNaN(lo) || Number.isNaN(hi)) {
|
||||||
|
throw new Error(`无效状态码区间: ${trimmed}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lo = Number.parseInt(trimmed, 10);
|
||||||
|
if (Number.isNaN(lo)) {
|
||||||
|
throw new Error(`无效状态码: ${trimmed}`);
|
||||||
|
}
|
||||||
|
hi = lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lo > hi) {
|
||||||
|
throw new Error(`状态码区间左右端点反序: ${trimmed}`);
|
||||||
|
}
|
||||||
|
if (lo < STATUS_CODE_MIN || hi > STATUS_CODE_MAX) {
|
||||||
|
throw new Error(
|
||||||
|
`状态码须在 ${STATUS_CODE_MIN}–${STATUS_CODE_MAX}: ${trimmed}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lo, hi };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand tags into a sorted unique list of integers. */
|
||||||
|
export function expandStatusCodeTags(tags: string[]): number[] {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
const { lo, hi } = parseStatusCodeTag(tag);
|
||||||
|
for (let code = lo; code <= hi; code += 1) {
|
||||||
|
set.add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft validator for TagsInput: returns error message or null if ok. */
|
||||||
|
export function validateStatusCodeTagMessage(tag: string): string | null {
|
||||||
|
try {
|
||||||
|
parseStatusCodeTag(tag);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return error instanceof Error ? error.message : '无效状态码标签';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a full tag list before save (must be non-empty and expand cleanly). */
|
||||||
|
export function validateStatusCodeTags(tags: string[]): void {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
throw new Error('请至少添加一个状态码标签');
|
||||||
|
}
|
||||||
|
const codes = expandStatusCodeTags(tags);
|
||||||
|
if (codes.length === 0) {
|
||||||
|
throw new Error('状态码展开结果为空');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatusCodeTagsJSON(raw: string | undefined): string[] {
|
||||||
|
if (!raw || !raw.trim()) {
|
||||||
|
return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS];
|
||||||
|
}
|
||||||
|
const tags = parsed
|
||||||
|
.filter((item): item is string => typeof item === 'string')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return tags.length > 0 ? tags : [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS];
|
||||||
|
} catch {
|
||||||
|
return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,23 @@ export const searchData: SearchItem[] = [
|
|||||||
category: 'page',
|
category: 'page',
|
||||||
keywords: ['origin', '源站', '后端', 'backend', '服务器', '负载均衡'],
|
keywords: ['origin', '源站', '后端', 'backend', '服务器', '负载均衡'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'console-error-pages',
|
||||||
|
title: '错误页',
|
||||||
|
description: '配置源站错误状态码触发的统一 HTML 错误页',
|
||||||
|
url: '/error-pages',
|
||||||
|
category: 'page',
|
||||||
|
keywords: [
|
||||||
|
'错误页',
|
||||||
|
'源站',
|
||||||
|
'502',
|
||||||
|
'503',
|
||||||
|
'5xx',
|
||||||
|
'error page',
|
||||||
|
'origin error',
|
||||||
|
'error_page',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'console-waf',
|
id: 'console-waf',
|
||||||
title: 'WAF 防火墙',
|
title: 'WAF 防火墙',
|
||||||
|
|||||||
@@ -522,6 +522,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
|||||||
}
|
}
|
||||||
normalizedRoute := string(data)
|
normalizedRoute := string(data)
|
||||||
if m.NginxCertDir != "" {
|
if m.NginxCertDir != "" {
|
||||||
|
// Longer error-page path must be restored before the cert-dir prefix rewrite.
|
||||||
|
errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
|
||||||
|
normalizedRoute = strings.ReplaceAll(normalizedRoute, errorPagePath, openrestyrender.ErrorPageTmplPlaceholder)
|
||||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder)
|
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder)
|
||||||
}
|
}
|
||||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||||
@@ -1375,6 +1378,8 @@ func (m *Manager) renderRouteConfig(content string) string {
|
|||||||
rendered := content
|
rendered := content
|
||||||
if m.NginxCertDir != "" {
|
if m.NginxCertDir != "" {
|
||||||
rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir)
|
rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir)
|
||||||
|
errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
|
||||||
|
rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorPageTmplPlaceholder, errorPagePath)
|
||||||
}
|
}
|
||||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||||
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||||
sharedprotocol "github.com/Rain-kl/Wavelet/pkg/protocol"
|
sharedprotocol "github.com/Rain-kl/Wavelet/pkg/protocol"
|
||||||
|
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runCall struct {
|
type runCall struct {
|
||||||
@@ -299,13 +300,17 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
|||||||
if !strings.Contains(string(routeData), "/etc/nginx/openflare-certs/1.crt") {
|
if !strings.Contains(string(routeData), "/etc/nginx/openflare-certs/1.crt") {
|
||||||
t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData))
|
t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData))
|
||||||
}
|
}
|
||||||
renderedRoute := manager.renderRouteConfig("access_by_lua_file __OPENFLARE_LUA_DIR__/pow/check.lua;\nlocation /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n")
|
renderedRoute := manager.renderRouteConfig("access_by_lua_file __OPENFLARE_LUA_DIR__/pow/check.lua;\nlocation /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\nio.open(\"__OPENFLARE_ERROR_PAGE_TMPL__\", \"r\")\n")
|
||||||
if !strings.Contains(renderedRoute, "access_by_lua_file /etc/nginx/openflare-lua/pow/check.lua;") {
|
if !strings.Contains(renderedRoute, "access_by_lua_file /etc/nginx/openflare-lua/pow/check.lua;") {
|
||||||
t.Fatalf("expected lua dir placeholder replacement in route config, got %s", renderedRoute)
|
t.Fatalf("expected lua dir placeholder replacement in route config, got %s", renderedRoute)
|
||||||
}
|
}
|
||||||
if !strings.Contains(renderedRoute, "alias /etc/nginx/openflare-lua/pow/static/;") {
|
if !strings.Contains(renderedRoute, "alias /etc/nginx/openflare-lua/pow/static/;") {
|
||||||
t.Fatalf("expected pow static dir placeholder replacement in route config, got %s", renderedRoute)
|
t.Fatalf("expected pow static dir placeholder replacement in route config, got %s", renderedRoute)
|
||||||
}
|
}
|
||||||
|
wantErrorPagePath := filepath.ToSlash(filepath.Join(manager.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
|
||||||
|
if !strings.Contains(renderedRoute, `io.open("`+wantErrorPagePath+`", "r")`) {
|
||||||
|
t.Fatalf("expected error page template placeholder replacement, got %s", renderedRoute)
|
||||||
|
}
|
||||||
mainData, err := os.ReadFile(manager.MainConfigPath)
|
mainData, err := os.ReadFile(manager.MainConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read main config: %v", err)
|
t.Fatalf("failed to read main config: %v", err)
|
||||||
|
|||||||
@@ -534,9 +534,23 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
|
|||||||
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
|
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
|
||||||
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
|
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
|
||||||
appendIfChanged("OpenRestyDefaultLimitReqPerIP", left.DefaultLimitReqPerIP, right.DefaultLimitReqPerIP)
|
appendIfChanged("OpenRestyDefaultLimitReqPerIP", left.DefaultLimitReqPerIP, right.DefaultLimitReqPerIP)
|
||||||
|
appendIfChanged("OriginErrorPageEnabled", fmt.Sprintf("%t", left.OriginErrorPageEnabled), fmt.Sprintf("%t", right.OriginErrorPageEnabled))
|
||||||
|
appendIfChanged("OriginErrorPageStatusCodes", encodeOriginErrorPageStatusCodes(left.OriginErrorPageStatusCodes), encodeOriginErrorPageStatusCodes(right.OriginErrorPageStatusCodes))
|
||||||
|
appendIfChanged("OriginErrorPageHTML", left.OriginErrorPageHTML, right.OriginErrorPageHTML)
|
||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func encodeOriginErrorPageStatusCodes(tags []string) string {
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(tags)
|
||||||
|
if err != nil {
|
||||||
|
return strings.Join(tags, ",")
|
||||||
|
}
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
|
||||||
func extractOptionDiffKeys(details []ConfigOptionDiffItem) []string {
|
func extractOptionDiffKeys(details []ConfigOptionDiffItem) []string {
|
||||||
keys := make([]string, 0, len(details))
|
keys := make([]string, 0, len(details))
|
||||||
for _, item := range details {
|
for _, item := range details {
|
||||||
@@ -586,5 +600,8 @@ func openRestyOptionKeys() []string {
|
|||||||
"OpenRestyDefaultLimitConnPerIP",
|
"OpenRestyDefaultLimitConnPerIP",
|
||||||
"OpenRestyDefaultLimitRate",
|
"OpenRestyDefaultLimitRate",
|
||||||
"OpenRestyDefaultLimitReqPerIP",
|
"OpenRestyDefaultLimitReqPerIP",
|
||||||
|
"OriginErrorPageEnabled",
|
||||||
|
"OriginErrorPageStatusCodes",
|
||||||
|
"OriginErrorPageHTML",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package config_version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupOriginErrorPageSnapshotDB(t *testing.T) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, sqliteDB.AutoMigrate(&model.SystemConfig{}))
|
||||||
|
db.SetDB(sqliteDB)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
db.SetDB(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults(t *testing.T) {
|
||||||
|
cleanup := setupOriginErrorPageSnapshotDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
snapshot := buildOpenRestyConfigSnapshot(context.Background())
|
||||||
|
assert.True(t, snapshot.OriginErrorPageEnabled)
|
||||||
|
assert.Equal(t, []string{"500-599"}, snapshot.OriginErrorPageStatusCodes)
|
||||||
|
assert.Empty(t, snapshot.OriginErrorPageHTML)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(snapshot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(payload), `"origin_error_page_enabled":true`)
|
||||||
|
assert.Contains(t, string(payload), `"origin_error_page_status_codes":["500-599"]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOpenRestyConfigSnapshotOriginErrorPageCustom(t *testing.T) {
|
||||||
|
cleanup := setupOriginErrorPageSnapshotDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.SystemConfig{
|
||||||
|
Key: model.ConfigKeyOriginErrorPageEnabled, Value: "false", Type: "business",
|
||||||
|
}).Error)
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.SystemConfig{
|
||||||
|
Key: model.ConfigKeyOriginErrorPageStatusCodes, Value: `["522","500-502"]`, Type: "business",
|
||||||
|
}).Error)
|
||||||
|
require.NoError(t, db.DB(ctx).Create(&model.SystemConfig{
|
||||||
|
Key: model.ConfigKeyOriginErrorPageHTML, Value: "<h1>{{status}}</h1>", Type: "business",
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
snapshot := buildOpenRestyConfigSnapshot(ctx)
|
||||||
|
assert.False(t, snapshot.OriginErrorPageEnabled)
|
||||||
|
assert.Equal(t, []string{"522", "500-502"}, snapshot.OriginErrorPageStatusCodes)
|
||||||
|
assert.Equal(t, "<h1>{{status}}</h1>", snapshot.OriginErrorPageHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOriginErrorPageStatusCodesFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"500-599"}, parseOriginErrorPageStatusCodes(""))
|
||||||
|
assert.Equal(t, []string{"500-599"}, parseOriginErrorPageStatusCodes("not-json"))
|
||||||
|
assert.Equal(t, []string{"500-599"}, parseOriginErrorPageStatusCodes("[]"))
|
||||||
|
assert.Equal(t, []string{"502"}, parseOriginErrorPageStatusCodes(`["502"]`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffOpenRestyOptionDetailsOriginErrorPage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
left := openRestyConfigSnapshot{
|
||||||
|
OriginErrorPageEnabled: true,
|
||||||
|
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||||
|
OriginErrorPageHTML: "",
|
||||||
|
}
|
||||||
|
right := openRestyConfigSnapshot{
|
||||||
|
OriginErrorPageEnabled: false,
|
||||||
|
OriginErrorPageStatusCodes: []string{"522"},
|
||||||
|
OriginErrorPageHTML: "<p>x</p>",
|
||||||
|
}
|
||||||
|
details := diffOpenRestyOptionDetails(left, right)
|
||||||
|
keys := make(map[string]ConfigOptionDiffItem, len(details))
|
||||||
|
for _, item := range details {
|
||||||
|
keys[item.Key] = item
|
||||||
|
}
|
||||||
|
assert.Equal(t, "true", keys["OriginErrorPageEnabled"].PreviousValue)
|
||||||
|
assert.Equal(t, "false", keys["OriginErrorPageEnabled"].CurrentValue)
|
||||||
|
assert.Equal(t, `["500-599"]`, keys["OriginErrorPageStatusCodes"].PreviousValue)
|
||||||
|
assert.Equal(t, `["522"]`, keys["OriginErrorPageStatusCodes"].CurrentValue)
|
||||||
|
assert.Equal(t, "", keys["OriginErrorPageHTML"].PreviousValue)
|
||||||
|
assert.Equal(t, "<p>x</p>", keys["OriginErrorPageHTML"].CurrentValue)
|
||||||
|
}
|
||||||
@@ -99,47 +99,50 @@ type snapshotWAFDocument struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type openRestyConfigSnapshot struct {
|
type openRestyConfigSnapshot struct {
|
||||||
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
||||||
WorkerProcesses string `json:"worker_processes"`
|
WorkerProcesses string `json:"worker_processes"`
|
||||||
WorkerConnections int `json:"worker_connections"`
|
WorkerConnections int `json:"worker_connections"`
|
||||||
WorkerRlimitNofile int `json:"worker_rlimit_nofile"`
|
WorkerRlimitNofile int `json:"worker_rlimit_nofile"`
|
||||||
EventsUse string `json:"events_use,omitempty"`
|
EventsUse string `json:"events_use,omitempty"`
|
||||||
EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"`
|
EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"`
|
||||||
KeepaliveTimeout int `json:"keepalive_timeout"`
|
KeepaliveTimeout int `json:"keepalive_timeout"`
|
||||||
KeepaliveRequests int `json:"keepalive_requests"`
|
KeepaliveRequests int `json:"keepalive_requests"`
|
||||||
ClientHeaderTimeout int `json:"client_header_timeout"`
|
ClientHeaderTimeout int `json:"client_header_timeout"`
|
||||||
ClientBodyTimeout int `json:"client_body_timeout"`
|
ClientBodyTimeout int `json:"client_body_timeout"`
|
||||||
ClientMaxBodySize string `json:"client_max_body_size"`
|
ClientMaxBodySize string `json:"client_max_body_size"`
|
||||||
LargeClientHeaderBuffers string `json:"large_client_header_buffers"`
|
LargeClientHeaderBuffers string `json:"large_client_header_buffers"`
|
||||||
SendTimeout int `json:"send_timeout"`
|
SendTimeout int `json:"send_timeout"`
|
||||||
ProxyConnectTimeout int `json:"proxy_connect_timeout"`
|
ProxyConnectTimeout int `json:"proxy_connect_timeout"`
|
||||||
ProxySendTimeout int `json:"proxy_send_timeout"`
|
ProxySendTimeout int `json:"proxy_send_timeout"`
|
||||||
ProxyReadTimeout int `json:"proxy_read_timeout"`
|
ProxyReadTimeout int `json:"proxy_read_timeout"`
|
||||||
WebsocketEnabled bool `json:"websocket_enabled"`
|
WebsocketEnabled bool `json:"websocket_enabled"`
|
||||||
HTTP3Enabled bool `json:"http3_enabled"`
|
HTTP3Enabled bool `json:"http3_enabled"`
|
||||||
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
|
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
|
||||||
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
|
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
|
||||||
ProxyBuffers string `json:"proxy_buffers"`
|
ProxyBuffers string `json:"proxy_buffers"`
|
||||||
ProxyBufferSize string `json:"proxy_buffer_size"`
|
ProxyBufferSize string `json:"proxy_buffer_size"`
|
||||||
ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"`
|
ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"`
|
||||||
GzipEnabled bool `json:"gzip_enabled"`
|
GzipEnabled bool `json:"gzip_enabled"`
|
||||||
GzipMinLength int `json:"gzip_min_length"`
|
GzipMinLength int `json:"gzip_min_length"`
|
||||||
GzipCompLevel int `json:"gzip_comp_level"`
|
GzipCompLevel int `json:"gzip_comp_level"`
|
||||||
Resolvers string `json:"resolvers,omitempty"`
|
Resolvers string `json:"resolvers,omitempty"`
|
||||||
CacheEnabled bool `json:"cache_enabled"`
|
CacheEnabled bool `json:"cache_enabled"`
|
||||||
CachePath string `json:"cache_path,omitempty"`
|
CachePath string `json:"cache_path,omitempty"`
|
||||||
CacheLevels string `json:"cache_levels"`
|
CacheLevels string `json:"cache_levels"`
|
||||||
CacheInactive string `json:"cache_inactive"`
|
CacheInactive string `json:"cache_inactive"`
|
||||||
CacheMaxSize string `json:"cache_max_size"`
|
CacheMaxSize string `json:"cache_max_size"`
|
||||||
CacheKeyTemplate string `json:"cache_key_template"`
|
CacheKeyTemplate string `json:"cache_key_template"`
|
||||||
CacheLockEnabled bool `json:"cache_lock_enabled"`
|
CacheLockEnabled bool `json:"cache_lock_enabled"`
|
||||||
CacheLockTimeout string `json:"cache_lock_timeout"`
|
CacheLockTimeout string `json:"cache_lock_timeout"`
|
||||||
CacheUseStale string `json:"cache_use_stale"`
|
CacheUseStale string `json:"cache_use_stale"`
|
||||||
MainConfigTemplate string `json:"main_config_template,omitempty"`
|
MainConfigTemplate string `json:"main_config_template,omitempty"`
|
||||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||||
DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
|
DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
|
||||||
|
OriginErrorPageEnabled bool `json:"origin_error_page_enabled"`
|
||||||
|
OriginErrorPageStatusCodes []string `json:"origin_error_page_status_codes,omitempty"`
|
||||||
|
OriginErrorPageHTML string `json:"origin_error_page_html,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type snapshotDocument struct {
|
type snapshotDocument struct {
|
||||||
@@ -511,47 +514,50 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
snapshot := openRestyConfigSnapshot{
|
snapshot := openRestyConfigSnapshot{
|
||||||
DefaultServerReturnStatus: getIntConfig(model.ConfigKeyOpenRestyDefaultServerReturnStatus, defaultOpenRestyReturnStatus),
|
DefaultServerReturnStatus: getIntConfig(model.ConfigKeyOpenRestyDefaultServerReturnStatus, defaultOpenRestyReturnStatus),
|
||||||
WorkerProcesses: getStringConfig(model.ConfigKeyOpenRestyWorkerProcesses, "auto"),
|
WorkerProcesses: getStringConfig(model.ConfigKeyOpenRestyWorkerProcesses, "auto"),
|
||||||
WorkerConnections: getIntConfig(model.ConfigKeyOpenRestyWorkerConnections, defaultOpenRestyWorkerConns),
|
WorkerConnections: getIntConfig(model.ConfigKeyOpenRestyWorkerConnections, defaultOpenRestyWorkerConns),
|
||||||
WorkerRlimitNofile: getIntConfig(model.ConfigKeyOpenRestyWorkerRlimitNofile, defaultOpenRestyRlimitNofile),
|
WorkerRlimitNofile: getIntConfig(model.ConfigKeyOpenRestyWorkerRlimitNofile, defaultOpenRestyRlimitNofile),
|
||||||
EventsUse: getStringConfig(model.ConfigKeyOpenRestyEventsUse, "epoll"),
|
EventsUse: getStringConfig(model.ConfigKeyOpenRestyEventsUse, "epoll"),
|
||||||
EventsMultiAcceptEnabled: getBoolConfig(model.ConfigKeyOpenRestyEventsMultiAcceptEnabled, true),
|
EventsMultiAcceptEnabled: getBoolConfig(model.ConfigKeyOpenRestyEventsMultiAcceptEnabled, true),
|
||||||
KeepaliveTimeout: getIntConfig(model.ConfigKeyOpenRestyKeepaliveTimeout, defaultOpenRestyKeepaliveTimeout),
|
KeepaliveTimeout: getIntConfig(model.ConfigKeyOpenRestyKeepaliveTimeout, defaultOpenRestyKeepaliveTimeout),
|
||||||
KeepaliveRequests: getIntConfig(model.ConfigKeyOpenRestyKeepaliveRequests, defaultOpenRestyKeepaliveReqs),
|
KeepaliveRequests: getIntConfig(model.ConfigKeyOpenRestyKeepaliveRequests, defaultOpenRestyKeepaliveReqs),
|
||||||
ClientHeaderTimeout: getIntConfig(model.ConfigKeyOpenRestyClientHeaderTimeout, defaultOpenRestyHeaderTimeout),
|
ClientHeaderTimeout: getIntConfig(model.ConfigKeyOpenRestyClientHeaderTimeout, defaultOpenRestyHeaderTimeout),
|
||||||
ClientBodyTimeout: getIntConfig(model.ConfigKeyOpenRestyClientBodyTimeout, defaultOpenRestyBodyTimeout),
|
ClientBodyTimeout: getIntConfig(model.ConfigKeyOpenRestyClientBodyTimeout, defaultOpenRestyBodyTimeout),
|
||||||
ClientMaxBodySize: getStringConfig(model.ConfigKeyOpenRestyClientMaxBodySize, "64m"),
|
ClientMaxBodySize: getStringConfig(model.ConfigKeyOpenRestyClientMaxBodySize, "64m"),
|
||||||
LargeClientHeaderBuffers: getStringConfig(model.ConfigKeyOpenRestyLargeClientHeaderBuffers, "4 16k"),
|
LargeClientHeaderBuffers: getStringConfig(model.ConfigKeyOpenRestyLargeClientHeaderBuffers, "4 16k"),
|
||||||
SendTimeout: getIntConfig(model.ConfigKeyOpenRestySendTimeout, defaultOpenRestySendTimeout),
|
SendTimeout: getIntConfig(model.ConfigKeyOpenRestySendTimeout, defaultOpenRestySendTimeout),
|
||||||
ProxyConnectTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyConnectTimeout, defaultOpenRestyConnectTimeout),
|
ProxyConnectTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyConnectTimeout, defaultOpenRestyConnectTimeout),
|
||||||
ProxySendTimeout: getIntConfig(model.ConfigKeyOpenRestyProxySendTimeout, defaultOpenRestyProxyTimeout),
|
ProxySendTimeout: getIntConfig(model.ConfigKeyOpenRestyProxySendTimeout, defaultOpenRestyProxyTimeout),
|
||||||
ProxyReadTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyReadTimeout, defaultOpenRestyProxyTimeout),
|
ProxyReadTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyReadTimeout, defaultOpenRestyProxyTimeout),
|
||||||
WebsocketEnabled: getBoolConfig(model.ConfigKeyOpenRestyWebsocketEnabled, true),
|
WebsocketEnabled: getBoolConfig(model.ConfigKeyOpenRestyWebsocketEnabled, true),
|
||||||
HTTP3Enabled: getBoolConfig(model.ConfigKeyOpenRestyHTTP3Enabled, true),
|
HTTP3Enabled: getBoolConfig(model.ConfigKeyOpenRestyHTTP3Enabled, true),
|
||||||
ProxyRequestBuffering: getBoolConfig(model.ConfigKeyOpenRestyProxyRequestBufferingEnabled, false),
|
ProxyRequestBuffering: getBoolConfig(model.ConfigKeyOpenRestyProxyRequestBufferingEnabled, false),
|
||||||
ProxyBufferingEnabled: getBoolConfig(model.ConfigKeyOpenRestyProxyBufferingEnabled, true),
|
ProxyBufferingEnabled: getBoolConfig(model.ConfigKeyOpenRestyProxyBufferingEnabled, true),
|
||||||
ProxyBuffers: getStringConfig(model.ConfigKeyOpenRestyProxyBuffers, "16 16k"),
|
ProxyBuffers: getStringConfig(model.ConfigKeyOpenRestyProxyBuffers, "16 16k"),
|
||||||
ProxyBufferSize: getStringConfig(model.ConfigKeyOpenRestyProxyBufferSize, "8k"),
|
ProxyBufferSize: getStringConfig(model.ConfigKeyOpenRestyProxyBufferSize, "8k"),
|
||||||
ProxyBusyBuffersSize: getStringConfig(model.ConfigKeyOpenRestyProxyBusyBuffersSize, "64k"),
|
ProxyBusyBuffersSize: getStringConfig(model.ConfigKeyOpenRestyProxyBusyBuffersSize, "64k"),
|
||||||
GzipEnabled: getBoolConfig(model.ConfigKeyOpenRestyGzipEnabled, true),
|
GzipEnabled: getBoolConfig(model.ConfigKeyOpenRestyGzipEnabled, true),
|
||||||
GzipMinLength: getIntConfig(model.ConfigKeyOpenRestyGzipMinLength, defaultOpenRestyGzipMinLen),
|
GzipMinLength: getIntConfig(model.ConfigKeyOpenRestyGzipMinLength, defaultOpenRestyGzipMinLen),
|
||||||
GzipCompLevel: getIntConfig(model.ConfigKeyOpenRestyGzipCompLevel, defaultOpenRestyGzipLevel),
|
GzipCompLevel: getIntConfig(model.ConfigKeyOpenRestyGzipCompLevel, defaultOpenRestyGzipLevel),
|
||||||
Resolvers: getStringConfig(model.ConfigKeyOpenRestyResolvers, ""),
|
Resolvers: getStringConfig(model.ConfigKeyOpenRestyResolvers, ""),
|
||||||
CacheEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheEnabled, false),
|
CacheEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheEnabled, false),
|
||||||
CachePath: getStringConfig(model.ConfigKeyOpenRestyCachePath, ""),
|
CachePath: getStringConfig(model.ConfigKeyOpenRestyCachePath, ""),
|
||||||
CacheLevels: getStringConfig(model.ConfigKeyOpenRestyCacheLevels, "1:2"),
|
CacheLevels: getStringConfig(model.ConfigKeyOpenRestyCacheLevels, "1:2"),
|
||||||
CacheInactive: getStringConfig(model.ConfigKeyOpenRestyCacheInactive, "30m"),
|
CacheInactive: getStringConfig(model.ConfigKeyOpenRestyCacheInactive, "30m"),
|
||||||
CacheMaxSize: getStringConfig(model.ConfigKeyOpenRestyCacheMaxSize, "1g"),
|
CacheMaxSize: getStringConfig(model.ConfigKeyOpenRestyCacheMaxSize, "1g"),
|
||||||
CacheKeyTemplate: getStringConfig(model.ConfigKeyOpenRestyCacheKeyTemplate, "$scheme$host$request_uri"),
|
CacheKeyTemplate: getStringConfig(model.ConfigKeyOpenRestyCacheKeyTemplate, "$scheme$host$request_uri"),
|
||||||
CacheLockEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheLockEnabled, true),
|
CacheLockEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheLockEnabled, true),
|
||||||
CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"),
|
CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"),
|
||||||
CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"),
|
CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"),
|
||||||
MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate),
|
MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate),
|
||||||
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
||||||
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
|
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
|
||||||
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
|
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
|
||||||
DefaultLimitReqPerIP: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitReqPerIP, ""))),
|
DefaultLimitReqPerIP: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitReqPerIP, ""))),
|
||||||
|
OriginErrorPageEnabled: getBoolConfig(model.ConfigKeyOriginErrorPageEnabled, true),
|
||||||
|
OriginErrorPageStatusCodes: parseOriginErrorPageStatusCodes(getStringConfig(model.ConfigKeyOriginErrorPageStatusCodes, `["500-599"]`)),
|
||||||
|
OriginErrorPageHTML: getStringConfig(model.ConfigKeyOriginErrorPageHTML, ""),
|
||||||
}
|
}
|
||||||
if snapshot.DefaultLimitRate == "0" {
|
if snapshot.DefaultLimitRate == "0" {
|
||||||
snapshot.DefaultLimitRate = ""
|
snapshot.DefaultLimitRate = ""
|
||||||
@@ -563,6 +569,19 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseOriginErrorPageStatusCodes(raw string) []string {
|
||||||
|
const defaultTag = "500-599"
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return []string{defaultTag}
|
||||||
|
}
|
||||||
|
var tags []string
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &tags); err != nil || len(tags) == 0 {
|
||||||
|
return []string{defaultTag}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeProxyCachePathForSnapshot(cacheEnabled bool, cachePath string) string {
|
func normalizeProxyCachePathForSnapshot(cacheEnabled bool, cachePath string) string {
|
||||||
if !cacheEnabled {
|
if !cacheEnabled {
|
||||||
return strings.TrimSpace(cachePath)
|
return strings.TrimSpace(cachePath)
|
||||||
|
|||||||
@@ -4,14 +4,18 @@
|
|||||||
package option
|
package option
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
|
||||||
|
|
||||||
var openRestyOptionValidators = map[string]func(key, value string) error{
|
var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||||
model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus,
|
model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus,
|
||||||
model.ConfigKeyOpenRestyWorkerProcesses: validateOpenRestyWorkerProcesses,
|
model.ConfigKeyOpenRestyWorkerProcesses: validateOpenRestyWorkerProcesses,
|
||||||
@@ -54,11 +58,18 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
|
|||||||
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
|
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
|
||||||
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
|
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
|
||||||
model.ConfigKeyOpenRestyDefaultLimitReqPerIP: validateOpenRestyDefaultLimitReqPerIP,
|
model.ConfigKeyOpenRestyDefaultLimitReqPerIP: validateOpenRestyDefaultLimitReqPerIP,
|
||||||
|
model.ConfigKeyOriginErrorPageEnabled: validateBooleanOption,
|
||||||
|
model.ConfigKeyOriginErrorPageStatusCodes: validateOriginErrorPageStatusCodes,
|
||||||
|
model.ConfigKeyOriginErrorPageHTML: validateOriginErrorPageHTML,
|
||||||
}
|
}
|
||||||
|
|
||||||
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
|
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
|
||||||
|
|
||||||
func validateOpenRestyOption(key, value string) error {
|
func validateOpenRestyOption(key, value string) error {
|
||||||
|
// HTML 按原始字节长度校验,避免 TrimSpace 影响上限判断
|
||||||
|
if key == model.ConfigKeyOriginErrorPageHTML {
|
||||||
|
return validateOriginErrorPageHTML(key, value)
|
||||||
|
}
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
if validator, ok := openRestyOptionValidators[key]; ok {
|
if validator, ok := openRestyOptionValidators[key]; ok {
|
||||||
return validator(key, trimmed)
|
return validator(key, trimmed)
|
||||||
@@ -207,3 +218,31 @@ func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateOriginErrorPageStatusCodes(key, trimmed string) error {
|
||||||
|
if trimmed == "" {
|
||||||
|
return fmt.Errorf("%s 不能为空", key)
|
||||||
|
}
|
||||||
|
var tags []string
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &tags); err != nil {
|
||||||
|
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
|
||||||
|
}
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return fmt.Errorf("%s 至少包含一个状态码标签", key)
|
||||||
|
}
|
||||||
|
codes, err := openrestyrender.ExpandStatusCodeTags(tags)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %v", key, err)
|
||||||
|
}
|
||||||
|
if len(codes) == 0 {
|
||||||
|
return fmt.Errorf("%s 展开后不能为空", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOriginErrorPageHTML(key, value string) error {
|
||||||
|
if len(value) > maxOriginErrorPageHTMLBytes {
|
||||||
|
return fmt.Errorf("%s 长度不能超过 %d 字节(256 KiB)", key, maxOriginErrorPageHTMLBytes)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package option
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateOriginErrorPageStatusCodes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "合法单码与区间",
|
||||||
|
value: `["522","500-502"]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "默认区间",
|
||||||
|
value: `["500-599"]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非法标签",
|
||||||
|
value: `["abc"]`,
|
||||||
|
wantErr: "无效状态码",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非 JSON 数组",
|
||||||
|
value: `500-599`,
|
||||||
|
wantErr: "必须为 JSON 字符串数组",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空数组",
|
||||||
|
value: `[]`,
|
||||||
|
wantErr: "至少包含一个状态码标签",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "越界状态码",
|
||||||
|
value: `["399"]`,
|
||||||
|
wantErr: "状态码须在",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空字符串",
|
||||||
|
value: "",
|
||||||
|
wantErr: "不能为空",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageStatusCodes, tt.value)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateOriginErrorPageHTML(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, ""))
|
||||||
|
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, "<html>ok</html>"))
|
||||||
|
|
||||||
|
oversized := strings.Repeat("a", maxOriginErrorPageHTMLBytes+1)
|
||||||
|
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, oversized)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "长度不能超过")
|
||||||
|
|
||||||
|
// 恰好上限应通过
|
||||||
|
atLimit := strings.Repeat("b", maxOriginErrorPageHTMLBytes)
|
||||||
|
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, atLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateOriginErrorPageEnabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "true"))
|
||||||
|
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "false"))
|
||||||
|
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "yes")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "true 或 false")
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
-- +goose Up
|
||||||
|
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DELETE FROM w_system_configs WHERE key IN (
|
||||||
|
'origin_error_page_enabled',
|
||||||
|
'origin_error_page_status_codes',
|
||||||
|
'origin_error_page_html'
|
||||||
|
);
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
-- +goose Up
|
||||||
|
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DELETE FROM w_system_configs WHERE key IN (
|
||||||
|
'origin_error_page_enabled',
|
||||||
|
'origin_error_page_status_codes',
|
||||||
|
'origin_error_page_html'
|
||||||
|
);
|
||||||
@@ -109,6 +109,11 @@ const (
|
|||||||
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
|
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
|
||||||
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
|
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
|
||||||
ConfigKeyOpenRestyDefaultLimitReqPerIP = "openresty_default_limit_req_per_ip" // 默认单 IP 请求频率限制
|
ConfigKeyOpenRestyDefaultLimitReqPerIP = "openresty_default_limit_req_per_ip" // 默认单 IP 请求频率限制
|
||||||
|
|
||||||
|
// 源站错误页
|
||||||
|
ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled" // 是否启用源站错误页
|
||||||
|
ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes" // 源站错误页触发状态码标签 JSON 数组
|
||||||
|
ConfigKeyOriginErrorPageHTML = "origin_error_page_html" // 源站错误页自定义 HTML(空则内置默认)
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package openresty
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OriginErrorPageSupportPath is the SupportFile path for the origin error HTML template.
|
||||||
|
OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"
|
||||||
|
|
||||||
|
// OriginErrorPageInternalLocation is the internal nginx location that serves the error body.
|
||||||
|
OriginErrorPageInternalLocation = "/__openflare_origin_error"
|
||||||
|
|
||||||
|
defaultOriginErrorPageStatusTag = "500-599"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultOriginErrorPageHTML is the built-in Cloudflare-style origin error page.
|
||||||
|
// Placeholders {{status}} and {{host}} are substituted at request time by Lua.
|
||||||
|
const DefaultOriginErrorPageHTML = `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{status}} | 源站暂时无法提供服务</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||||
|
background: #f6f7f9;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: #0f1419; color: #e5e7eb; }
|
||||||
|
.card { background: #1a2332; border-color: #2d3a4d; box-shadow: none; }
|
||||||
|
.status { color: #f3f4f6; }
|
||||||
|
.muted { color: #9ca3af; }
|
||||||
|
.host { background: #243044; color: #d1d5db; }
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(32rem, calc(100% - 2rem));
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(3.5rem, 12vw, 5.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p { margin: 0.4rem 0; line-height: 1.6; }
|
||||||
|
.muted { color: #6b7280; font-size: 0.95rem; }
|
||||||
|
.host {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card" role="main">
|
||||||
|
<p class="status" aria-label="HTTP status">{{status}}</p>
|
||||||
|
<h1>源站暂时无法提供服务</h1>
|
||||||
|
<p class="muted">网关已拦截源站错误响应。请稍后重试;若问题持续,请联系站点管理员。</p>
|
||||||
|
<p class="host">{{host}}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
// EffectiveOriginErrorPageHTML returns custom HTML when set, otherwise the built-in default.
|
||||||
|
func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string {
|
||||||
|
if strings.TrimSpace(cfg.OriginErrorPageHTML) == "" {
|
||||||
|
return DefaultOriginErrorPageHTML
|
||||||
|
}
|
||||||
|
return cfg.OriginErrorPageHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveOriginErrorPageStatusTags(cfg ConfigSnapshot) []string {
|
||||||
|
if len(cfg.OriginErrorPageStatusCodes) == 0 {
|
||||||
|
return []string{defaultOriginErrorPageStatusTag}
|
||||||
|
}
|
||||||
|
return cfg.OriginErrorPageStatusCodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func originErrorPageSupportFile(cfg ConfigSnapshot) SupportFile {
|
||||||
|
return SupportFile{
|
||||||
|
Path: OriginErrorPageSupportPath,
|
||||||
|
Content: EffectiveOriginErrorPageHTML(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderOriginErrorPageIntercept(cfg ConfigSnapshot) string {
|
||||||
|
if !cfg.OriginErrorPageEnabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, err := ExpandStatusCodeTags(effectiveOriginErrorPageStatusTags(cfg)); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " proxy_intercept_errors on;\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOriginErrorPageServerBits emits server-level error_page + internal location.
|
||||||
|
// Returns empty string when disabled, expand fails, or no codes remain.
|
||||||
|
func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string {
|
||||||
|
if !cfg.OriginErrorPageEnabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
codes, err := ExpandStatusCodeTags(effectiveOriginErrorPageStatusTags(cfg))
|
||||||
|
if err != nil || len(codes) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, len(codes))
|
||||||
|
for i, code := range codes {
|
||||||
|
parts[i] = strconv.Itoa(code)
|
||||||
|
}
|
||||||
|
var builder strings.Builder
|
||||||
|
fmt.Fprintf(&builder, " error_page %s = %s;\n", strings.Join(parts, " "), OriginErrorPageInternalLocation)
|
||||||
|
builder.WriteString(renderOriginErrorPageInternalLocation())
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderOriginErrorPageInternalLocation() string {
|
||||||
|
return fmt.Sprintf(` location = %s {
|
||||||
|
internal;
|
||||||
|
default_type text/html;
|
||||||
|
charset utf-8;
|
||||||
|
content_by_lua_block {
|
||||||
|
local f = io.open("%s", "r")
|
||||||
|
if not f then
|
||||||
|
ngx.status = ngx.status
|
||||||
|
ngx.say("Error ", ngx.status)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local body = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
local status = tostring(ngx.status)
|
||||||
|
local host = ngx.var.host or ""
|
||||||
|
body = body:gsub("{{status}}", status, 1)
|
||||||
|
body = body:gsub("{{host}}", host, 1)
|
||||||
|
body = body:gsub("{{status}}", status)
|
||||||
|
body = body:gsub("{{host}}", host)
|
||||||
|
ngx.header["Content-Type"] = "text/html; charset=utf-8"
|
||||||
|
ngx.say(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, OriginErrorPageInternalLocation, ErrorPageTmplPlaceholder)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package openresty
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderOriginErrorPageEnabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
doc := Document{
|
||||||
|
Routes: []Route{{
|
||||||
|
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||||
|
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||||
|
}},
|
||||||
|
OpenRestyConfig: ConfigSnapshot{
|
||||||
|
OriginErrorPageEnabled: true,
|
||||||
|
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := RenderRouteConfig(doc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "proxy_intercept_errors on") {
|
||||||
|
t.Fatal("missing intercept")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "error_page") || !strings.Contains(out, "/__openflare_origin_error") {
|
||||||
|
t.Fatal("missing error_page")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "error_page 500") {
|
||||||
|
t.Fatalf("expected expanded status codes in error_page, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "= /__openflare_origin_error") {
|
||||||
|
t.Fatal("error_page must keep original status via = redirect form")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, ErrorPageTmplPlaceholder) {
|
||||||
|
t.Fatal("missing error page template placeholder")
|
||||||
|
}
|
||||||
|
res, err := Render(doc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, f := range res.SupportFiles {
|
||||||
|
if f.Path == OriginErrorPageSupportPath {
|
||||||
|
found = true
|
||||||
|
if !strings.Contains(f.Content, "{{status}}") {
|
||||||
|
t.Fatal("template missing placeholder")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f.Content, "{{host}}") {
|
||||||
|
t.Fatal("template missing host placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("missing support file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOriginErrorPageDisabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
doc := Document{
|
||||||
|
Routes: []Route{{
|
||||||
|
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||||
|
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||||
|
}},
|
||||||
|
OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false},
|
||||||
|
}
|
||||||
|
out, err := RenderRouteConfig(doc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "proxy_intercept_errors") {
|
||||||
|
t.Fatal("should not intercept when disabled")
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "/__openflare_origin_error") {
|
||||||
|
t.Fatal("should not emit internal error location when disabled")
|
||||||
|
}
|
||||||
|
res, err := Render(doc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, f := range res.SupportFiles {
|
||||||
|
if f.Path == OriginErrorPageSupportPath {
|
||||||
|
t.Fatal("should not emit support file when disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOriginErrorPageDefaultsEmptyHTMLAndStatusCodes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
doc := Document{
|
||||||
|
Routes: []Route{{
|
||||||
|
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||||
|
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||||
|
}},
|
||||||
|
OpenRestyConfig: ConfigSnapshot{
|
||||||
|
OriginErrorPageEnabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := RenderRouteConfig(doc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "error_page 500") {
|
||||||
|
t.Fatalf("empty status codes should default to 500-599, got:\n%s", out)
|
||||||
|
}
|
||||||
|
html := EffectiveOriginErrorPageHTML(doc.OpenRestyConfig)
|
||||||
|
if html != DefaultOriginErrorPageHTML {
|
||||||
|
t.Fatal("empty HTML should use default template")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "{{status}}") || !strings.Contains(html, "{{host}}") {
|
||||||
|
t.Fatal("default HTML must include placeholders")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "源站暂时无法提供服务") {
|
||||||
|
t.Fatal("default HTML missing neutral copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOriginErrorPageCustomHTMLInSupportFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
custom := "<html><body>custom {{status}} @ {{host}}</body></html>"
|
||||||
|
doc := Document{
|
||||||
|
Routes: []Route{{
|
||||||
|
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||||
|
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||||
|
}},
|
||||||
|
OpenRestyConfig: ConfigSnapshot{
|
||||||
|
OriginErrorPageEnabled: true,
|
||||||
|
OriginErrorPageStatusCodes: []string{"502"},
|
||||||
|
OriginErrorPageHTML: custom,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := Render(doc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, f := range res.SupportFiles {
|
||||||
|
if f.Path == OriginErrorPageSupportPath {
|
||||||
|
found = true
|
||||||
|
if f.Content != custom {
|
||||||
|
t.Fatalf("support file content = %q, want custom HTML", f.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("missing support file")
|
||||||
|
}
|
||||||
|
out, err := RenderRouteConfig(doc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "error_page 502 = /__openflare_origin_error") {
|
||||||
|
t.Fatalf("expected single 502 error_page, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOriginErrorPageSkipsPagesRoutes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
doc := Document{
|
||||||
|
Routes: []Route{{
|
||||||
|
ID: 1, SiteName: "pages", Domains: []string{"pages.test"},
|
||||||
|
UpstreamType: "pages", Enabled: true,
|
||||||
|
PagesDeployment: &PagesDeployment{
|
||||||
|
ProjectID: 1, LocalRoot: PagesDirPlaceholder + "/projects/1/current",
|
||||||
|
EntryFile: "index.html",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
OpenRestyConfig: ConfigSnapshot{
|
||||||
|
OriginErrorPageEnabled: true,
|
||||||
|
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := RenderRouteConfig(doc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "proxy_intercept_errors") {
|
||||||
|
t.Fatal("pages routes must not get proxy_intercept_errors")
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "/__openflare_origin_error") {
|
||||||
|
t.Fatal("pages routes must not get origin error location")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,9 @@ func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
|
|||||||
}
|
}
|
||||||
files := append([]SupportFile(nil), certificateFiles...)
|
files := append([]SupportFile(nil), certificateFiles...)
|
||||||
files = append(files, SupportFile{Path: "waf_config.json", Content: wafConfig})
|
files = append(files, SupportFile{Path: "waf_config.json", Content: wafConfig})
|
||||||
|
if doc.OpenRestyConfig.OriginErrorPageEnabled {
|
||||||
|
files = append(files, originErrorPageSupportFile(doc.OpenRestyConfig))
|
||||||
|
}
|
||||||
files = DedupeSupportFiles(files)
|
files = DedupeSupportFiles(files)
|
||||||
return &Result{
|
return &Result{
|
||||||
MainConfig: mainConfig,
|
MainConfig: mainConfig,
|
||||||
@@ -270,7 +273,7 @@ func renderOpenRestyObservabilityTemplateBlock() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderHTTPProxyServer(serverNames string, siteName string, originURL string, originHost string, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
|
func renderHTTPProxyServer(serverNames string, siteName string, originURL string, originHost string, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
|
||||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPagesAPIProxyLocationBlock(deployment *PagesDeployment) string {
|
func renderPagesAPIProxyLocationBlock(deployment *PagesDeployment) string {
|
||||||
@@ -333,7 +336,7 @@ func renderHTTPSServer(serverNames string, siteName string, originURL string, or
|
|||||||
h3Listen = " listen 443 quic;\n"
|
h3Listen = " listen 443 quic;\n"
|
||||||
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
|
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package openresty
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StatusCodeMin is the lowest HTTP status code accepted for origin error pages.
|
||||||
|
StatusCodeMin = 400
|
||||||
|
// StatusCodeMax is the highest HTTP status code accepted for origin error pages.
|
||||||
|
StatusCodeMax = 599
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseStatusCodeTag parses a single tag such as "502" or "500-599".
|
||||||
|
// Bounds must fall within StatusCodeMin–StatusCodeMax inclusive.
|
||||||
|
func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag == "" {
|
||||||
|
return 0, 0, fmt.Errorf("状态码标签不能为空")
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
||||||
|
lo, err = strconv.Atoi(tag[:i])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||||
|
}
|
||||||
|
hi, err = strconv.Atoi(tag[i+1:])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lo, err = strconv.Atoi(tag)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("无效状态码: %s", tag)
|
||||||
|
}
|
||||||
|
hi = lo
|
||||||
|
}
|
||||||
|
if lo > hi {
|
||||||
|
return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag)
|
||||||
|
}
|
||||||
|
if lo < StatusCodeMin || hi > StatusCodeMax {
|
||||||
|
return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag)
|
||||||
|
}
|
||||||
|
return lo, hi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandStatusCodeTags expands status code tags into a sorted unique list of integers.
|
||||||
|
func ExpandStatusCodeTags(tags []string) ([]int, error) {
|
||||||
|
set := map[int]struct{}{}
|
||||||
|
for _, tag := range tags {
|
||||||
|
lo, hi, err := ParseStatusCodeTag(tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for c := lo; c <= hi; c++ {
|
||||||
|
set[c] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]int, 0, len(set))
|
||||||
|
for c := range set {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Ints(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package openresty
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExpandStatusCodeTags(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// want sorted unique: 500,501,502,522
|
||||||
|
if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 {
|
||||||
|
t.Fatalf("got %v", codes)
|
||||||
|
}
|
||||||
|
_, err = ExpandStatusCodeTags([]string{"399"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
_, err = ExpandStatusCodeTags([]string{"503-500"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected reverse range error")
|
||||||
|
}
|
||||||
|
_, err = ExpandStatusCodeTags([]string{"5xx"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected syntax error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
|
ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
|
||||||
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
||||||
PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__"
|
PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__"
|
||||||
|
ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"
|
||||||
|
|
||||||
SourceConfigFileName = "openresty_config.json"
|
SourceConfigFileName = "openresty_config.json"
|
||||||
)
|
)
|
||||||
@@ -273,47 +274,50 @@ type WAFDocument struct {
|
|||||||
// ConfigSnapshot holds the full set of OpenResty tuning parameters that are
|
// ConfigSnapshot holds the full set of OpenResty tuning parameters that are
|
||||||
// rendered into the nginx main configuration template.
|
// rendered into the nginx main configuration template.
|
||||||
type ConfigSnapshot struct {
|
type ConfigSnapshot struct {
|
||||||
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
||||||
WorkerProcesses string `json:"worker_processes"`
|
WorkerProcesses string `json:"worker_processes"`
|
||||||
WorkerConnections int `json:"worker_connections"`
|
WorkerConnections int `json:"worker_connections"`
|
||||||
WorkerRlimitNofile int `json:"worker_rlimit_nofile"`
|
WorkerRlimitNofile int `json:"worker_rlimit_nofile"`
|
||||||
EventsUse string `json:"events_use,omitempty"`
|
EventsUse string `json:"events_use,omitempty"`
|
||||||
EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"`
|
EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"`
|
||||||
KeepaliveTimeout int `json:"keepalive_timeout"`
|
KeepaliveTimeout int `json:"keepalive_timeout"`
|
||||||
KeepaliveRequests int `json:"keepalive_requests"`
|
KeepaliveRequests int `json:"keepalive_requests"`
|
||||||
ClientHeaderTimeout int `json:"client_header_timeout"`
|
ClientHeaderTimeout int `json:"client_header_timeout"`
|
||||||
ClientBodyTimeout int `json:"client_body_timeout"`
|
ClientBodyTimeout int `json:"client_body_timeout"`
|
||||||
ClientMaxBodySize string `json:"client_max_body_size"`
|
ClientMaxBodySize string `json:"client_max_body_size"`
|
||||||
LargeClientHeaderBuffers string `json:"large_client_header_buffers"`
|
LargeClientHeaderBuffers string `json:"large_client_header_buffers"`
|
||||||
SendTimeout int `json:"send_timeout"`
|
SendTimeout int `json:"send_timeout"`
|
||||||
ProxyConnectTimeout int `json:"proxy_connect_timeout"`
|
ProxyConnectTimeout int `json:"proxy_connect_timeout"`
|
||||||
ProxySendTimeout int `json:"proxy_send_timeout"`
|
ProxySendTimeout int `json:"proxy_send_timeout"`
|
||||||
ProxyReadTimeout int `json:"proxy_read_timeout"`
|
ProxyReadTimeout int `json:"proxy_read_timeout"`
|
||||||
WebsocketEnabled bool `json:"websocket_enabled"`
|
WebsocketEnabled bool `json:"websocket_enabled"`
|
||||||
HTTP3Enabled bool `json:"http3_enabled"`
|
HTTP3Enabled bool `json:"http3_enabled"`
|
||||||
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
|
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
|
||||||
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
|
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
|
||||||
ProxyBuffers string `json:"proxy_buffers"`
|
ProxyBuffers string `json:"proxy_buffers"`
|
||||||
ProxyBufferSize string `json:"proxy_buffer_size"`
|
ProxyBufferSize string `json:"proxy_buffer_size"`
|
||||||
ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"`
|
ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"`
|
||||||
GzipEnabled bool `json:"gzip_enabled"`
|
GzipEnabled bool `json:"gzip_enabled"`
|
||||||
GzipMinLength int `json:"gzip_min_length"`
|
GzipMinLength int `json:"gzip_min_length"`
|
||||||
GzipCompLevel int `json:"gzip_comp_level"`
|
GzipCompLevel int `json:"gzip_comp_level"`
|
||||||
Resolvers string `json:"resolvers,omitempty"`
|
Resolvers string `json:"resolvers,omitempty"`
|
||||||
CacheEnabled bool `json:"cache_enabled"`
|
CacheEnabled bool `json:"cache_enabled"`
|
||||||
CachePath string `json:"cache_path,omitempty"`
|
CachePath string `json:"cache_path,omitempty"`
|
||||||
CacheLevels string `json:"cache_levels"`
|
CacheLevels string `json:"cache_levels"`
|
||||||
CacheInactive string `json:"cache_inactive"`
|
CacheInactive string `json:"cache_inactive"`
|
||||||
CacheMaxSize string `json:"cache_max_size"`
|
CacheMaxSize string `json:"cache_max_size"`
|
||||||
CacheKeyTemplate string `json:"cache_key_template"`
|
CacheKeyTemplate string `json:"cache_key_template"`
|
||||||
CacheLockEnabled bool `json:"cache_lock_enabled"`
|
CacheLockEnabled bool `json:"cache_lock_enabled"`
|
||||||
CacheLockTimeout string `json:"cache_lock_timeout"`
|
CacheLockTimeout string `json:"cache_lock_timeout"`
|
||||||
CacheUseStale string `json:"cache_use_stale"`
|
CacheUseStale string `json:"cache_use_stale"`
|
||||||
MainConfigTemplate string `json:"main_config_template,omitempty"`
|
MainConfigTemplate string `json:"main_config_template,omitempty"`
|
||||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||||
DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
|
DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
|
||||||
|
OriginErrorPageEnabled bool `json:"origin_error_page_enabled"`
|
||||||
|
OriginErrorPageStatusCodes []string `json:"origin_error_page_status_codes,omitempty"`
|
||||||
|
OriginErrorPageHTML string `json:"origin_error_page_html,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document is the top-level input structure for the OpenResty renderer,
|
// Document is the top-level input structure for the OpenResty renderer,
|
||||||
|
|||||||
Reference in New Issue
Block a user