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 => (
+
+
+ {svc}
+ {t('coreEditor.api.alwaysOn', { defaultValue: 'always on' })}
+
+ ))}
+ {OPTIONAL_API_SERVICES.map(svc => (
+
+ toggle(svc, checked === true)} />
+ {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 => (
+
+
+ {svc}
+
+ {t('coreConfigModal.apiServiceAlwaysOn', { defaultValue: 'always on' })}
+
+
+ ))}
+ {OPTIONAL_API_SERVICES.map(svc => (
+
+ handleToggleApiService(svc, checked === true)}
+ disabled={!validation.isValid}
+ />
+ {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
+}