docs: vendor the documentation site into the monorepo

Fold the standalone 3x-ui-docs project (Next.js 16 + Fumadocs, deployed to
docs.sanaei.dev) into docs/ so the panel and its documentation share a single
source of truth, the way sing-box keeps its docs in-tree. The old repo becomes
redundant and can be retired.

- Import the full site under docs/ (app, components, content, lib, public,
  scripts, config). The self-contained pnpm project sits alongside the existing
  engineering notes with no filename collisions.
- Re-point "Edit on GitHub" links from MHSanaei/3x-ui-docs to this repo's
  docs/content/docs path (docs/lib/shared.ts, docs/app/.../page.tsx).
- Add docs-ci.yml and docs-deploy.yml under .github/workflows/, scoped to
  docs/** and run with working-directory: docs, since GitHub only runs
  workflows from the repo-root .github/. deploy-static.yml's GitHub Pages
  publish (CNAME docs.sanaei.dev) carries over unchanged.

Follow-up (outside this commit): attach the docs.sanaei.dev custom domain to
this repository's Pages (or set the Vercel project's root directory to docs),
confirm the site is live from the monorepo, then delete MHSanaei/3x-ui-docs.
This commit is contained in:
MHSanaei
2026-07-07 23:07:14 +02:00
parent 2c49dbf54e
commit 9b91f0f42e
283 changed files with 44179 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { twMerge as cn } from 'tailwind-merge';
+72
View File
@@ -0,0 +1,72 @@
import { productRepo } from './shared';
export interface GitHubStats {
stars: number;
forks: number;
latestVersion: string;
}
// Real, recent numbers used as a fallback when the GitHub API is unavailable
// at build time (offline CI, rate limit). Update periodically.
const FALLBACK: GitHubStats = {
stars: 41500,
forks: 7700,
latestVersion: 'v3.x',
};
const API_BASE = `https://api.github.com/repos/${productRepo.user}/${productRepo.repo}`;
/**
* Fetch live repo stats. Runs both at build time (Node) and in the browser —
* api.github.com sends CORS headers, and the unauthenticated limit (60 req/h
* per client IP) is plenty for one call per landing-page visit.
* Returns null on any failure so callers keep the numbers they already have;
* `latestVersion` is '' when the release lookup alone fails.
*/
export async function fetchGitHubStats(init?: RequestInit): Promise<GitHubStats | null> {
try {
const [repoRes, releaseRes] = await Promise.all([
fetch(API_BASE, init),
fetch(`${API_BASE}/releases/latest`, init),
]);
if (!repoRes.ok) return null;
const repo = (await repoRes.json()) as { stargazers_count?: number; forks_count?: number };
if (typeof repo.stargazers_count !== 'number' || typeof repo.forks_count !== 'number') {
return null;
}
let latestVersion = '';
if (releaseRes.ok) {
const release = (await releaseRes.json()) as { tag_name?: string };
if (release.tag_name) latestVersion = release.tag_name;
}
return { stars: repo.stargazers_count, forks: repo.forks_count, latestVersion };
} catch {
return null;
}
}
/**
* Build-time stats used as the initial render (no layout shift, works without
* JS). The client refreshes them via fetchGitHubStats() after hydration.
* Always resolves; on any error it returns the hardcoded fallback.
*/
export async function getGitHubStats(): Promise<GitHubStats> {
const headers: Record<string, string> = {
'User-Agent': '3x-ui-docs',
Accept: 'application/vnd.github+json',
};
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const live = await fetchGitHubStats({ headers, next: { revalidate: 3600 } });
if (!live) return FALLBACK;
return { ...live, latestVersion: live.latestVersion || FALLBACK.latestVersion };
}
/** Compact display, e.g. 41523 -> "41.5k". */
export function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
+11
View File
@@ -0,0 +1,11 @@
import { defineI18nUI } from 'fumadocs-ui/i18n';
import { i18n } from './i18n';
// UI-side i18n config: provides the language-switcher display names and the
// `provider(locale)` props consumed by <RootProvider i18n={...}>.
export const { provider } = defineI18nUI(i18n, {
en: { displayName: 'English' },
fa: { displayName: 'فارسی' },
ru: { displayName: 'Русский' },
zh: { displayName: '中文' },
});
+29
View File
@@ -0,0 +1,29 @@
import { defineI18n } from 'fumadocs-core/i18n';
// Directory-based i18n: content lives in `content/docs/{locale}/...`.
// `fallbackLanguage` defaults to `defaultLanguage` ('en'), so untranslated
// pages transparently serve English instead of 404ing.
export const i18n = defineI18n({
defaultLanguage: 'en',
languages: ['en', 'fa', 'ru', 'zh'],
parser: 'dir',
// English keeps canonical `/docs/...`; other locales are prefixed `/fa/...`.
hideLocale: 'default-locale',
});
export type Locale = (typeof i18n.languages)[number];
// Display names for the language switcher (in their own script).
export const locales: { locale: Locale; name: string }[] = [
{ locale: 'en', name: 'English' },
{ locale: 'fa', name: 'فارسی' },
{ locale: 'ru', name: 'Русский' },
{ locale: 'zh', name: '中文' },
];
// Right-to-left locales (Persian). Drives `<html dir>` and `rtl:` variants.
const rtlLocales = new Set<string>(['fa']);
export function localeDirection(locale: string): 'rtl' | 'ltr' {
return rtlLocales.has(locale) ? 'rtl' : 'ltr';
}
+60
View File
@@ -0,0 +1,60 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { Heart } from 'lucide-react';
import { Logo } from '@/components/logo';
import { TelegramIcon } from '@/components/icons';
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl } from './shared';
import { getSiteMessages } from './site-i18n';
// Build locale-aware shared layout options. With `hideLocale: 'default-locale'`,
// English URLs have no prefix while other locales are prefixed (`/fa`, `/ru`, `/zh`).
export function baseOptions(lang: string): BaseLayoutProps {
const prefix = lang === 'en' ? '' : `/${lang}`;
const m = getSiteMessages(lang);
return {
nav: {
title: (
<span className="inline-flex items-center gap-2 font-semibold">
<Logo className="h-6" />
{appName}
</span>
),
url: `${prefix}/`,
},
githubUrl: productRepoUrl,
links: [
{
text: m.documentation,
url: `${prefix}/docs`,
active: 'nested-url',
},
{
type: 'icon',
label: `Telegram channel (@${telegramChannel})`,
icon: <TelegramIcon />,
text: 'Telegram',
url: telegramChannelUrl,
external: true,
},
// Compact heart icon in the top nav bars (home + docs).
{
type: 'icon',
on: 'nav',
label: m.donate,
icon: <Heart />,
text: m.donate,
url: donateUrl,
external: true,
},
// Prominent labelled entry in the docs sidebar / mobile menu.
{
type: 'main',
on: 'menu',
icon: <Heart />,
text: m.donate,
url: donateUrl,
external: true,
},
],
};
}
+7
View File
@@ -0,0 +1,7 @@
import { createOpenAPI } from 'fumadocs-openapi/server';
// The 3x-ui OpenAPI spec is committed at public/openapi.json (synced from
// the upstream repo's frontend/public/openapi.json).
export const openapi = createOpenAPI({
input: ['./public/openapi.json'],
});
+40
View File
@@ -0,0 +1,40 @@
export const appName = '3x-ui';
export const appTagline = 'Advanced web panel for managing Xray-core servers';
export const docsRoute = '/docs';
export const docsImageRoute = '/og/docs';
export const docsContentRoute = '/llms.mdx/docs';
// The 3x-ui product repository — used for the navbar GitHub link,
// build-time star/release stats, and install commands.
export const productRepo = {
user: 'MHSanaei',
repo: '3x-ui',
branch: 'main',
};
// Where these docs live in the 3x-ui monorepo — used for "Edit on GitHub" links.
export const gitConfig = {
user: 'MHSanaei',
repo: '3x-ui',
branch: 'main',
docsDir: 'docs/content/docs',
};
export const productRepoUrl = `https://github.com/${productRepo.user}/${productRepo.repo}`;
// AI-generated interactive wiki of the 3x-ui codebase.
export const deepWikiUrl = `https://deepwiki.com/${productRepo.user}/${productRepo.repo}`;
// Official 3x-ui community channel on Telegram (announcements & support).
export const telegramChannel = 'XrayUI';
export const telegramChannelUrl = `https://t.me/${telegramChannel}`;
// Support the developer — donation page with funding goals/targets.
export const donateUrl = 'https://donate.sanaei.dev/';
// Public site origin, used for metadataBase / canonical URLs / OG images.
// Defaults to the production domain, so the env var is optional. Use `||` (not
// `??`) so an empty string — e.g. an unset `${{ vars.NEXT_PUBLIC_SITE_URL }}`
// in CI — also falls back instead of shipping a blank origin.
export const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://docs.sanaei.dev';
+236
View File
@@ -0,0 +1,236 @@
import type { Locale } from './i18n';
// UI strings for the marketing chrome (landing page hero/features/footer + the
// shared navbar labels). The docs *pages* are translated as MDX under
// content/docs/{locale}; this covers the React-rendered home page and nav that
// can't live in MDX. English is the source; fa/ru/zh fall back to en.
//
// Convention matches the docs: translate prose only — product/protocol names
// (3x-ui, Xray, VLESS, REALITY, x25519, Docker, REST API, …) stay in Latin.
export interface SiteMessages {
tagline: string;
getStarted: string;
viewOnGitHub: string;
documentation: string;
donate: string;
docs: string;
stars: string;
forks: string;
latest: string;
copyCommand: string;
copied: string;
featuresHeading: string;
featuresSubtitle: string;
// Order matches the icon list in components/home/features.tsx.
features: { title: string; description: string }[];
// Footer license line: `{app} — {before}<a>GPL-3.0</a>{after}` (spacing baked in).
licenseBefore: string;
licenseAfter: string;
}
const en: SiteMessages = {
tagline: 'Advanced web panel for managing Xray-core servers',
getStarted: 'Get started',
viewOnGitHub: 'View on GitHub',
documentation: 'Documentation',
donate: 'Donate',
docs: 'Docs',
stars: 'stars',
forks: 'forks',
latest: 'latest',
copyCommand: 'Copy install command',
copied: 'Copied',
featuresHeading: 'Everything you need to run Xray',
featuresSubtitle:
'A modern, fast control panel for Xray-core — built for operators who want power without the command-line grind.',
features: [
{
title: 'Every major protocol',
description:
'VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, SOCKS, HTTP and Dokodemo-door — managed from one panel.',
},
{
title: 'REALITY & XTLS-Vision',
description:
'First-class support for VLESS + REALITY with x25519 keys, short IDs and the xtls-rprx-vision flow for stealth and speed.',
},
{
title: 'Clients & traffic control',
description:
'Per-client traffic quotas, expiry dates, IP limits and live online status, with one-click share links and QR codes.',
},
{
title: 'Multi-node & subscriptions',
description:
'Coordinate multiple servers, managed hosts and external proxies, and serve VLESS / Clash / JSON subscriptions.',
},
{
title: 'Telegram bot & alerts',
description:
'Built-in Telegram notifications for traffic caps, expiry warnings and system load, plus admin actions.',
},
{
title: 'Self-hosted & scriptable',
description:
'A single Go binary or Docker image, an SQLite/PostgreSQL backend, and a full REST API for automation.',
},
],
licenseBefore: 'released under the ',
licenseAfter: ' license.',
};
const fa: SiteMessages = {
tagline: 'پنل وب پیشرفته برای مدیریت سرورهای Xray-core',
getStarted: 'شروع کنید',
viewOnGitHub: 'مشاهده در GitHub',
documentation: 'مستندات',
donate: 'حمایت مالی',
docs: 'مستندات',
stars: 'ستاره',
forks: 'فورک',
latest: 'آخرین',
copyCommand: 'کپی دستور نصب',
copied: 'کپی شد',
featuresHeading: 'هر آنچه برای اجرای Xray لازم دارید',
featuresSubtitle:
'یک پنل کنترلِ مدرن و سریع برای Xray-core — ساخته‌شده برای ادمین‌هایی که قدرت می‌خواهند، بدون درگیری با خط فرمان.',
features: [
{
title: 'همه‌ی پروتکل‌های اصلی',
description:
'VLESS، VMess، Trojan، Shadowsocks، WireGuard، Hysteria2، SOCKS، HTTP و Dokodemo-door — همه از یک پنل مدیریت می‌شوند.',
},
{
title: 'REALITY و XTLS-Vision',
description:
'پشتیبانی درجه‌یک از VLESS + REALITY با کلیدهای x25519، short ID‌ها و فلوی xtls-rprx-vision برای مخفی‌کاری و سرعت.',
},
{
title: 'کلاینت‌ها و کنترل ترافیک',
description:
'سهمیه‌ی ترافیک برای هر کلاینت، تاریخ انقضا، محدودیت IP و وضعیت آنلاینِ زنده، همراه با لینک‌های اشتراک‌گذاری و کدهای QR تنها با یک کلیک.',
},
{
title: 'چندنودی و سابسکریپشن‌ها',
description:
'هماهنگ‌سازی چند سرور، هاست‌های مدیریت‌شده و پروکسی‌های خارجی، و ارائه‌ی سابسکریپشن‌های VLESS / Clash / JSON.',
},
{
title: 'ربات Telegram و هشدارها',
description:
'اعلان‌های داخلیِ Telegram برای سقف ترافیک، هشدار انقضا و بار سیستم، به‌علاوه‌ی کنش‌های مدیریتی.',
},
{
title: 'خودمیزبان و قابل‌اسکریپت',
description:
'یک باینری Go یا ایمیج Docker، بک‌اندِ SQLite/PostgreSQL، و یک REST API کامل برای خودکارسازی.',
},
],
licenseBefore: 'تحت مجوز ',
licenseAfter: ' منتشر شده است.',
};
const ru: SiteMessages = {
tagline: 'Продвинутая веб-панель для управления серверами Xray-core',
getStarted: 'Начать',
viewOnGitHub: 'Открыть на GitHub',
documentation: 'Документация',
donate: 'Поддержать',
docs: 'Документация',
stars: 'звёзд',
forks: 'форков',
latest: 'последняя',
copyCommand: 'Скопировать команду установки',
copied: 'Скопировано',
featuresHeading: 'Всё необходимое для запуска Xray',
featuresSubtitle:
'Современная и быстрая панель управления для Xray-core — создана для администраторов, которым нужна мощь без возни с командной строкой.',
features: [
{
title: 'Все основные протоколы',
description:
'VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, SOCKS, HTTP и Dokodemo-door — под управлением из одной панели.',
},
{
title: 'REALITY и XTLS-Vision',
description:
'Первоклассная поддержка VLESS + REALITY с ключами x25519, short ID и потоком xtls-rprx-vision для скрытности и скорости.',
},
{
title: 'Клиенты и контроль трафика',
description:
'Квоты трафика по клиентам, даты окончания, лимиты IP и статус «онлайн» в реальном времени, плюс ссылки-подписки и QR-коды в один клик.',
},
{
title: 'Мультинода и подписки',
description:
'Координация нескольких серверов, управляемых хостов и внешних прокси, а также выдача подписок VLESS / Clash / JSON.',
},
{
title: 'Telegram-бот и оповещения',
description:
'Встроенные уведомления Telegram о лимитах трафика, истечении срока и нагрузке системы, а также действия администратора.',
},
{
title: 'Свой хостинг и скрипты',
description:
'Один бинарный файл Go или Docker-образ, бэкенд SQLite/PostgreSQL и полноценный REST API для автоматизации.',
},
],
licenseBefore: 'распространяется под лицензией ',
licenseAfter: '.',
};
const zh: SiteMessages = {
tagline: '用于管理 Xray-core 服务器的高级 Web 面板',
getStarted: '开始使用',
viewOnGitHub: '在 GitHub 上查看',
documentation: '文档',
donate: '捐赠',
docs: '文档',
stars: '星标',
forks: '复刻',
latest: '最新',
copyCommand: '复制安装命令',
copied: '已复制',
featuresHeading: '运行 Xray 所需的一切',
featuresSubtitle:
'为 Xray-core 打造的现代、快速控制面板 —— 专为想要强大功能又不愿折腾命令行的运维者而生。',
features: [
{
title: '支持所有主流协议',
description:
'VLESS、VMess、Trojan、Shadowsocks、WireGuard、Hysteria2、SOCKS、HTTP 和 Dokodemo-door —— 全部在一个面板中管理。',
},
{
title: 'REALITY 与 XTLS-Vision',
description:
'一流支持 VLESS + REALITY,配备 x25519 密钥、short ID 和 xtls-rprx-vision 流,兼顾隐蔽与速度。',
},
{
title: '客户端与流量控制',
description:
'为每个客户端设置流量配额、到期日期、IP 限制和实时在线状态,并支持一键分享链接和二维码。',
},
{
title: '多节点与订阅',
description: '协调多台服务器、托管主机和外部代理,并提供 VLESS / Clash / JSON 订阅。',
},
{
title: 'Telegram 机器人与告警',
description: '内置 Telegram 通知,覆盖流量上限、到期提醒和系统负载,并支持管理员操作。',
},
{
title: '自托管且可脚本化',
description: '单个 Go 二进制文件或 Docker 镜像、SQLite/PostgreSQL 后端,以及用于自动化的完整 REST API。',
},
],
licenseBefore: '基于 ',
licenseAfter: ' 许可证发布。',
};
const messages: Record<Locale, SiteMessages> = { en, fa, ru, zh };
export function getSiteMessages(lang: string): SiteMessages {
return messages[lang as Locale] ?? en;
}
+42
View File
@@ -0,0 +1,42 @@
import { docs } from 'collections/server';
import { loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { openapiPlugin } from 'fumadocs-openapi/server';
import { i18n } from '@/lib/i18n';
import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
// See https://fumadocs.dev/docs/headless/source-api for more info.
// `i18n` builds one page tree per language; untranslated pages fall back to en.
// `openapiPlugin` adds HTTP-method badges to generated API reference pages.
export const source = loader({
i18n,
baseUrl: docsRoute,
source: docs.toFumadocsSource(),
plugins: [lucideIconsPlugin(), openapiPlugin()],
});
export function getPageImage(page: (typeof source)['$inferPage']) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `${docsImageRoute}/${segments.join('/')}`,
};
}
export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) {
const segments = [...page.slugs, 'content.md'];
return {
segments,
url: `${docsContentRoute}/${segments.join('/')}`,
};
}
export async function getLLMText(page: (typeof source)['$inferPage']) {
const processed = await page.data.getText('processed');
return `# ${page.data.title} (${page.url})
${processed}`;
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { normalizeBase, joinUrl, buildCurl, buildFetchSnippet } from './api-client';
describe('normalizeBase', () => {
it('strips a trailing slash', () => {
expect(normalizeBase('https://panel.example.com:2053/')).toBe('https://panel.example.com:2053');
});
it('leaves a clean base unchanged', () => {
expect(normalizeBase('https://panel.example.com:2053')).toBe('https://panel.example.com:2053');
});
});
describe('joinUrl', () => {
it('joins with exactly one slash regardless of input slashes', () => {
expect(joinUrl('https://x.com/', 'panel/api/inbounds/list')).toBe(
'https://x.com/panel/api/inbounds/list',
);
expect(joinUrl('https://x.com', '/panel/api/inbounds/list')).toBe(
'https://x.com/panel/api/inbounds/list',
);
});
});
const base = {
baseUrl: 'https://panel.example.com:2053',
token: 'TKN',
path: '/panel/api/inbounds/list',
};
describe('buildCurl', () => {
it('GET emits the Bearer header, a single-quoted URL, and no body flag', () => {
const cmd = buildCurl({ ...base, method: 'GET' });
expect(cmd).toContain("-X GET");
expect(cmd).toContain("-H 'Authorization: Bearer TKN'");
expect(cmd).toContain("'https://panel.example.com:2053/panel/api/inbounds/list'");
expect(cmd).not.toContain('--data');
expect(cmd).not.toContain('-d ');
});
it('POST with a body emits --data and a JSON content type', () => {
const cmd = buildCurl({ ...base, method: 'POST', path: '/panel/api/inbounds/add', body: '{"up":0}' });
expect(cmd).toContain('-X POST');
expect(cmd).toContain("--data '{\"up\":0}'");
expect(cmd).toContain("Content-Type: application/json");
});
it('POST without a body omits --data', () => {
const cmd = buildCurl({ ...base, method: 'POST', path: '/panel/api/inbounds/resetAllTraffics' });
expect(cmd).not.toContain('--data');
});
});
describe('buildFetchSnippet', () => {
it('GET sets method + Authorization and no body', () => {
const snip = buildFetchSnippet({ ...base, method: 'GET' });
expect(snip).toContain("method: 'GET'");
expect(snip).toContain("'Authorization': 'Bearer TKN'");
expect(snip).not.toContain('body:');
});
it('POST with a body includes a JSON.stringify body', () => {
const snip = buildFetchSnippet({ ...base, method: 'POST', path: '/panel/api/inbounds/add', body: '{"up":0}' });
expect(snip).toContain("method: 'POST'");
expect(snip).toContain('body: JSON.stringify(');
});
});
+49
View File
@@ -0,0 +1,49 @@
// Pure builders for 3x-ui panel API requests. The panel exposes every endpoint
// under /panel/api/* and authenticates with `Authorization: Bearer <token>`
// (reference/api/authentication). Emits a ready cURL command and a fetch()
// snippet. No React/DOM imports.
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export interface ApiRequestInput {
baseUrl: string;
token: string;
path: string; // e.g. /panel/api/inbounds/list
method: HttpMethod;
body?: string; // JSON string, for POST/PUT
}
export function normalizeBase(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}
export function joinUrl(baseUrl: string, path: string): string {
const base = normalizeBase(baseUrl);
const p = path.trim().startsWith('/') ? path.trim() : `/${path.trim()}`;
return `${base}${p}`;
}
function hasBody(i: ApiRequestInput): boolean {
return (i.method === 'POST' || i.method === 'PUT') && !!i.body && i.body.trim().length > 0;
}
export function buildCurl(i: ApiRequestInput): string {
const url = joinUrl(i.baseUrl, i.path);
const lines = [`curl -X ${i.method} '${url}'`, ` -H 'Authorization: Bearer ${i.token}'`];
if (hasBody(i)) {
lines.push(` -H 'Content-Type: application/json'`);
lines.push(` --data '${i.body!.trim()}'`);
}
return lines.join(' \\\n');
}
export function buildFetchSnippet(i: ApiRequestInput): string {
const url = joinUrl(i.baseUrl, i.path);
const headers = [`'Authorization': 'Bearer ${i.token}'`];
if (hasBody(i)) headers.push(`'Content-Type': 'application/json'`);
const opts = [`method: '${i.method}'`, `headers: { ${headers.join(', ')} }`];
if (hasBody(i)) opts.push(`body: JSON.stringify(${i.body!.trim()})`);
return `await fetch('${url}', {\n ${opts.join(',\n ')},\n});`;
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import {
bytesToBase64,
base64ToBytes,
bytesToBase64Url,
base64UrlToBytes,
textToBase64,
base64ToText,
} from './base64';
describe('base64', () => {
it('round-trips raw bytes through standard base64', () => {
const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]);
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
});
it('encodes URL-safe base64 without padding or +//', () => {
const bytes = new Uint8Array([251, 255, 191, 0]);
const url = bytesToBase64Url(bytes);
expect(url).not.toMatch(/[+/=]/);
expect(base64UrlToBytes(url)).toEqual(bytes);
});
it('tolerates missing padding when decoding', () => {
// "M" => 0x33; "TQ" decodes to one byte 0x4d.
expect(base64ToBytes('TQ')).toEqual(new Uint8Array([0x4d]));
});
it('round-trips UTF-8 text (including non-ASCII) for vmess payloads', () => {
const text = JSON.stringify({ ps: 'سرور تهران', net: 'ws' });
expect(base64ToText(textToBase64(text))).toBe(text);
});
});
+42
View File
@@ -0,0 +1,42 @@
// Browser- and Node-safe base64 / base64url helpers used by the in-browser
// config tools. No Node `Buffer` so the same code runs in the browser and in
// vitest (Node). `btoa`/`atob` and `TextEncoder`/`TextDecoder` are available in
// both environments.
export function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
/** Decode standard or URL-safe base64, tolerating missing padding. */
export function base64ToBytes(b64: string): Uint8Array {
const normalized = b64.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
/** Encode bytes as URL-safe base64 with no padding (xray's key format). */
export function bytesToBase64Url(bytes: Uint8Array): string {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function base64UrlToBytes(s: string): Uint8Array {
return base64ToBytes(s);
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/** UTF-8 text → standard base64 (used by vmess:// links). */
export function textToBase64(text: string): string {
return bytesToBase64(encoder.encode(text));
}
/** standard/URL-safe base64 → UTF-8 text. */
export function base64ToText(b64: string): string {
return decoder.decode(base64ToBytes(b64));
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { buildUfwCommands, buildNftablesRuleset, type FirewallOptions } from './firewall';
const options: FirewallOptions = {
allowSsh: true,
sshPort: 22,
ports: [
{ port: 443, protocol: 'tcp', label: 'inbound' },
{ port: 51820, protocol: 'udp', label: 'wireguard' },
{ port: 2053, protocol: 'both', label: 'panel' },
],
};
describe('buildUfwCommands', () => {
it('emits ufw allow rules per protocol plus ssh and enable', () => {
const out = buildUfwCommands(options);
expect(out).toContain('ufw allow 22/tcp');
expect(out).toContain('ufw allow 443/tcp');
expect(out).toContain('ufw allow 51820/udp');
// 'both' expands to tcp + udp
expect(out).toContain('ufw allow 2053/tcp');
expect(out).toContain('ufw allow 2053/udp');
expect(out.trim().endsWith('ufw enable')).toBe(true);
});
});
describe('buildNftablesRuleset', () => {
it('emits a drop-by-default inet table with accept rules', () => {
const out = buildNftablesRuleset(options);
expect(out).toContain('table inet filter');
expect(out).toContain('policy drop;');
expect(out).toContain('ct state established,related accept');
expect(out).toContain('tcp dport 443 accept');
expect(out).toContain('udp dport 51820 accept');
expect(out).toContain('tcp dport 2053 accept');
expect(out).toContain('udp dport 2053 accept');
});
});
+65
View File
@@ -0,0 +1,65 @@
// Pure builders for firewall rules (ufw + nftables) from a port list.
export type PortProtocol = 'tcp' | 'udp' | 'both';
export interface PortRule {
port: number;
protocol: PortProtocol;
label?: string;
}
export interface FirewallOptions {
ports: PortRule[];
allowSsh: boolean;
sshPort: number;
}
function expand(protocol: PortProtocol): ('tcp' | 'udp')[] {
return protocol === 'both' ? ['tcp', 'udp'] : [protocol];
}
export function buildUfwCommands(o: FirewallOptions): string {
const lines: string[] = [];
if (o.allowSsh) lines.push(`ufw allow ${o.sshPort}/tcp # SSH`);
for (const rule of o.ports) {
for (const proto of expand(rule.protocol)) {
const comment = rule.label ? ` # ${rule.label}` : '';
lines.push(`ufw allow ${rule.port}/${proto}${comment}`);
}
}
lines.push('ufw enable');
return lines.join('\n');
}
export function buildNftablesRuleset(o: FirewallOptions): string {
const accepts: string[] = [];
if (o.allowSsh) accepts.push(` tcp dport ${o.sshPort} accept # SSH`);
for (const rule of o.ports) {
for (const proto of expand(rule.protocol)) {
const comment = rule.label ? ` # ${rule.label}` : '';
accepts.push(` ${proto} dport ${rule.port} accept${comment}`);
}
}
return `#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state established,related accept
icmp type echo-request accept
${accepts.join('\n')}
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}`;
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import {
buildScriptCommand,
buildDockerRun,
buildDockerCompose,
type InstallOptions,
} from './install';
const base: InstallOptions = {
method: 'script',
version: '',
enableFail2ban: true,
panelPort: '',
webBasePath: '',
};
describe('buildScriptCommand', () => {
it('uses the master install.sh for the latest version', () => {
expect(buildScriptCommand(base)).toBe(
'bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)',
);
});
it('pins a specific version by passing the tag to master install.sh', () => {
const cmd = buildScriptCommand({ ...base, version: 'v3.4.1' });
expect(cmd).toBe(
'bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.1',
);
});
it('supports the rolling dev-latest build', () => {
const cmd = buildScriptCommand({ ...base, version: 'dev-latest' });
expect(cmd).toBe(
'bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest',
);
});
});
describe('buildDockerRun', () => {
it('reflects fail2ban, port, and base path options', () => {
const cmd = buildDockerRun({
...base,
enableFail2ban: false,
panelPort: '8443',
webBasePath: '/panel',
});
expect(cmd).toContain('XUI_ENABLE_FAIL2BAN=false');
expect(cmd).toContain('XUI_PORT=8443');
expect(cmd).toContain('XUI_INIT_WEB_BASE_PATH=/panel');
expect(cmd).toContain('ghcr.io/mhsanaei/3x-ui:latest');
expect(cmd).toContain('-v $PWD/db/:/etc/x-ui/');
});
it('omits unset port and path', () => {
const cmd = buildDockerRun(base);
expect(cmd).not.toContain('XUI_PORT');
expect(cmd).not.toContain('XUI_INIT_WEB_BASE_PATH');
});
});
describe('buildDockerCompose', () => {
it('produces valid-looking compose with the image and volumes', () => {
const yaml = buildDockerCompose({ ...base, panelPort: '2096' });
expect(yaml).toContain('image: ghcr.io/mhsanaei/3x-ui:latest');
expect(yaml).toContain('network_mode: host');
expect(yaml).toContain("XUI_PORT: '2096'");
expect(yaml).toContain('- ./db/:/etc/x-ui/');
});
});
+74
View File
@@ -0,0 +1,74 @@
// Pure builders for 3x-ui install commands (script + Docker). No React/DOM.
export type InstallMethod = 'script' | 'docker';
export interface InstallOptions {
method: InstallMethod;
/** A release tag like `v3.4.1`, or empty/`latest` for the latest release. */
version: string;
enableFail2ban: boolean;
panelPort: string;
webBasePath: string;
}
const REPO_RAW = 'https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh';
const IMAGE = 'ghcr.io/mhsanaei/3x-ui:latest';
function isLatest(version: string): boolean {
const v = version.trim().toLowerCase();
return v === '' || v === 'latest';
}
/**
* The one-line script install command. The master install script reads the
* version as its first argument: empty = latest stable release, a tag like
* `v3.4.0` = that release, and `dev-latest` = the rolling per-commit dev build.
*/
export function buildScriptCommand(options: InstallOptions): string {
if (isLatest(options.version)) {
return `bash <(curl -Ls ${REPO_RAW})`;
}
return `bash <(curl -Ls ${REPO_RAW}) ${options.version.trim()}`;
}
/** A `docker run` command reflecting the chosen options. */
export function buildDockerRun(options: InstallOptions): string {
const lines = ['docker run -itd'];
lines.push(` -e XRAY_VMESS_AEAD_FORCED=false`);
lines.push(` -e XUI_ENABLE_FAIL2BAN=${options.enableFail2ban ? 'true' : 'false'}`);
if (options.panelPort.trim()) lines.push(` -e XUI_PORT=${options.panelPort.trim()}`);
if (options.webBasePath.trim())
lines.push(` -e XUI_INIT_WEB_BASE_PATH=${options.webBasePath.trim()}`);
lines.push(` -v $PWD/db/:/etc/x-ui/`);
lines.push(` -v $PWD/cert/:/root/cert/`);
lines.push(` --network=host`);
lines.push(` --restart=unless-stopped`);
lines.push(` --name 3x-ui`);
lines.push(` ${IMAGE}`);
return lines.join(' \\\n');
}
/** A `docker-compose.yml` reflecting the chosen options. */
export function buildDockerCompose(options: InstallOptions): string {
const env: string[] = [
` XRAY_VMESS_AEAD_FORCED: 'false'`,
` XUI_ENABLE_FAIL2BAN: '${options.enableFail2ban ? 'true' : 'false'}'`,
];
if (options.panelPort.trim()) env.push(` XUI_PORT: '${options.panelPort.trim()}'`);
if (options.webBasePath.trim())
env.push(` XUI_INIT_WEB_BASE_PATH: '${options.webBasePath.trim()}'`);
return [
`services:`,
` 3x-ui:`,
` image: ${IMAGE}`,
` container_name: 3x-ui`,
` volumes:`,
` - ./db/:/etc/x-ui/`,
` - ./cert/:/root/cert/`,
` environment:`,
...env,
` network_mode: host`,
` restart: unless-stopped`,
].join('\n');
}
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import {
parseLink,
detectProtocol,
buildVless,
buildVmess,
buildTrojan,
buildShadowsocks,
} from './links';
describe('detectProtocol', () => {
it('recognises supported schemes', () => {
expect(detectProtocol('vless://x')).toBe('vless');
expect(detectProtocol('vmess://x')).toBe('vmess');
expect(detectProtocol('trojan://x')).toBe('trojan');
expect(detectProtocol('ss://x')).toBe('ss');
expect(detectProtocol('https://x')).toBeNull();
});
});
describe('parseLink — vless', () => {
const link =
'vless://11111111-2222-3333-4444-555555555555@example.com:443?security=reality&pbk=abc&sid=ff&sni=www.microsoft.com&fp=chrome&flow=xtls-rprx-vision&type=tcp#My%20Node';
it('extracts address, port, credential, name, and params', () => {
const r = parseLink(link);
expect(r.protocol).toBe('vless');
expect(r.address).toBe('example.com');
expect(r.port).toBe(443);
expect(r.credential).toBe('11111111-2222-3333-4444-555555555555');
expect(r.name).toBe('My Node');
expect(r.params.security).toBe('reality');
expect(r.params.pbk).toBe('abc');
expect(r.params.flow).toBe('xtls-rprx-vision');
});
});
describe('parseLink — trojan', () => {
it('parses password and query params', () => {
const r = parseLink('trojan://p%40ss@1.2.3.4:8443?sni=a.com&type=ws&path=%2Fws#t');
expect(r.protocol).toBe('trojan');
expect(r.credential).toBe('p@ss');
expect(r.address).toBe('1.2.3.4');
expect(r.port).toBe(8443);
expect(r.params.path).toBe('/ws');
expect(r.name).toBe('t');
});
});
describe('parseLink — vmess', () => {
it('decodes the base64 JSON payload', () => {
const link = buildVmess({
ps: 'tehran',
add: 'host.example',
port: 443,
id: 'uuid-123',
net: 'ws',
tls: 'tls',
host: 'cdn.example',
path: '/v2',
});
const r = parseLink(link);
expect(r.protocol).toBe('vmess');
expect(r.name).toBe('tehran');
expect(r.address).toBe('host.example');
expect(r.port).toBe(443);
expect(r.credential).toBe('uuid-123');
expect(r.params.net).toBe('ws');
expect(r.params.path).toBe('/v2');
});
});
describe('parseLink — shadowsocks', () => {
it('parses SIP002 with base64 userinfo', () => {
const link = buildShadowsocks({
method: 'aes-256-gcm',
password: 's3cret',
address: '192.0.2.1',
port: 8388,
name: 'ss-node',
});
const r = parseLink(link);
expect(r.protocol).toBe('ss');
expect(r.credential).toBe('aes-256-gcm:s3cret');
expect(r.address).toBe('192.0.2.1');
expect(r.port).toBe(8388);
expect(r.name).toBe('ss-node');
});
it('parses the legacy fully-base64 form', () => {
// base64("aes-128-gcm:pw@example.com:8388")
const legacy =
'ss://' + Buffer.from('aes-128-gcm:pw@example.com:8388').toString('base64') + '#legacy';
const r = parseLink(legacy);
expect(r.credential).toBe('aes-128-gcm:pw');
expect(r.address).toBe('example.com');
expect(r.port).toBe(8388);
expect(r.name).toBe('legacy');
});
});
describe('build → parse round-trips', () => {
it('vless round-trips', () => {
const link = buildVless({
credential: 'uuid-abc',
address: 'example.com',
port: 443,
params: { security: 'reality', sni: 'a.com', flow: 'xtls-rprx-vision' },
name: 'node 1',
});
const r = parseLink(link);
expect(r.credential).toBe('uuid-abc');
expect(r.address).toBe('example.com');
expect(r.port).toBe(443);
expect(r.params.sni).toBe('a.com');
expect(r.name).toBe('node 1');
});
it('trojan round-trips', () => {
const r = parseLink(buildTrojan({ credential: 'pw', address: 'h.com', port: 443, name: 'x' }));
expect(r.credential).toBe('pw');
expect(r.port).toBe(443);
});
it('ss round-trips', () => {
const r = parseLink(
buildShadowsocks({ method: 'chacha20-poly1305', password: 'p', address: 'h', port: 8388 }),
);
expect(r.credential).toBe('chacha20-poly1305:p');
});
});
describe('parseLink — errors', () => {
it('throws on unsupported scheme', () => {
expect(() => parseLink('http://example.com')).toThrow(/Unsupported/);
});
});
+202
View File
@@ -0,0 +1,202 @@
// Pure parsing/building of Xray share links (vless / vmess / trojan / ss).
// No React/DOM — runs in the browser and in vitest (Node) alike.
import { base64ToText, textToBase64 } from './base64';
export type Protocol = 'vless' | 'vmess' | 'trojan' | 'ss';
export interface ParsedLink {
protocol: Protocol;
/** Remark / fragment label. */
name: string;
address: string;
port: number;
/** UUID (vless/vmess), password (trojan), or `method:password` (ss). */
credential: string;
/** All remaining parameters, for display. */
params: Record<string, string>;
}
export function detectProtocol(link: string): Protocol | null {
const scheme = link.trim().slice(0, link.indexOf('://')).toLowerCase();
if (scheme === 'vless' || scheme === 'vmess' || scheme === 'trojan' || scheme === 'ss') {
return scheme;
}
return null;
}
export function parseLink(link: string): ParsedLink {
const trimmed = link.trim();
const protocol = detectProtocol(trimmed);
switch (protocol) {
case 'vless':
case 'trojan':
return parseUserinfoLink(trimmed, protocol);
case 'vmess':
return parseVmess(trimmed);
case 'ss':
return parseShadowsocks(trimmed);
default:
throw new Error(
`Unsupported or invalid link. Expected vless://, vmess://, trojan://, or ss://`,
);
}
}
// vless:// and trojan:// share the `cred@host:port?params#name` structure.
function parseUserinfoLink(link: string, protocol: 'vless' | 'trojan'): ParsedLink {
const url = new URL(link);
const params: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
params[key] = value;
});
return {
protocol,
name: safeDecode(url.hash.replace(/^#/, '')),
address: stripBrackets(url.hostname),
port: Number(url.port) || 0,
credential: safeDecode(url.username),
params,
};
}
function parseVmess(link: string): ParsedLink {
const payload = link.slice('vmess://'.length);
let obj: Record<string, unknown>;
try {
obj = JSON.parse(base64ToText(payload)) as Record<string, unknown>;
} catch {
throw new Error('Invalid vmess link: payload is not base64-encoded JSON.');
}
const reserved = new Set(['ps', 'add', 'port', 'id', 'v']);
const params: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
if (reserved.has(key) || value === undefined || value === null || value === '') continue;
params[key] = String(value);
}
return {
protocol: 'vmess',
name: String(obj.ps ?? ''),
address: String(obj.add ?? ''),
port: Number(obj.port) || 0,
credential: String(obj.id ?? ''),
params,
};
}
function parseShadowsocks(link: string): ParsedLink {
const rest = link.slice('ss://'.length);
const hashIndex = rest.indexOf('#');
const name = hashIndex >= 0 ? safeDecode(rest.slice(hashIndex + 1)) : '';
const body = hashIndex >= 0 ? rest.slice(0, hashIndex) : rest;
if (body.includes('@')) {
// SIP002: ss://<userinfo>@host:port[/][?plugin=...]
const atIndex = body.lastIndexOf('@');
const userinfo = body.slice(0, atIndex);
const hostPart = body.slice(atIndex + 1);
const credential = userinfo.includes(':') ? safeDecode(userinfo) : tryBase64(userinfo);
const { address, port, params } = parseHostPortQuery(hostPart);
return { protocol: 'ss', name, address, port, credential, params };
}
// Legacy: ss://base64(method:password@host:port)
const decoded = base64ToText(body);
const atIndex = decoded.lastIndexOf('@');
if (atIndex < 0) throw new Error('Invalid ss link: missing host.');
const credential = decoded.slice(0, atIndex);
const { address, port } = parseHostPortQuery(decoded.slice(atIndex + 1));
return { protocol: 'ss', name, address, port, credential, params: {} };
}
function parseHostPortQuery(input: string): {
address: string;
port: number;
params: Record<string, string>;
} {
const queryIndex = input.search(/[/?]/);
const hostPort = queryIndex >= 0 ? input.slice(0, queryIndex) : input;
const query = queryIndex >= 0 ? input.slice(queryIndex).replace(/^\/?\??/, '') : '';
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(hostPort);
if (!match) throw new Error('Invalid host:port.');
const params: Record<string, string> = {};
if (query) {
new URLSearchParams(query).forEach((value, key) => {
params[key] = value;
});
}
return { address: stripBrackets(match[1]), port: Number(match[2]), params };
}
// ---- Builders -------------------------------------------------------------
export interface VlessTrojanInput {
credential: string;
address: string;
port: number;
params?: Record<string, string>;
name?: string;
}
export function buildVless(input: VlessTrojanInput): string {
return buildUserinfoLink('vless', input);
}
export function buildTrojan(input: VlessTrojanInput): string {
return buildUserinfoLink('trojan', input);
}
function buildUserinfoLink(scheme: 'vless' | 'trojan', input: VlessTrojanInput): string {
const search = new URLSearchParams(
Object.entries(input.params ?? {}).filter(([, v]) => v !== '' && v != null),
).toString();
const host = input.address.includes(':') ? `[${input.address}]` : input.address;
const query = search ? `?${search}` : '';
const fragment = input.name ? `#${encodeURIComponent(input.name)}` : '';
return `${scheme}://${encodeURIComponent(input.credential)}@${host}:${input.port}${query}${fragment}`;
}
export function buildVmess(obj: Record<string, string | number>): string {
return `vmess://${textToBase64(JSON.stringify({ v: '2', ...obj }))}`;
}
export interface ShadowsocksInput {
method: string;
password: string;
address: string;
port: number;
name?: string;
}
export function buildShadowsocks(input: ShadowsocksInput): string {
const userinfo = textToBase64(`${input.method}:${input.password}`)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const host = input.address.includes(':') ? `[${input.address}]` : input.address;
const fragment = input.name ? `#${encodeURIComponent(input.name)}` : '';
return `ss://${userinfo}@${host}:${input.port}${fragment}`;
}
// ---- helpers --------------------------------------------------------------
function stripBrackets(host: string): string {
return host.replace(/^\[/, '').replace(/\]$/, '');
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function tryBase64(value: string): string {
try {
const decoded = base64ToText(value);
return decoded.includes(':') ? decoded : value;
} catch {
return value;
}
}
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect } from 'vitest';
import {
buildOutbound,
buildOutboundJson,
buildStreamSettings,
type OutboundInput,
type StreamInput,
} from './outbounds';
describe('buildOutbound — freedom & blackhole', () => {
it('freedom has empty settings by default and no streamSettings', () => {
const ob = buildOutbound({ kind: 'freedom', tag: 'direct' });
expect(ob.protocol).toBe('freedom');
expect(ob.tag).toBe('direct');
expect(ob.settings).toEqual({});
expect('streamSettings' in ob).toBe(false);
});
it('freedom carries domainStrategy when set', () => {
const ob = buildOutbound({ kind: 'freedom', tag: 'direct', domainStrategy: 'UseIP' });
expect(ob.settings).toEqual({ domainStrategy: 'UseIP' });
});
it('blackhole has empty settings', () => {
const ob = buildOutbound({ kind: 'blackhole', tag: 'block' });
expect(ob.protocol).toBe('blackhole');
expect(ob.settings).toEqual({});
});
});
describe('buildOutbound — proxy protocols', () => {
const base = { address: 'example.com', port: 443 };
it('vless uses the FLAT settings form (address/port/id/flow/encryption), not vnext', () => {
const ob = buildOutbound({
kind: 'vless',
tag: 'v',
server: { ...base, id: 'uuid-1', flow: 'xtls-rprx-vision' },
});
expect(ob.protocol).toBe('vless');
const s = ob.settings as Record<string, unknown>;
expect(s.address).toBe('example.com');
expect(s.port).toBe(443);
expect(s.id).toBe('uuid-1');
expect(s.flow).toBe('xtls-rprx-vision');
expect(s.encryption).toBe('none');
expect('vnext' in s).toBe(false);
});
it('vmess uses the vnext settings form', () => {
const ob = buildOutbound({ kind: 'vmess', tag: 'm', server: { ...base, id: 'uuid-2' } });
const vnext = (ob.settings as { vnext: Array<Record<string, unknown>> }).vnext;
expect(vnext[0].address).toBe('example.com');
expect(vnext[0].port).toBe(443);
const users = vnext[0].users as Array<Record<string, unknown>>;
expect(users[0].id).toBe('uuid-2');
expect(users[0].security).toBe('auto');
});
it('trojan uses servers[] with a password', () => {
const ob = buildOutbound({ kind: 'trojan', tag: 't', server: { ...base, password: 'pw' } });
const servers = (ob.settings as { servers: Array<Record<string, unknown>> }).servers;
expect(servers[0].address).toBe('example.com');
expect(servers[0].password).toBe('pw');
});
it('shadowsocks servers[] include a method', () => {
const ob = buildOutbound({
kind: 'shadowsocks',
tag: 's',
server: { ...base, password: 'pw', method: 'aes-256-gcm' },
});
const servers = (ob.settings as { servers: Array<Record<string, unknown>> }).servers;
expect(servers[0].method).toBe('aes-256-gcm');
expect(servers[0].password).toBe('pw');
});
it('coerces a string port to a number', () => {
const ob = buildOutbound({
kind: 'vless',
tag: 'v',
server: { address: 'a', port: '8443' as unknown as number, id: 'x' },
});
expect((ob.settings as Record<string, unknown>).port).toBe(8443);
});
});
describe('buildStreamSettings', () => {
const baseStream: StreamInput = { network: 'ws', security: 'none' };
it('ws emits wsSettings.path and omits an empty host', () => {
const st = buildStreamSettings({ ...baseStream, path: '/ray' });
expect((st.wsSettings as Record<string, unknown>).path).toBe('/ray');
expect('host' in (st.wsSettings as Record<string, unknown>)).toBe(false);
expect('tlsSettings' in st).toBe(false);
});
it('reality emits realitySettings and no tlsSettings', () => {
const st = buildStreamSettings({
network: 'tcp',
security: 'reality',
publicKey: 'PBK',
shortId: 'ab',
sni: 'www.microsoft.com',
});
const r = st.realitySettings as Record<string, unknown>;
expect(r.publicKey).toBe('PBK');
expect(r.shortId).toBe('ab');
expect(r.serverName).toBe('www.microsoft.com');
expect(r.fingerprint).toBe('chrome');
expect('tlsSettings' in st).toBe(false);
});
it('tls emits tlsSettings.serverName from sni', () => {
const st = buildStreamSettings({ network: 'ws', security: 'tls', sni: 'a.com', path: '/' });
expect((st.tlsSettings as Record<string, unknown>).serverName).toBe('a.com');
});
it('security none emits no security sub-object', () => {
const st = buildStreamSettings({ network: 'grpc', security: 'none', serviceName: 'svc' });
expect('tlsSettings' in st).toBe(false);
expect('realitySettings' in st).toBe(false);
expect((st.grpcSettings as Record<string, unknown>).serviceName).toBe('svc');
});
it('kcp emits the version-correct defaults (no header/seed)', () => {
const st = buildStreamSettings({ network: 'kcp', security: 'none' });
const k = st.kcpSettings as Record<string, unknown>;
expect(k.tti).toBe(20);
expect(k.cwndMultiplier).toBe(1);
expect(k.maxSendingWindow).toBe(2097152);
expect('header' in k).toBe(false);
expect('seed' in k).toBe(false);
});
});
describe('buildOutbound — stream attachment', () => {
it('attaches streamSettings for vless when a stream is provided', () => {
const ob = buildOutbound({
kind: 'vless',
tag: 'v',
server: { address: 'a', port: 443, id: 'x' },
stream: { network: 'ws', security: 'tls', path: '/p', sni: 's' },
});
expect((ob.streamSettings as Record<string, unknown>).network).toBe('ws');
});
it('does not attach streamSettings to freedom even if a stream is provided', () => {
const ob = buildOutbound({
kind: 'freedom',
tag: 'd',
stream: { network: 'ws', security: 'none' },
});
expect('streamSettings' in ob).toBe(false);
});
});
describe('buildOutbound — wireguard & warp', () => {
it('wireguard peers carry publicKey, endpoint, and default allowedIPs', () => {
const ob = buildOutbound({
kind: 'wireguard',
tag: 'wg',
wireguard: { secretKey: 'sk', address: ['10.0.0.2/32'], publicKey: 'pk', endpoint: 'host:51820' },
});
const s = ob.settings as Record<string, unknown>;
expect(s.secretKey).toBe('sk');
expect(s.mtu).toBe(1420);
const peers = s.peers as Array<Record<string, unknown>>;
expect(peers[0].publicKey).toBe('pk');
expect(peers[0].endpoint).toBe('host:51820');
expect(peers[0].allowedIPs).toEqual(['0.0.0.0/0', '::/0']);
});
it('warp uses the wireguard protocol, forces tag=warp, and the Cloudflare endpoint', () => {
const ob = buildOutbound({
kind: 'warp',
tag: 'ignored',
wireguard: { secretKey: 'sk', address: [], publicKey: 'pk', endpoint: '' },
});
expect(ob.protocol).toBe('wireguard');
expect(ob.tag).toBe('warp');
const peers = (ob.settings as Record<string, unknown>).peers as Array<Record<string, unknown>>;
expect(peers[0].endpoint).toBe('engage.cloudflareclient.com:2408');
});
});
describe('buildOutboundJson', () => {
it('round-trips and is 2-space indented', () => {
const input: OutboundInput = {
kind: 'vless',
tag: 'v',
server: { address: 'a', port: 443, id: 'x' },
};
const json = buildOutboundJson(input);
expect(json).toContain('\n "');
expect(JSON.parse(json)).toEqual(buildOutbound(input));
});
});
+229
View File
@@ -0,0 +1,229 @@
// Pure builders for Xray outbound objects, matching the wire shapes 3x-ui
// emits (internal/util/link/outbound.go + the panel's outbound-defaults.ts):
// - VLESS uses the FLAT settings form {address,port,id,flow,encryption}.
// - VMess uses the vnext form {vnext:[{address,port,users:[...]}]}.
// - trojan/ss/socks/http use {servers:[...]}.
// - freedom/blackhole use empty settings.
// - wireguard/warp use {secretKey,address,peers:[...],mtu}.
// No React/DOM imports — unit-tested in Node.
export type OutboundKind =
| 'freedom'
| 'blackhole'
| 'vless'
| 'vmess'
| 'trojan'
| 'shadowsocks'
| 'socks'
| 'http'
| 'wireguard'
| 'warp';
export type Network = 'tcp' | 'kcp' | 'ws' | 'grpc' | 'httpupgrade' | 'xhttp';
export type Security = 'none' | 'tls' | 'reality';
export interface StreamInput {
network: Network;
security: Security;
host?: string;
path?: string;
serviceName?: string; // grpc
sni?: string; // tls/reality serverName
fingerprint?: string; // utls fingerprint
publicKey?: string; // reality
shortId?: string; // reality
spiderX?: string; // reality
}
export interface ProxyServerInput {
address: string;
port: number;
id?: string; // vless / vmess uuid
password?: string; // trojan / ss / socks / http
method?: string; // ss cipher
flow?: string; // vless xtls flow
encryption?: string; // vless, default 'none'
vmessSecurity?: string; // vmess scy, default 'auto'
username?: string; // socks / http
}
export interface WireguardInput {
secretKey: string;
address: string[]; // local interface addresses
publicKey: string; // peer public key
endpoint: string; // peer endpoint host:port
preSharedKey?: string;
reserved?: number[];
mtu?: number;
keepAlive?: number;
}
export interface OutboundInput {
kind: OutboundKind;
tag: string;
server?: ProxyServerInput;
wireguard?: WireguardInput;
stream?: StreamInput;
domainStrategy?: string; // freedom
}
// The well-known Cloudflare WARP WireGuard endpoint. The panel fills the real
// keys/reserved bytes when you register WARP; this is the template default.
const WARP_ENDPOINT = 'engage.cloudflareclient.com:2408';
// Protocols that carry a streamSettings transport in the panel.
const STREAM_KINDS = new Set<OutboundKind>(['vless', 'vmess', 'trojan', 'shadowsocks']);
function toPort(port: number | undefined): number {
const n = Number(port);
return Number.isFinite(n) && n > 0 ? n : 443;
}
export function buildStreamSettings(s: StreamInput): Record<string, unknown> {
const out: Record<string, unknown> = { network: s.network, security: s.security };
switch (s.network) {
case 'tcp':
out.tcpSettings = { header: { type: 'none' } };
break;
case 'kcp':
out.kcpSettings = {
mtu: 1350,
tti: 20,
uplinkCapacity: 5,
downlinkCapacity: 20,
cwndMultiplier: 1,
maxSendingWindow: 2097152,
};
break;
case 'ws': {
const ws: Record<string, unknown> = { path: s.path || '/' };
if (s.host) ws.host = s.host;
out.wsSettings = ws;
break;
}
case 'grpc':
out.grpcSettings = { serviceName: s.serviceName || '', multiMode: false };
break;
case 'httpupgrade': {
const hu: Record<string, unknown> = { path: s.path || '/' };
if (s.host) hu.host = s.host;
out.httpupgradeSettings = hu;
break;
}
case 'xhttp': {
const xh: Record<string, unknown> = { path: s.path || '/', mode: 'auto' };
if (s.host) xh.host = s.host;
out.xhttpSettings = xh;
break;
}
}
if (s.security === 'tls') {
const tls: Record<string, unknown> = {};
if (s.sni) tls.serverName = s.sni;
if (s.fingerprint) tls.fingerprint = s.fingerprint;
out.tlsSettings = tls;
} else if (s.security === 'reality') {
const reality: Record<string, unknown> = { fingerprint: s.fingerprint || 'chrome' };
if (s.publicKey) reality.publicKey = s.publicKey;
if (s.sni) reality.serverName = s.sni;
if (s.shortId) reality.shortId = s.shortId;
if (s.spiderX) reality.spiderX = s.spiderX;
out.realitySettings = reality;
}
return out;
}
function buildSettings(o: OutboundInput): Record<string, unknown> {
const s = o.server;
switch (o.kind) {
case 'freedom':
return o.domainStrategy ? { domainStrategy: o.domainStrategy } : {};
case 'blackhole':
return {};
case 'vless':
return {
address: s?.address ?? '',
port: toPort(s?.port),
id: s?.id ?? '',
flow: s?.flow ?? '',
encryption: s?.encryption || 'none',
};
case 'vmess':
return {
vnext: [
{
address: s?.address ?? '',
port: toPort(s?.port),
users: [{ id: s?.id ?? '', security: s?.vmessSecurity || 'auto' }],
},
],
};
case 'trojan':
return { servers: [{ address: s?.address ?? '', port: toPort(s?.port), password: s?.password ?? '' }] };
case 'shadowsocks':
return {
servers: [
{
address: s?.address ?? '',
port: toPort(s?.port),
password: s?.password ?? '',
method: s?.method || '2022-blake3-aes-128-gcm',
},
],
};
case 'socks':
case 'http': {
const server: Record<string, unknown> = { address: s?.address ?? '', port: toPort(s?.port) };
server.users = s?.username ? [{ user: s.username, pass: s.password ?? '' }] : [];
return { servers: [server] };
}
case 'wireguard':
case 'warp':
return buildWireguardSettings(o);
}
}
function buildWireguardSettings(o: OutboundInput): Record<string, unknown> {
const w = o.wireguard;
const isWarp = o.kind === 'warp';
const peer: Record<string, unknown> = {
publicKey: w?.publicKey ?? '',
endpoint: w?.endpoint || (isWarp ? WARP_ENDPOINT : ''),
allowedIPs: ['0.0.0.0/0', '::/0'],
};
if (w?.preSharedKey) peer.preSharedKey = w.preSharedKey;
if (w?.keepAlive) peer.keepAlive = w.keepAlive;
const settings: Record<string, unknown> = {
secretKey: w?.secretKey ?? '',
address: w?.address ?? [],
peers: [peer],
mtu: w?.mtu ?? 1420,
};
if (w?.reserved && w.reserved.length > 0) settings.reserved = w.reserved;
return settings;
}
function protocolFor(kind: OutboundKind): string {
return kind === 'warp' ? 'wireguard' : kind;
}
export function buildOutbound(o: OutboundInput): Record<string, unknown> {
const tag = o.kind === 'warp' ? 'warp' : o.tag;
const ob: Record<string, unknown> = {
tag,
protocol: protocolFor(o.kind),
settings: buildSettings(o),
};
if (o.stream && STREAM_KINDS.has(o.kind)) {
ob.streamSettings = buildStreamSettings(o.stream);
}
return ob;
}
export function buildOutboundJson(o: OutboundInput): string {
return JSON.stringify(buildOutbound(o), null, 2);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { recommend } from './protocols';
describe('recommend', () => {
it('recommends REALITY for heavy censorship + modern clients', () => {
const r = recommend({ useCase: 'censorship', censorship: 'high', clientSupport: 'modern' });
expect(r.protocol).toBe('VLESS');
expect(r.security).toContain('REALITY');
expect(r.links.some((l) => l.href === '/docs/config/reality')).toBe(true);
});
it('falls back to VMess+WS+TLS for broad clients under censorship', () => {
const r = recommend({ useCase: 'censorship', censorship: 'high', clientSupport: 'broad' });
expect(r.protocol).toBe('VMess');
expect(r.transport).toBe('WebSocket');
expect(r.security).toBe('TLS');
});
it('recommends Trojan for speed with broad clients', () => {
const r = recommend({ useCase: 'speed', censorship: 'low', clientSupport: 'broad' });
expect(r.protocol).toBe('Trojan');
});
it('recommends a CDN-friendly default for general modern use', () => {
const r = recommend({ useCase: 'general', censorship: 'low', clientSupport: 'modern' });
expect(r.protocol).toBe('VLESS');
expect(r.transport).toBe('WebSocket');
});
it('always returns a non-empty rationale and at least one link', () => {
const r = recommend({ useCase: 'general', censorship: 'medium', clientSupport: 'broad' });
expect(r.rationale.length).toBeGreaterThan(0);
expect(r.links.length).toBeGreaterThan(0);
});
});
+87
View File
@@ -0,0 +1,87 @@
// Pure decision logic for the protocol wizard. No React/DOM.
export type UseCase = 'censorship' | 'general' | 'speed';
export type CensorshipLevel = 'high' | 'medium' | 'low';
export type ClientSupport = 'modern' | 'broad';
export interface WizardAnswers {
useCase: UseCase;
censorship: CensorshipLevel;
clientSupport: ClientSupport;
}
export interface Recommendation {
protocol: string;
transport: string;
security: string;
rationale: string;
links: { title: string; href: string }[];
}
const REALITY_LINK = { title: 'REALITY setup', href: '/docs/config/reality' };
const TRANSPORTS_LINK = { title: 'Transports', href: '/docs/config/transports' };
const INBOUNDS_LINK = { title: 'Inbounds', href: '/docs/config/inbounds' };
export function recommend(a: WizardAnswers): Recommendation {
const heavyCensorship = a.useCase === 'censorship' || a.censorship === 'high';
if (heavyCensorship) {
if (a.clientSupport === 'modern') {
return {
protocol: 'VLESS',
transport: 'TCP',
security: 'REALITY + XTLS-Vision',
rationale:
'REALITY disguises traffic as a real TLS site without a certificate, and XTLS-Vision keeps it fast. The best stealth option for heavy censorship — needs a modern client.',
links: [REALITY_LINK, INBOUNDS_LINK],
};
}
return {
protocol: 'VMess',
transport: 'WebSocket',
security: 'TLS',
rationale:
'WebSocket + TLS works through CDNs and is supported by almost every client, making it a resilient fallback when broad client support matters more than peak stealth.',
links: [TRANSPORTS_LINK, INBOUNDS_LINK],
};
}
if (a.useCase === 'speed') {
if (a.clientSupport === 'modern') {
return {
protocol: 'VLESS',
transport: 'TCP',
security: 'REALITY + XTLS-Vision',
rationale:
'XTLS-Vision over raw TCP has the lowest overhead, so it is the fastest option for modern clients.',
links: [REALITY_LINK, TRANSPORTS_LINK],
};
}
return {
protocol: 'Trojan',
transport: 'TCP',
security: 'TLS',
rationale: 'Trojan over TCP + TLS is simple and fast, and is widely supported by clients.',
links: [INBOUNDS_LINK, TRANSPORTS_LINK],
};
}
// general use
if (a.clientSupport === 'modern') {
return {
protocol: 'VLESS',
transport: 'WebSocket',
security: 'TLS',
rationale:
'VLESS + WebSocket + TLS is a flexible, CDN-friendly default for everyday use with modern clients.',
links: [TRANSPORTS_LINK, INBOUNDS_LINK],
};
}
return {
protocol: 'VMess',
transport: 'WebSocket',
security: 'TLS',
rationale: 'VMess + WebSocket + TLS is the most broadly compatible everyday setup.',
links: [TRANSPORTS_LINK, INBOUNDS_LINK],
};
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import {
generateX25519KeyPair,
isX25519Available,
randomShortId,
realityClientLink,
realityServerInbound,
type RealityConfig,
} from './reality';
import { base64UrlToBytes } from './base64';
import { parseLink } from './links';
describe('X25519 keygen', () => {
it('is available in this environment (Node 22+ / modern browser)', () => {
expect(isX25519Available()).toBe(true);
});
it('produces 32-byte base64url keys that differ', async () => {
const { privateKey, publicKey } = await generateX25519KeyPair();
expect(privateKey).not.toMatch(/[+/=]/);
expect(publicKey).not.toMatch(/[+/=]/);
expect(base64UrlToBytes(privateKey)).toHaveLength(32);
expect(base64UrlToBytes(publicKey)).toHaveLength(32);
expect(privateKey).not.toEqual(publicKey);
});
it('generates a fresh keypair each call', async () => {
const a = await generateX25519KeyPair();
const b = await generateX25519KeyPair();
expect(a.privateKey).not.toEqual(b.privateKey);
});
});
describe('randomShortId', () => {
it('returns lowercase hex of the requested byte length', () => {
const id = randomShortId(4);
expect(id).toMatch(/^[0-9a-f]{8}$/);
});
});
const CONFIG: RealityConfig = {
address: 'example.com',
port: 443,
uuid: '11111111-2222-3333-4444-555555555555',
dest: 'www.microsoft.com:443',
serverNames: ['www.microsoft.com'],
shortIds: ['ab12'],
privateKey: 'PRIV',
publicKey: 'PUB',
fingerprint: 'chrome',
spiderX: '/',
flow: 'xtls-rprx-vision',
};
describe('realityClientLink', () => {
it('builds a parseable vless link with the public REALITY params', () => {
const link = realityClientLink(CONFIG);
const parsed = parseLink(link);
expect(parsed.protocol).toBe('vless');
expect(parsed.credential).toBe(CONFIG.uuid);
expect(parsed.port).toBe(443);
expect(parsed.params.security).toBe('reality');
expect(parsed.params.pbk).toBe('PUB');
expect(parsed.params.sid).toBe('ab12');
expect(parsed.params.sni).toBe('www.microsoft.com');
expect(parsed.params.flow).toBe('xtls-rprx-vision');
// The private key must never appear in a client link.
expect(link).not.toContain('PRIV');
});
});
describe('realityServerInbound', () => {
it('produces a vless+reality inbound with the private key', () => {
const inbound = realityServerInbound(CONFIG) as {
protocol: string;
streamSettings: { security: string; realitySettings: { privateKey: string; dest: string } };
};
expect(inbound.protocol).toBe('vless');
expect(inbound.streamSettings.security).toBe('reality');
expect(inbound.streamSettings.realitySettings.privateKey).toBe('PRIV');
expect(inbound.streamSettings.realitySettings.dest).toBe('www.microsoft.com:443');
});
});
+114
View File
@@ -0,0 +1,114 @@
// Pure REALITY helpers: X25519 keygen (WebCrypto) and server/client config
// templating. No React/DOM — runs in the browser and in vitest (Node 22+,
// which exposes globalThis.crypto.subtle with X25519).
import { bytesToBase64Url } from './base64';
import { buildVless } from './links';
export interface X25519KeyPair {
/** base64url-encoded 32-byte private scalar (xray's format). */
privateKey: string;
/** base64url-encoded 32-byte public key. */
publicKey: string;
}
export function isX25519Available(): boolean {
return typeof globalThis.crypto !== 'undefined' && !!globalThis.crypto.subtle;
}
/**
* Generate an X25519 keypair and return raw 32-byte keys as base64url, matching
* the output of `xray x25519`. The private key cannot be exported as 'raw' in
* WebCrypto, so we export PKCS#8 and take the final 32 bytes (the scalar).
*
* `subtle` is injectable so tests can run deterministically against a stub.
*/
export async function generateX25519KeyPair(
subtle: SubtleCrypto = globalThis.crypto.subtle,
): Promise<X25519KeyPair> {
const pair = (await subtle.generateKey({ name: 'X25519' }, true, [
'deriveBits',
])) as CryptoKeyPair;
const rawPublic = new Uint8Array(await subtle.exportKey('raw', pair.publicKey));
const pkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', pair.privateKey));
const rawPrivate = pkcs8.slice(pkcs8.length - 32);
return {
privateKey: bytesToBase64Url(rawPrivate),
publicKey: bytesToBase64Url(rawPublic),
};
}
export function randomShortId(byteLength = 4): string {
const bytes = new Uint8Array(byteLength);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
export function randomUuid(): string {
return globalThis.crypto.randomUUID();
}
export interface RealityConfig {
address: string;
port: number;
uuid: string;
/** Camouflage target, e.g. `www.microsoft.com:443`. */
dest: string;
/** SNI / serverNames; the first is used in the client link. */
serverNames: string[];
shortIds: string[];
privateKey: string;
publicKey: string;
fingerprint: string;
spiderX: string;
flow: string;
}
/** Server-side VLESS + REALITY inbound (Xray config shape). */
export function realityServerInbound(c: RealityConfig): unknown {
return {
listen: null,
port: c.port,
protocol: 'vless',
settings: {
clients: [{ id: c.uuid, flow: c.flow }],
decryption: 'none',
},
streamSettings: {
network: 'tcp',
security: 'reality',
realitySettings: {
show: false,
dest: c.dest,
xver: 0,
serverNames: c.serverNames,
privateKey: c.privateKey,
shortIds: c.shortIds,
fingerprint: c.fingerprint,
},
},
sniffing: { enabled: true, destOverride: ['http', 'tls', 'quic'] },
};
}
/** Client `vless://` share link carrying the public REALITY parameters. */
export function realityClientLink(c: RealityConfig): string {
return buildVless({
credential: c.uuid,
address: c.address,
port: c.port,
params: {
type: 'tcp',
security: 'reality',
pbk: c.publicKey,
fp: c.fingerprint,
sni: c.serverNames[0] ?? '',
sid: c.shortIds[0] ?? '',
spx: c.spiderX,
flow: c.flow,
},
name: `${c.address}-reality`,
});
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { buildProxyConfig, buildCertCommand, type ReverseProxyOptions } from './reverse-proxy';
const base: ReverseProxyOptions = {
server: 'nginx',
domain: 'panel.example.com',
panelPort: '2053',
panelPath: '/panel',
certTool: 'certbot',
};
describe('buildProxyConfig — nginx', () => {
it('includes WebSocket upgrade headers and the upstream port', () => {
const cfg = buildProxyConfig(base);
expect(cfg).toContain('server_name panel.example.com;');
expect(cfg).toContain('proxy_pass http://127.0.0.1:2053;');
expect(cfg).toContain('proxy_set_header Upgrade $http_upgrade;');
expect(cfg).toContain('proxy_set_header Connection "upgrade";');
expect(cfg).toContain('location /panel/');
});
});
describe('buildProxyConfig — caddy', () => {
it('produces a path-scoped reverse_proxy', () => {
const cfg = buildProxyConfig({ ...base, server: 'caddy' });
expect(cfg).toContain('panel.example.com {');
expect(cfg).toContain('reverse_proxy /panel/* 127.0.0.1:2053');
});
it('proxies the whole site when path is root', () => {
const cfg = buildProxyConfig({ ...base, server: 'caddy', panelPath: '/' });
expect(cfg).toContain('reverse_proxy 127.0.0.1:2053');
});
});
describe('buildCertCommand', () => {
it('supports certbot and acme.sh', () => {
expect(buildCertCommand(base)).toContain('certbot certonly --nginx -d panel.example.com');
expect(buildCertCommand({ ...base, certTool: 'acme.sh' })).toContain(
'acme.sh --issue -d panel.example.com',
);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Pure builders for reverse-proxy configs (Nginx / Caddy) and cert commands.
export type ProxyServer = 'nginx' | 'caddy';
export type CertTool = 'certbot' | 'acme.sh';
export interface ReverseProxyOptions {
server: ProxyServer;
domain: string;
panelPort: string;
/** Web base path the panel is served under, e.g. `/panel`. */
panelPath: string;
certTool: CertTool;
}
function normalizePath(path: string): string {
const p = path.trim();
if (!p) return '/';
return p.startsWith('/') ? p : `/${p}`;
}
export function buildProxyConfig(o: ReverseProxyOptions): string {
return o.server === 'nginx' ? buildNginx(o) : buildCaddy(o);
}
function buildNginx(o: ReverseProxyOptions): string {
const path = normalizePath(o.panelPath);
const loc = path === '/' ? '/' : `${path.replace(/\/$/, '')}/`;
return `server {
listen 443 ssl http2;
server_name ${o.domain};
ssl_certificate /etc/letsencrypt/live/${o.domain}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${o.domain}/privkey.pem;
location ${loc} {
proxy_pass http://127.0.0.1:${o.panelPort};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}`;
}
function buildCaddy(o: ReverseProxyOptions): string {
const path = normalizePath(o.panelPath);
if (path === '/') {
return `${o.domain} {
reverse_proxy 127.0.0.1:${o.panelPort}
}`;
}
const matcher = `${path.replace(/\/$/, '')}/*`;
return `${o.domain} {
reverse_proxy ${matcher} 127.0.0.1:${o.panelPort}
}`;
}
export function buildCertCommand(o: ReverseProxyOptions): string {
if (o.certTool === 'certbot') {
return `certbot certonly --nginx -d ${o.domain}`;
}
return `acme.sh --issue -d ${o.domain} --nginx`;
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import {
buildBalancer,
buildRule,
buildObservatory,
buildRouting,
buildRoutingJson,
type RoutingInput,
} from './routing';
describe('buildBalancer', () => {
it('emits tag, selector, and strategy.type; omits fallbackTag when empty', () => {
const b = buildBalancer({ tag: 'lb', selector: ['proxy', 'hk-'], strategy: 'roundRobin' });
expect(b.tag).toBe('lb');
expect(b.selector).toEqual(['proxy', 'hk-']);
expect((b.strategy as Record<string, unknown>).type).toBe('roundRobin');
expect('fallbackTag' in b).toBe(false);
});
it('includes fallbackTag when set', () => {
const b = buildBalancer({ tag: 'lb', selector: ['a'], strategy: 'random', fallbackTag: 'direct' });
expect(b.fallbackTag).toBe('direct');
});
});
describe('buildRule', () => {
it('emits only the matchers that are set, with outboundTag', () => {
const r = buildRule({ domain: ['geosite:google'], target: { kind: 'outbound', tag: 'warp' } });
expect(r.type).toBe('field');
expect(r.domain).toEqual(['geosite:google']);
expect('ip' in r).toBe(false);
expect(r.outboundTag).toBe('warp');
expect('balancerTag' in r).toBe(false);
});
it('uses balancerTag (and not outboundTag) when the target is a balancer', () => {
const r = buildRule({ ip: ['geoip:cn'], target: { kind: 'balancer', tag: 'lb' } });
expect(r.balancerTag).toBe('lb');
expect('outboundTag' in r).toBe(false);
expect(r.ip).toEqual(['geoip:cn']);
});
it('passes port + network through and carries ruleTag', () => {
const r = buildRule({
port: '443,8443',
network: 'tcp,udp',
ruleTag: 'r1',
target: { kind: 'outbound', tag: 'direct' },
});
expect(r.port).toBe('443,8443');
expect(r.network).toBe('tcp,udp');
expect(r.ruleTag).toBe('r1');
});
});
describe('buildObservatory', () => {
it('observatory mode emits probeURL default + enableConcurrency, no burst', () => {
const o = buildObservatory({ mode: 'observatory', subjectSelector: ['proxy'] });
const obs = (o as Record<string, Record<string, unknown>>).observatory;
expect(obs.probeURL).toBe('https://www.google.com/generate_204');
expect(obs.subjectSelector).toEqual(['proxy']);
expect(obs.enableConcurrency).toBe(true);
expect('burstObservatory' in o).toBe(false);
});
it('burst mode emits pingConfig.destination default and no observatory', () => {
const o = buildObservatory({ mode: 'burst', subjectSelector: ['proxy'] });
const burst = (o as Record<string, Record<string, Record<string, unknown>>>).burstObservatory;
expect(burst.pingConfig.destination).toBe('https://www.google.com/generate_204');
expect('observatory' in o).toBe(false);
});
});
describe('buildRouting', () => {
const base: RoutingInput = {
balancers: [{ tag: 'lb', selector: ['proxy'], strategy: 'random' }],
rules: [{ domain: ['geosite:category-ads-all'], target: { kind: 'outbound', tag: 'block' } }],
};
it('nests rules and balancers under routing', () => {
const out = buildRouting(base) as { routing: { rules: unknown[]; balancers: unknown[] } };
expect(out.routing.rules).toHaveLength(1);
expect(out.routing.balancers).toHaveLength(1);
});
it('puts observatory at the TOP level (not under routing) for a leastPing balancer', () => {
const out = buildRouting({
...base,
balancers: [{ tag: 'lb', selector: ['proxy'], strategy: 'leastPing' }],
}) as Record<string, Record<string, unknown>>;
expect(out.observatory).toBeDefined();
expect('observatory' in out.routing).toBe(false);
});
it('uses a burstObservatory for a leastLoad balancer', () => {
const out = buildRouting({
...base,
balancers: [{ tag: 'lb', selector: ['proxy'], strategy: 'leastLoad' }],
}) as Record<string, unknown>;
expect(out.burstObservatory).toBeDefined();
});
it('carries domainStrategy when set', () => {
const out = buildRouting({ ...base, domainStrategy: 'IPIfNonMatch' }) as {
routing: Record<string, unknown>;
};
expect(out.routing.domainStrategy).toBe('IPIfNonMatch');
});
it('does not auto-add an observatory for random/roundRobin balancers', () => {
const out = buildRouting(base) as Record<string, unknown>;
expect('observatory' in out).toBe(false);
expect('burstObservatory' in out).toBe(false);
});
});
describe('buildRoutingJson', () => {
it('round-trips and is 2-space indented', () => {
const input: RoutingInput = {
balancers: [],
rules: [{ ip: ['geoip:private'], target: { kind: 'outbound', tag: 'direct' } }],
};
const json = buildRoutingJson(input);
expect(json).toContain('\n "');
expect(JSON.parse(json)).toEqual(buildRouting(input));
});
});
+137
View File
@@ -0,0 +1,137 @@
// Pure builders for an Xray routing block — balancers, routing rules, and the
// observatory/burstObservatory health monitors — matching 3x-ui's schemas
// (frontend schemas/routing.ts, observatory.ts, xray.ts):
// - balancers live under `routing.balancers[]`; rules under `routing.rules[]`.
// - `observatory` / `burstObservatory` are TOP-LEVEL (siblings of routing).
// - leastPing pairs with observatory; leastLoad with burstObservatory.
// No React/DOM imports — unit-tested in Node.
export type Strategy = 'random' | 'roundRobin' | 'leastPing' | 'leastLoad';
export type RuleNetwork = 'tcp' | 'udp' | 'tcp,udp';
export type DomainStrategy = 'AsIs' | 'IPIfNonMatch' | 'IPOnDemand';
export interface BalancerInput {
tag: string;
selector: string[];
strategy: Strategy;
fallbackTag?: string;
}
export interface RuleTarget {
kind: 'outbound' | 'balancer';
tag: string;
}
export interface RuleInput {
domain?: string[];
ip?: string[];
port?: string; // "443", "1000-2000", or "443,8443"
network?: RuleNetwork;
protocol?: string[]; // http | tls | quic | bittorrent
inboundTag?: string[];
source?: string[]; // -> sourceIP
target: RuleTarget;
ruleTag?: string;
}
export interface ObservatoryInput {
mode: 'observatory' | 'burst';
subjectSelector: string[];
probeURL?: string; // observatory
probeInterval?: string; // observatory
destination?: string; // burst pingConfig
interval?: string; // burst pingConfig
}
export interface RoutingInput {
domainStrategy?: DomainStrategy;
balancers: BalancerInput[];
rules: RuleInput[];
observatory?: ObservatoryInput;
}
const DEFAULT_PROBE_URL = 'https://www.google.com/generate_204';
const DEFAULT_PROBE_INTERVAL = '1m';
export function buildBalancer(b: BalancerInput): Record<string, unknown> {
const out: Record<string, unknown> = {
tag: b.tag,
selector: b.selector,
strategy: { type: b.strategy },
};
if (b.fallbackTag) out.fallbackTag = b.fallbackTag;
return out;
}
export function buildRule(r: RuleInput): Record<string, unknown> {
const out: Record<string, unknown> = { type: 'field' };
if (r.domain?.length) out.domain = r.domain;
if (r.ip?.length) out.ip = r.ip;
if (r.port) out.port = r.port;
if (r.network) out.network = r.network;
if (r.protocol?.length) out.protocol = r.protocol;
if (r.inboundTag?.length) out.inboundTag = r.inboundTag;
if (r.source?.length) out.sourceIP = r.source;
if (r.ruleTag) out.ruleTag = r.ruleTag;
if (r.target.kind === 'balancer') out.balancerTag = r.target.tag;
else out.outboundTag = r.target.tag;
return out;
}
export function buildObservatory(o: ObservatoryInput): Record<string, unknown> {
if (o.mode === 'observatory') {
return {
observatory: {
subjectSelector: o.subjectSelector,
probeURL: o.probeURL || DEFAULT_PROBE_URL,
probeInterval: o.probeInterval || DEFAULT_PROBE_INTERVAL,
enableConcurrency: true,
},
};
}
return {
burstObservatory: {
subjectSelector: o.subjectSelector,
pingConfig: {
destination: o.destination || DEFAULT_PROBE_URL,
interval: o.interval || DEFAULT_PROBE_INTERVAL,
timeout: '5s',
sampling: 2,
httpMethod: 'HEAD',
},
},
};
}
function uniqueSelectors(balancers: BalancerInput[]): string[] {
return [...new Set(balancers.flatMap((b) => b.selector))];
}
export function buildRouting(input: RoutingInput): Record<string, unknown> {
const routing: Record<string, unknown> = {};
if (input.domainStrategy) routing.domainStrategy = input.domainStrategy;
routing.rules = input.rules.map(buildRule);
routing.balancers = input.balancers.map(buildBalancer);
const out: Record<string, unknown> = { routing };
// Latency-aware strategies need a health monitor. Honor an explicit one;
// otherwise scaffold the matching monitor (observatory for leastPing,
// burstObservatory for leastLoad) selecting the balancers' own selectors.
if (input.observatory) {
Object.assign(out, buildObservatory(input.observatory));
} else if (input.balancers.some((b) => b.strategy === 'leastLoad')) {
Object.assign(out, buildObservatory({ mode: 'burst', subjectSelector: uniqueSelectors(input.balancers) }));
} else if (input.balancers.some((b) => b.strategy === 'leastPing')) {
Object.assign(
out,
buildObservatory({ mode: 'observatory', subjectSelector: uniqueSelectors(input.balancers) }),
);
}
return out;
}
export function buildRoutingJson(input: RoutingInput): string {
return JSON.stringify(buildRouting(input), null, 2);
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest';
import {
buildSubscriptionUrls,
buildShareLinks,
buildBase64Subscription,
buildJsonSubscription,
type SubClient,
type SubUrlInput,
} from './subscription';
import { base64ToText } from './base64';
import { parseLink } from './links';
describe('buildSubscriptionUrls', () => {
const base: SubUrlInput = {
scheme: 'http',
host: 'sub.example.com',
port: 2096,
subPath: '/sub/',
jsonPath: '/json/',
subId: 'ABC',
};
it('builds the sub and json URLs with the port', () => {
const u = buildSubscriptionUrls(base);
expect(u.base64).toBe('http://sub.example.com:2096/sub/ABC');
expect(u.json).toBe('http://sub.example.com:2096/json/ABC');
});
it('omits the port behind a reverse proxy', () => {
const u = buildSubscriptionUrls({
...base,
scheme: 'https',
host: 'example.com',
subPath: '/sub-xxx/',
behindProxy: true,
});
expect(u.base64).toBe('https://example.com/sub-xxx/ABC');
expect(u.json).toBe('https://example.com/json/ABC');
});
it('normalizes a path missing its slashes', () => {
const u = buildSubscriptionUrls({ ...base, subPath: 'sub' });
expect(u.base64).toBe('http://sub.example.com:2096/sub/ABC');
});
it('returns empty strings for an empty subId', () => {
const u = buildSubscriptionUrls({ ...base, subId: '' });
expect(u.base64).toBe('');
expect(u.json).toBe('');
});
});
const vlessClient: SubClient = {
protocol: 'vless',
remark: 'HK-01',
address: 'a.example.com',
port: 443,
id: '11111111-2222-3333-4444-555555555555',
flow: 'xtls-rprx-vision',
network: 'tcp',
security: 'reality',
sni: 'www.microsoft.com',
publicKey: 'PBK',
shortId: 'ab',
fingerprint: 'chrome',
};
describe('buildShareLinks', () => {
it('builds a vless link that round-trips through parseLink', () => {
const [link] = buildShareLinks([vlessClient]);
const parsed = parseLink(link);
expect(parsed.protocol).toBe('vless');
expect(parsed.address).toBe('a.example.com');
expect(parsed.port).toBe(443);
expect(parsed.credential).toBe('11111111-2222-3333-4444-555555555555');
expect(parsed.params.security).toBe('reality');
expect(parsed.name).toBe('HK-01');
});
});
describe('buildBase64Subscription', () => {
it('decodes back to the newline-joined links', () => {
const trojan: SubClient = {
protocol: 'trojan',
remark: 'T',
address: 'b.example.com',
port: 443,
password: 'pw',
network: 'ws',
security: 'tls',
path: '/x',
sni: 'b.example.com',
};
const body = buildBase64Subscription([vlessClient, trojan]);
const decoded = base64ToText(body);
expect(decoded.split('\n')).toEqual(buildShareLinks([vlessClient, trojan]));
});
it('returns empty for no clients', () => {
expect(buildBase64Subscription([])).toBe('');
});
});
describe('buildJsonSubscription', () => {
it('emits a single object for one client with a FLAT proxy outbound (settings.id, no vnext)', () => {
const cfg = JSON.parse(buildJsonSubscription([vlessClient]));
expect(Array.isArray(cfg)).toBe(false);
const proxy = cfg.outbounds[0];
expect(proxy.protocol).toBe('vless');
expect(proxy.tag).toBe('proxy');
expect(proxy.settings.id).toBe(vlessClient.id);
expect('vnext' in proxy.settings).toBe(false);
expect(proxy.settings.level).toBe(8);
expect(proxy.settings.flow).toBe('xtls-rprx-vision');
// the default.json skeleton outbounds are preserved after the proxy
expect(cfg.outbounds.some((o: { tag: string }) => o.tag === 'direct')).toBe(true);
expect(cfg.outbounds.some((o: { tag: string }) => o.tag === 'block')).toBe(true);
// sockopt is stripped from JSON-sub streamSettings
expect(JSON.stringify(proxy.streamSettings)).not.toContain('sockopt');
expect(cfg.remarks).toBe('HK-01');
});
it('trojan uses servers[] with a password and no method', () => {
const trojan: SubClient = {
protocol: 'trojan',
remark: 'T',
address: 'b',
port: 443,
password: 'pw',
network: 'tcp',
security: 'tls',
};
const cfg = JSON.parse(buildJsonSubscription([trojan]));
const server = cfg.outbounds[0].settings.servers[0];
expect(server.password).toBe('pw');
expect('method' in server).toBe(false);
});
it('shadowsocks servers[] include a method', () => {
const ss: SubClient = {
protocol: 'ss',
remark: 'S',
address: 'c',
port: 8388,
password: 'pw',
method: 'aes-256-gcm',
};
const cfg = JSON.parse(buildJsonSubscription([ss]));
expect(cfg.outbounds[0].protocol).toBe('shadowsocks');
expect(cfg.outbounds[0].settings.servers[0].method).toBe('aes-256-gcm');
});
it('emits an array for multiple clients', () => {
const json = buildJsonSubscription([vlessClient, { ...vlessClient, remark: 'HK-02' }]);
expect(Array.isArray(JSON.parse(json))).toBe(true);
});
it('returns empty for no clients', () => {
expect(buildJsonSubscription([])).toBe('');
});
});
+249
View File
@@ -0,0 +1,249 @@
// Pure builders for 3x-ui's subscription server: the subscription URLs plus
// previews of the two body formats — Base64 (newline-joined share links,
// standard base64) and JSON (Xray client config, one per client). Grounded in
// internal/sub/{controller,build_urls_test}.go, json_service.go, default.json.
// Reuses links.ts (share-link builders), base64.ts, and outbounds.ts
// (buildStreamSettings). No React/DOM imports.
import { textToBase64 } from './base64';
import { buildVless, buildVmess, buildTrojan, buildShadowsocks } from './links';
import { buildStreamSettings, type Network, type Security } from './outbounds';
export interface SubUrlInput {
scheme: 'http' | 'https';
host: string;
port: number;
subPath: string; // e.g. '/sub/'
jsonPath: string; // e.g. '/json/'
subId: string;
/** When behind a reverse proxy the public URL omits the sub-server port. */
behindProxy?: boolean;
}
export interface SubUrls {
base64: string;
json: string;
}
export interface SubClient {
protocol: 'vless' | 'vmess' | 'trojan' | 'ss';
remark: string;
address: string;
port: number;
// credentials
id?: string; // vless / vmess uuid
password?: string; // trojan / ss
method?: string; // ss cipher
flow?: string; // vless
encryption?: string; // vless server encryption, default 'none'
vmessSecurity?: string; // vmess scy, default 'auto'
// stream (subset, mirrored into share-link params + JSON streamSettings)
network?: Network;
security?: Security;
sni?: string;
fingerprint?: string;
path?: string;
host?: string;
serviceName?: string;
publicKey?: string; // reality
shortId?: string; // reality
}
function normPath(p: string): string {
let s = p.trim();
if (!s.startsWith('/')) s = `/${s}`;
if (!s.endsWith('/')) s = `${s}/`;
return s;
}
export function buildSubscriptionUrls(i: SubUrlInput): SubUrls {
if (!i.subId) return { base64: '', json: '' };
const origin = i.behindProxy ? `${i.scheme}://${i.host}` : `${i.scheme}://${i.host}:${i.port}`;
return {
base64: `${origin}${normPath(i.subPath)}${i.subId}`,
json: `${origin}${normPath(i.jsonPath)}${i.subId}`,
};
}
function streamParams(c: SubClient): Record<string, string> {
const p: Record<string, string> = {
type: c.network ?? 'tcp',
security: c.security ?? 'none',
};
if (c.sni) p.sni = c.sni;
if (c.fingerprint) p.fp = c.fingerprint;
if (c.path) p.path = c.path;
if (c.host) p.host = c.host;
if (c.serviceName) p.serviceName = c.serviceName;
if (c.publicKey) p.pbk = c.publicKey;
if (c.shortId) p.sid = c.shortId;
return p;
}
function shareLink(c: SubClient): string {
switch (c.protocol) {
case 'vless': {
const params = streamParams(c);
if (c.flow) params.flow = c.flow;
return buildVless({
credential: c.id ?? '',
address: c.address,
port: c.port,
name: c.remark,
params,
});
}
case 'trojan':
return buildTrojan({
credential: c.password ?? '',
address: c.address,
port: c.port,
name: c.remark,
params: streamParams(c),
});
case 'vmess':
return buildVmess({
ps: c.remark,
add: c.address,
port: c.port,
id: c.id ?? '',
scy: c.vmessSecurity || 'auto',
net: c.network ?? 'tcp',
tls: c.security === 'tls' ? 'tls' : '',
sni: c.sni ?? '',
host: c.host ?? '',
path: c.path ?? '',
});
case 'ss':
return buildShadowsocks({
method: c.method || '',
password: c.password ?? '',
address: c.address,
port: c.port,
name: c.remark,
});
}
}
export function buildShareLinks(clients: SubClient[]): string[] {
return clients.map(shareLink);
}
export function buildBase64Subscription(clients: SubClient[]): string {
if (clients.length === 0) return '';
return textToBase64(buildShareLinks(clients).join('\n'));
}
// The non-outbound skeleton of internal/sub/default.json. A factory so every
// call returns a fresh object (pure, no shared mutation).
function subJsonSkeleton(): Record<string, unknown> {
return {
dns: {
tag: 'dns_out',
queryStrategy: 'UseIP',
servers: [{ address: '8.8.8.8', skipFallback: false }],
},
inbounds: [
{
port: 10808,
protocol: 'mixed',
settings: { auth: 'noauth', udp: true, userLevel: 8 },
sniffing: { destOverride: ['http', 'tls', 'quic', 'fakedns'], enabled: true },
tag: 'mixed',
},
{ port: 10809, protocol: 'http', settings: { userLevel: 8 }, tag: 'http' },
],
log: { loglevel: 'warning' },
policy: {
levels: { '8': { connIdle: 300, downlinkOnly: 1, handshake: 4, uplinkOnly: 1 } },
system: { statsOutboundUplink: true, statsOutboundDownlink: true },
},
routing: {
domainStrategy: 'AsIs',
rules: [{ type: 'field', network: 'tcp,udp', outboundTag: 'proxy' }],
},
stats: {},
};
}
function skeletonOutbounds(): Record<string, unknown>[] {
return [
{
tag: 'direct',
protocol: 'freedom',
settings: { domainStrategy: 'AsIs', redirect: '', noises: [] },
},
{ tag: 'block', protocol: 'blackhole', settings: { response: { type: 'http' } } },
];
}
function proxyOutbound(c: SubClient): Record<string, unknown> {
const streamSettings = buildStreamSettings({
network: c.network ?? 'tcp',
security: c.security ?? 'none',
sni: c.sni,
fingerprint: c.fingerprint,
path: c.path,
host: c.host,
serviceName: c.serviceName,
publicKey: c.publicKey,
shortId: c.shortId,
});
let settings: Record<string, unknown>;
switch (c.protocol) {
case 'vless': {
const s: Record<string, unknown> = {
address: c.address,
port: c.port,
id: c.id ?? '',
encryption: c.encryption || 'none',
level: 8,
};
if (c.flow) s.flow = c.flow;
settings = s;
break;
}
case 'vmess':
settings = {
address: c.address,
port: c.port,
id: c.id ?? '',
security: c.vmessSecurity || 'auto',
level: 8,
};
break;
case 'trojan':
settings = { servers: [{ address: c.address, port: c.port, password: c.password ?? '', level: 8 }] };
break;
case 'ss':
settings = {
servers: [
{ address: c.address, port: c.port, password: c.password ?? '', level: 8, method: c.method || '' },
],
};
break;
}
return {
protocol: c.protocol === 'ss' ? 'shadowsocks' : c.protocol,
tag: 'proxy',
streamSettings,
settings,
};
}
function jsonConfig(c: SubClient): Record<string, unknown> {
return {
remarks: c.remark,
...subJsonSkeleton(),
outbounds: [proxyOutbound(c), ...skeletonOutbounds()],
};
}
export function buildJsonSubscription(clients: SubClient[]): string {
if (clients.length === 0) return '';
const configs = clients.map(jsonConfig);
// 3x-ui returns a single object for one client, an array for several.
return JSON.stringify(configs.length === 1 ? configs[0] : configs, null, 2);
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import {
validateBotToken,
parseAdminIds,
validateRunTime,
telegramApiBase,
renderMessageTemplate,
buildBotConfigSummary,
} from './telegram';
const VALID_TOKEN = '123456789:AAH-abcdefghijklmnopqrstuvwxyz0123456';
describe('validateBotToken', () => {
it('accepts a BotFather-format token and extracts the bot id', () => {
const r = validateBotToken(VALID_TOKEN);
expect(r.valid).toBe(true);
expect(r.botId).toBe('123456789');
});
it('rejects a token with no colon', () => {
expect(validateBotToken('123456789AAH').valid).toBe(false);
});
it('rejects a too-short secret', () => {
expect(validateBotToken('123:short').valid).toBe(false);
});
it('rejects an empty token', () => {
expect(validateBotToken(' ').valid).toBe(false);
});
});
describe('parseAdminIds', () => {
it('splits a comma list into integer ids', () => {
expect(parseAdminIds('111, 222')).toEqual({ ids: [111, 222], invalid: [] });
});
it('accepts negative group ids and captures invalid entries', () => {
expect(parseAdminIds('-1001234567, abc, 42')).toEqual({ ids: [-1001234567, 42], invalid: ['abc'] });
});
it('returns empty for blank input', () => {
expect(parseAdminIds(' ')).toEqual({ ids: [], invalid: [] });
});
});
describe('validateRunTime', () => {
it('accepts the @daily macro', () => {
expect(validateRunTime('@daily')).toMatchObject({ valid: true, kind: 'macro' });
});
it('accepts an @every duration', () => {
expect(validateRunTime('@every 8h')).toMatchObject({ valid: true, kind: 'every' });
});
it('accepts a 5-field cron', () => {
expect(validateRunTime('0 0 * * *')).toMatchObject({ valid: true, kind: 'cron' });
});
it('accepts a 6-field cron', () => {
expect(validateRunTime('*/30 * * * * *')).toMatchObject({ valid: true, kind: 'cron' });
});
it('rejects an unknown macro', () => {
expect(validateRunTime('@bogus').valid).toBe(false);
});
it('rejects a malformed @every duration', () => {
expect(validateRunTime('@every 8x').valid).toBe(false);
});
});
describe('telegramApiBase', () => {
it('builds the bot API base URL', () => {
expect(telegramApiBase(VALID_TOKEN)).toBe(`https://api.telegram.org/bot${VALID_TOKEN}`);
});
});
describe('renderMessageTemplate', () => {
it('substitutes known variables', () => {
expect(renderMessageTemplate('Host {{host}} up {{uptime}}', { host: 'srv', uptime: '3d' })).toBe(
'Host srv up 3d',
);
});
it('leaves unknown variables literal', () => {
expect(renderMessageTemplate('{{a}} {{b}}', { a: 'x' })).toBe('x {{b}}');
});
});
describe('buildBotConfigSummary', () => {
it('emits the panel settings keys with admin ids joined', () => {
const s = buildBotConfigSummary({ token: VALID_TOKEN, adminIds: '111, 222', runTime: '@daily' });
expect(s.tgBotEnable).toBe(true);
expect(s.tgBotToken).toBe(VALID_TOKEN);
expect(s.tgBotChatId).toBe('111,222');
expect(s.tgRunTime).toBe('@daily');
});
});
+115
View File
@@ -0,0 +1,115 @@
// Pure validation + templating helpers for 3x-ui's Telegram bot settings.
// Grounded in internal/web/service/tgbot/tgbot.go (admin ids parsed with
// strconv.ParseInt(_,10,64); token handed to telego.NewBot → api.telegram.org)
// and the panel's tg* settings (tgRunTime uses robfig/cron). No React/DOM.
export interface BotConfig {
token: string;
adminIds: string; // raw comma-separated input
runTime: string; // @daily | @every 8h | 5/6-field cron
}
export interface TokenValidation {
valid: boolean;
botId?: string;
error?: string;
}
export interface AdminIdsResult {
ids: number[];
invalid: string[];
}
export type CronKind = 'macro' | 'every' | 'cron' | 'invalid';
export interface CronValidation {
valid: boolean;
kind: CronKind;
error?: string;
}
// BotFather tokens are <bot-id>:<35+ char secret of [A-Za-z0-9_-]>.
const TOKEN_RE = /^(\d+):[A-Za-z0-9_-]{35,}$/;
export function validateBotToken(token: string): TokenValidation {
const t = token.trim();
if (!t) return { valid: false, error: 'Token is empty.' };
const m = TOKEN_RE.exec(t);
if (!m) {
return { valid: false, error: 'Expected the BotFather format <bot-id>:<35+ char secret>.' };
}
return { valid: true, botId: m[1] };
}
export function parseAdminIds(raw: string): AdminIdsResult {
const ids: number[] = [];
const invalid: string[] = [];
for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
// Telegram chat ids are integers; group/channel ids are negative.
if (/^-?\d+$/.test(part)) ids.push(Number(part));
else invalid.push(part);
}
return { ids, invalid };
}
// robfig/cron predefined macros (note: @reboot is NOT supported).
const CRON_MACROS = new Set([
'@yearly',
'@annually',
'@monthly',
'@weekly',
'@daily',
'@midnight',
'@hourly',
]);
// Go duration: one or more <number><unit> chunks (ns, us/µs, ms, s, m, h).
const GO_DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
export function validateRunTime(s: string): CronValidation {
const v = s.trim();
if (!v) return { valid: false, kind: 'invalid', error: 'Schedule is empty.' };
if (v.startsWith('@every ')) {
const dur = v.slice('@every '.length).trim();
if (GO_DURATION_RE.test(dur)) return { valid: true, kind: 'every' };
return { valid: false, kind: 'invalid', error: `Invalid @every duration: "${dur}".` };
}
if (v.startsWith('@')) {
if (CRON_MACROS.has(v)) return { valid: true, kind: 'macro' };
return { valid: false, kind: 'invalid', error: `Unknown macro: "${v}".` };
}
const fields = v.split(/\s+/);
if (fields.length === 5 || fields.length === 6) return { valid: true, kind: 'cron' };
return {
valid: false,
kind: 'invalid',
error: 'Use a 5/6-field cron, an @macro (e.g. @daily), or @every <duration>.',
};
}
export function telegramApiBase(token: string): string {
return `https://api.telegram.org/bot${token.trim()}`;
}
export function renderMessageTemplate(tpl: string, vars: Record<string, string>): string {
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key: string) =>
key in vars ? vars[key] : `{{${key}}}`,
);
}
export function buildBotConfigSummary(c: BotConfig): {
tgBotEnable: boolean;
tgBotToken: string;
tgBotChatId: string;
tgRunTime: string;
} {
const { ids } = parseAdminIds(c.adminIds);
return {
tgBotEnable: true,
tgBotToken: c.token.trim(),
tgBotChatId: ids.join(','),
tgRunTime: c.runTime.trim(),
};
}