后端与全仓代码质量清理(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:
ryan
2026-08-16 21:23:37 +08:00
parent 5a8722ff07
commit 2f60329886
292 changed files with 1362 additions and 793 deletions
+5 -1
View File
@@ -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)
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package main
import (
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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 分钟的整数",
+1 -1
View File
@@ -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 分钟的整数",
+2
View File
@@ -1,4 +1,6 @@
allowBuilds:
'@parcel/watcher': true
'@swc/core': true
core-js: true
sharp: true
unrs-resolver: true
+1 -1
View File
@@ -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();
@@ -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: '新建规则' }));
+7 -3
View File
@@ -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 -4
View File
@@ -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();
+26 -41
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+6 -6
View File
@@ -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
}
+1
View File
@@ -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)
}
+2 -3
View File
@@ -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
}
+13 -12
View File
@@ -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) {
+3 -2
View File
@@ -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,
}
+6 -5
View File
@@ -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)
})
}
+5 -12
View File
@@ -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 -10
View File
@@ -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, "内部服务器错误")
+3
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package agent
import (
+3
View File
@@ -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 (
+3
View File
@@ -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"
+3
View File
@@ -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.
+3
View File
@@ -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 (
+3
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package heartbeat
import (
+3
View File
@@ -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
+9 -6
View File
@@ -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 (
+3
View File
@@ -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 (
+3
View File
@@ -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 (
+3
View File
@@ -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 (
+3
View File
@@ -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 (
+3
View File
@@ -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 (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package state
import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package state
import (
+7 -9
View File
@@ -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)
}
+3 -3
View File
@@ -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()
+6
View File
@@ -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"
+3
View File
@@ -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
+3
View File
@@ -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
+1 -4
View File
@@ -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 {
+3
View File
@@ -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
+3
View File
@@ -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 (
+3
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package logging configures structured logging for edge applications.
package logging
+3
View File
@@ -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
+16 -16
View File
@@ -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 (
+3
View File
@@ -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
+7 -3
View File
@@ -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 (
+7 -3
View File
@@ -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 (
+3
View File
@@ -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 (
+3
View File
@@ -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