mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
后端与全仓代码质量清理(golangci 扩展集 · 测试质量 · 并发安全 · 文档同步)
代码质量全量清理,零行为变化:golangci 扩展集 13 类 linter(gosec/modernize/perfsprint/canonicalheader/usestdlibvars/wastedassign/intrange/errorlint/forcetypeassert/recvcheck/exhaustive/unparam)全量修复,测试代码质量(testifylint/thelper/usetesting)25→0,frpc 进程生命周期真 bug(进程组击杀)、全仓 go test -race 6 类数据竞争(含 1 个生产竞争)、SPDX license 头补齐 131 文件、前端测试套件 next-intl 迁移后 44 失败→全绿、过期 swagger 文档重新生成、pnpm-workspace 构建审批。 Experiments: #2-#17, #18, #20, #21, #23 Metric: total_issues 108 → 8 (-92.6%)
This commit is contained in:
+5
-1
@@ -1,8 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command agent runs the OpenFlare edge agent daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -132,7 +136,7 @@ func main() {
|
||||
go geoIPUpdater.Run(ctx)
|
||||
slog.Info("agent process started")
|
||||
|
||||
if err = runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
if err = runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("agent process exited with error", "error", err)
|
||||
stop()
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
+5
-1
@@ -1,8 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command flared runs the OpenFlare tunnel client daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -63,7 +67,7 @@ func main() {
|
||||
|
||||
slog.Info("flared process started")
|
||||
|
||||
if err := runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
if err := runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("flared process exited with error", "error", err)
|
||||
stop()
|
||||
os.Exit(1)
|
||||
|
||||
+5
-1
@@ -1,8 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command relay runs the OpenFlare relay node daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -63,7 +67,7 @@ func main() {
|
||||
|
||||
slog.Info("relay process started")
|
||||
|
||||
if err := runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
if err := runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("relay process exited with error", "error", err)
|
||||
stop()
|
||||
os.Exit(1)
|
||||
|
||||
+10
-1
@@ -15582,7 +15582,7 @@ const docTemplate = `{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -18403,6 +18403,15 @@ const docTemplate = `{
|
||||
"request_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status_2xx_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status_4xx_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status_5xx_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"unique_visitor_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
||||
+10
-1
@@ -15575,7 +15575,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -18396,6 +18396,15 @@
|
||||
"request_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status_2xx_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status_4xx_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status_5xx_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"unique_visitor_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
||||
+7
-1
@@ -645,7 +645,7 @@ definitions:
|
||||
type: integer
|
||||
results:
|
||||
items:
|
||||
additionalProperties: true
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
type: array
|
||||
type:
|
||||
@@ -2515,6 +2515,12 @@ definitions:
|
||||
type: integer
|
||||
request_count:
|
||||
type: integer
|
||||
status_2xx_count:
|
||||
type: integer
|
||||
status_4xx_count:
|
||||
type: integer
|
||||
status_5xx_count:
|
||||
type: integer
|
||||
unique_visitor_count:
|
||||
type: integer
|
||||
type: object
|
||||
|
||||
@@ -272,7 +272,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
}
|
||||
setActiveAction(null);
|
||||
setActionTimedOut(false);
|
||||
}, [activeAction, executionQuery.data, invalidateSourceState]);
|
||||
}, [activeAction, executionQuery.data, invalidateSourceState, t]);
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: () => PagesService.checkSource(projectId),
|
||||
|
||||
@@ -3243,7 +3243,7 @@
|
||||
"switchFailed": "Switch failed",
|
||||
"urlRequired": "Enter a Remote URL",
|
||||
"urlInvalid": "Enter a valid HTTP(S) URL",
|
||||
"githubUrlInvalid": "Enter a public repository URL in https://github.com/{owner}/{repo} format",
|
||||
"githubUrlInvalid": "Enter a public repository URL in https://github.com/'{owner}'/'{repo}' format",
|
||||
"tagInvalid": "Release tag must be a valid Git ref (1–255 bytes; / # & = are allowed)",
|
||||
"assetInvalid": "Asset name must be 1–255 bytes and cannot be a path or contain control, newline, or bidi characters",
|
||||
"intervalInvalid": "Check interval must be an integer between 5 and 1440 minutes",
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
"switchFailed": "Switch failed",
|
||||
"urlRequired": "Enter a Remote URL",
|
||||
"urlInvalid": "Enter a valid HTTP(S) URL",
|
||||
"githubUrlInvalid": "Enter a public repository URL in https://github.com/{owner}/{repo} format",
|
||||
"githubUrlInvalid": "Enter a public repository URL in https://github.com/'{owner}'/'{repo}' format",
|
||||
"tagInvalid": "Release tag must be a valid Git ref (1–255 bytes; / # & = are allowed)",
|
||||
"assetInvalid": "Asset name must be 1–255 bytes and cannot be a path or contain control, newline, or bidi characters",
|
||||
"intervalInvalid": "Check interval must be an integer between 5 and 1440 minutes",
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
"switchFailed": "切换失败",
|
||||
"urlRequired": "请输入 Remote URL",
|
||||
"urlInvalid": "请输入有效的 HTTP(S) URL",
|
||||
"githubUrlInvalid": "请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址",
|
||||
"githubUrlInvalid": "请输入 https://github.com/'{owner}'/'{repo}' 格式的公开仓库地址",
|
||||
"tagInvalid": "Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)",
|
||||
"assetInvalid": "Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符",
|
||||
"intervalInvalid": "检查间隔须为 5–1440 分钟的整数",
|
||||
|
||||
@@ -3243,7 +3243,7 @@
|
||||
"switchFailed": "切换失败",
|
||||
"urlRequired": "请输入 Remote URL",
|
||||
"urlInvalid": "请输入有效的 HTTP(S) URL",
|
||||
"githubUrlInvalid": "请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址",
|
||||
"githubUrlInvalid": "请输入 https://github.com/'{owner}'/'{repo}' 格式的公开仓库地址",
|
||||
"tagInvalid": "Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)",
|
||||
"assetInvalid": "Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符",
|
||||
"intervalInvalid": "检查间隔须为 5–1440 分钟的整数",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@swc/core': true
|
||||
core-js: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
|
||||
@@ -68,7 +68,7 @@ try {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(themes, null, 2), 'utf-8');
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(themes, null, 2) + '\n', 'utf-8');
|
||||
console.log(`Successfully generated themes.json at ${OUTPUT_FILE}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate themes.json:', error);
|
||||
|
||||
@@ -10,6 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CloudflareGroupDetailPageClient } from '@/app/(main)/cloudflare/groups/[id]/page-client';
|
||||
import { CloudflareService, NodeService } from '@/lib/services/openflare';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: '7' }),
|
||||
@@ -39,9 +41,11 @@ function renderPage() {
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CloudflareGroupDetailPageClient />
|
||||
</QueryClientProvider>,
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={client}>
|
||||
<CloudflareGroupDetailPageClient />
|
||||
</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CloudflarePage from '@/app/(main)/cloudflare/page';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
import { AdminTaskService } from '@/lib/services/admin';
|
||||
import { CloudflareService, NodeService } from '@/lib/services/openflare';
|
||||
|
||||
@@ -42,11 +43,11 @@ function renderPage() {
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
render(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(CloudflarePage),
|
||||
),
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={client}>
|
||||
<CloudflarePage />
|
||||
</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberAddDialog } from '@/app/(main)/cloudflare/components/member-add-dialog';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
import type { CloudflareAvailableDomain } from '@/lib/services/openflare';
|
||||
|
||||
const domains: CloudflareAvailableDomain[] = [
|
||||
@@ -35,15 +37,17 @@ describe('MemberAddDialog advanced domain picker', () => {
|
||||
it('groups domains under top-level zone roots and supports batch select', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<MemberAddDialog
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<MemberAddDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
domains={domains}
|
||||
defaultProxied
|
||||
pending={false}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
/>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('example.com').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('other.io').length).toBeGreaterThan(0);
|
||||
@@ -61,15 +65,17 @@ describe('MemberAddDialog advanced domain picker', () => {
|
||||
|
||||
it('filters by keyword across domain and zone root', () => {
|
||||
render(
|
||||
<MemberAddDialog
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<MemberAddDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
domains={domains}
|
||||
defaultProxied={false}
|
||||
pending={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
/>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索域名或顶级域…'), {
|
||||
target: { value: 'other' },
|
||||
@@ -83,15 +89,17 @@ describe('MemberAddDialog advanced domain picker', () => {
|
||||
it('selects an entire top-level domain group', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<MemberAddDialog
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<MemberAddDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
domains={domains}
|
||||
defaultProxied
|
||||
pending={false}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
/>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', { name: '选择顶级域 example.com' }),
|
||||
@@ -107,15 +115,17 @@ describe('MemberAddDialog advanced domain picker', () => {
|
||||
it('toggles individual domains within a group', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<MemberAddDialog
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<MemberAddDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
domains={domains}
|
||||
defaultProxied={false}
|
||||
pending={false}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
/>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
const wwwLabel = screen.getByText('www.example.com').closest('label');
|
||||
expect(wwwLabel).toBeTruthy();
|
||||
|
||||
@@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SyncTasksPanel } from '@/app/(main)/cloudflare/components/sync-tasks-panel';
|
||||
import { AdminTaskService } from '@/lib/services/admin';
|
||||
import type { TaskExecution } from '@/lib/services/admin';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
vi.mock('@/lib/services/admin', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/services/admin')>();
|
||||
@@ -24,9 +26,11 @@ function renderPanel() {
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SyncTasksPanel />
|
||||
</QueryClientProvider>,
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={client}>
|
||||
<SyncTasksPanel />
|
||||
</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
type PagesGitHubReleaseSource,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -48,16 +50,18 @@ function renderWithQuery(ui: React.ReactNode) {
|
||||
},
|
||||
});
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
return {
|
||||
...rendered,
|
||||
queryClient,
|
||||
rerenderWithQuery: (nextUI: React.ReactNode) =>
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{nextUI}
|
||||
</QueryClientProvider>,
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={queryClient}>{nextUI}</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type PagesRemoteURLSource,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -56,7 +58,9 @@ function renderWithQuery(ui: React.ReactNode) {
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -390,7 +394,7 @@ describe('Pages source UI', () => {
|
||||
expect(screen.getByText('下次检查')).toBeVisible();
|
||||
expect(screen.getByText('自动更新')).toBeVisible();
|
||||
expect(screen.getByText('已关闭')).toBeVisible();
|
||||
expect(screen.getByText('检查间隔')).toBeVisible();
|
||||
expect(screen.getByText('检查间隔(分钟)')).toBeVisible();
|
||||
expect(screen.getByText('1440 分钟')).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -9,6 +8,8 @@ import {
|
||||
} from '@/app/(main)/proxy-routes/detail/components/waf-section';
|
||||
import type { ProxyRouteItem, WAFRule } from '@/lib/services/openflare';
|
||||
import { WafService } from '@/lib/services/openflare';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -97,11 +98,11 @@ describe('WAF route binding order', () => {
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(WafSection, { route: { id: 9 } as ProxyRouteItem }),
|
||||
),
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={client}>
|
||||
<WafSection route={{ id: 9 } as ProxyRouteItem} />
|
||||
</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('全局规则')).toBeInTheDocument();
|
||||
|
||||
+7
-2
@@ -1,7 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import { createElement } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import WafPage from '@/app/(main)/waf/page';
|
||||
@@ -156,7 +157,11 @@ describe('WAF rule creation flow', () => {
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
render(
|
||||
createElement(QueryClientProvider, { client }, createElement(WafPage)),
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={client}>
|
||||
<WafPage />
|
||||
</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '新建规则' }));
|
||||
@@ -4,6 +4,8 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import WebsitesPage from '@/app/(main)/websites/page';
|
||||
import { ZoneService } from '@/lib/services/openflare';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({
|
||||
@@ -30,9 +32,11 @@ function renderPage() {
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WebsitesPage />
|
||||
</QueryClientProvider>,
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={client}>
|
||||
<WebsitesPage />
|
||||
</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ZoneDomainSelector } from '@/app/(main)/proxy-routes/components/zone-domain-selector';
|
||||
import type { ZoneDomainItem, ZoneItem } from '@/lib/services/openflare';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({
|
||||
@@ -46,7 +48,9 @@ function renderSelector(ui: React.ReactElement) {
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>{ui}</QueryClientProvider>,
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
TlsCertificateService,
|
||||
ZoneService,
|
||||
} from '@/lib/services/openflare';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
@@ -68,9 +70,11 @@ function renderPage(zoneId: number, paramZoneId = zoneId) {
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ZonePageClient />
|
||||
</QueryClientProvider>,
|
||||
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
|
||||
<QueryClientProvider client={client}>
|
||||
<ZonePageClient />
|
||||
</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +117,7 @@ describe('ZonePageClient', () => {
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'example.com' }),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText('唯一访问者')).toBeVisible();
|
||||
expect(await screen.findByText('查询窗口独立访客')).toBeVisible();
|
||||
expect(screen.getByText('请求总数')).toBeVisible();
|
||||
expect(screen.getByText('已提供的数据总计')).toBeVisible();
|
||||
expect(screen.getByRole('tab', { name: '域名 (0)' })).toBeVisible();
|
||||
|
||||
@@ -50,9 +50,9 @@ type GetTableDataRequest struct {
|
||||
|
||||
// TableDataResponse 动态数据表响应结构体
|
||||
type TableDataResponse struct {
|
||||
Columns []string `json:"columns"`
|
||||
Total int64 `json:"total"`
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
Columns []string `json:"columns"`
|
||||
Total int64 `json:"total"`
|
||||
Results []map[string]any `json:"results"`
|
||||
}
|
||||
|
||||
// ExecuteSQLRequest 执行自定义 SQL 请求结构体
|
||||
@@ -62,11 +62,11 @@ type ExecuteSQLRequest struct {
|
||||
|
||||
// ExecuteSQLResponse 执行自定义 SQL 响应结构体
|
||||
type ExecuteSQLResponse struct {
|
||||
Type string `json:"type"` // "select" 或 "exec"
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Results []map[string]interface{} `json:"results,omitempty"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMs int64 `json:"execution_time_ms"`
|
||||
Type string `json:"type"` // "select" 或 "exec"
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Results []map[string]any `json:"results,omitempty"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMs int64 `json:"execution_time_ms"`
|
||||
}
|
||||
|
||||
// formatBytes 格式化字节大小为可读字符串
|
||||
@@ -103,7 +103,7 @@ func formatBytes(bytes uint64) string {
|
||||
}
|
||||
|
||||
// getSQLiteOverview 获取 SQLite 数据库概览信息
|
||||
func getSQLiteOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
|
||||
func getSQLiteOverview(gormDB *gorm.DB) DBOverviewResponse {
|
||||
name := config.Config.Database.SQLitePath
|
||||
if name == "" {
|
||||
name = "./data/openflare.db"
|
||||
@@ -119,10 +119,7 @@ func getSQLiteOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
|
||||
|
||||
var sizeStr string
|
||||
if fi, err := os.Stat(name); err == nil {
|
||||
size := fi.Size()
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
size := max(fi.Size(), 0)
|
||||
sizeStr = formatBytes(uint64(size))
|
||||
} else {
|
||||
sizeStr = "0 B"
|
||||
@@ -147,11 +144,11 @@ func getSQLiteOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
|
||||
Size: sizeStr,
|
||||
TableCount: tableCount,
|
||||
Connections: connCount,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// getPostgresOverview 获取 PostgreSQL 数据库概览信息
|
||||
func getPostgresOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
|
||||
func getPostgresOverview(gormDB *gorm.DB) DBOverviewResponse {
|
||||
name := config.Config.Database.Database
|
||||
|
||||
var version string
|
||||
@@ -165,10 +162,7 @@ func getPostgresOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
|
||||
var sizeStr string
|
||||
var sizeBytes sql.NullInt64
|
||||
if err := gormDB.Raw("SELECT pg_database_size(current_database())").Scan(&sizeBytes).Error; err == nil && sizeBytes.Valid {
|
||||
size := sizeBytes.Int64
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
size := max(sizeBytes.Int64, 0)
|
||||
sizeStr = formatBytes(uint64(size))
|
||||
} else {
|
||||
sizeStr = "0 B"
|
||||
@@ -198,7 +192,7 @@ func getPostgresOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
|
||||
Size: sizeStr,
|
||||
TableCount: tableCount,
|
||||
Connections: connCount,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetDBOverview 获取数据库运行概览
|
||||
@@ -220,17 +214,11 @@ func GetDBOverview(c *gin.Context) {
|
||||
}
|
||||
|
||||
var overview DBOverviewResponse
|
||||
var err error
|
||||
|
||||
if !config.Config.Database.Enabled {
|
||||
overview, err = getSQLiteOverview(gormDB)
|
||||
overview = getSQLiteOverview(gormDB)
|
||||
} else {
|
||||
overview, err = getPostgresOverview(gormDB)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.AbortInternal(c, err.Error())
|
||||
return
|
||||
overview = getPostgresOverview(gormDB)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(overview))
|
||||
@@ -294,10 +282,7 @@ func GetDBTableData(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
offset := max((req.Page-1)*req.PageSize, 0)
|
||||
limit := req.PageSize
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
@@ -332,11 +317,11 @@ func GetDBTableData(c *gin.Context) {
|
||||
}
|
||||
|
||||
// scanTableRows 扫描并提取数据表行数据,做截断处理
|
||||
func scanTableRows(rows *sql.Rows, cols []string) ([]map[string]interface{}, error) {
|
||||
results := make([]map[string]interface{}, 0)
|
||||
func scanTableRows(rows *sql.Rows, cols []string) ([]map[string]any, error) {
|
||||
results := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
columns := make([]interface{}, len(cols))
|
||||
columnPointers := make([]interface{}, len(cols))
|
||||
columns := make([]any, len(cols))
|
||||
columnPointers := make([]any, len(cols))
|
||||
for i := range columns {
|
||||
columnPointers[i] = &columns[i]
|
||||
}
|
||||
@@ -345,7 +330,7 @@ func scanTableRows(rows *sql.Rows, cols []string) ([]map[string]interface{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rowMap := make(map[string]interface{})
|
||||
rowMap := make(map[string]any)
|
||||
for i, colName := range cols {
|
||||
val := columns[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
@@ -385,10 +370,10 @@ func executeSQLQuery(gormDB *gorm.DB, sqlStr string, startTime time.Time) (Execu
|
||||
return ExecuteSQLResponse{}, err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0)
|
||||
results := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
columns := make([]interface{}, len(cols))
|
||||
columnPointers := make([]interface{}, len(cols))
|
||||
columns := make([]any, len(cols))
|
||||
columnPointers := make([]any, len(cols))
|
||||
for i := range columns {
|
||||
columnPointers[i] = &columns[i]
|
||||
}
|
||||
@@ -397,7 +382,7 @@ func executeSQLQuery(gormDB *gorm.DB, sqlStr string, startTime time.Time) (Execu
|
||||
return ExecuteSQLResponse{}, err
|
||||
}
|
||||
|
||||
rowMap := make(map[string]interface{})
|
||||
rowMap := make(map[string]any)
|
||||
for i, colName := range cols {
|
||||
val := columns[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
|
||||
@@ -56,11 +56,11 @@ func GetLogs(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "200")
|
||||
|
||||
var cursor, limit int
|
||||
if _, err := parsePositiveInt(cursorStr, &cursor); err != nil {
|
||||
if err := parsePositiveInt(cursorStr, &cursor); err != nil {
|
||||
response.AbortWithError(c, http.StatusBadRequest, admin.InvalidCursorParam)
|
||||
return
|
||||
}
|
||||
if _, err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 {
|
||||
if err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 {
|
||||
limit = defaultLimit
|
||||
}
|
||||
if limit > maxLimit {
|
||||
|
||||
@@ -35,8 +35,8 @@ func getUpgrader() *websocket.Upgrader {
|
||||
ctx := r.Context()
|
||||
if sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress); err == nil && sc.Value != "" {
|
||||
originToCheck := strings.TrimRight(strings.TrimSpace(origin), "/")
|
||||
allowedOrigins := strings.Split(sc.Value, ",")
|
||||
for _, allowed := range allowedOrigins {
|
||||
allowedOrigins := strings.SplitSeq(sc.Value, ",")
|
||||
for allowed := range allowedOrigins {
|
||||
allowed = strings.TrimRight(strings.TrimSpace(allowed), "/")
|
||||
if allowed != "" && strings.EqualFold(allowed, originToCheck) {
|
||||
return true
|
||||
@@ -49,15 +49,15 @@ func getUpgrader() *websocket.Upgrader {
|
||||
}
|
||||
|
||||
// parsePositiveInt 解析非负整数字符串
|
||||
func parsePositiveInt(s string, result *int) (bool, error) {
|
||||
func parsePositiveInt(s string, result *int) error {
|
||||
if s == "" {
|
||||
*result = 0
|
||||
return true, nil
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
*result = n
|
||||
return true, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbConn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite in memory: %v", err)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
package push
|
||||
|
||||
import "slices"
|
||||
|
||||
import "sync"
|
||||
|
||||
const (
|
||||
@@ -66,13 +68,7 @@ func ListDefinitions() []Definition {
|
||||
}
|
||||
// Add any others
|
||||
for t, d := range definitions {
|
||||
found := false
|
||||
for _, o := range order {
|
||||
if o == t {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
found := slices.Contains(order, t)
|
||||
if !found {
|
||||
res = append(res, d)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
@@ -34,9 +35,7 @@ func (m NotificationMessage) Flatten() map[string]any {
|
||||
keyContent: m.Content,
|
||||
keyLevel: m.Level,
|
||||
}
|
||||
for k, v := range m.Ext {
|
||||
res[k] = v
|
||||
}
|
||||
maps.Copy(res, m.Ext)
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ func (m *mockPusher) ValidateConfig(cfg pkgpush.Config) error {
|
||||
}
|
||||
|
||||
func setupPushTest(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) {
|
||||
t.Helper()
|
||||
dbConn, mr, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
|
||||
// AutoMigrate push tables in SQLite test environment
|
||||
@@ -319,7 +320,7 @@ func TestPushHandler(t *testing.T) {
|
||||
assert.Equal(t, "Structured Alert", mPusher.sentBody["title"])
|
||||
assert.Equal(t, "Hello World", mPusher.sentBody["content"])
|
||||
assert.Equal(t, "WARNING", mPusher.sentBody["level"])
|
||||
assert.Equal(t, float64(42), mPusher.sentBody["extra_val"]) // unmarshaled json numbers are float64 by default
|
||||
assert.InDelta(t, float64(42), mPusher.sentBody["extra_val"], 1e-9) // unmarshaled json numbers are float64 by default
|
||||
mPusher.mu.Unlock()
|
||||
|
||||
// Verify PushHistory recorded
|
||||
@@ -436,7 +437,7 @@ func TestPushRouters(t *testing.T) {
|
||||
|
||||
dataMap, ok := resp.Data.(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, float64(1), dataMap["total"])
|
||||
assert.InDelta(t, float64(1), dataMap["total"], 1e-9)
|
||||
})
|
||||
|
||||
t.Run("test push endpoint", func(t *testing.T) {
|
||||
@@ -614,37 +615,37 @@ func TestPushChannelAPI(t *testing.T) {
|
||||
t.Run("validate push channel model constraints", func(t *testing.T) {
|
||||
// 校验名称合法性
|
||||
c1 := &model.PushChannel{Name: "invalid-name!", URL: "https://hook.com", Other: "{}"}
|
||||
assert.Error(t, c1.Validate())
|
||||
require.Error(t, c1.Validate())
|
||||
|
||||
// 校验 URL 安全前缀 HTTPS
|
||||
c2 := &model.PushChannel{Name: "custom_channel", URL: "http://insecure-hook.com", Other: "{}"}
|
||||
assert.Error(t, c2.Validate())
|
||||
require.Error(t, c2.Validate())
|
||||
|
||||
// 校验 JSON 格式
|
||||
c3 := &model.PushChannel{Name: "custom_channel", URL: "https://hook.com", Other: "{invalid-json}"}
|
||||
assert.Error(t, c3.Validate())
|
||||
require.Error(t, c3.Validate())
|
||||
|
||||
// 正确配置
|
||||
c4 := &model.PushChannel{Name: "custom_channel", URL: "https://hook.com", Other: "{\"content\":\"$content\"}"}
|
||||
assert.NoError(t, c4.Validate())
|
||||
require.NoError(t, c4.Validate())
|
||||
|
||||
// 飞书渠道校验:非 HTTPS 地址报错
|
||||
c5 := &model.PushChannel{Name: "lark_channel", Type: "lark", URL: "http://open.feishu.cn", Other: ""}
|
||||
assert.Error(t, c5.Validate())
|
||||
require.Error(t, c5.Validate())
|
||||
|
||||
// 飞书正确配置
|
||||
c6 := &model.PushChannel{Name: "lark_channel", Type: "lark", URL: "https://open.feishu.cn", Other: ""}
|
||||
assert.NoError(t, c6.Validate())
|
||||
require.NoError(t, c6.Validate())
|
||||
|
||||
// Telegram 渠道校验
|
||||
cTelegramErr := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "https://api.telegram.org", Token: "", Other: ""}
|
||||
assert.Error(t, cTelegramErr.Validate())
|
||||
require.Error(t, cTelegramErr.Validate())
|
||||
|
||||
cTelegramErr2 := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "http://api.telegram.org", Token: "123:abc", Other: ""}
|
||||
assert.Error(t, cTelegramErr2.Validate())
|
||||
require.Error(t, cTelegramErr2.Validate())
|
||||
|
||||
cTelegramOk := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "", Token: "123:abc", Other: "-100123"}
|
||||
assert.NoError(t, cTelegramOk.Validate())
|
||||
require.NoError(t, cTelegramOk.Validate())
|
||||
assert.Equal(t, "https://api.telegram.org", cTelegramOk.URL)
|
||||
|
||||
// 邮件配置校验:允许空配置以复用系统全局设置
|
||||
@@ -733,7 +734,7 @@ func TestPushChannelAPI(t *testing.T) {
|
||||
dbConn.First(&updated, createdID)
|
||||
assert.Equal(t, "Updated remark", updated.Description)
|
||||
assert.Equal(t, "new_chan_token", updated.Token)
|
||||
assert.Equal(t, `{"text": "$content"}`, updated.Other)
|
||||
assert.JSONEq(t, `{"text": "$content"}`, updated.Other)
|
||||
})
|
||||
|
||||
t.Run("admin test channel endpoint", func(t *testing.T) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
||||
@@ -309,7 +310,7 @@ func exportSQLite(c *gin.Context) {
|
||||
|
||||
c.Header("Content-Disposition", `attachment; filename="openflare.db"`)
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", fi.Size()))
|
||||
c.Header("Content-Length", strconv.FormatInt(fi.Size(), 10))
|
||||
c.Status(http.StatusOK)
|
||||
http.ServeContent(c.Writer, c.Request, "openflare.db", fi.ModTime(), f)
|
||||
}
|
||||
@@ -328,7 +329,7 @@ func exportPostgres(c *gin.Context) {
|
||||
args := []string{
|
||||
"--no-password",
|
||||
"-h", dbCfg.Host,
|
||||
"-p", fmt.Sprintf("%d", dbCfg.Port),
|
||||
"-p", strconv.Itoa(dbCfg.Port),
|
||||
"-U", dbCfg.Username,
|
||||
dbCfg.Database,
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ func registerInternalOnlyTaskMeta() {
|
||||
}
|
||||
|
||||
func setupTaskTestEnvironment(t *testing.T) func() {
|
||||
t.Helper()
|
||||
_, mr, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
bootstrap.RegisterTasks()
|
||||
task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
|
||||
@@ -490,7 +491,7 @@ func TestListTaskExecutions(t *testing.T) {
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(3), data["total"])
|
||||
assert.InDelta(t, float64(3), data["total"], 1e-9)
|
||||
})
|
||||
|
||||
t.Run("filter by status", func(t *testing.T) {
|
||||
@@ -507,7 +508,7 @@ func TestListTaskExecutions(t *testing.T) {
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(1), data["total"])
|
||||
assert.InDelta(t, float64(1), data["total"], 1e-9)
|
||||
})
|
||||
|
||||
t.Run("filter by task_type (asynq task name)", func(t *testing.T) {
|
||||
@@ -524,7 +525,7 @@ func TestListTaskExecutions(t *testing.T) {
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(3), data["total"])
|
||||
assert.InDelta(t, float64(3), data["total"], 1e-9)
|
||||
})
|
||||
|
||||
t.Run("filter by task_type (management task type)", func(t *testing.T) {
|
||||
@@ -541,7 +542,7 @@ func TestListTaskExecutions(t *testing.T) {
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(3), data["total"])
|
||||
assert.InDelta(t, float64(3), data["total"], 1e-9)
|
||||
})
|
||||
|
||||
t.Run("pagination", func(t *testing.T) {
|
||||
@@ -558,7 +559,7 @@ func TestListTaskExecutions(t *testing.T) {
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(dataBytes, &data)
|
||||
|
||||
assert.Equal(t, float64(3), data["total"])
|
||||
assert.InDelta(t, float64(3), data["total"], 1e-9)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -163,13 +164,7 @@ func selectLatestRelease(repository string, releases []githubRelease) (githubRel
|
||||
}
|
||||
expectedNames := expectedAssetNames(repository, release.TagName)
|
||||
for _, asset := range release.Assets {
|
||||
matched := false
|
||||
for _, name := range expectedNames {
|
||||
if asset.Name == name {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
matched := slices.Contains(expectedNames, asset.Name)
|
||||
if !matched || asset.BrowserDownloadURL == "" || asset.State != "uploaded" {
|
||||
continue
|
||||
}
|
||||
@@ -199,7 +194,7 @@ func (m *manager) fetchRelease(ctx context.Context, repository string) (githubRe
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "OpenFlare-Updater")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("X-Github-Api-Version", "2022-11-28")
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -348,10 +343,8 @@ func getCandidateBinaryNames(executable string, repository string) []string {
|
||||
if runtime.GOOS == windowsOS && !strings.HasSuffix(strings.ToLower(name), ".exe") {
|
||||
name += ".exe"
|
||||
}
|
||||
for _, existing := range names {
|
||||
if existing == name {
|
||||
return
|
||||
}
|
||||
if slices.Contains(names, name) {
|
||||
return
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package user
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -93,17 +94,13 @@ func abortUserLogicError(c *gin.Context, err error, notFoundMsg string, forbidde
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, m := range badRequestMsgs {
|
||||
if msg == m {
|
||||
response.AbortBadRequest(c, msg)
|
||||
return true
|
||||
}
|
||||
if slices.Contains(badRequestMsgs, msg) {
|
||||
response.AbortBadRequest(c, msg)
|
||||
return true
|
||||
}
|
||||
for _, m := range forbiddenMsgs {
|
||||
if msg == m {
|
||||
response.AbortForbidden(c, msg)
|
||||
return true
|
||||
}
|
||||
if slices.Contains(forbiddenMsgs, msg) {
|
||||
response.AbortForbidden(c, msg)
|
||||
return true
|
||||
}
|
||||
logger.ErrorF(c.Request.Context(), "Admin user error: %v", err)
|
||||
response.AbortInternal(c, "内部服务器错误")
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package agent implements the local OpenFlare agent runtime loop.
|
||||
package agent
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config loads and persists agent daemon configuration.
|
||||
package config
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import edgeconfig "github.com/Rain-kl/Wavelet/internal/apps/edge/config"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
// Version is the current agent version string, overridden at build time.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package geoipdata holds shared GeoIP database filename constants.
|
||||
//
|
||||
// MaxMind MMDB files are NOT embedded into the agent binary. Docker images
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package geoipupdate schedules local MaxMind GeoIP database updates for the agent.
|
||||
package geoipupdate
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoipupdate
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package heartbeat implements the periodic heartbeat cycle executed by the agent,
|
||||
// including payload preparation, config sync, WAF IP group application, and observability buffering.
|
||||
package heartbeat
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package logging configures structured logging for the agent process.
|
||||
package logging
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package nginx manages OpenResty configuration, runtime, and supporting assets.
|
||||
package nginx
|
||||
|
||||
@@ -134,7 +137,7 @@ func (e *PathExecutor) Reload(ctx context.Context) error {
|
||||
slog.Warn("openresty reload reported runtime is not running, starting binary", "path", e.Path)
|
||||
startOutput, startErr := e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath)
|
||||
if startErr != nil {
|
||||
return fmt.Errorf("openresty reload failed: %w: %s; start failed: %v: %s", err, string(output), startErr, string(startOutput))
|
||||
return fmt.Errorf("openresty reload failed: %w: %s; start failed: %w: %s", err, string(output), startErr, string(startOutput))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -355,14 +358,14 @@ func (m *Manager) activateConfig(ctx context.Context) error {
|
||||
func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupState, applyErr error) ApplyOutcome {
|
||||
slog.Warn("openresty apply failed, restoring previous config", "error", applyErr)
|
||||
if err := m.restore(backup); err != nil {
|
||||
return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %v: %w", applyErr, err))
|
||||
return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %w: %w", applyErr, err))
|
||||
}
|
||||
if err := m.activateConfig(ctx); err != nil {
|
||||
if backup != nil && backup.MainExisted {
|
||||
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err))
|
||||
return fatalApplyOutcome(fmt.Errorf("apply failed: %w; rollback recovery failed: %w", applyErr, err))
|
||||
}
|
||||
if fallbackErr := m.EnsureSafeFallbackRuntime(ctx, fmt.Sprintf("apply failed: %v; rollback recovery failed: %v", applyErr, err)); fallbackErr != nil {
|
||||
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w; fallback recovery failed: %v", applyErr, err, fallbackErr))
|
||||
return fatalApplyOutcome(fmt.Errorf("apply failed: %w; rollback recovery failed: %w; fallback recovery failed: %w", applyErr, err, fallbackErr))
|
||||
}
|
||||
message := fmt.Sprintf("apply failed, but fallback runtime started: %v; rollback recovery failed: %v", applyErr, err)
|
||||
slog.Warn("openresty apply recovered with safe default fallback", "message", message)
|
||||
@@ -516,7 +519,7 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, listen, openrestyrender.ObservabilityListenPlaceholder)
|
||||
}
|
||||
if m.OpenrestyObservabilityPort > 0 {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), openrestyrender.ObservabilityPortPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, strconv.Itoa(m.OpenrestyObservabilityPort), openrestyrender.ObservabilityPortPlaceholder)
|
||||
}
|
||||
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder)
|
||||
@@ -1463,7 +1466,7 @@ func (m *Manager) renderMainConfig(content string) string {
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityListenPlaceholder, listen)
|
||||
}
|
||||
if m.OpenrestyObservabilityPort > 0 {
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityPortPlaceholder, strconv.Itoa(m.OpenrestyObservabilityPort))
|
||||
}
|
||||
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
|
||||
rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
// DefaultMimeTypes is the embedded nginx mime.types map used by generated configs.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package observability provides system and service level observability data collection for the agent.
|
||||
package observability
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
@@ -62,7 +65,7 @@ func CollectEdgeHealth(ctx context.Context, cfg *config.Config) *EdgeHealthSnaps
|
||||
}
|
||||
|
||||
func fetchLocalJSON(ctx context.Context, client *http.Client, url string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package protocol defines type aliases and constants for the agent protocol.
|
||||
package protocol
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package runtimeuser defines the shared OS account used by the agent process
|
||||
// and OpenResty worker processes so file ownership stays aligned.
|
||||
package runtimeuser
|
||||
@@ -120,7 +123,7 @@ func ensureWorldTraversablePath(targetDir string) error {
|
||||
if current == "" || current == "." {
|
||||
return nil
|
||||
}
|
||||
for depth := 0; depth < maxDepth; depth++ {
|
||||
for range maxDepth {
|
||||
if err := os.Chmod(current, DefaultDirPerm); err != nil { //nolint:gosec // parent dirs must be traversable by the runtime user
|
||||
if os.IsNotExist(err) || os.IsPermission(err) {
|
||||
break
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package runtimeuser
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//go:build unix
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package runtimeuser
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package state persists agent runtime state and observability snapshots.
|
||||
package state
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||
@@ -198,7 +199,7 @@ func (s *Service) ensurePagesProject(ctx context.Context, snapshot *state.Snapsh
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < pagesLatestPullAttempts; attempt++ {
|
||||
for attempt := range pagesLatestPullAttempts {
|
||||
latest, err := s.client.GetPagesProjectLatestHash(ctx, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch Pages project %d latest hash: %w", projectID, err)
|
||||
@@ -359,10 +360,7 @@ func validatePagesPackageMetadata(
|
||||
if extractedBytes == 0 {
|
||||
extractedBytes = 1
|
||||
}
|
||||
maxFileBytes := extractedBytes
|
||||
if maxFileBytes > agentPagesMaxFileBytes {
|
||||
maxFileBytes = agentPagesMaxFileBytes
|
||||
}
|
||||
maxFileBytes := min(extractedBytes, agentPagesMaxFileBytes)
|
||||
|
||||
return pagesPackageLimits{
|
||||
PackageBytes: metadata.PackageSize,
|
||||
@@ -395,7 +393,7 @@ func (s *Service) downloadPagesProjectPackage(
|
||||
metadata *protocol.PagesProjectLatestHashResponse,
|
||||
maxBytes int64,
|
||||
) (packagePath string, hash string, err error) {
|
||||
releasesRoot := filepath.Join(s.pagesDir, "projects", fmt.Sprintf("%d", projectID), "releases")
|
||||
releasesRoot := filepath.Join(s.pagesDir, "projects", strconv.FormatUint(uint64(projectID), 10), "releases")
|
||||
if err := os.MkdirAll(releasesRoot, pagesDirPerm); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -444,7 +442,7 @@ func cleanupPagesProjectStaleReleases(baseDir string, projectID uint, keepHash s
|
||||
if projectID == 0 || keepHash == "" {
|
||||
return nil
|
||||
}
|
||||
releasesRoot := filepath.Join(baseDir, "projects", fmt.Sprintf("%d", projectID), "releases")
|
||||
releasesRoot := filepath.Join(baseDir, "projects", strconv.FormatUint(uint64(projectID), 10), "releases")
|
||||
entries, err := os.ReadDir(releasesRoot) //nolint:gosec // managed PagesDir
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -985,9 +983,9 @@ func writePagesMarker(dir string, project pagesProjectRef) error {
|
||||
}
|
||||
|
||||
func pagesProjectCurrentDir(baseDir string, projectID uint) string {
|
||||
return filepath.Join(baseDir, "projects", fmt.Sprintf("%d", projectID), "current")
|
||||
return filepath.Join(baseDir, "projects", strconv.FormatUint(uint64(projectID), 10), "current")
|
||||
}
|
||||
|
||||
func pagesProjectReleaseDir(baseDir string, projectID uint, checksum string) string {
|
||||
return filepath.Join(baseDir, "projects", fmt.Sprintf("%d", projectID), "releases", checksum)
|
||||
return filepath.Join(baseDir, "projects", strconv.FormatUint(uint64(projectID), 10), "releases", checksum)
|
||||
}
|
||||
|
||||
@@ -238,10 +238,7 @@ func TestPromoteSameHashReleaseFailureRestoresPreviousCurrent(t *testing.T) {
|
||||
if err := switchPagesProjectCurrentDir(pagesDir, projectID, releaseDir); err != nil {
|
||||
t.Fatalf("seed same-hash current error = %v", err)
|
||||
}
|
||||
stagingDir, err := os.MkdirTemp(filepath.Dir(releaseDir), ".same-hash-*.tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("create same-hash staging error = %v", err)
|
||||
}
|
||||
stagingDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(stagingDir, "index.html"), []byte("new"), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write repaired same-hash release error = %v", err)
|
||||
}
|
||||
@@ -249,7 +246,7 @@ func TestPromoteSameHashReleaseFailureRestoresPreviousCurrent(t *testing.T) {
|
||||
t.Fatalf("write repaired same-hash marker error = %v", err)
|
||||
}
|
||||
copyErr := errors.New("injected same-hash copy failure")
|
||||
err = promotePagesReleaseWithCopy(
|
||||
err := promotePagesReleaseWithCopy(
|
||||
stagingDir,
|
||||
releaseDir,
|
||||
project,
|
||||
@@ -298,10 +295,7 @@ func TestPromotePagesReleaseRepairsDanglingCurrent(t *testing.T) {
|
||||
}
|
||||
|
||||
requireTestMkdirAll(t, filepath.Dir(releaseDir))
|
||||
stagingDir, err := os.MkdirTemp(filepath.Dir(releaseDir), ".dangling-*.tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("create dangling repair staging error = %v", err)
|
||||
}
|
||||
stagingDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(stagingDir, "index.html"), []byte("repaired"), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write dangling repair staging error = %v", err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -363,7 +363,7 @@ func referencedWAFIPGroupIDs(supportFiles []protocol.SupportFile) ([]uint, error
|
||||
for id := range seen {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
slices.Sort(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ func (s *Service) ensureRuntimeForCurrentConfig(ctx context.Context, mode string
|
||||
snapshot.OpenrestyMessage = "safe default fallback runtime started"
|
||||
return nil
|
||||
}
|
||||
err = fmt.Errorf("%v; fallback recovery failed: %w", err, fallbackErr)
|
||||
err = fmt.Errorf("%w; fallback recovery failed: %w", err, fallbackErr)
|
||||
}
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
@@ -220,6 +223,9 @@ func updateSnapshotFromApplyOutcome(mode string, snapshot *state.Snapshot, confi
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = result.message
|
||||
result.reportResult = ApplyResultWarning
|
||||
case nginx.ApplyStatusFatal:
|
||||
// 致命错误与普通失败同走失败路径:标记阻塞并上报 Unhealthy。
|
||||
fallthrough
|
||||
default:
|
||||
if result.message == "" {
|
||||
result.message = "openresty apply failed"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package updater provides agent self-update integration with the edge updater.
|
||||
package updater
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package wsclient provides the agent-side WebSocket client for connecting to the OpenFlare server.
|
||||
package wsclient
|
||||
|
||||
|
||||
@@ -78,10 +78,7 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano() / int64(time.Millisecond)
|
||||
nonceTTL := time.Duration(payload.Expires-now) * time.Millisecond
|
||||
if nonceTTL < time.Second {
|
||||
nonceTTL = time.Second
|
||||
}
|
||||
nonceTTL := max(time.Duration(payload.Expires-now)*time.Millisecond, time.Second)
|
||||
|
||||
set, err := m.store.SetNX(ctx, nonceKey, "1", nonceTTL)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config provides shared configuration types for edge applications.
|
||||
package config
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package heartbeat handles periodic heartbeat and update checks.
|
||||
package heartbeat
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package httpclient provides an authenticated HTTP client for edge services.
|
||||
package httpclient
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package logging configures structured logging for edge applications.
|
||||
package logging
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package nodeip detects the preferred public IP address for edge nodes.
|
||||
package nodeip
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package observability provides helpers that read Linux /proc and /sys metrics for system monitoring.
|
||||
package observability
|
||||
|
||||
@@ -96,10 +99,7 @@ func ReadMemInfo() (int64, int64) {
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
used := total - (memAvailableKB * 1024)
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
used := max(total-(memAvailableKB*1024), 0)
|
||||
return total, used
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@ func ReadLinuxCPUStat() (uint64, uint64) {
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
lines := strings.SplitSeq(string(content), "\n")
|
||||
for line := range lines {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
@@ -258,23 +258,23 @@ func StatFilesystem(path string) (int64, int64) {
|
||||
if err := syscall.Statfs(absPath, &stat); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
total := multiplyUint64ToInt64(stat.Blocks, uint64(stat.Bsize))
|
||||
free := multiplyUint64ToInt64(stat.Bavail, uint64(stat.Bsize))
|
||||
used := total - free
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
total := multiplyUint64Int64(stat.Blocks, stat.Bsize)
|
||||
free := multiplyUint64Int64(stat.Bavail, stat.Bsize)
|
||||
used := max(total-free, 0)
|
||||
return total, used
|
||||
}
|
||||
|
||||
func multiplyUint64ToInt64(a uint64, b uint64) int64 {
|
||||
if a == 0 || b == 0 {
|
||||
// multiplyUint64Int64 multiplies a uint64 by a positive int64, saturating at
|
||||
// math.MaxInt64 to avoid int64 overflow.
|
||||
func multiplyUint64Int64(a uint64, b int64) int64 {
|
||||
if a == 0 || b <= 0 {
|
||||
return 0
|
||||
}
|
||||
if a > math.MaxInt64/b {
|
||||
v := a * uint64(b)
|
||||
if v > math.MaxInt64 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return int64(a * b) //nolint:gosec // product is bounded to math.MaxInt64 above
|
||||
return int64(v)
|
||||
}
|
||||
|
||||
// ReadFirstLine reads and returns the trimmed first line of a file.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package runner provides shared WebSocket reconnect helpers for edge daemons.
|
||||
package runner
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package updater provides capabilities to check for, download, and apply updates.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -19,7 +23,7 @@ func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
renameErr := err
|
||||
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Error("remove tmp binary failed", "path", tmpPath, "error", err)
|
||||
return fmt.Errorf("backup current binary: %w; remove tmp binary: %v", renameErr, err)
|
||||
return fmt.Errorf("backup current binary: %w; remove tmp binary: %w", renameErr, err)
|
||||
}
|
||||
return fmt.Errorf("backup current binary: %w", renameErr)
|
||||
}
|
||||
@@ -27,7 +31,7 @@ func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
replaceErr := err
|
||||
if err := os.Rename(backupPath, execPath); err != nil {
|
||||
slog.Error("restore backup binary failed", "path", backupPath, "error", err)
|
||||
return fmt.Errorf("replace binary: %w; restore backup binary: %v", replaceErr, err)
|
||||
return fmt.Errorf("replace binary: %w; restore backup binary: %w", replaceErr, err)
|
||||
}
|
||||
return fmt.Errorf("replace binary: %w", replaceErr)
|
||||
}
|
||||
@@ -37,7 +41,7 @@ func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil { //nolint:gosec // execPath is the validated edge updater binary path
|
||||
return fmt.Errorf("exec restart: %w", err)
|
||||
}
|
||||
return fmt.Errorf("unreachable after exec")
|
||||
return errors.New("unreachable after exec")
|
||||
}
|
||||
|
||||
func removeBackupBinary(path string) error {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package updater provides capabilities to check for, download, and apply updates.
|
||||
package updater
|
||||
|
||||
@@ -6,6 +9,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -285,7 +289,7 @@ func (s *Service) downloadChecksum(ctx context.Context, url string, assetName st
|
||||
|
||||
func parseSHA256Checksum(content string, assetName string) (string, error) {
|
||||
assetName = strings.TrimSpace(assetName)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
for line := range strings.SplitSeq(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
@@ -295,7 +299,7 @@ func parseSHA256Checksum(content string, assetName string) (string, error) {
|
||||
}
|
||||
}
|
||||
if assetName == "" {
|
||||
return "", fmt.Errorf("checksum asset does not contain a valid sha256 digest")
|
||||
return "", errors.New("checksum asset does not contain a valid sha256 digest")
|
||||
}
|
||||
return "", fmt.Errorf("checksum asset does not contain a sha256 digest for %q", assetName)
|
||||
}
|
||||
@@ -340,7 +344,7 @@ func isSHA256Hex(value string) bool {
|
||||
func (s *Service) downloadAndRestart(ctx context.Context, url string, expectedChecksum string, targetPath string) error {
|
||||
expectedChecksum = strings.ToLower(strings.TrimSpace(expectedChecksum))
|
||||
if !isSHA256Hex(expectedChecksum) {
|
||||
return fmt.Errorf("invalid expected sha256 checksum")
|
||||
return errors.New("invalid expected sha256 checksum")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package wsclient provides WebSocket client abstractions for edge node communication.
|
||||
package wsclient
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package wsclient
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config loads and persists flared daemon configuration.
|
||||
package config
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user