diff --git a/.gitignore b/.gitignore index 8e2d5f1bf..6b2347651 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,4 @@ deadlock-analysis.md # AI .AGENT graphify-out +superpowers/ \ No newline at end of file diff --git a/dashboard/package.json b/dashboard/package.json index ffa37da85..a18b1f27f 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -8,7 +8,8 @@ "preview": "vite preview", "gen:api": "orval", "wait-port": "wait-port http://localhost:$UVICORN_PORT/openapi.json", - "wait-port-gen-api": "bun run wait-port && bun run gen:api" + "wait-port-gen-api": "bun run wait-port && bun run gen:api", + "test": "node --test 'src/**/*.test.ts'" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 722b35950..8a0043f2c 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2218,6 +2218,7 @@ "balancers": "Balancers", "dns": "DNS", "bindings": "Bindings", + "api": "API", "advanced": "Advanced", "interface": "Interface" }, @@ -2229,7 +2230,12 @@ "routing": "Traffic rules and routing", "balancers": "Outbound load balancing setup", "dns": "DNS resolver and server config", - "bindings": "Inbound tag fallbacks and exclusions" + "bindings": "Inbound tag fallbacks and exclusions", + "api": "Xray gRPC API services" + }, + "api": { + "title": "API Services", + "hint": "gRPC API services exposed by this core. The node always enables the required services; enable optional ones as needed." }, "wg": { "interfaceBlurb": "WireGuard interface settings and key material for this core.", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 392654359..85df9c5c8 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -2133,6 +2133,7 @@ "balancers": "متعادل‌کننده‌ها", "dns": "DNS", "bindings": "اتصال‌ها", + "api": "API", "advanced": "پیشرفته", "interface": "رابط" }, @@ -2144,7 +2145,12 @@ "routing": "قوانین مسیریابی و ترافیک", "balancers": "استراتژی‌های متعادل‌سازی بار", "dns": "پیکربندی DNS و حل‌کننده", - "bindings": "پشتیبان و مستثنا برای برچسب‌های ورودی" + "bindings": "پشتیبان و مستثنا برای برچسب‌های ورودی", + "api": "سرویس‌های gRPC API مربوط به Xray" + }, + "api": { + "title": "سرویس‌های API", + "hint": "سرویس‌های gRPC API که این هسته ارائه می‌دهد. گره همیشه سرویس‌های موردنیاز را فعال می‌کند؛ موارد اختیاری را در صورت نیاز فعال کنید." }, "wg": { "interfaceBlurb": "تنظیمات رابط وایرگارد و مواد کلیدی این هسته.", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 0cec30c9e..456ccf5ad 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -2107,6 +2107,7 @@ "balancers": "Балансировщики", "dns": "DNS", "bindings": "Привязки", + "api": "API", "advanced": "Дополнительно", "interface": "Интерфейс" }, @@ -2118,7 +2119,12 @@ "routing": "Правила маршрутизации трафика", "balancers": "Стратегии балансировки нагрузки", "dns": "Настройки DNS и резолвера", - "bindings": "Резервные и исключённые теги входящих" + "bindings": "Резервные и исключённые теги входящих", + "api": "Сервисы gRPC API Xray" + }, + "api": { + "title": "Сервисы API", + "hint": "Сервисы gRPC API, предоставляемые этим ядром. Узел всегда включает обязательные сервисы; необязательные включайте по мере необходимости." }, "wg": { "interfaceBlurb": "Настройки интерфейса WireGuard и ключи для этого ядра.", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 1b5dc65f3..a17542a96 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2178,6 +2178,7 @@ "balancers": "负载均衡", "dns": "DNS", "bindings": "绑定", + "api": "API", "advanced": "高级", "interface": "接口" }, @@ -2189,7 +2190,12 @@ "routing": "路由规则与流量策略", "balancers": "负载均衡与调度策略", "dns": "DNS 解析与解析器", - "bindings": "入站标签回退与排除" + "bindings": "入站标签回退与排除", + "api": "Xray gRPC API 服务" + }, + "api": { + "title": "API 服务", + "hint": "此核心提供的 gRPC API 服务。节点始终启用必需的服务;可按需启用可选服务。" }, "wg": { "interfaceBlurb": "此核心的 WireGuard 接口与密钥设置。", diff --git a/dashboard/src/components/layout/page-header.tsx b/dashboard/src/components/layout/page-header.tsx index 87de4966b..79a71867c 100644 --- a/dashboard/src/components/layout/page-header.tsx +++ b/dashboard/src/components/layout/page-header.tsx @@ -10,7 +10,11 @@ import { useLocation } from 'react-router' interface PageHeaderProps { title: string + /** Fallback text when the `title` i18n key is missing (e.g. a stale cached locale bundle). */ + titleDefault?: string description?: string + /** Fallback text when the `description` i18n key is missing. */ + descriptionDefault?: string buttonText?: string onButtonClick?: () => void buttonIcon?: LucideIcon @@ -19,7 +23,7 @@ interface PageHeaderProps { className?: string } -export default function PageHeader({ title, description, buttonText, onButtonClick, buttonIcon: Icon = Plus, buttonTooltip, tutorialUrl, className }: PageHeaderProps) { +export default function PageHeader({ title, titleDefault, description, descriptionDefault, buttonText, onButtonClick, buttonIcon: Icon = Plus, buttonTooltip, tutorialUrl, className }: PageHeaderProps) { const { t } = useTranslation() const dir = useDirDetection() const location = useLocation() @@ -32,7 +36,7 @@ export default function PageHeader({ title, description, buttonText, onButtonCli
-

{t(title)}

+

{t(title, titleDefault ? { defaultValue: titleDefault } : undefined)}

@@ -52,7 +56,7 @@ export default function PageHeader({ title, description, buttonText, onButtonCli
- {description && {t(description)}} + {description && {t(description, descriptionDefault ? { defaultValue: descriptionDefault } : undefined)}}
{buttonText && onButtonClick && (
diff --git a/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx new file mode 100644 index 000000000..a4def7c1c --- /dev/null +++ b/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx @@ -0,0 +1,82 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { useCoreEditorStore } from '@/features/core-editor/state/core-editor-store' +import { findUnknownApiServices, getSelectedOptional, OPTIONAL_API_SERVICES, REQUIRED_API_SERVICES, setRawOptionalService } from '@/lib/xray-api-services' +import type { Profile } from '@pasarguard/xray-config-kit' +import { Webhook } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +// The structured editor keeps unmodeled top-level keys (incl. `api`) on +// `profile.raw.topLevel` (see UNMODELED_TOP_LEVEL_KEYS_TO_PRESERVE in xray-adapter.ts), +// which is structurally a config object for the api-services helpers. +function readTopLevel(profile: Profile): Record { + return (profile.raw?.topLevel ?? {}) as Record +} + +function withApiService(profile: Profile, service: string, enabled: boolean): Profile { + return { ...profile, raw: setRawOptionalService(profile.raw, service, enabled) } as Profile +} + +export function XrayApiSection() { + const { t } = useTranslation() + const profile = useCoreEditorStore(s => s.xrayProfile) + const updateXrayProfile = useCoreEditorStore(s => s.updateXrayProfile) + + const { selected, unknown } = useMemo(() => { + const topLevel = profile ? readTopLevel(profile) : {} + return { + selected: getSelectedOptional(topLevel), + unknown: findUnknownApiServices(topLevel), + } + }, [profile]) + + if (!profile) return null + + const toggle = (service: string, enabled: boolean) => { + updateXrayProfile(p => withApiService(p, service, enabled)) + } + + return ( +
+ + + + + {t('coreEditor.api.title', { defaultValue: 'API Services' })} + + + +

+ {t('coreEditor.api.hint', { + defaultValue: 'gRPC API services exposed by this core. The node always enables the required services; enable optional ones as needed.', + })} +

+
+ {REQUIRED_API_SERVICES.map(svc => ( + + ))} + {OPTIONAL_API_SERVICES.map(svc => ( + + ))} +
+ {unknown.length > 0 && ( +

+ {t('coreEditor.api.unknown', { + defaultValue: 'Unrecognized API service(s): {{names}}. Fix them in the Advanced tab.', + names: unknown.join(', '), + })} +

+ )} +
+
+
+ ) +} diff --git a/dashboard/src/features/core-editor/components/xray/xray-core-editor.tsx b/dashboard/src/features/core-editor/components/xray/xray-core-editor.tsx index c797929a3..547c1de92 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-core-editor.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-core-editor.tsx @@ -1,5 +1,6 @@ import { XrayInboundTagSelectors } from '@/features/core-editor/components/shared/xray-inbound-tag-selectors' import { XrayAdvancedSection } from '@/features/core-editor/components/xray/xray-advanced-section' +import { XrayApiSection } from '@/features/core-editor/components/xray/xray-api-section' import { XrayBalancersSection } from '@/features/core-editor/components/xray/xray-balancers-section' import { XrayDnsSection } from '@/features/core-editor/components/xray/xray-dns-section' import { XrayInboundsSection } from '@/features/core-editor/components/xray/xray-inbounds-section' @@ -31,6 +32,7 @@ export function XrayCoreEditor({ headerAddPulse, headerAddEpoch }: XrayCoreEdito {section === 'balancers' && } {section === 'dns' && } {section === 'bindings' && } + {section === 'api' && } {section === 'advanced' && }
) diff --git a/dashboard/src/features/core-editor/kit/core-section-nav.ts b/dashboard/src/features/core-editor/kit/core-section-nav.ts index bb357d9ae..0090780bc 100644 --- a/dashboard/src/features/core-editor/kit/core-section-nav.ts +++ b/dashboard/src/features/core-editor/kit/core-section-nav.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react' -import { ArrowDownToLine, ArrowUpFromLine, Braces, Cable, Globe, Link2, Scale, Waypoints } from 'lucide-react' +import { ArrowDownToLine, ArrowUpFromLine, Braces, Cable, Globe, Link2, Scale, Waypoints, Webhook } from 'lucide-react' import type { WgCoreSection, XrayCoreSection } from '@/features/core-editor/state/core-editor-store' export type XraySectionNavItem = { @@ -23,6 +23,7 @@ export const XRAY_CORE_SECTION_NAV: XraySectionNavItem[] = [ { id: 'balancers', labelKey: 'coreEditor.section.balancers', defaultLabel: 'Balancers', icon: Scale }, { id: 'dns', labelKey: 'coreEditor.section.dns', defaultLabel: 'DNS', icon: Globe }, { id: 'bindings', labelKey: 'coreEditor.section.bindings', defaultLabel: 'Bindings', icon: Link2 }, + { id: 'api', labelKey: 'coreEditor.section.api', defaultLabel: 'API', icon: Webhook }, { id: 'advanced', labelKey: 'coreEditor.section.advanced', defaultLabel: 'Advanced', icon: Braces }, ] diff --git a/dashboard/src/features/core-editor/routes/core-editor-page.tsx b/dashboard/src/features/core-editor/routes/core-editor-page.tsx index aeeabb83e..186c6a55d 100644 --- a/dashboard/src/features/core-editor/routes/core-editor-page.tsx +++ b/dashboard/src/features/core-editor/routes/core-editor-page.tsx @@ -31,6 +31,15 @@ import useDirDetection from '@/hooks/use-dir-detection' type LoadingCoreKind = 'xray' | 'wg' +type SectionHeaderConfig = { + title: string + /** Fallback shown when the `title` key is missing from a stale cached locale bundle. */ + titleDefault?: string + description?: string + descriptionDefault?: string + buttonText?: string +} + function loadingSectionPageHeaderProps(coreKind?: LoadingCoreKind): { title: string; description?: string } { if (coreKind === 'wg') { return { @@ -461,10 +470,10 @@ export default function CoreEditorPage() { ) - const sectionHeaderConfig = useMemo(() => { + const sectionHeaderConfig: SectionHeaderConfig | undefined = useMemo(() => { if (kind === 'wg') { const section = activeSection as WgCoreSection - return { + const map: Record = { interface: { title: 'coreEditor.section.interface', description: 'coreEditor.sectionDesc.wgInterface', @@ -473,11 +482,12 @@ export default function CoreEditorPage() { title: 'coreEditor.section.advanced', description: 'coreEditor.sectionDesc.advanced', }, - }[section] + } + return map[section] } const section = activeSection as XrayCoreSection - return { + const map: Record = { inbounds: { title: 'coreEditor.section.inbounds', description: 'coreEditor.sectionDesc.inbounds', @@ -506,11 +516,18 @@ export default function CoreEditorPage() { title: 'coreEditor.section.bindings', description: 'coreEditor.sectionDesc.bindings', }, + api: { + title: 'coreEditor.section.api', + titleDefault: 'API', + description: 'coreEditor.sectionDesc.api', + descriptionDefault: 'Xray gRPC API services', + }, advanced: { title: 'coreEditor.section.advanced', description: 'coreEditor.sectionDesc.advanced', }, - }[section] + } + return map[section] }, [kind, activeSection]) if (!isNew && validId && isLoading) { @@ -559,7 +576,9 @@ export default function CoreEditorPage() { sectionHeaderConfig ? ( setHeaderAddPulse(p => ({ target: String(activeSection), n: p.n + 1 })) : undefined} diff --git a/dashboard/src/features/core-editor/state/core-editor-store.ts b/dashboard/src/features/core-editor/state/core-editor-store.ts index ca149bb3e..abb526666 100644 --- a/dashboard/src/features/core-editor/state/core-editor-store.ts +++ b/dashboard/src/features/core-editor/state/core-editor-store.ts @@ -7,7 +7,7 @@ import { apiCoreTypeToKind } from '../kit/core-kind' import { createNewXrayProfile, importRawToProfile, profileToPersistedConfig } from '../kit/xray-adapter' import { createNewWireGuardDraft, draftToPersistedConfig, wireGuardConfigToDraft } from '../kit/wireguard-adapter' -export type XrayCoreSection = 'bindings' | 'inbounds' | 'outbounds' | 'routing' | 'balancers' | 'dns' | 'advanced' +export type XrayCoreSection = 'bindings' | 'inbounds' | 'outbounds' | 'routing' | 'balancers' | 'dns' | 'api' | 'advanced' export type WgCoreSection = 'interface' | 'advanced' diff --git a/dashboard/src/features/nodes/dialogs/core-config-modal.tsx b/dashboard/src/features/nodes/dialogs/core-config-modal.tsx index 2c8660749..f9a50894f 100644 --- a/dashboard/src/features/nodes/dialogs/core-config-modal.tsx +++ b/dashboard/src/features/nodes/dialogs/core-config-modal.tsx @@ -21,6 +21,7 @@ import { type VlessBuilderOptions, } from '@/lib/xray-generation' import { cn } from '@/lib/utils' +import { findUnknownApiServices, getSelectedOptional, OPTIONAL_API_SERVICES, REQUIRED_API_SERVICES, setOptionalService } from '@/lib/xray-api-services' import { useCreateCoreConfig, useModifyCoreConfig } from '@/service/api' import { isEmptyObject } from '@/utils/isEmptyObject.ts' import { generateMldsa65 } from '@/utils/mldsa65' @@ -30,7 +31,7 @@ import { encodeURLSafe } from '@stablelib/base64' import { generateKeyPair } from '@stablelib/x25519' import { debounce } from 'es-toolkit' import { Sparkles, Pencil, Cpu } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import type { FieldErrors, UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -147,6 +148,45 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit [validateJsonContent], ) + // Optional Xray API services derived from the config JSON (the JSON is the source of truth). + const watchedConfig = form.watch('config') + const { selectedApiServices, unknownApiServices } = useMemo(() => { + let parsed: unknown = null + try { + parsed = JSON.parse(watchedConfig || '{}') + } catch { + parsed = null + } + return { + selectedApiServices: getSelectedOptional(parsed), + unknownApiServices: findUnknownApiServices(parsed), + } + }, [watchedConfig]) + + const handleToggleApiService = useCallback( + (service: string, enabled: boolean) => { + let parsed: Record + try { + parsed = JSON.parse(form.getValues('config') || '{}') as Record + } catch { + return + } + const nextJson = JSON.stringify(setOptionalService(parsed, service, enabled), null, 2) + form.setValue('config', nextJson, { shouldDirty: true, shouldValidate: true }) + // ponytail: re-serializing pretty-prints the whole config (2-space) and normalizes + // number literals — accepted per the "edit api.services in the JSON" design; + // setOptionalService preserves all other keys. + validateJsonContent(nextJson) + }, + [form, validateJsonContent], + ) + + const apiServicesUnknownMessage = (names: string[]) => + t('coreConfigModal.apiServicesUnknown', { + defaultValue: 'Unrecognized API service(s): {{names}}. Remove or fix them to save.', + names: names.join(', '), + }) + // Debounce config changes to improve performance const debouncedConfigChange = useCallback( debounce((value: string) => { @@ -325,6 +365,14 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit return } + const unknownServices = findUnknownApiServices(configObj) + if (unknownServices.length > 0) { + const message = apiServicesUnknownMessage(unknownServices) + form.setError('config', { type: 'manual', message }) + toast.error(message) + return + } + const backendType = values.type ?? 'xray' const fallbackTags = backendType !== 'wg' ? values.fallback_id || [] : [] const excludeInboundTags = backendType !== 'wg' ? values.excluded_inbound_ids || [] : [] @@ -968,6 +1016,36 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit )} /> + {/* API services: opt into optional Xray API services. + The node always injects the required ones regardless. */} +
+ {t('coreConfigModal.apiServices', { defaultValue: 'API Services' })} +
+ {REQUIRED_API_SERVICES.map(svc => ( + + ))} + {OPTIONAL_API_SERVICES.map(svc => ( + + ))} + {unknownApiServices.length > 0 && ( +

{apiServicesUnknownMessage(unknownApiServices)}

+ )} +
+
+ {/* Enhanced TabsList with Text Overflow */} @@ -1127,7 +1205,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit 0 || createCoreMutation.isPending || modifyCoreMutation.isPending || form.formState.isSubmitting} isLoading={createCoreMutation.isPending || modifyCoreMutation.isPending} loadingText={editingCore ? t('modifying') : t('creating')} > diff --git a/dashboard/src/lib/xray-api-services.test.ts b/dashboard/src/lib/xray-api-services.test.ts new file mode 100644 index 000000000..0f7abdd3d --- /dev/null +++ b/dashboard/src/lib/xray-api-services.test.ts @@ -0,0 +1,126 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + CANONICAL_API_SERVICES, + findUnknownApiServices, + getSelectedOptional, + OPTIONAL_API_SERVICES, + REQUIRED_API_SERVICES, + setOptionalService, + setRawOptionalService, +} from './xray-api-services.ts' + +test('getSelectedOptional: no api yields empty', () => { + assert.deepEqual(getSelectedOptional({}), []) +}) + +test('getSelectedOptional: reads optional services case-insensitively', () => { + assert.deepEqual(getSelectedOptional({ api: { services: ['routingservice'] } }), ['RoutingService']) +}) + +test('getSelectedOptional: ignores required and unknown services', () => { + assert.deepEqual(getSelectedOptional({ api: { services: ['HandlerService', 'Foo'] } }), []) +}) + +test('getSelectedOptional: non-object input is safe', () => { + assert.deepEqual(getSelectedOptional(null), []) +}) + +test('findUnknownApiServices: flags unrecognized names, preserving order', () => { + assert.deepEqual( + findUnknownApiServices({ api: { services: ['RoutingService', 'Foo', 'RoutigService'] } }), + ['Foo', 'RoutigService'], + ) +}) + +test('findUnknownApiServices: known names (any case) are not flagged', () => { + assert.deepEqual(findUnknownApiServices({ api: { services: ['routingservice', 'StatsService'] } }), []) +}) + +test('setOptionalService: enabling creates api.services', () => { + assert.deepEqual(setOptionalService({}, 'RoutingService', true), { api: { services: ['RoutingService'] } }) +}) + +test('setOptionalService: enabling dedupes case-insensitively to canonical', () => { + assert.deepEqual( + setOptionalService({ api: { services: ['routingservice'] } }, 'RoutingService', true), + { api: { services: ['RoutingService'] } }, + ) +}) + +test('setOptionalService: disabling removes the service and drops an empty api', () => { + assert.deepEqual(setOptionalService({ api: { services: ['RoutingService'] } }, 'RoutingService', false), {}) +}) + +test('setOptionalService: disabling preserves other api keys', () => { + assert.deepEqual( + setOptionalService({ api: { services: ['RoutingService'], tag: 'API' } }, 'RoutingService', false), + { api: { tag: 'API' } }, + ) +}) + +test('setOptionalService: preserves other config keys and untouched (unknown) services', () => { + assert.deepEqual( + setOptionalService({ inbounds: [], api: { services: ['Foo'] } }, 'RoutingService', true), + { inbounds: [], api: { services: ['Foo', 'RoutingService'] } }, + ) +}) + +test('setOptionalService: unknown service name is a no-op', () => { + assert.deepEqual(setOptionalService({ api: { services: [] } }, 'Nope', true), { api: { services: [] } }) +}) + +test('allowlist constants are exactly the expected canonical names (drift guard)', () => { + assert.deepEqual([...REQUIRED_API_SERVICES], ['HandlerService', 'LoggerService', 'StatsService']) + assert.deepEqual([...OPTIONAL_API_SERVICES], ['RoutingService', 'ObservatoryService', 'ReflectionService']) +}) + +test('CANONICAL_API_SERVICES maps exactly REQUIRED ∪ OPTIONAL (both directions)', () => { + const all = [...REQUIRED_API_SERVICES, ...OPTIONAL_API_SERVICES] + for (const name of all) { + assert.equal(CANONICAL_API_SERVICES[name.toLowerCase()], name) + } + assert.deepEqual( + Object.keys(CANONICAL_API_SERVICES).sort(), + all.map(n => n.toLowerCase()).sort(), + ) +}) + +test('getSelectedOptional: trims and dedupes whitespace/case variants', () => { + assert.deepEqual(getSelectedOptional({ api: { services: [' routingservice ', 'RoutingService'] } }), ['RoutingService']) +}) + +// Regression: the kit re-emits `api` from `raw.source`, so a removal expressed only +// on `raw.topLevel` is undone (Save never enables). setRawOptionalService must edit both. +test('setRawOptionalService: disabling the last service clears api from BOTH source and topLevel', () => { + const raw = { + source: { api: { services: ['RoutingService'] }, log: { loglevel: 'warning' } }, + topLevel: { api: { services: ['RoutingService'] } }, + } + assert.deepEqual(setRawOptionalService(raw, 'RoutingService', false), { + source: { log: { loglevel: 'warning' } }, + topLevel: {}, + }) +}) + +test('setRawOptionalService: enabling adds the service to BOTH source and topLevel', () => { + assert.deepEqual(setRawOptionalService({ source: { log: {} }, topLevel: {} }, 'RoutingService', true), { + source: { log: {}, api: { services: ['RoutingService'] } }, + topLevel: { api: { services: ['RoutingService'] } }, + }) +}) + +test('setRawOptionalService: preserves other raw keys and only touches api', () => { + const raw = { extra: 1, source: { api: { services: ['RoutingService', 'ObservatoryService'] }, routing: { rules: [] } }, topLevel: { api: { services: ['RoutingService', 'ObservatoryService'] }, policy: {} } } + assert.deepEqual(setRawOptionalService(raw, 'RoutingService', false), { + extra: 1, + source: { api: { services: ['ObservatoryService'] }, routing: { rules: [] } }, + topLevel: { api: { services: ['ObservatoryService'] }, policy: {} }, + }) +}) + +test('setRawOptionalService: missing source updates only topLevel; non-object raw is safe', () => { + assert.deepEqual(setRawOptionalService({ topLevel: {} }, 'RoutingService', true), { topLevel: { api: { services: ['RoutingService'] } } }) + assert.deepEqual(setRawOptionalService(undefined, 'RoutingService', true), { topLevel: { api: { services: ['RoutingService'] } } }) +}) diff --git a/dashboard/src/lib/xray-api-services.ts b/dashboard/src/lib/xray-api-services.ts new file mode 100644 index 000000000..9fcb1dccd --- /dev/null +++ b/dashboard/src/lib/xray-api-services.ts @@ -0,0 +1,118 @@ +// Always-injected by the node (backend/xray/config.go: requiredAPIServices). +// Shown locked in the UI; never written to the config by the panel. +export const REQUIRED_API_SERVICES = ['HandlerService', 'LoggerService', 'StatsService'] as const + +// Optional services an admin may opt into (node allowlist minus the required ones). +export const OPTIONAL_API_SERVICES = ['RoutingService', 'ObservatoryService', 'ReflectionService'] as const + +// Lowercase -> canonical name. Mirrors canonicalAPIServices in the node's +// backend/xray/config.go. Ceiling: there is no automatic drift detection — this +// map must be synced by hand when xray-core changes its API services. The unit +// test pins the expected names so a stale edit fails loudly. +export const CANONICAL_API_SERVICES: Record = { + reflectionservice: 'ReflectionService', + handlerservice: 'HandlerService', + loggerservice: 'LoggerService', + statsservice: 'StatsService', + observatoryservice: 'ObservatoryService', + routingservice: 'RoutingService', +} + +function readApiServices(config: unknown): string[] { + if (typeof config !== 'object' || config === null) return [] + const api = (config as Record).api + if (typeof api !== 'object' || api === null) return [] + const services = (api as Record).services + if (!Array.isArray(services)) return [] + return services.filter((s): s is string => typeof s === 'string') +} + +// Optional services currently present in the config, canonicalized and deduped. +export function getSelectedOptional(config: unknown): string[] { + const optional = new Set(OPTIONAL_API_SERVICES.map(s => s.toLowerCase())) + const selected: string[] = [] + const seen = new Set() + for (const raw of readApiServices(config)) { + const key = raw.trim().toLowerCase() + if (optional.has(key) && !seen.has(key)) { + seen.add(key) + selected.push(CANONICAL_API_SERVICES[key]) + } + } + return selected +} + +// Service names present in the config that are not in the node allowlist. +export function findUnknownApiServices(config: unknown): string[] { + const unknown: string[] = [] + const seen = new Set() + for (const raw of readApiServices(config)) { + const key = raw.trim().toLowerCase() + if (key === '' || seen.has(key)) continue + seen.add(key) + if (!CANONICAL_API_SERVICES[key]) unknown.push(raw) + } + return unknown +} + +// Return a new config with `service` added or removed from api.services. +// Only `api.services` is touched; other api/config keys are preserved. An empty +// services array is removed, and an api object with no remaining keys is dropped. +export function setOptionalService( + config: Record, + service: string, + enabled: boolean, +): Record { + const key = service.trim().toLowerCase() + const canonical = CANONICAL_API_SERVICES[key] + if (!canonical) return config + + const next: Record = { ...config } + const api: Record = + typeof next.api === 'object' && next.api !== null ? { ...(next.api as Record) } : {} + + const current = Array.isArray(api.services) + ? (api.services as unknown[]).filter((s): s is string => typeof s === 'string') + : [] + + const filtered = current.filter(s => s.trim().toLowerCase() !== key) + if (enabled) filtered.push(canonical) + + if (filtered.length > 0) { + api.services = filtered + next.api = api + return next + } + + delete api.services + if (Object.keys(api).length === 0) { + delete next.api + } else { + next.api = api + } + return next +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Toggle an optional service on a structured-editor profile's `raw`. +// +// The structured editor holds two copies of the unmodeled `api` section: +// - `raw.source` — the original config the kit re-emits verbatim on compile, and +// - `raw.topLevel` — the adapter's override, which only wins when the `api` key is +// present (see applyUnmodeledTopLevelSectionsToCompiledConfig in xray-adapter.ts). +// Editing only `topLevel` desyncs them: removing the last optional service deletes +// `topLevel.api`, the override vanishes, and the original `api` resurfaces from +// `raw.source` — so the change is silently dropped (no diff -> Save stays disabled). +// Apply the same edit to both so toggles always persist and round-trip cleanly. +export function setRawOptionalService(raw: unknown, service: string, enabled: boolean): Record { + const base = isRecord(raw) ? raw : {} + const topLevel = isRecord(base.topLevel) ? base.topLevel : {} + const next: Record = { ...base, topLevel: setOptionalService(topLevel, service, enabled) } + if (isRecord(base.source)) { + next.source = setOptionalService(base.source, service, enabled) + } + return next +}