diff --git a/backend/repository/db.go b/backend/repository/db.go index d2acfdb..8ec0c4b 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -539,50 +539,25 @@ func (d *JsonDB) migrateSchema() bool { return true } -func InitDB() error { - cfg := config.GetConfig() - _ = os.MkdirAll(cfg.DataDir, 0700) - - d := &JsonDB{ - dataFile: cfg.DatabasePath, - Users: []models.User{}, - Clusters: []models.Cluster{}, - Hosts: []models.Host{}, - Storages: []models.StorageConfig{}, - Deployments: []models.LokiDeployment{}, - Collectors: []models.Collector{}, - Logs: []models.DeploymentLog{}, - Settings: []models.SystemSetting{}, - LogSources: []models.LogSource{}, - AlertRules: []models.AlertRule{}, - AlertDeliveries: []models.AlertDelivery{}, - NotifyChannels: []models.NotifyChannel{}, - LogPolicyGroups: []models.LogPolicyGroup{}, - AuditLogs: []models.AuditLog{}, - DailyVolumeSnapshots: []models.DailyVolumeSnapshot{}, - SchemaVersion: CurrentSchemaVersion, - sequences: map[string]uint{ - "users": 0, - "clusters": 0, - "hosts": 0, - "storages": 0, - "deployments": 0, - "collectors": 0, - "logs": 0, - "logPolicyGroups": 0, - "logSources": 0, - "notifyChannels": 0, - "alertRules": 0, - "alertDeliveries": 0, - "auditLogs": 0, - "dailyVolumeSnapshots": 0, - }, - saveChan: make(chan saveRequest, 10), - stopChan: make(chan struct{}), - } - +// loadFromDisk 读取并解析数据文件,重建 ID 序列;文件损坏时备份留档绝不覆盖。 +func (d *JsonDB) loadFromDisk() { if data, err := os.ReadFile(d.dataFile); err == nil { - if err := json.Unmarshal(data, d); err == nil { + if err := json.Unmarshal(data, d); err != nil { + // 损坏的数据文件可能已被部分填充,绝不能让随后的空库落盘覆盖原文件: + // 先把原文件改名留档,再清回空库继续启动(seed 会重建 admin)。 + // 改名失败(权限/只读)时宁可退出,也不销毁可能可恢复的数据。 + backup := fmt.Sprintf("%s.corrupt-%s", d.dataFile, time.Now().Format("20060102-150405")) + if renameErr := os.Rename(d.dataFile, backup); renameErr != nil { + log.Fatalf("[db] 数据文件 %s 解析失败(%v)且无法备份(%v),为避免覆盖可恢复数据进程退出,请人工处理", d.dataFile, err, renameErr) + } + log.Printf("[ERROR] [db] 数据文件 %s 解析失败(%v),原文件已备份为 %s,以空库继续启动", d.dataFile, err, backup) + d.Users, d.Clusters, d.Hosts, d.Storages = nil, nil, nil, nil + d.Deployments, d.Collectors, d.Logs, d.Settings = nil, nil, nil, nil + d.LogSources, d.AlertRules, d.AlertDeliveries = nil, nil, nil + d.NotifyChannels, d.LogPolicyGroups, d.AuditLogs = nil, nil, nil + d.DailyVolumeSnapshots = nil + d.SchemaVersion = 0 + } else { d.sequences = map[string]uint{} for _, u := range d.Users { if u.ID > d.sequences["users"] { @@ -661,6 +636,51 @@ func InitDB() error { } } +} + +func InitDB() error { + cfg := config.GetConfig() + _ = os.MkdirAll(cfg.DataDir, 0700) + + d := &JsonDB{ + dataFile: cfg.DatabasePath, + Users: []models.User{}, + Clusters: []models.Cluster{}, + Hosts: []models.Host{}, + Storages: []models.StorageConfig{}, + Deployments: []models.LokiDeployment{}, + Collectors: []models.Collector{}, + Logs: []models.DeploymentLog{}, + Settings: []models.SystemSetting{}, + LogSources: []models.LogSource{}, + AlertRules: []models.AlertRule{}, + AlertDeliveries: []models.AlertDelivery{}, + NotifyChannels: []models.NotifyChannel{}, + LogPolicyGroups: []models.LogPolicyGroup{}, + AuditLogs: []models.AuditLog{}, + DailyVolumeSnapshots: []models.DailyVolumeSnapshot{}, + SchemaVersion: CurrentSchemaVersion, + sequences: map[string]uint{ + "users": 0, + "clusters": 0, + "hosts": 0, + "storages": 0, + "deployments": 0, + "collectors": 0, + "logs": 0, + "logPolicyGroups": 0, + "logSources": 0, + "notifyChannels": 0, + "alertRules": 0, + "alertDeliveries": 0, + "auditLogs": 0, + "dailyVolumeSnapshots": 0, + }, + saveChan: make(chan saveRequest, 10), + stopChan: make(chan struct{}), + } + + d.loadFromDisk() migratedLogAccess := migrateLogAccessToGroups(d) // 结构版本迁移(审计表、采集项等新字段的兼容处理) migratedSchema := d.migrateSchema() diff --git a/backend/repository/db_corrupt_test.go b/backend/repository/db_corrupt_test.go new file mode 100644 index 0000000..0c5a6a1 --- /dev/null +++ b/backend/repository/db_corrupt_test.go @@ -0,0 +1,42 @@ +package repository + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestInitDBCorruptedFileIsNotOverwritten 数据文件损坏时,原内容必须以 .corrupt-* 备份留档, +// 不允许被随后的空库落盘覆盖(可恢复数据销毁 = P0)。 +func TestInitDBCorruptedFileIsNotOverwritten(t *testing.T) { + dir := t.TempDir() + dataFile := filepath.Join(dir, "lokistack.json") + corrupt := "{not valid json" + if err := os.WriteFile(dataFile, []byte(corrupt), 0o600); err != nil { + t.Fatal(err) + } + d := &JsonDB{dataFile: dataFile} + d.loadFromDisk() + // 模拟启动后任意一次落盘:损坏原文若未被移走,这里就会覆盖它 + if err := d.doSaveInner(); err != nil { + t.Fatalf("空库落盘失败: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + backedUp := false + for _, e := range entries { + if strings.HasPrefix(e.Name(), "lokistack.json.corrupt-") { + content, readErr := os.ReadFile(filepath.Join(dir, e.Name())) + if readErr == nil && string(content) == corrupt { + backedUp = true + } + } + } + if !backedUp { + t.Fatalf("损坏原文未被完整备份留档: %v", entries) + } +} diff --git a/backend/services/collector.go b/backend/services/collector.go index a1169c2..7caafef 100644 --- a/backend/services/collector.go +++ b/backend/services/collector.go @@ -229,6 +229,17 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称") } + // 入库前统一校验采集项(含敏感路径拦截):Create 也要挡, + // 否则坏数据先落库、部署时才报错(Update 已有校验) + if err := models.ValidateScrapeJobs(req.ScrapeJobs); err != nil { + return nil, err + } + if strings.TrimSpace(req.LogPath) != "" { + if err := models.ValidateScrapeJobs([]models.ScrapeJob{{Name: "app", Paths: strings.Split(req.LogPath, ",")}}); err != nil { + return nil, err + } + } + refType := "cluster" if req.Type == "host-promtail" { refType = "host" diff --git a/backend/services/deployment.go b/backend/services/deployment.go index 23f9a24..67a1e4b 100644 --- a/backend/services/deployment.go +++ b/backend/services/deployment.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "regexp" "strconv" "strings" "sync" @@ -255,6 +256,9 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro db.Deployments[i].Description = v } if v, ok := updates["version"].(string); ok && v != "" { + if err := ValidateLokiVersion(v); err != nil { + return err + } db.Deployments[i].Version = v } if v, ok := updates["namespace"].(string); ok && v != "" { @@ -601,17 +605,20 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string { // ==================== 版本兼容性检查 ==================== // ValidateLokiVersion 检查 Loki 版本是否在支持列表中且满足最低版本要求 +// lokiVersionPattern 版本号会被拼进主机上的 shell 命令(下载 URL 等), +// 只放行纯数字三段式,从根上杜绝引号/命令替换等注入 +var lokiVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) + func ValidateLokiVersion(version string) error { if version == "" { return nil // 使用默认值,不报错 } - // 基本格式验证 - parts := strings.Split(version, ".") - if len(parts) < 2 { + if !lokiVersionPattern.MatchString(version) { return fmt.Errorf("版本号格式无效: %s,期望格式如 3.0.0", version) } + parts := strings.Split(version, ".") major, err := strconv.Atoi(parts[0]) if err != nil { return fmt.Errorf("无法解析主版本号: %s", version) diff --git a/backend/services/deployment_version_test.go b/backend/services/deployment_version_test.go new file mode 100644 index 0000000..ba74b85 --- /dev/null +++ b/backend/services/deployment_version_test.go @@ -0,0 +1,32 @@ +package services + +import "testing" + +// TestValidateLokiVersionRejectsShellMetacharacters 版本号会拼进主机 shell 命令(下载 URL 等), +// 必须只放行纯数字三段式,杜绝命令注入。 +func TestValidateLokiVersionRejectsShellMetacharacters(t *testing.T) { + bad := []string{ + `3.0.0'; id; echo '`, + `2.8.9$(id)`, + `3.0.0;id`, + `3.0.0 && wget evil.sh`, + "3.0.0\nid", + "3.0.0`id`", + `v3.0.0`, + `3.0`, + `3.0.0-beta`, + `3..0`, + ` 3.0.0`, + } + for _, version := range bad { + if err := ValidateLokiVersion(version); err == nil { + t.Errorf("ValidateLokiVersion(%q) 应被拒绝(shell 注入面)", version) + } + } + good := []string{"", "2.8.9", "3.0.0", "3.4.0"} + for _, version := range good { + if err := ValidateLokiVersion(version); err != nil { + t.Errorf("ValidateLokiVersion(%q) 不应报错: %v", version, err) + } + } +} diff --git a/backend/services/loki_cleanup_test.go b/backend/services/loki_cleanup_test.go new file mode 100644 index 0000000..5e8c7e5 --- /dev/null +++ b/backend/services/loki_cleanup_test.go @@ -0,0 +1,13 @@ +package services + +import "testing" + +// TestCleanupKindsNeverIncludePVC Stop/Restart 复用 CleanupLoki, +// 清理清单一旦加入 PVC 就会把 filesystem 存储的日志数据永久清空——用测试锁死这条红线。 +func TestCleanupKindsNeverIncludePVC(t *testing.T) { + for _, item := range lokiCleanupKinds { + if item[0] == "persistentvolumeclaim" { + t.Fatalf("清理清单包含 PVC(%s):停止部署不得删除日志数据,如需卸载清理请走显式的删除流程", item[1]) + } + } +} diff --git a/backend/services/loki_deployer.go b/backend/services/loki_deployer.go index ce7fdf9..9a31e1d 100644 --- a/backend/services/loki_deployer.go +++ b/backend/services/loki_deployer.go @@ -216,13 +216,22 @@ func (d *LokiDeployer) log(level, message, detail string) { // ==================== 清理 / 卸载 ==================== +// lokiCleanupKinds 停止/清理时删除的 Kubernetes 资源清单。 +// 刻意不包含 persistentvolumeclaim(loki-data):Stop/Restart 也会走到这里, +// 删 PVC 等于把 filesystem 存储的日志数据永久清空——数据只随显式卸载流程处理。 +var lokiCleanupKinds = [][2]string{ + {"statefulset", "loki"}, + {"service", "loki"}, + {"configmap", "loki-config"}, +} + func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { if deployment.Mode == "k8s-yaml" && deployment.Cluster != nil { ns := deployment.Namespace if ns == "" { ns = "log" } - for _, item := range [][2]string{{"statefulset", "loki"}, {"service", "loki"}, {"configmap", "loki-config"}, {"persistentvolumeclaim", "loki-data"}} { + for _, item := range lokiCleanupKinds { _ = deleteK8sResource(ctx, deployment.Cluster, item[0], ns, item[1]) } } diff --git a/backend/services/promtail_forbidden_path_test.go b/backend/services/promtail_forbidden_path_test.go new file mode 100644 index 0000000..aa70bda --- /dev/null +++ b/backend/services/promtail_forbidden_path_test.go @@ -0,0 +1,25 @@ +package services + +import "testing" + +// TestPromtailConfigForHostRejectsForbiddenDefaultLogPath 敏感路径防护不得只覆盖 ScrapeJobs 分支: +// 默认 LogPath 填 /etc/shadow、/root/.ssh/id_rsa 同样必须被拒绝(否则主机凭据被采进日志平台)。 +func TestPromtailConfigForHostRejectsForbiddenDefaultLogPath(t *testing.T) { + bad := []string{"/etc/shadow", "/root/.ssh/id_rsa", "/proc/self/mem", "/etc/ssh/ssh_host_rsa_key"} + for _, path := range bad { + _, err := PromtailConfigForHost(HostPromtailOptions{ + Hostname: "h1", + LogPath: path, + }) + if err == nil { + t.Errorf("默认 LogPath=%q 应被拒绝(敏感路径)", path) + } + } + // 正常业务路径不受影响 + if _, err := PromtailConfigForHost(HostPromtailOptions{ + Hostname: "h1", + LogPath: "/var/log/app/*.log", + }); err != nil { + t.Errorf("正常 LogPath 不应报错: %v", err) + } +} diff --git a/backend/services/promtail_host_config.go b/backend/services/promtail_host_config.go index 9bd1d62..3343c39 100644 --- a/backend/services/promtail_host_config.go +++ b/backend/services/promtail_host_config.go @@ -116,8 +116,12 @@ scrape_configs: return b.String(), nil } - // 默认 1:应用日志 + // 默认 1:应用日志(与 ScrapeJobs 分支同款敏感路径校验, + // 否则 /etc/shadow 之类可从 LogPath 绕过防线被采进日志平台) if path := strings.TrimSpace(o.LogPath); path != "" { + if err := models.ValidateScrapeJobs([]models.ScrapeJob{{Name: "app", Paths: splitPaths(path)}}); err != nil { + return "", err + } rendered, err := renderScrapeJob(models.ScrapeJob{ Name: "app", Paths: splitPaths(path), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 184a83d..b240345 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router' import { Toaster } from '@/components/ui/sonner' import { isLoggedIn, getUser } from '@/lib/auth-storage' import { routes, findRouteMeta } from '@/router/routes' +import ErrorBoundary from '@/components/ErrorBoundary' import { useTranslation } from 'react-i18next' const Layout = lazy(() => import('@/components/Layout')) @@ -65,7 +66,10 @@ const App = memo(function App() { path={route.path} element={ route.auth ? ( - {} + + {/* 页面级错误边界:单页崩溃/chunk 加载失败只影响当前页,侧栏与其它路由仍可用 */} + {} + ) : ( }> diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index 09c0211..eb712d0 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -88,12 +88,13 @@ export interface RestoreResult { export const systemApi = { // 审计 - auditList: (params: AuditListParams = {}) => + auditList: (params: AuditListParams = {}, opts?: { signal?: AbortSignal }) => http.get<{ list: AuditLog[]; total: number; page: number; pageSize: number }>( `/audit-logs${buildQuery(params as Record)}`, + opts, ), auditGet: (id: number) => http.get(`/audit-logs/${id}`), - auditStats: () => http.get('/audit-logs/stats'), + auditStats: (opts?: { signal?: AbortSignal }) => http.get('/audit-logs/stats', opts), // 自检 selfCheck: () => http.get('/system/self-check'), diff --git a/frontend/src/hooks/useDebouncedValue.ts b/frontend/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..3ee042a --- /dev/null +++ b/frontend/src/hooks/useDebouncedValue.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react' + +/** 防抖值:输入类过滤(如用户名逐键)先防抖再触发请求,避免每个键一组查询 */ +export function useDebouncedValue(value: T, delayMs = 300): T { + const [debounced, setDebounced] = useState(value) + useEffect(() => { + const timer = window.setTimeout(() => setDebounced(value), delayMs) + return () => window.clearTimeout(timer) + }, [value, delayMs]) + return debounced +} diff --git a/frontend/src/i18n/locales/en/wizard.ts b/frontend/src/i18n/locales/en/wizard.ts index adc0924..eea30c0 100644 --- a/frontend/src/i18n/locales/en/wizard.ts +++ b/frontend/src/i18n/locales/en/wizard.ts @@ -47,6 +47,7 @@ const wizard = { hint: 'Object storage (MinIO / RustFS / S3 / OSS) recommended; skip to use local disk.', }, deploy: { + autoTriggerFailed: 'Auto deploy failed; trigger it manually on the Deploy Loki page', meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}', endpointPending: 'endpoint available after deployment', openGrafana: 'Open log query ({url})', diff --git a/frontend/src/i18n/locales/zh/wizard.ts b/frontend/src/i18n/locales/zh/wizard.ts index d0d8b44..298c28e 100644 --- a/frontend/src/i18n/locales/zh/wizard.ts +++ b/frontend/src/i18n/locales/zh/wizard.ts @@ -48,6 +48,7 @@ const wizard = { hint: '推荐对象存储(MinIO / RustFS / S3 / OSS),更可靠;跳过则使用本地磁盘。', }, deploy: { + autoTriggerFailed: '自动触发部署失败,请到「部署 Loki」页手动触发', meta: '{{mode}} · 命名空间 {{namespace}} · {{endpoint}}', endpointPending: 'endpoint 待部署完成生成', openGrafana: '打开日志查询({{url}})', diff --git a/frontend/src/pages/audit/index.tsx b/frontend/src/pages/audit/index.tsx index ffff24e..8d7dc89 100644 --- a/frontend/src/pages/audit/index.tsx +++ b/frontend/src/pages/audit/index.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { History, ShieldAlert, Search, RefreshCw, Eye } from 'lucide-react' import '@/i18n/domains' @@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/LoadingSpinner' import { EmptyState } from '@/components/EmptyState' import { ErrorState } from '@/components/ErrorState' import { PaginationBar } from '@/components/PaginationBar' +import { useDebouncedValue } from '@/hooks/useDebouncedValue' import { systemApi, type AuditLog, type AuditStats } from '@/api/system' import { toast } from 'sonner' @@ -176,27 +177,41 @@ export default function AuditPage() { const [module, setModule] = useState('') const [statusFilter, setStatusFilter] = useState(0) + // 乱序/重复防护:AbortController 取消上一轮 + 版本号丢弃过期响应; + // username 是逐键输入,先防抖 300ms 再触发,避免每个键一组请求 + const abortRef = useRef(null) + const versionRef = useRef(0) const load = useCallback(async () => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + const version = ++versionRef.current setLoading(true) setError(null) try { const [res, st] = await Promise.all([ - systemApi.auditList({ page, pageSize: PAGE_SIZE, username, module, status: statusFilter }), - systemApi.auditStats(), + systemApi.auditList({ page, pageSize: PAGE_SIZE, username, module, status: statusFilter }, { signal: controller.signal }), + systemApi.auditStats({ signal: controller.signal }), ]) + if (version !== versionRef.current) return setList(Array.isArray(res.list) ? res.list : []) setTotal(res.total ?? 0) setStats(st ?? null) } catch (e) { + if (version !== versionRef.current || (e as Error).name === 'AbortError') return setError(e instanceof Error ? e.message : t('loadFailed')) } finally { - setLoading(false) + if (version === versionRef.current) setLoading(false) } }, [page, username, module, statusFilter, t]) + const debouncedUsername = useDebouncedValue(username, 300) + useEffect(() => { void load() - }, [load]) + return () => { abortRef.current?.abort() } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, debouncedUsername, module, statusFilter]) // 过滤条件变化时回到第一页,避免停在空白页 useEffect(() => { diff --git a/frontend/src/pages/deployments/components/DeploymentFormDialog.tsx b/frontend/src/pages/deployments/components/DeploymentFormDialog.tsx index 04ffa64..cbedf17 100644 --- a/frontend/src/pages/deployments/components/DeploymentFormDialog.tsx +++ b/frontend/src/pages/deployments/components/DeploymentFormDialog.tsx @@ -42,7 +42,7 @@ interface DeploymentFormDialogProps { open: boolean onOpenChange: (open: boolean) => void /** 创建成功回调(父级刷新列表并回到第 1 页) */ - onCreated: () => void + onCreated: (created?: { id: number; name: string }) => void clusters: Cluster[] hosts: Host[] storages: StorageConfig[] @@ -98,7 +98,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({ const onSubmit = form.handleSubmit(async (values) => { setSubmitting(true) try { - await deploymentApi.create({ + const created = await deploymentApi.create({ name: values.name, description: values.description, version: values.version, @@ -117,7 +117,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({ storageSize: values.storageSize, }) onOpenChange(false) - onCreated() + onCreated(created) } catch (err) { toast.error(err instanceof Error ? err.message : t('form.errCreate')) } finally { diff --git a/frontend/src/pages/wizard/components/StepDeploy.tsx b/frontend/src/pages/wizard/components/StepDeploy.tsx index 8c70c61..8af3c66 100644 --- a/frontend/src/pages/wizard/components/StepDeploy.tsx +++ b/frontend/src/pages/wizard/components/StepDeploy.tsx @@ -41,19 +41,26 @@ export const StepDeploy = memo(function StepDeploy({ const [open, setOpen] = useState(false) const [deploying, setDeploying] = useState(null) - const handleCreated = async () => { + const handleCreated = async (created?: { id: number; name: string }) => { onChanged() // 创建成功即自动触发部署,向导内完成闭环 try { - const list = await deploymentApi.list({ page: 1, pageSize: 5 }) - const latest = list.list[0] - if (latest && latest.status === 'pending') { - setDeploying(latest.id) - await deploymentApi.deploy(latest.id) - toast.info(t('deploy.deployTriggered', { name: latest.name })) + // 优先用创建接口返回的实例;拿不到时按「id 最大且 pending」匹配, + // 绝不盲取 list[0] —— 并发创建/排序变化会对错误实例发起真实部署 + let target = created + if (!target) { + const list = await deploymentApi.list({ page: 1, pageSize: 5 }) + const pending = list.list.filter(item => item.status === 'pending') + target = pending.sort((a, b) => b.id - a.id)[0] } - } catch { - // 列表/触发失败不打断向导,用户可到部署页手动触发 + if (target) { + setDeploying(target.id) + await deploymentApi.deploy(target.id) + toast.info(t('deploy.deployTriggered', { name: target.name })) + } + } catch (e) { + // 自动触发失败不打断向导,但要让用户知道需要去部署页手动触发 + toast.error(e instanceof Error ? e.message : t('deploy.autoTriggerFailed')) } finally { setDeploying(null) }