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:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
@@ -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>
);
}