mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps
- Merge all files inside openflare-server to the repository root directory. - Relocate agent, relay, and flared subprojects from internal/ to internal/apps/. - Combine docker-compose files and update build context paths to root. - Update GitHub workflows and Dockerfiles to refer to new directories and package names. - Rewrite Go package imports across all files. - Resolve database renew test race condition and clean up docs.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import type {
|
||||
ProxyRouteConfigSection,
|
||||
ProxyRouteCustomHeader,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
export const proxyRouteConfigSections = [
|
||||
{
|
||||
key: 'domains' as const,
|
||||
label: '域名设置',
|
||||
description: '维护站点标识、域名列表和证书绑定。',
|
||||
},
|
||||
{
|
||||
key: 'limits' as const,
|
||||
label: '流量限制',
|
||||
description: '设置连接数和限速。',
|
||||
},
|
||||
{
|
||||
key: 'proxy' as const,
|
||||
label: '反向代理',
|
||||
description: '配置主回源和上游地址。',
|
||||
},
|
||||
{
|
||||
key: 'cache' as const,
|
||||
label: '缓存',
|
||||
description: '配置站点缓存策略。',
|
||||
},
|
||||
{
|
||||
key: 'waf' as const,
|
||||
label: 'WAF',
|
||||
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
|
||||
},
|
||||
{
|
||||
key: 'auth' as const,
|
||||
label: '认证配置',
|
||||
description: '配置基础鉴权访问,需要输入账号密码才能访问网站。',
|
||||
},
|
||||
] satisfies ReadonlyArray<{
|
||||
key: ProxyRouteConfigSection;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
const domainPattern =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
||||
|
||||
export function getProxyRouteConfigSection(
|
||||
value: string | null | undefined,
|
||||
): ProxyRouteConfigSection {
|
||||
return proxyRouteConfigSections.some((section) => section.key === value)
|
||||
? (value as ProxyRouteConfigSection)
|
||||
: 'domains';
|
||||
}
|
||||
|
||||
export function validateDomain(domain: string): string | null {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return '请输入域名';
|
||||
}
|
||||
if (!domainPattern.test(normalized)) {
|
||||
return '域名格式不合法';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseOriginUrl(originUrl: string) {
|
||||
const parsed = new URL(originUrl);
|
||||
const port = parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
|
||||
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
|
||||
return {
|
||||
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
|
||||
address: parsed.hostname,
|
||||
port,
|
||||
uri: parsed.search ? `${path}${parsed.search}` || parsed.search : path,
|
||||
};
|
||||
}
|
||||
|
||||
export function getUpstreamSummary(route: ProxyRouteItem): string {
|
||||
if (route.upstream_type === 'pages') {
|
||||
return route.pages_project_id
|
||||
? `Pages 项目 #${route.pages_project_id}`
|
||||
: 'Pages 项目未绑定';
|
||||
}
|
||||
if (route.upstream_type === 'tunnel') {
|
||||
const protocol = route.tunnel_target_protocol || 'http';
|
||||
const target = route.tunnel_target_addr || '未配置目标';
|
||||
return `Tunnel → ${protocol}://${target}`;
|
||||
}
|
||||
if (route.upstream_list.length <= 1) {
|
||||
return route.origin_url;
|
||||
}
|
||||
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
|
||||
}
|
||||
|
||||
const originHostPattern =
|
||||
/^(?:(?:[a-z0-9-]+\.)*[a-z0-9-]+|\[[0-9a-f:.]+\]|[0-9.]+)(?::\d{1,5})?$/i;
|
||||
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
|
||||
const limitRatePattern = /^\d+(?:[kKmM])?$/;
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function linesFromTextarea(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function validateDomains(domains: string[]) {
|
||||
if (domains.length === 0) {
|
||||
return '请至少填写一个域名';
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
const error = validateDomain(normalized);
|
||||
if (error && normalized) {
|
||||
return `域名格式不合法:${domain}`;
|
||||
}
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(normalized)) {
|
||||
return `域名重复:${domain}`;
|
||||
}
|
||||
seen.add(normalized);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseOriginUrls(value: string) {
|
||||
const urls = linesFromTextarea(value);
|
||||
if (urls.length === 0) {
|
||||
return { urls: [], error: '请至少填写一个上游地址' };
|
||||
}
|
||||
|
||||
let sharedScheme = '';
|
||||
for (const originUrl of urls) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(originUrl);
|
||||
} catch {
|
||||
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return {
|
||||
urls: [],
|
||||
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed.hostname) {
|
||||
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
|
||||
}
|
||||
|
||||
if (urls.length > 1) {
|
||||
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
|
||||
return {
|
||||
urls: [],
|
||||
error: '多上游模式暂不支持带路径或查询参数的地址',
|
||||
};
|
||||
}
|
||||
|
||||
if (!sharedScheme) {
|
||||
sharedScheme = parsed.protocol;
|
||||
} else if (sharedScheme !== parsed.protocol) {
|
||||
return {
|
||||
urls: [],
|
||||
error: '同一站点的多个上游必须使用相同协议',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { urls, error: null };
|
||||
}
|
||||
|
||||
export function validateOriginHost(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
normalized.includes('://') ||
|
||||
/[/\\\s]/.test(normalized) ||
|
||||
!originHostPattern.test(normalized)
|
||||
) {
|
||||
return '回源 Host 格式不合法';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCustomHeadersText(value: string) {
|
||||
const lines = linesFromTextarea(value);
|
||||
const headers: ProxyRouteCustomHeader[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const separatorIndex = line.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return {
|
||||
headers: [],
|
||||
error: `自定义请求头格式不合法:${line}`,
|
||||
};
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const headerValue = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (!headerKeyPattern.test(key)) {
|
||||
return {
|
||||
headers: [],
|
||||
error: `自定义请求头名称不合法:${key}`,
|
||||
};
|
||||
}
|
||||
|
||||
headers.push({ key, value: headerValue });
|
||||
}
|
||||
|
||||
return { headers, error: null };
|
||||
}
|
||||
|
||||
export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
|
||||
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
|
||||
}
|
||||
|
||||
export function validateLimitRate(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === '0') {
|
||||
return null;
|
||||
}
|
||||
if (!limitRatePattern.test(normalized)) {
|
||||
return '限速格式不合法,请使用 512k、1m 或纯数字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeLimitRate(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === '0' ? '' : normalized;
|
||||
}
|
||||
|
||||
export function validateCacheRules(
|
||||
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
|
||||
rules: string[],
|
||||
) {
|
||||
if (policy === 'url') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return '当前缓存策略至少需要一条规则';
|
||||
}
|
||||
|
||||
if (policy === 'suffix') {
|
||||
for (const rule of rules) {
|
||||
const normalized = rule.replace(/^\./, '');
|
||||
if (!normalized || /[/\\\s]/.test(normalized)) {
|
||||
return `缓存后缀格式不合法:${rule}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
|
||||
return `缓存路径规则格式不合法:${rule}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPayloadFromRoute(
|
||||
route: ProxyRouteItem,
|
||||
overrides: Partial<ProxyRouteMutationPayload>,
|
||||
): ProxyRouteMutationPayload {
|
||||
const primaryOrigin =
|
||||
route.upstream_type === 'pages'
|
||||
? parseOriginUrl('http://127.0.0.1')
|
||||
: parseOriginUrl(route.origin_url);
|
||||
|
||||
return {
|
||||
site_name: route.site_name,
|
||||
domain: route.primary_domain,
|
||||
domains: route.domains,
|
||||
origin_id: null,
|
||||
origin_url: route.origin_url,
|
||||
origin_scheme: primaryOrigin.scheme,
|
||||
origin_address: primaryOrigin.address,
|
||||
origin_port: primaryOrigin.port,
|
||||
origin_uri: primaryOrigin.uri,
|
||||
origin_host: route.origin_host || '',
|
||||
upstreams: route.upstream_list.slice(1),
|
||||
enabled: route.enabled,
|
||||
enable_https: route.enable_https,
|
||||
cert_id: route.cert_id,
|
||||
cert_ids: route.cert_ids,
|
||||
domain_cert_ids: route.domain_cert_ids,
|
||||
redirect_http: route.redirect_http,
|
||||
limit_conn_per_server: route.limit_conn_per_server,
|
||||
limit_conn_per_ip: route.limit_conn_per_ip,
|
||||
limit_rate: route.limit_rate,
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: route.cache_policy || 'url',
|
||||
cache_rules: route.cache_rule_list,
|
||||
custom_headers: route.custom_header_list,
|
||||
remark: route.remark || '',
|
||||
pow_enabled: route.pow_enabled,
|
||||
pow_config: JSON.stringify(route.pow_config),
|
||||
basic_auth_enabled: route.basic_auth_enabled,
|
||||
basic_auth_username: route.basic_auth_username,
|
||||
basic_auth_password: route.basic_auth_password,
|
||||
upstream_type: route.upstream_type,
|
||||
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
|
||||
tunnel_target_addr: route.tunnel_target_addr || '',
|
||||
tunnel_target_protocol: route.tunnel_target_protocol || '',
|
||||
pages_project_id: route.pages_project_id ?? null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import type {ProxyRouteItem} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService} from '@/lib/services/openflare';
|
||||
|
||||
import {parseOriginUrl, validateDomain} from './helpers';
|
||||
|
||||
const createProxyRouteSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||
domain: z.string().trim().min(1, '请输入域名'),
|
||||
origin_url: z.string().trim().min(1, '请输入上游地址'),
|
||||
enabled: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const domainError = validateDomain(value.domain);
|
||||
if (domainError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['domain'],
|
||||
message: domainError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value.origin_url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_url'],
|
||||
message: '上游地址必须以 http:// 或 https:// 开头',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_url'],
|
||||
message: '上游地址格式不合法',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type CreateProxyRouteFormValues = z.infer<typeof createProxyRouteSchema>;
|
||||
|
||||
const defaultValues: CreateProxyRouteFormValues = {
|
||||
site_name: '',
|
||||
domain: '',
|
||||
origin_url: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
interface ProxyRouteCreateSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: (route: ProxyRouteItem) => void;
|
||||
}
|
||||
|
||||
export function ProxyRouteCreateSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: ProxyRouteCreateSheetProps) {
|
||||
const form = useForm<CreateProxyRouteFormValues>({
|
||||
resolver: zodResolver(createProxyRouteSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [form, open]);
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
const domain = values.domain.trim().toLowerCase();
|
||||
const origin = parseOriginUrl(values.origin_url.trim());
|
||||
|
||||
try {
|
||||
const route = await ProxyRouteService.create({
|
||||
site_name: values.site_name.trim() || domain,
|
||||
domain,
|
||||
domains: [domain],
|
||||
origin_id: null,
|
||||
origin_url: values.origin_url.trim(),
|
||||
origin_scheme: origin.scheme,
|
||||
origin_address: origin.address,
|
||||
origin_port: origin.port,
|
||||
origin_uri: origin.uri,
|
||||
origin_host: '',
|
||||
upstreams: [],
|
||||
enabled: values.enabled,
|
||||
enable_https: false,
|
||||
cert_id: null,
|
||||
cert_ids: [],
|
||||
domain_cert_ids: [0],
|
||||
redirect_http: false,
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
limit_rate: '',
|
||||
cache_enabled: false,
|
||||
cache_policy: 'url',
|
||||
cache_rules: [],
|
||||
custom_headers: [],
|
||||
pow_enabled: false,
|
||||
pow_config: '{}',
|
||||
basic_auth_enabled: false,
|
||||
remark: values.remark.trim(),
|
||||
upstream_type: 'direct',
|
||||
});
|
||||
|
||||
form.reset(defaultValues);
|
||||
onOpenChange(false);
|
||||
onCreated(route);
|
||||
} catch (error) {
|
||||
form.setError('root', {
|
||||
message: error instanceof Error ? error.message : '创建失败,请稍后重试',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>新建规则</SheetTitle>
|
||||
<SheetDescription>
|
||||
创建网站后可进入详情页继续配置 HTTPS、缓存和限流等高级选项。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-4 pb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="site_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>站点标识</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="marketing-site" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>可选,留空时会自动使用域名。</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>主域名</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="www.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="origin_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>上游地址</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://origin.internal:443" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>主回源完整 URL,创建后可在详情页添加多上游。</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>创建后立即启用</FormLabel>
|
||||
<FormDescription>关闭后站点会以草稿保存。</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root?.message ? (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
|
||||
) : null}
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user