mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-16 10:55:00 +03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 608ef4e401 | |||
| 35b5e0989a | |||
| b177b98ce8 | |||
| 6bd1b8676a | |||
| ff3f42ee2a | |||
| 760f428c41 | |||
| c45ffcdafa | |||
| cb4f2ec92f | |||
| db8b01b8ed | |||
| a66b360c09 | |||
| f8b72de995 | |||
| 6f1dd0d757 | |||
| 319e693276 | |||
| 61885c9126 |
@@ -261,6 +261,11 @@ jobs:
|
|||||||
go version
|
go version
|
||||||
gcc --version
|
gcc --version
|
||||||
|
|
||||||
|
# Version info + manifest resource: a bare stripped Go exe scores high on AV heuristics.
|
||||||
|
PANEL_VERSION=$(tr -d '[:space:]' < internal/config/version)
|
||||||
|
go run github.com/tc-hib/go-winres@v0.3.3 make --arch amd64 \
|
||||||
|
--product-version "$PANEL_VERSION" --file-version "$PANEL_VERSION"
|
||||||
|
|
||||||
# Stamp the commit into per-commit (dev channel) builds only.
|
# Stamp the commit into per-commit (dev channel) builds only.
|
||||||
LDFLAGS="-w -s"
|
LDFLAGS="-w -s"
|
||||||
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
||||||
@@ -273,7 +278,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
|
$retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
|
||||||
mkdir x-ui
|
mkdir x-ui
|
||||||
Copy-Item xui-release.exe x-ui\x-ui.exe
|
Copy-Item xui-release.exe x-ui\3x-ui.exe
|
||||||
mkdir x-ui\bin
|
mkdir x-ui\bin
|
||||||
cd x-ui\bin
|
cd x-ui\bin
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Ignore Go build files
|
# Ignore Go build files
|
||||||
*.exe
|
*.exe
|
||||||
|
*.syso
|
||||||
x-ui.db
|
x-ui.db
|
||||||
x-ui.db-shm
|
x-ui.db-shm
|
||||||
x-ui.db-wal
|
x-ui.db-wal
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
.sponsor-head {
|
.sponsor-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -77,15 +77,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-title {
|
.sponsor-title {
|
||||||
overflow: hidden;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
overflow-wrap: anywhere;
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-text {
|
.sponsor-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ant-color-text-secondary);
|
color: var(--ant-color-text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-visit {
|
.sponsor-visit {
|
||||||
@@ -129,42 +128,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-compact .sponsor-main {
|
.sponsor-card-compact .sponsor-main {
|
||||||
gap: 10px;
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-compact .sponsor-logo {
|
.sponsor-card-compact .sponsor-body,
|
||||||
width: 32px;
|
.sponsor-card-compact .sponsor-head {
|
||||||
height: 32px;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-compact .sponsor-head {
|
.sponsor-card-compact .sponsor-logo {
|
||||||
flex-direction: column;
|
width: 28px;
|
||||||
align-items: flex-start;
|
height: 28px;
|
||||||
gap: 2px;
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-tag {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-title,
|
||||||
|
.sponsor-card-compact .sponsor-text {
|
||||||
|
display: -webkit-box;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-compact .sponsor-title {
|
.sponsor-card-compact .sponsor-title {
|
||||||
display: -webkit-box;
|
|
||||||
max-width: 100%;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: normal;
|
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-compact .sponsor-text {
|
.sponsor-card-compact .sponsor-text {
|
||||||
display: -webkit-box;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 4;
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-compact .sponsor-close {
|
.sponsor-card-compact .sponsor-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
inset-inline-end: 4px;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
margin: 4px 4px 0 0;
|
margin: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,28 +184,37 @@
|
|||||||
.sponsor-card-card .sponsor-main {
|
.sponsor-card-card .sponsor-main {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 16px;
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-card .sponsor-logo {
|
.sponsor-card-card .sponsor-logo {
|
||||||
width: 56px;
|
width: 72px;
|
||||||
height: 56px;
|
height: 72px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-logo-fallback {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-body {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-title {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-card .sponsor-visit {
|
.sponsor-card-card .sponsor-visit {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
font-size: 14px;
|
||||||
|
|
||||||
.sponsor-card-icon {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 6px;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsor-card-icon .sponsor-logo {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
|
|||||||
@@ -7,13 +7,41 @@ import SponsorCard from './SponsorCard';
|
|||||||
const sponsor: Sponsor = {
|
const sponsor: Sponsor = {
|
||||||
id: 'acme-2026-10',
|
id: 'acme-2026-10',
|
||||||
name: 'Acme VPS',
|
name: 'Acme VPS',
|
||||||
slots: ['dashboard', 'sidebar', 'page', 'login'],
|
slots: ['dashboard', 'page', 'login'],
|
||||||
until: '2099-01-01T00:00:00Z',
|
until: '2099-01-01T00:00:00Z',
|
||||||
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
|
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
|
||||||
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
|
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
|
||||||
link: 'https://acme.example/?utm_source=3x-ui',
|
link: 'https://acme.example/?utm_source=3x-ui',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Copy as long as the real sponsors.json entries; each story renders it at its slot's width.
|
||||||
|
const longSponsor: Sponsor = {
|
||||||
|
...sponsor,
|
||||||
|
title: { en: 'Advertise here — reach 3X-UI server admins' },
|
||||||
|
text: {
|
||||||
|
en: 'Put your VPS, hosting or domain brand in front of the admins who use 3X-UI every day. Message us for slots and pricing.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function part(root: HTMLElement, selector: string): HTMLElement {
|
||||||
|
const el = root.querySelector<HTMLElement>(selector);
|
||||||
|
if (!el) throw new Error(`missing ${selector}`);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectInsideCard(root: HTMLElement, selector: string) {
|
||||||
|
const card = part(root, '.sponsor-card').getBoundingClientRect();
|
||||||
|
const box = part(root, selector).getBoundingClientRect();
|
||||||
|
await expect(box.left).toBeGreaterThanOrEqual(card.left - 0.5);
|
||||||
|
await expect(box.right).toBeLessThanOrEqual(card.right + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNotClipped(root: HTMLElement, selector: string) {
|
||||||
|
const el = part(root, selector);
|
||||||
|
await expect(el.scrollWidth).toBeLessThanOrEqual(el.clientWidth + 1);
|
||||||
|
await expect(el.scrollHeight).toBeLessThanOrEqual(el.clientHeight + 1);
|
||||||
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Sponsor/SponsorCard',
|
title: 'Sponsor/SponsorCard',
|
||||||
component: SponsorCard,
|
component: SponsorCard,
|
||||||
@@ -29,9 +57,8 @@ const meta = {
|
|||||||
argTypes: {
|
argTypes: {
|
||||||
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
|
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
|
||||||
variant: {
|
variant: {
|
||||||
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
|
description: 'banner (dashboard), compact (login) or card (Sponsors page).',
|
||||||
},
|
},
|
||||||
iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
|
|
||||||
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
|
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
|
||||||
},
|
},
|
||||||
args: { sponsor },
|
args: { sponsor },
|
||||||
@@ -53,7 +80,7 @@ export const Banner: Story = {
|
|||||||
export const Compact: Story = {
|
export const Compact: Story = {
|
||||||
args: { variant: 'compact', onClose: () => {} },
|
args: { variant: 'compact', onClose: () => {} },
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<div style={{ width: 204 }}>
|
<div style={{ width: 334 }}>
|
||||||
<SponsorCard {...args} />
|
<SponsorCard {...args} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -68,6 +95,45 @@ export const Card: Story = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IconOnly: Story = {
|
export const BannerLongCopy: Story = {
|
||||||
args: { iconOnly: true },
|
args: { variant: 'banner', sponsor: longSponsor, onClose: () => {} },
|
||||||
|
render: (args) => (
|
||||||
|
<div style={{ width: 360 }}>
|
||||||
|
<SponsorCard {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expectNotClipped(canvasElement, '.sponsor-title');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LoginLongCopy: Story = {
|
||||||
|
args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
|
||||||
|
render: (args) => (
|
||||||
|
<div style={{ width: 334 }}>
|
||||||
|
<SponsorCard {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expectNotClipped(canvasElement, '.sponsor-title');
|
||||||
|
await expectNotClipped(canvasElement, '.sponsor-text');
|
||||||
|
await expect(part(canvasElement, '.sponsor-main')).toHaveAttribute(
|
||||||
|
'title',
|
||||||
|
`${longSponsor.title.en}\n${longSponsor.text.en}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageLongCopy: Story = {
|
||||||
|
args: { variant: 'card', sponsor: longSponsor },
|
||||||
|
render: (args) => (
|
||||||
|
<div style={{ width: 320 }}>
|
||||||
|
<SponsorCard {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expectInsideCard(canvasElement, '.sponsor-title');
|
||||||
|
await expectInsideCard(canvasElement, '.sponsor-text');
|
||||||
|
await expectNotClipped(canvasElement, '.sponsor-title');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export type SponsorCardVariant = 'banner' | 'compact' | 'card';
|
|||||||
export interface SponsorCardProps {
|
export interface SponsorCardProps {
|
||||||
sponsor: Sponsor;
|
sponsor: Sponsor;
|
||||||
variant?: SponsorCardVariant;
|
variant?: SponsorCardVariant;
|
||||||
iconOnly?: boolean;
|
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,33 +35,13 @@ function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SponsorCard({
|
export default function SponsorCard({ sponsor, variant = 'banner', onClose }: SponsorCardProps) {
|
||||||
sponsor,
|
|
||||||
variant = 'banner',
|
|
||||||
iconOnly = false,
|
|
||||||
onClose,
|
|
||||||
}: SponsorCardProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.resolvedLanguage || i18n.language || 'en';
|
const lang = i18n.resolvedLanguage || i18n.language || 'en';
|
||||||
const title = pickLocale(sponsor.title, lang) || sponsor.name;
|
const title = pickLocale(sponsor.title, lang) || sponsor.name;
|
||||||
const text = pickLocale(sponsor.text, lang);
|
const text = pickLocale(sponsor.text, lang);
|
||||||
const tag = t('pages.sponsors.tag');
|
const tag = t('pages.sponsors.tag');
|
||||||
|
|
||||||
if (iconOnly) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className="sponsor-card sponsor-card-icon"
|
|
||||||
href={sponsor.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer sponsored"
|
|
||||||
title={`${tag} · ${title}`}
|
|
||||||
aria-label={`${tag}: ${title}`}
|
|
||||||
>
|
|
||||||
<SponsorLogo sponsor={sponsor} />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`sponsor-card sponsor-card-${variant}`}>
|
<div className={`sponsor-card sponsor-card-${variant}`}>
|
||||||
<a
|
<a
|
||||||
@@ -70,6 +49,7 @@ export default function SponsorCard({
|
|||||||
href={sponsor.link}
|
href={sponsor.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer sponsored"
|
rel="noopener noreferrer sponsored"
|
||||||
|
title={variant === 'compact' ? [title, text].filter(Boolean).join('\n') : undefined}
|
||||||
>
|
>
|
||||||
<SponsorLogo sponsor={sponsor} />
|
<SponsorLogo sponsor={sponsor} />
|
||||||
<span className="sponsor-body" dir="auto">
|
<span className="sponsor-body" dir="auto">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||||
import {
|
import {
|
||||||
@@ -9,47 +9,29 @@ import {
|
|||||||
} from '@/lib/sponsors';
|
} from '@/lib/sponsors';
|
||||||
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
|
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
|
||||||
|
|
||||||
const ROTATE_MS = 30_000;
|
|
||||||
|
|
||||||
interface SponsorSlotProps {
|
interface SponsorSlotProps {
|
||||||
slot: Slot;
|
slot: Slot;
|
||||||
variant?: SponsorCardVariant;
|
variant?: SponsorCardVariant;
|
||||||
iconOnly?: boolean;
|
|
||||||
rotate?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SponsorSlot({
|
export default function SponsorSlot({ slot, variant = 'banner', className }: SponsorSlotProps) {
|
||||||
slot,
|
|
||||||
variant = 'banner',
|
|
||||||
iconOnly,
|
|
||||||
rotate,
|
|
||||||
className,
|
|
||||||
}: SponsorSlotProps) {
|
|
||||||
const { data } = useSponsorsQuery();
|
const { data } = useSponsorsQuery();
|
||||||
const [, setDismissTick] = useState(0);
|
const [, setDismissTick] = useState(0);
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
|
|
||||||
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
|
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
|
||||||
const visible = sponsorsForSlot(data.sponsors, slot).filter(
|
const visible = sponsorsForSlot(data.sponsors, slot).filter(
|
||||||
(s) => !isSponsorDismissed(s.id, slot),
|
(s) => !isSponsorDismissed(s.id, slot),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!rotate || visible.length < 2) return;
|
|
||||||
const timer = window.setInterval(() => setIndex((i) => i + 1), ROTATE_MS);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [rotate, visible.length]);
|
|
||||||
|
|
||||||
if (visible.length === 0) return null;
|
if (visible.length === 0) return null;
|
||||||
const sponsor = visible[(rotate ? index : 0) % visible.length];
|
const sponsor = visible[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<SponsorCard
|
<SponsorCard
|
||||||
sponsor={sponsor}
|
sponsor={sponsor}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
iconOnly={iconOnly}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
dismissSponsor(sponsor.id, slot);
|
dismissSponsor(sponsor.id, slot);
|
||||||
setDismissTick((n) => n + 1);
|
setDismissTick((n) => n + 1);
|
||||||
|
|||||||
@@ -247,10 +247,6 @@
|
|||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-sponsor {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-pin {
|
.sidebar-pin {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import { formatPanelVersion } from '@/lib/panel-version';
|
|||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
|
||||||
import './AppSidebar.css';
|
import './AppSidebar.css';
|
||||||
|
|
||||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||||
@@ -452,13 +451,6 @@ export default function AppSidebar() {
|
|||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
<div className="sider-footer">
|
<div className="sider-footer">
|
||||||
<SponsorSlot
|
|
||||||
slot="sidebar"
|
|
||||||
variant="compact"
|
|
||||||
iconOnly={railCollapsed}
|
|
||||||
rotate
|
|
||||||
className="sider-sponsor"
|
|
||||||
/>
|
|
||||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
</Layout.Sider>
|
</Layout.Sider>
|
||||||
@@ -544,7 +536,6 @@ export default function AppSidebar() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="drawer-footer">
|
<div className="drawer-footer">
|
||||||
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
|
|
||||||
<VersionBadge version={panelVersion} />
|
<VersionBadge version={panelVersion} />
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Sponsor } from '@/generated/types';
|
import type { Sponsor } from '@/generated/types';
|
||||||
|
|
||||||
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
|
export type SponsorSlot = 'dashboard' | 'page' | 'login';
|
||||||
|
|
||||||
const DISMISS_KEY = 'xui.sponsor.dismissed';
|
const DISMISS_KEY = 'xui.sponsor.dismissed';
|
||||||
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
|
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -38,25 +38,3 @@ export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now())
|
|||||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
|
|
||||||
export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
|
|
||||||
dashboard: 1,
|
|
||||||
login: 1,
|
|
||||||
sidebar: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface PlacementStatus {
|
|
||||||
count: number;
|
|
||||||
capacity?: number;
|
|
||||||
takenUntil?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// When full, a place frees up once enough bookings end to drop below capacity.
|
|
||||||
export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
|
|
||||||
const booked = sponsorsForSlot(sponsors, slot);
|
|
||||||
const capacity = SLOT_CAPACITY[slot];
|
|
||||||
if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
|
|
||||||
const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
|
|
||||||
return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -543,7 +543,20 @@ export function parseVmessLink(link: string): Raw | null {
|
|||||||
xhttp.path = json.path ?? '/';
|
xhttp.path = json.path ?? '/';
|
||||||
if (json.mode) xhttp.mode = json.mode;
|
if (json.mode) xhttp.mode = json.mode;
|
||||||
applyXhttpStringFromJson(xhttp, json);
|
applyXhttpStringFromJson(xhttp, json);
|
||||||
|
} else if (network === 'kcp') {
|
||||||
|
applyTransportParams(
|
||||||
|
stream,
|
||||||
|
new URLSearchParams({ mtu: String(json.mtu ?? ''), tti: String(json.tti ?? '') }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
// The vmess object carries fm as-is and the mKCP header and seed as type and path.
|
||||||
|
const maskParams = new URLSearchParams();
|
||||||
|
if (typeof json.fm === 'string') maskParams.set('fm', json.fm);
|
||||||
|
if (network === 'kcp') {
|
||||||
|
if (typeof json.type === 'string') maskParams.set('headerType', json.type);
|
||||||
|
if (typeof json.path === 'string') maskParams.set('seed', json.path);
|
||||||
|
}
|
||||||
|
applyFinalMaskParam(stream, maskParams);
|
||||||
if (security === 'tls') {
|
if (security === 'tls') {
|
||||||
const tls = stream.tlsSettings as Raw;
|
const tls = stream.tlsSettings as Raw;
|
||||||
tls.serverName = json.sni ?? '';
|
tls.serverName = json.sni ?? '';
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ export default function MtprotoFields() {
|
|||||||
>
|
>
|
||||||
<InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
|
<InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'pendingHandshakesMaxPerIp']}
|
||||||
|
label={t('pages.inbounds.form.mtgPendingHandshakesMaxPerIp')}
|
||||||
|
tooltip={t('pages.inbounds.form.mtgPendingHandshakesMaxPerIpHint')}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
name={['settings', 'routeThroughXray']}
|
name={['settings', 'routeThroughXray']}
|
||||||
label={t('pages.inbounds.form.mtgRouteThroughXray')}
|
label={t('pages.inbounds.form.mtgRouteThroughXray')}
|
||||||
|
|||||||
@@ -29,30 +29,14 @@
|
|||||||
color: #faad14;
|
color: #faad14;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsors-page .sponsors-section-title {
|
|
||||||
margin: 32px 0 12px;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-yourbrand {
|
.sponsors-yourbrand {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100%;
|
padding: 40px 24px;
|
||||||
min-height: 170px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1.5px dashed var(--ant-color-border);
|
border: 1.5px dashed var(--ant-color-border);
|
||||||
border-radius: var(--ant-border-radius-lg, 8px);
|
border-radius: var(--ant-border-radius-lg, 8px);
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-yourbrand.is-large {
|
|
||||||
align-items: center;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 40px 24px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--bg-card, transparent);
|
background: var(--bg-card, transparent);
|
||||||
}
|
}
|
||||||
@@ -82,58 +66,6 @@
|
|||||||
color: var(--ant-color-text-secondary);
|
color: var(--ant-color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsors-yourbrand:not(.is-large) .ant-btn {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
height: 100%;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
|
||||||
border-radius: var(--ant-border-radius-lg, 8px);
|
|
||||||
background: var(--bg-card, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 17px;
|
|
||||||
color: var(--ant-color-primary);
|
|
||||||
background: var(--ant-color-primary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ant-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-body {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-status {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-status .ant-tag {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sponsors-page .content-area {
|
.sponsors-page .content-area {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
|
import { Button, Col, ConfigProvider, Layout, Row, Spin, Typography } from 'antd';
|
||||||
import {
|
import { CrownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
CrownOutlined,
|
|
||||||
DashboardOutlined,
|
|
||||||
LoginOutlined,
|
|
||||||
MenuUnfoldOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||||
import SponsorCard from '@/components/sponsor/SponsorCard';
|
import SponsorCard from '@/components/sponsor/SponsorCard';
|
||||||
import { IntlUtil } from '@/utils';
|
import { sponsorsForSlot } from '@/lib/sponsors';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
|
||||||
import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
|
|
||||||
import './SponsorsPage.css';
|
import './SponsorsPage.css';
|
||||||
|
|
||||||
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
|
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
|
||||||
@@ -36,10 +27,10 @@ function BecomeButton({ contact, block }: { contact?: string; block?: boolean })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
|
function YourBrandCard({ contact }: { contact?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
|
<div className="sponsors-yourbrand">
|
||||||
<span className="sponsors-yourbrand-icon">
|
<span className="sponsors-yourbrand-icon">
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
</span>
|
</span>
|
||||||
@@ -56,34 +47,6 @@ export default function SponsorsPage() {
|
|||||||
const { data, fetched } = useSponsorsQuery();
|
const { data, fetched } = useSponsorsQuery();
|
||||||
const sponsors = sponsorsForSlot(data.sponsors, 'page');
|
const sponsors = sponsorsForSlot(data.sponsors, 'page');
|
||||||
|
|
||||||
const { datepicker } = useDatepicker();
|
|
||||||
const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
|
|
||||||
{
|
|
||||||
slot: 'dashboard',
|
|
||||||
icon: <DashboardOutlined />,
|
|
||||||
title: t('pages.sponsors.placementDashboard'),
|
|
||||||
desc: t('pages.sponsors.placementDashboardDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slot: 'sidebar',
|
|
||||||
icon: <MenuUnfoldOutlined />,
|
|
||||||
title: t('pages.sponsors.placementSidebar'),
|
|
||||||
desc: t('pages.sponsors.placementSidebarDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slot: 'login',
|
|
||||||
icon: <LoginOutlined />,
|
|
||||||
title: t('pages.sponsors.placementLogin'),
|
|
||||||
desc: t('pages.sponsors.placementLoginDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slot: 'page',
|
|
||||||
icon: <CrownOutlined />,
|
|
||||||
title: t('pages.sponsors.placementPage'),
|
|
||||||
desc: t('pages.sponsors.placementPageDesc'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const pageClass = useMemo(() => {
|
const pageClass = useMemo(() => {
|
||||||
const classes = ['sponsors-page'];
|
const classes = ['sponsors-page'];
|
||||||
if (isDark) classes.push('is-dark');
|
if (isDark) classes.push('is-dark');
|
||||||
@@ -110,63 +73,17 @@ export default function SponsorsPage() {
|
|||||||
|
|
||||||
<Spin spinning={!fetched} delay={200}>
|
<Spin spinning={!fetched} delay={200}>
|
||||||
{sponsors.length === 0 ? (
|
{sponsors.length === 0 ? (
|
||||||
fetched && <YourBrandCard contact={data.contact} large />
|
fetched && <YourBrandCard contact={data.contact} />
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{sponsors.map((sponsor) => (
|
{sponsors.map((sponsor) => (
|
||||||
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
|
<Col key={sponsor.id} xs={24} lg={12}>
|
||||||
<SponsorCard sponsor={sponsor} variant="card" />
|
<SponsorCard sponsor={sponsor} variant="card" />
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
{data.contact && (
|
|
||||||
<Col xs={24} sm={12} xl={8}>
|
|
||||||
<YourBrandCard contact={data.contact} />
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
||||||
<Typography.Title level={5} className="sponsors-section-title">
|
|
||||||
{t('pages.sponsors.placements')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
{placements.map((p) => {
|
|
||||||
const status = placementStatus(data.sponsors, p.slot);
|
|
||||||
return (
|
|
||||||
<Col key={p.slot} xs={24} sm={12} xl={6}>
|
|
||||||
<div className="sponsors-placement">
|
|
||||||
<span className="sponsors-placement-icon">{p.icon}</span>
|
|
||||||
<div className="sponsors-placement-body">
|
|
||||||
<div className="sponsors-placement-title">{p.title}</div>
|
|
||||||
<div className="sponsors-placement-desc">{p.desc}</div>
|
|
||||||
<div className="sponsors-placement-status">
|
|
||||||
{status.takenUntil ? (
|
|
||||||
<Tag color="orange">
|
|
||||||
{t('pages.sponsors.takenUntil', {
|
|
||||||
date: IntlUtil.formatDate(status.takenUntil, datepicker),
|
|
||||||
})}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="green">{t('pages.sponsors.available')}</Tag>
|
|
||||||
)}
|
|
||||||
{status.count > 0 && !status.takenUntil && (
|
|
||||||
<Tag>
|
|
||||||
{t('pages.sponsors.activeCount', {
|
|
||||||
count:
|
|
||||||
status.capacity && status.capacity > 1
|
|
||||||
? `${status.count}/${status.capacity}`
|
|
||||||
: status.count,
|
|
||||||
})}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Row>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export const MtprotoInboundSettingsSchema = z.object({
|
|||||||
// Caps concurrent connections across all users with a fair-share algorithm;
|
// Caps concurrent connections across all users with a fair-share algorithm;
|
||||||
// 0 or unset disables throttling.
|
// 0 or unset disables throttling.
|
||||||
throttleMaxConnections: z.number().int().min(0).optional(),
|
throttleMaxConnections: z.number().int().min(0).optional(),
|
||||||
|
// mtg's [defense.pending-handshakes] max-per-ip; 0 or unset leaves it off.
|
||||||
|
pendingHandshakesMaxPerIp: z.number().int().min(0).optional(),
|
||||||
// When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
|
// When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
|
||||||
// the Xray config so the egress obeys routing rules. `outboundTag` optionally
|
// the Xray config so the egress obeys routing rules. `outboundTag` optionally
|
||||||
// forces that traffic out a specific outbound/balancer. `routeXrayPort` is the
|
// forces that traffic out a specific outbound/balancer. `routeXrayPort` is the
|
||||||
|
|||||||
@@ -305,8 +305,25 @@ describe('mKCP share params', () => {
|
|||||||
// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
|
// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
|
||||||
// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
|
// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
|
||||||
type Mask = { type: string; settings: { header: string; value: string } };
|
type Mask = { type: string; settings: { header: string; value: string } };
|
||||||
|
const vmessKcp = (fields: Record<string, unknown>) =>
|
||||||
|
'vmess://' +
|
||||||
|
Base64.encode(
|
||||||
|
JSON.stringify({
|
||||||
|
v: '2',
|
||||||
|
add: 'h.com',
|
||||||
|
port: 443,
|
||||||
|
id: '11111111-2222-4333-8444-555555555555',
|
||||||
|
net: 'kcp',
|
||||||
|
tls: 'none',
|
||||||
|
...fields,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const parse = (link: string) => {
|
const parse = (link: string) => {
|
||||||
const out = link.startsWith('trojan://') ? parseTrojanLink(link) : parseVlessLink(link);
|
const out = link.startsWith('trojan://')
|
||||||
|
? parseTrojanLink(link)
|
||||||
|
: link.startsWith('vmess://')
|
||||||
|
? parseVmessLink(link)
|
||||||
|
: parseVlessLink(link);
|
||||||
expect(out).not.toBeNull();
|
expect(out).not.toBeNull();
|
||||||
const stream = out!.streamSettings as Record<string, unknown>;
|
const stream = out!.streamSettings as Record<string, unknown>;
|
||||||
const kcp = stream.kcpSettings as { mtu: number; tti: number };
|
const kcp = stream.kcpSettings as { mtu: number; tti: number };
|
||||||
@@ -357,9 +374,42 @@ describe('mKCP share params', () => {
|
|||||||
{ mtu: 1350, tti: 20 },
|
{ mtu: 1350, tti: 20 },
|
||||||
[],
|
[],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'vmess header and seed from type and path',
|
||||||
|
vmessKcp({ type: 'wechat-video', path: 'secret-seed', mtu: 1400, tti: 50 }),
|
||||||
|
{ mtu: 1400, tti: 50 },
|
||||||
|
[
|
||||||
|
['mkcp-legacy', '', 'secret-seed'],
|
||||||
|
['mkcp-legacy', 'wechat', ''],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'vmess fm mkcp-legacy mask wins over path',
|
||||||
|
vmessKcp({
|
||||||
|
type: 'none',
|
||||||
|
path: 's3cr3tSeed',
|
||||||
|
mtu: 1350,
|
||||||
|
tti: 20,
|
||||||
|
fm: JSON.stringify({
|
||||||
|
udp: [{ settings: { header: '', value: 's3cr3tSeed' }, type: 'mkcp-legacy' }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ mtu: 1350, tti: 20 },
|
||||||
|
[['mkcp-legacy', '', 's3cr3tSeed']],
|
||||||
|
],
|
||||||
])('%s', (_name, link, kcp, masks) => {
|
])('%s', (_name, link, kcp, masks) => {
|
||||||
expect(parse(link)).toEqual({ kcp, masks });
|
expect(parse(link)).toEqual({ kcp, masks });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a vmess fm udp mask', () => {
|
||||||
|
const fm = JSON.stringify({
|
||||||
|
udp: [{ type: 'salamander', settings: { password: 'obfspw123' } }],
|
||||||
|
});
|
||||||
|
const out = parseVmessLink(vmessKcp({ type: 'none', fm }));
|
||||||
|
expect((out!.streamSettings as Record<string, unknown>).finalmask).toEqual({
|
||||||
|
udp: [{ type: 'salamander', settings: { password: 'obfspw123' } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseTrojanLink', () => {
|
describe('parseTrojanLink', () => {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test } from 'vitest';
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import {
|
import { SPONSOR_DISMISS_MS, dismissSponsor, isSponsorDismissed, pickLocale } from '@/lib/sponsors';
|
||||||
SPONSOR_DISMISS_MS,
|
|
||||||
dismissSponsor,
|
|
||||||
isSponsorDismissed,
|
|
||||||
pickLocale,
|
|
||||||
placementStatus,
|
|
||||||
} from '@/lib/sponsors';
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -33,10 +27,10 @@ describe('sponsor dismissal', () => {
|
|||||||
const t0 = 1_800_000_000_000;
|
const t0 = 1_800_000_000_000;
|
||||||
|
|
||||||
test('hides only the dismissed slot until the window elapses', () => {
|
test('hides only the dismissed slot until the window elapses', () => {
|
||||||
dismissSponsor('acme', 'sidebar', t0);
|
dismissSponsor('acme', 'login', t0);
|
||||||
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
|
expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
|
||||||
expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
|
expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
|
||||||
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
|
expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prunes expired entries on the next dismiss', () => {
|
test('prunes expired entries on the next dismiss', () => {
|
||||||
@@ -48,51 +42,6 @@ describe('sponsor dismissal', () => {
|
|||||||
|
|
||||||
test('corrupt storage is treated as nothing dismissed', () => {
|
test('corrupt storage is treated as nothing dismissed', () => {
|
||||||
localStorage.setItem('xui.sponsor.dismissed', 'not json');
|
localStorage.setItem('xui.sponsor.dismissed', 'not json');
|
||||||
expect(isSponsorDismissed('acme', 'sidebar', t0)).toBe(false);
|
expect(isSponsorDismissed('acme', 'login', t0)).toBe(false);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('placementStatus', () => {
|
|
||||||
const sp = (id: string, slots: string[], until: string) => ({
|
|
||||||
id,
|
|
||||||
name: id,
|
|
||||||
slots,
|
|
||||||
until,
|
|
||||||
title: {},
|
|
||||||
text: {},
|
|
||||||
link: 'https://x.example/',
|
|
||||||
});
|
|
||||||
|
|
||||||
test('exclusive slot is taken until the latest booking ends', () => {
|
|
||||||
const sponsors = [
|
|
||||||
sp('a', ['dashboard'], '2026-11-01T00:00:00Z'),
|
|
||||||
sp('b', ['dashboard'], '2026-12-15T00:00:00Z'),
|
|
||||||
];
|
|
||||||
expect(placementStatus(sponsors, 'dashboard')).toEqual({
|
|
||||||
count: 2,
|
|
||||||
capacity: 1,
|
|
||||||
takenUntil: '2026-12-15T00:00:00Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar is full at three and frees up when the earliest ends', () => {
|
|
||||||
const sponsors = [
|
|
||||||
sp('a', ['sidebar'], '2026-12-01T00:00:00Z'),
|
|
||||||
sp('b', ['sidebar'], '2026-10-20T00:00:00Z'),
|
|
||||||
sp('c', ['sidebar'], '2026-11-10T00:00:00Z'),
|
|
||||||
];
|
|
||||||
expect(placementStatus(sponsors, 'sidebar')).toEqual({
|
|
||||||
count: 3,
|
|
||||||
capacity: 3,
|
|
||||||
takenUntil: '2026-10-20T00:00:00Z',
|
|
||||||
});
|
|
||||||
expect(placementStatus(sponsors.slice(0, 2), 'sidebar')).toEqual({ count: 2, capacity: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uncapped page slot reports only a count', () => {
|
|
||||||
expect(placementStatus([sp('a', ['page'], '2026-12-01T00:00:00Z')], 'page')).toEqual({
|
|
||||||
count: 1,
|
|
||||||
capacity: undefined,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3.10.0
|
3.10.2
|
||||||
+34
-24
@@ -58,6 +58,10 @@ type Instance struct {
|
|||||||
// fair-share algorithm; zero disables throttling.
|
// fair-share algorithm; zero disables throttling.
|
||||||
ThrottleMaxConnections int
|
ThrottleMaxConnections int
|
||||||
|
|
||||||
|
// PendingHandshakesMaxPerIP caps unauthenticated handshakes held open by one
|
||||||
|
// client IP (mtg-multi >= 1.16.0); zero leaves the limit off.
|
||||||
|
PendingHandshakesMaxPerIP int
|
||||||
|
|
||||||
// PublicIPv4/PublicIPv6 pin the proxy's reachable address the Telegram
|
// PublicIPv4/PublicIPv6 pin the proxy's reachable address the Telegram
|
||||||
// middle proxy needs when clients carry advertising tags; they are omitted
|
// middle proxy needs when clients carry advertising tags; they are omitted
|
||||||
// when empty so mtg auto-detects, and a change forces a restart.
|
// when empty so mtg auto-detects, and a change forces a restart.
|
||||||
@@ -93,6 +97,7 @@ func (inst Instance) structuralFingerprint() string {
|
|||||||
strconv.Itoa(inst.FrontingPort),
|
strconv.Itoa(inst.FrontingPort),
|
||||||
strconv.FormatBool(inst.FrontingProxyProtocol),
|
strconv.FormatBool(inst.FrontingProxyProtocol),
|
||||||
strconv.Itoa(inst.ThrottleMaxConnections),
|
strconv.Itoa(inst.ThrottleMaxConnections),
|
||||||
|
strconv.Itoa(inst.PendingHandshakesMaxPerIP),
|
||||||
strconv.FormatBool(inst.RouteThroughXray),
|
strconv.FormatBool(inst.RouteThroughXray),
|
||||||
strconv.Itoa(inst.XrayRoutePort),
|
strconv.Itoa(inst.XrayRoutePort),
|
||||||
inst.PublicIPv4,
|
inst.PublicIPv4,
|
||||||
@@ -179,13 +184,14 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
ProxyProtocol bool `json:"proxyProtocol"`
|
ProxyProtocol bool `json:"proxyProtocol"`
|
||||||
} `json:"domainFronting"`
|
} `json:"domainFronting"`
|
||||||
PreferIP string `json:"preferIp"`
|
PreferIP string `json:"preferIp"`
|
||||||
ThrottleMaxConnections int `json:"throttleMaxConnections"`
|
ThrottleMaxConnections int `json:"throttleMaxConnections"`
|
||||||
RouteThroughXray bool `json:"routeThroughXray"`
|
PendingHandshakesMaxPerIP int `json:"pendingHandshakesMaxPerIp"`
|
||||||
RouteXrayPort int `json:"routeXrayPort"`
|
RouteThroughXray bool `json:"routeThroughXray"`
|
||||||
PublicIPv4 string `json:"publicIpv4"`
|
RouteXrayPort int `json:"routeXrayPort"`
|
||||||
PublicIPv6 string `json:"publicIpv6"`
|
PublicIPv4 string `json:"publicIpv4"`
|
||||||
Clients []struct {
|
PublicIPv6 string `json:"publicIpv6"`
|
||||||
|
Clients []struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
AdTag string `json:"adTag"`
|
AdTag string `json:"adTag"`
|
||||||
@@ -215,22 +221,23 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
return Instance{}, false
|
return Instance{}, false
|
||||||
}
|
}
|
||||||
return Instance{
|
return Instance{
|
||||||
Id: ib.Id,
|
Id: ib.Id,
|
||||||
Tag: ib.Tag,
|
Tag: ib.Tag,
|
||||||
Listen: ib.Listen,
|
Listen: ib.Listen,
|
||||||
Port: ib.Port,
|
Port: ib.Port,
|
||||||
Secrets: secrets,
|
Secrets: secrets,
|
||||||
Debug: parsed.Debug,
|
Debug: parsed.Debug,
|
||||||
ProxyProtocolListener: parsed.ProxyProtocolListener,
|
ProxyProtocolListener: parsed.ProxyProtocolListener,
|
||||||
PreferIP: parsed.PreferIP,
|
PreferIP: parsed.PreferIP,
|
||||||
FrontingIP: parsed.DomainFronting.IP,
|
FrontingIP: parsed.DomainFronting.IP,
|
||||||
FrontingPort: parsed.DomainFronting.Port,
|
FrontingPort: parsed.DomainFronting.Port,
|
||||||
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
|
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
|
||||||
ThrottleMaxConnections: parsed.ThrottleMaxConnections,
|
ThrottleMaxConnections: parsed.ThrottleMaxConnections,
|
||||||
RouteThroughXray: parsed.RouteThroughXray,
|
PendingHandshakesMaxPerIP: parsed.PendingHandshakesMaxPerIP,
|
||||||
XrayRoutePort: parsed.RouteXrayPort,
|
RouteThroughXray: parsed.RouteThroughXray,
|
||||||
PublicIPv4: strings.TrimSpace(parsed.PublicIPv4),
|
XrayRoutePort: parsed.RouteXrayPort,
|
||||||
PublicIPv6: strings.TrimSpace(parsed.PublicIPv6),
|
PublicIPv4: strings.TrimSpace(parsed.PublicIPv4),
|
||||||
|
PublicIPv6: strings.TrimSpace(parsed.PublicIPv6),
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +530,7 @@ func FreeLocalPort() (int, error) {
|
|||||||
// precede any [section] header in TOML, and [secrets] must be the final section
|
// precede any [section] header in TOML, and [secrets] must be the final section
|
||||||
// so trailing keys are not swallowed by another table. The layout is therefore:
|
// so trailing keys are not swallowed by another table. The layout is therefore:
|
||||||
// top-level scalars (incl. api-bind-to and api-token), then [domain-fronting],
|
// top-level scalars (incl. api-bind-to and api-token), then [domain-fronting],
|
||||||
// [network] and [throttle], then [secret-ad-tags] for clients overriding the
|
// [network], [throttle] and [defense.pending-handshakes], then [secret-ad-tags] for clients overriding the
|
||||||
// global advertising tag, and finally [secrets] with one named secret per
|
// global advertising tag, and finally [secrets] with one named secret per
|
||||||
// active client.
|
// active client.
|
||||||
func renderConfig(inst Instance, apiPort int, apiToken string) string {
|
func renderConfig(inst Instance, apiPort int, apiToken string) string {
|
||||||
@@ -569,6 +576,9 @@ func renderConfig(inst Instance, apiPort int, apiToken string) string {
|
|||||||
if inst.ThrottleMaxConnections > 0 {
|
if inst.ThrottleMaxConnections > 0 {
|
||||||
fmt.Fprintf(&b, "\n[throttle]\nmax-connections = %d\n", inst.ThrottleMaxConnections)
|
fmt.Fprintf(&b, "\n[throttle]\nmax-connections = %d\n", inst.ThrottleMaxConnections)
|
||||||
}
|
}
|
||||||
|
if inst.PendingHandshakesMaxPerIP > 0 {
|
||||||
|
fmt.Fprintf(&b, "\n[defense.pending-handshakes]\nmax-per-ip = %d\n", inst.PendingHandshakesMaxPerIP)
|
||||||
|
}
|
||||||
// Only clients present in [secrets] may appear here: mtg rejects a config
|
// Only clients present in [secrets] may appear here: mtg rejects a config
|
||||||
// whose [secret-ad-tags] names an unknown secret, so a disabled client's
|
// whose [secret-ad-tags] names an unknown secret, so a disabled client's
|
||||||
// override must vanish together with its secret.
|
// override must vanish together with its secret.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestInstanceFromInbound(t *testing.T) {
|
|||||||
Settings: `{"fakeTlsDomain":"example.com",` +
|
Settings: `{"fakeTlsDomain":"example.com",` +
|
||||||
`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
|
`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
|
||||||
`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
|
`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
|
||||||
`"throttleMaxConnections":5000,` +
|
`"throttleMaxConnections":5000,"pendingHandshakesMaxPerIp":64,` +
|
||||||
`"routeThroughXray":true,"routeXrayPort":50000,` +
|
`"routeThroughXray":true,"routeXrayPort":50000,` +
|
||||||
`"adTag":" 0123456789abcdef0123456789abcdef ",` +
|
`"adTag":" 0123456789abcdef0123456789abcdef ",` +
|
||||||
`"clients":[` +
|
`"clients":[` +
|
||||||
@@ -60,6 +60,9 @@ func TestInstanceFromInbound(t *testing.T) {
|
|||||||
if inst.ThrottleMaxConnections != 5000 {
|
if inst.ThrottleMaxConnections != 5000 {
|
||||||
t.Fatalf("throttle not parsed: %+v", inst)
|
t.Fatalf("throttle not parsed: %+v", inst)
|
||||||
}
|
}
|
||||||
|
if inst.PendingHandshakesMaxPerIP != 64 {
|
||||||
|
t.Fatalf("pending-handshake limit not parsed: %+v", inst)
|
||||||
|
}
|
||||||
if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
|
if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
|
||||||
t.Fatalf("xray routing not parsed: %+v", inst)
|
t.Fatalf("xray routing not parsed: %+v", inst)
|
||||||
}
|
}
|
||||||
@@ -90,7 +93,7 @@ func TestRenderConfig(t *testing.T) {
|
|||||||
Secrets: []SecretEntry{{Name: "alice", Secret: "ee00"}},
|
Secrets: []SecretEntry{{Name: "alice", Secret: "ee00"}},
|
||||||
Listen: "0.0.0.0", Port: 8443,
|
Listen: "0.0.0.0", Port: 8443,
|
||||||
}, 5000, "")
|
}, 5000, "")
|
||||||
for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[secret-ad-tags]", "api-token"} {
|
for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[defense.pending-handshakes]", "[secret-ad-tags]", "api-token"} {
|
||||||
if strings.Contains(bare, unwanted) {
|
if strings.Contains(bare, unwanted) {
|
||||||
t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
|
t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
|
||||||
}
|
}
|
||||||
@@ -116,9 +119,10 @@ func TestRenderConfig(t *testing.T) {
|
|||||||
Listen: "0.0.0.0", Port: 443,
|
Listen: "0.0.0.0", Port: 443,
|
||||||
Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
|
Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
|
||||||
FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
|
FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
|
||||||
ThrottleMaxConnections: 5000,
|
ThrottleMaxConnections: 5000,
|
||||||
PublicIPv4: "1.2.3.4",
|
PendingHandshakesMaxPerIP: 64,
|
||||||
PublicIPv6: "2001:db8::1",
|
PublicIPv4: "1.2.3.4",
|
||||||
|
PublicIPv6: "2001:db8::1",
|
||||||
}, 6000, "sesame")
|
}, 6000, "sesame")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"debug = true\n",
|
"debug = true\n",
|
||||||
@@ -133,6 +137,7 @@ func TestRenderConfig(t *testing.T) {
|
|||||||
"proxy-protocol = true\n",
|
"proxy-protocol = true\n",
|
||||||
"[throttle]",
|
"[throttle]",
|
||||||
"max-connections = 5000",
|
"max-connections = 5000",
|
||||||
|
"[defense.pending-handshakes]\nmax-per-ip = 64\n",
|
||||||
"[secret-ad-tags]",
|
"[secret-ad-tags]",
|
||||||
`"bob" = "fedcba9876543210fedcba9876543210"`,
|
`"bob" = "fedcba9876543210fedcba9876543210"`,
|
||||||
} {
|
} {
|
||||||
@@ -160,6 +165,9 @@ func TestRenderConfig(t *testing.T) {
|
|||||||
if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[throttle]") {
|
if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[throttle]") {
|
||||||
t.Fatalf("[throttle] must precede [secrets]:\n%s", full)
|
t.Fatalf("[throttle] must precede [secrets]:\n%s", full)
|
||||||
}
|
}
|
||||||
|
if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[defense.pending-handshakes]") {
|
||||||
|
t.Fatalf("[defense.pending-handshakes] must precede [secrets]:\n%s", full)
|
||||||
|
}
|
||||||
if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[secret-ad-tags]") {
|
if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[secret-ad-tags]") {
|
||||||
t.Fatalf("[secret-ad-tags] must precede [secrets]:\n%s", full)
|
t.Fatalf("[secret-ad-tags] must precede [secrets]:\n%s", full)
|
||||||
}
|
}
|
||||||
@@ -244,19 +252,20 @@ func TestFingerprintSplit(t *testing.T) {
|
|||||||
base := Instance{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443}
|
base := Instance{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443}
|
||||||
|
|
||||||
for name, mutate := range map[string]func(*Instance){
|
for name, mutate := range map[string]func(*Instance){
|
||||||
"debug": func(i *Instance) { i.Debug = true },
|
"debug": func(i *Instance) { i.Debug = true },
|
||||||
"listener": func(i *Instance) { i.ProxyProtocolListener = true },
|
"listener": func(i *Instance) { i.ProxyProtocolListener = true },
|
||||||
"preferIp": func(i *Instance) { i.PreferIP = "only-ipv4" },
|
"preferIp": func(i *Instance) { i.PreferIP = "only-ipv4" },
|
||||||
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
|
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
|
||||||
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
|
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
|
||||||
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
|
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
|
||||||
"throttle": func(i *Instance) { i.ThrottleMaxConnections = 5000 },
|
"throttle": func(i *Instance) { i.ThrottleMaxConnections = 5000 },
|
||||||
"routeXray": func(i *Instance) { i.RouteThroughXray = true },
|
"pendingHandshakes": func(i *Instance) { i.PendingHandshakesMaxPerIP = 64 },
|
||||||
"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
|
"routeXray": func(i *Instance) { i.RouteThroughXray = true },
|
||||||
"port": func(i *Instance) { i.Port = 8443 },
|
"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
|
||||||
"listen": func(i *Instance) { i.Listen = "127.0.0.1" },
|
"port": func(i *Instance) { i.Port = 8443 },
|
||||||
"publicIpv4": func(i *Instance) { i.PublicIPv4 = "1.2.3.4" },
|
"listen": func(i *Instance) { i.Listen = "127.0.0.1" },
|
||||||
"publicIpv6": func(i *Instance) { i.PublicIPv6 = "2001:db8::1" },
|
"publicIpv4": func(i *Instance) { i.PublicIPv4 = "1.2.3.4" },
|
||||||
|
"publicIpv6": func(i *Instance) { i.PublicIPv6 = "2001:db8::1" },
|
||||||
} {
|
} {
|
||||||
t.Run("structural/"+name, func(t *testing.T) {
|
t.Run("structural/"+name, func(t *testing.T) {
|
||||||
changed := base
|
changed := base
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
@@ -540,8 +541,8 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
|||||||
proxy["fingerprint"] = certFingerprint
|
proxy["fingerprint"] = certFingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
|
// Salamander obfs (Hysteria2), read from the same finalmask.udp[salamander]
|
||||||
// block the subscription link generator uses.
|
// block as the link generator; a packetSize range is mihomo's gecko obfs.
|
||||||
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
|
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
|
||||||
if udpMasks, ok := finalmask["udp"].([]any); ok {
|
if udpMasks, ok := finalmask["udp"].([]any); ok {
|
||||||
for _, m := range udpMasks {
|
for _, m := range udpMasks {
|
||||||
@@ -553,6 +554,13 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
|||||||
if pw, ok := settings["password"].(string); ok && pw != "" {
|
if pw, ok := settings["password"].(string); ok && pw != "" {
|
||||||
proxy["obfs"] = "salamander"
|
proxy["obfs"] = "salamander"
|
||||||
proxy["obfs-password"] = pw
|
proxy["obfs-password"] = pw
|
||||||
|
packetSize, _ := settings["packetSize"].(string)
|
||||||
|
if gecko := parseHysteriaPacketSize(packetSize); gecko != "" {
|
||||||
|
minStr, maxStr := splitHysteriaPacketSize(gecko)
|
||||||
|
proxy["obfs"] = "gecko"
|
||||||
|
proxy["obfs-min-packet-size"], _ = strconv.Atoi(minStr)
|
||||||
|
proxy["obfs-max-packet-size"], _ = strconv.Atoi(maxStr)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -504,11 +504,15 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, builtinProfileURL(c, scheme, hostWithPort))
|
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, builtinProfileURL(c, scheme, hostWithPort))
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||||
|
|
||||||
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
// Incy's profile rides the raw body; other clients must not see that line (#6062).
|
||||||
|
if IsIncyClient(userAgent) && a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
||||||
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
|
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
|
||||||
if err == nil && strings.TrimSpace(incyRules) != "" {
|
if err == nil && strings.TrimSpace(incyRules) != "" {
|
||||||
result.WriteString(incyRules)
|
result.WriteString(incyRules)
|
||||||
result.WriteString("\n")
|
result.WriteString("\n")
|
||||||
|
// INCY ranks a Routing header above a body line, so the shared
|
||||||
|
// profile must not override INCY's own one here.
|
||||||
|
c.Writer.Header().Del("Routing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -957,6 +961,12 @@ func (a *SUBController) ApplyCommonHeaders(
|
|||||||
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
|
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Happ tab is Happ-only, so INCY reads it as blank and gets the shared
|
||||||
|
// JSON profile instead; INCY takes a routing link of any scheme (#6062).
|
||||||
|
incyClient := c.Request != nil && IsIncyClient(c.GetHeader("User-Agent"))
|
||||||
|
if incyClient {
|
||||||
|
profileEnableRouting, profileRoutingRules = false, ""
|
||||||
|
}
|
||||||
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
|
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
|
||||||
if strings.TrimSpace(profileRoutingRules) == "" {
|
if strings.TrimSpace(profileRoutingRules) == "" {
|
||||||
// Happ/INCY fetch the geo files the baked rules reference through this
|
// Happ/INCY fetch the geo files the baked rules reference through this
|
||||||
@@ -981,5 +991,5 @@ func (a *SUBController) ApplyCommonHeaders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplyHappHeaders(c, a.happConfig, happManaged)
|
ApplyHappHeaders(c, a.happConfig, happManaged)
|
||||||
ApplyIncyHeaders(c, a.incyConfig, a.incyConfig.AutoDetect && c.Request != nil && IsIncyClient(c.GetHeader("User-Agent")))
|
ApplyIncyHeaders(c, a.incyConfig, a.incyConfig.AutoDetect && incyClient)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -802,3 +802,58 @@ func TestLegacyClashEndpointNormalizesShadowsocksCipher(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) {
|
||||||
|
seedSubDB(t)
|
||||||
|
seedSubInbound(t, "s1", "vl", 4492, 1, `{"network":"tcp","security":"none"}`)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
const happRules = "happ://routing/onadd/existing-rules"
|
||||||
|
const incyRules = "incy://routing/onadd/AbC+/="
|
||||||
|
sharedRules := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"Name":"shared","DirectSites":["geosite:category-ru"]}`))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
userAgent string
|
||||||
|
incyRouting bool
|
||||||
|
wantRouting string
|
||||||
|
wantRoutingEnable string
|
||||||
|
wantIncyLine bool
|
||||||
|
}{
|
||||||
|
{name: "happ keeps its own tab and never sees the incy line", path: "/sub/s1", userAgent: "Happ/1.2.0", incyRouting: true, wantRouting: happRules, wantRoutingEnable: "true"},
|
||||||
|
{name: "incy without its own tab gets the shared json profile", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", wantRouting: sharedRules},
|
||||||
|
{name: "incy body profile is not outranked by a routing header", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantIncyLine: true},
|
||||||
|
{name: "incy json documents keep the shared profile header", path: "/json/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantRouting: sharedRules},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
router := gin.New()
|
||||||
|
NewSUBController(router.Group("/"),
|
||||||
|
WithSUBEncryption(false),
|
||||||
|
WithSUBJsonEnabled(true),
|
||||||
|
WithSUBEnableRouting(true),
|
||||||
|
WithSUBRoutingRules(happRules),
|
||||||
|
WithSUBJsonRoutingRules(sharedRules),
|
||||||
|
WithSUBIncyEnableRouting(tc.incyRouting),
|
||||||
|
WithSUBIncyRoutingRules(incyRules),
|
||||||
|
)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tc.path, nil)
|
||||||
|
req.Header.Set("User-Agent", tc.userAgent)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
body := resp.Body.String()
|
||||||
|
if resp.Code != http.StatusOK || !strings.Contains(body, "vless") {
|
||||||
|
t.Fatalf("status=%d, body=%s", resp.Code, body)
|
||||||
|
}
|
||||||
|
if got := resp.Header().Get("Routing"); got != tc.wantRouting {
|
||||||
|
t.Fatalf("Routing = %q, want %q", got, tc.wantRouting)
|
||||||
|
}
|
||||||
|
if got := resp.Header().Get("Routing-Enable"); got != tc.wantRoutingEnable {
|
||||||
|
t.Fatalf("Routing-Enable = %q, want %q", got, tc.wantRoutingEnable)
|
||||||
|
}
|
||||||
|
if got := strings.HasSuffix(body, incyRules+"\n"); got != tc.wantIncyLine || (!got && strings.Contains(body, incyRules)) {
|
||||||
|
t.Fatalf("incy line at body end = %v, want %v; body=%s", got, tc.wantIncyLine, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,3 +150,43 @@ func TestParseHysteriaPacketSizeBounds(t *testing.T) {
|
|||||||
t.Fatalf("valid range = %q", got)
|
t.Fatalf("valid range = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mihomo reads Gecko only from obfs: gecko plus integer packet-size keys; plain
|
||||||
|
// salamander on a Gecko server never completes the handshake.
|
||||||
|
func TestBuildHysteriaProxyExportsGeckoObfs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
settings string
|
||||||
|
want map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "packetSize exports gecko",
|
||||||
|
settings: `{"password":"pw","packetSize":"512-1200"}`,
|
||||||
|
want: map[string]any{
|
||||||
|
"obfs": "gecko", "obfs-password": "pw",
|
||||||
|
"obfs-min-packet-size": 512, "obfs-max-packet-size": 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password only stays salamander",
|
||||||
|
settings: `{"password":"pw"}`,
|
||||||
|
want: map[string]any{"obfs": "salamander", "obfs-password": "pw"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
in := &model.Inbound{
|
||||||
|
Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
|
||||||
|
Settings: `{"version":2}`,
|
||||||
|
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
|
||||||
|
tc.settings + `}]}}`,
|
||||||
|
}
|
||||||
|
proxy := (&SubClashService{}).buildHysteriaProxy(&SubService{}, in, model.Client{Email: "user", Auth: "secret"}, nil)
|
||||||
|
for _, key := range []string{"obfs", "obfs-password", "obfs-min-packet-size", "obfs-max-packet-size"} {
|
||||||
|
if got, want := proxy[key], tc.want[key]; got != want {
|
||||||
|
t.Errorf("%s = %#v, want %#v", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,12 +185,18 @@ func parseVmess(link string) (*ParseResult, error) {
|
|||||||
if m := getString(j, "mode", ""); m != "" {
|
if m := getString(j, "mode", ""); m != "" {
|
||||||
xh["mode"] = m
|
xh["mode"] = m
|
||||||
}
|
}
|
||||||
// xhttp advanced keys are passed through if present in the json
|
for _, k := range vmessXhttpKeys {
|
||||||
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
|
|
||||||
if v, ok := j[k]; ok {
|
if v, ok := j[k]; ok {
|
||||||
xh[k] = v
|
xh[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for legacy, renamed := range xhttpLegacyAliases {
|
||||||
|
if _, ok := xh[renamed]; !ok {
|
||||||
|
if v, ok := j[legacy].(string); ok && v != "" {
|
||||||
|
xh[renamed] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
case "tcp":
|
case "tcp":
|
||||||
if getString(j, "type", "") == "http" {
|
if getString(j, "type", "") == "http" {
|
||||||
stream["tcpSettings"] = map[string]any{
|
stream["tcpSettings"] = map[string]any{
|
||||||
@@ -205,8 +211,18 @@ func parseVmess(link string) (*ParseResult, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "kcp":
|
||||||
|
applyTransport(stream, url.Values{"mtu": {strconv.Itoa(num(j["mtu"]))}, "tti": {strconv.Itoa(num(j["tti"]))}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The vmess object carries fm as-is and the mKCP header and seed as type and path.
|
||||||
|
maskParams := url.Values{"fm": {getString(j, "fm", "")}}
|
||||||
|
if network == "kcp" {
|
||||||
|
maskParams.Set("headerType", getString(j, "type", ""))
|
||||||
|
maskParams.Set("seed", getString(j, "path", ""))
|
||||||
|
}
|
||||||
|
applyFinalMask(stream, maskParams)
|
||||||
|
|
||||||
if security == "tls" {
|
if security == "tls" {
|
||||||
tls := stream["tlsSettings"].(map[string]any)
|
tls := stream["tlsSettings"].(map[string]any)
|
||||||
tls["serverName"] = getString(j, "sni", "")
|
tls["serverName"] = getString(j, "sni", "")
|
||||||
@@ -248,6 +264,21 @@ func parseVmess(link string) (*ParseResult, error) {
|
|||||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vmessXhttpKeys are the XHTTP fields a vmess link carries inline, where vless/trojan put them in extra.
|
||||||
|
var vmessXhttpKeys = []string{
|
||||||
|
"xPaddingBytes", "xPaddingObfsMode", "xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod",
|
||||||
|
"sessionIDPlacement", "sessionIDKey", "sessionIDTable", "sessionIDLength",
|
||||||
|
"seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "uplinkHTTPMethod", "uplinkChunkSize",
|
||||||
|
"scMaxEachPostBytes", "scMinPostsIntervalMs", "scMaxBufferedPosts", "scStreamUpServerSecs",
|
||||||
|
"serverMaxHeaderBytes", "noSSEHeader", "noGRPCHeader", "xmux", "downloadSettings", "headers",
|
||||||
|
}
|
||||||
|
|
||||||
|
// xhttpLegacyAliases maps the pre-rename session keys older links carry onto the current ones.
|
||||||
|
var xhttpLegacyAliases = map[string]string{
|
||||||
|
"sessionPlacement": "sessionIDPlacement",
|
||||||
|
"sessionKey": "sessionIDKey",
|
||||||
|
}
|
||||||
|
|
||||||
func vmessIdentity(j map[string]any) string {
|
func vmessIdentity(j map[string]any) string {
|
||||||
// Remove ps (remark) for identity
|
// Remove ps (remark) for identity
|
||||||
core := map[string]any{}
|
core := map[string]any{}
|
||||||
|
|||||||
@@ -200,6 +200,52 @@ func TestParse_XhttpExtraAndSnakeCaseFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParse_VmessXhttpInlineFields(t *testing.T) {
|
||||||
|
// The vmess emitter writes the XHTTP extra fields at the top level of the link JSON.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
extra string
|
||||||
|
want map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "session placement and padding obfs",
|
||||||
|
extra: `"sessionIDPlacement":"header","sessionIDKey":"X-Sid","sessionPlacement":"header","sessionKey":"X-Sid","xPaddingObfsMode":true,"xPaddingKey":"pk","xPaddingHeader":"X-Pad","xPaddingPlacement":"header","xPaddingMethod":"tokenish"`,
|
||||||
|
want: map[string]any{
|
||||||
|
"sessionIDPlacement": "header", "sessionIDKey": "X-Sid", "xPaddingObfsMode": true,
|
||||||
|
"xPaddingKey": "pk", "xPaddingHeader": "X-Pad", "xPaddingPlacement": "header", "xPaddingMethod": "tokenish",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uplink and xmux",
|
||||||
|
extra: `"uplinkDataPlacement":"cookie","uplinkDataKey":"d","uplinkHTTPMethod":"PUT","uplinkChunkSize":4096,"xmux":{"maxConcurrency":"16-32"}`,
|
||||||
|
want: map[string]any{
|
||||||
|
"uplinkDataPlacement": "cookie", "uplinkDataKey": "d", "uplinkHTTPMethod": "PUT",
|
||||||
|
"uplinkChunkSize": float64(4096), "xmux": map[string]any{"maxConcurrency": "16-32"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy session keys",
|
||||||
|
extra: `"sessionPlacement":"query","sessionKey":"sid"`,
|
||||||
|
want: map[string]any{"sessionIDPlacement": "query", "sessionIDKey": "sid"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"xhttp","path":"/xh","mode":"packet-up","tls":"none",` + c.extra + `}`
|
||||||
|
res, err := ParseLink("vmess://" + base64.StdEncoding.EncodeToString([]byte(inner)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
xh := streamSub(t, res, "xhttpSettings")
|
||||||
|
for k, want := range c.want {
|
||||||
|
if !reflect.DeepEqual(xh[k], want) {
|
||||||
|
t.Errorf("xhttpSettings.%s = %#v, want %#v", k, xh[k], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParse_VmessWSPathWithoutHostKey(t *testing.T) {
|
func TestParse_VmessWSPathWithoutHostKey(t *testing.T) {
|
||||||
inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","path":"/api","tls":"tls"}`
|
inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","path":"/api","tls":"tls"}`
|
||||||
link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
|
link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
|
||||||
@@ -286,6 +332,11 @@ func mkcpLegacyMasks(t *testing.T, res *ParseResult) []mkcpMask {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func vmessKcpLink(fields string) string {
|
||||||
|
inner := `{"v":"2","add":"h.com","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"kcp","tls":"none",` + fields + `}`
|
||||||
|
return "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
|
||||||
|
}
|
||||||
|
|
||||||
func TestParse_KcpShareParams(t *testing.T) {
|
func TestParse_KcpShareParams(t *testing.T) {
|
||||||
// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
|
// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
|
||||||
// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
|
// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
|
||||||
@@ -323,6 +374,20 @@ func TestParse_KcpShareParams(t *testing.T) {
|
|||||||
wantMTU: 1350,
|
wantMTU: 1350,
|
||||||
wantTTI: 20,
|
wantTTI: 20,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "vmess header and seed from type and path",
|
||||||
|
link: vmessKcpLink(`"type":"wechat-video","path":"secret-seed","mtu":1400,"tti":50`),
|
||||||
|
wantMTU: 1400,
|
||||||
|
wantTTI: 50,
|
||||||
|
wantMasks: []mkcpMask{{"", "secret-seed"}, {"wechat", ""}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vmess fm mkcp-legacy mask wins over path",
|
||||||
|
link: vmessKcpLink(`"type":"none","path":"s3cr3tSeed","mtu":1350,"tti":20,"fm":"{\"udp\":[{\"settings\":{\"header\":\"\",\"value\":\"s3cr3tSeed\"},\"type\":\"mkcp-legacy\"}]}"`),
|
||||||
|
wantMTU: 1350,
|
||||||
|
wantTTI: 20,
|
||||||
|
wantMasks: []mkcpMask{{"", "s3cr3tSeed"}},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
@@ -340,3 +405,15 @@ func TestParse_KcpShareParams(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParse_VmessFinalMask(t *testing.T) {
|
||||||
|
res, err := ParseLink(vmessKcpLink(`"type":"none","fm":"{\"udp\":[{\"settings\":{\"password\":\"obfspw123\"},\"type\":\"salamander\"}]}"`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
udp := finalmaskUDP(t, res)
|
||||||
|
want := []any{map[string]any{"type": "salamander", "settings": map[string]any{"password": "obfspw123"}}}
|
||||||
|
if !reflect.DeepEqual(udp, want) {
|
||||||
|
t.Fatalf("finalmask.udp = %#v, want %#v", udp, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -324,3 +324,35 @@ func TestBulkAttach_PreservesVisionFlowWhenCanonicalColumnZeroed(t *testing.T) {
|
|||||||
t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList)
|
t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A client detached from its last inbound has no effective flow left to settle
|
||||||
|
// its record to, so the record keeps the flow a later attach falls back on.
|
||||||
|
func TestRecordFlow_DetachedFromLastInboundKeepsFlow(t *testing.T) {
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
||||||
|
|
||||||
|
reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 43001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
|
||||||
|
if err := database.GetDB().Create(reality).Error; err != nil {
|
||||||
|
t.Fatalf("create reality inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := ClientService{}
|
||||||
|
const email = "detached@example.com"
|
||||||
|
const vision = "xtls-rprx-vision"
|
||||||
|
client := model.Client{Email: email, ID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c333", Enable: true, Flow: vision}
|
||||||
|
if err := svc.SyncInbound(nil, reality.Id, []model.Client{client}); err != nil {
|
||||||
|
t.Fatalf("SyncInbound(attach): %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.SyncInbound(nil, reality.Id, nil); err != nil {
|
||||||
|
t.Fatalf("SyncInbound(detach): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := svc.GetRecordByEmail(nil, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRecordByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Flow != vision {
|
||||||
|
t.Errorf("record flow after detaching its last inbound = %q, want %q", rec.Flow, vision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
tx = database.GetDB()
|
tx = database.GetDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.validateProtocolClients(tx, inboundId, clients, detachEmails, prune); err != nil {
|
var inbound model.Inbound
|
||||||
|
if err := tx.Select("protocol").Where("id = ?", inboundId).Take(&inbound).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateProtocolClients(tx, inbound.Protocol, inboundId, clients, detachEmails, prune); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +141,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isTunnel := inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG
|
||||||
|
var onLowerTunnel map[int]struct{}
|
||||||
|
if isTunnel && len(existing) > 0 {
|
||||||
|
ids := make([]int, 0, len(existing))
|
||||||
|
for _, row := range existing {
|
||||||
|
ids = append(ids, row.Id)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, ids); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
idByEmail := make(map[string]int, len(emails))
|
idByEmail := make(map[string]int, len(emails))
|
||||||
pending := make(map[string]*model.ClientRecord, len(emails))
|
pending := make(map[string]*model.ClientRecord, len(emails))
|
||||||
toCreate := make([]*model.ClientRecord, 0, len(emails))
|
toCreate := make([]*model.ClientRecord, 0, len(emails))
|
||||||
@@ -161,6 +178,8 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
|
|
||||||
before := *row
|
before := *row
|
||||||
applyClientRecordMerge(row, incoming)
|
applyClientRecordMerge(row, incoming)
|
||||||
|
_, outranked := onLowerTunnel[row.Id]
|
||||||
|
keepLinkOwnedFields(row, &before, isTunnel && !outranked)
|
||||||
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
||||||
row.UpdatedAt = preservedUpdatedAt
|
row.UpdatedAt = preservedUpdatedAt
|
||||||
|
|
||||||
@@ -222,7 +241,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
wantedIds = append(wantedIds, id)
|
wantedIds = append(wantedIds, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil {
|
relinked, err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := settleRecordFlows(tx, relinked); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,19 +283,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileInboundLinks writes only the client_inbounds rows that differ. prune
|
// reconcileInboundLinks writes only the client_inbounds rows that differ and returns
|
||||||
// also removes links absent from wantedFlow, which only a full sync may do.
|
// their clients. prune also removes links absent from wantedFlow, as only a full sync may.
|
||||||
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) error {
|
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) ([]int, error) {
|
||||||
var current []model.ClientInbound
|
var current []model.ClientInbound
|
||||||
if prune {
|
if prune {
|
||||||
if err := tx.Where("inbound_id = ?", inboundId).Find(¤t).Error; err != nil {
|
if err := tx.Where("inbound_id = ?", inboundId).Find(¤t).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, batch := range chunkInts(wantedIds, sqlInChunk) {
|
for _, batch := range chunkInts(wantedIds, sqlInChunk) {
|
||||||
var rows []model.ClientInbound
|
var rows []model.ClientInbound
|
||||||
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
|
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
current = append(current, rows...)
|
current = append(current, rows...)
|
||||||
}
|
}
|
||||||
@@ -300,7 +323,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
|
|||||||
for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
|
for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
|
||||||
var ids []int
|
var ids []int
|
||||||
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
|
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if _, keep := wantedFlow[id]; !keep {
|
if _, keep := wantedFlow[id]; !keep {
|
||||||
@@ -325,7 +348,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
|
|||||||
for _, batch := range chunkInts(toDelete, sqlInChunk) {
|
for _, batch := range chunkInts(toDelete, sqlInChunk) {
|
||||||
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
||||||
Delete(&model.ClientInbound{}).Error; err != nil {
|
Delete(&model.ClientInbound{}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for flow, ids := range toUpdate {
|
for flow, ids := range toUpdate {
|
||||||
@@ -333,7 +356,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
|
|||||||
if err := tx.Model(&model.ClientInbound{}).
|
if err := tx.Model(&model.ClientInbound{}).
|
||||||
Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
||||||
Update("flow_override", flow).Error; err != nil {
|
Update("flow_override", flow).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,8 +367,87 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
|
|||||||
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
|
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
|
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
|
||||||
}).CreateInBatches(toInsert, 200).Error; err != nil {
|
}).CreateInBatches(toInsert, 200).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relinked := append([]int(nil), toDelete...)
|
||||||
|
for _, ids := range toUpdate {
|
||||||
|
relinked = append(relinked, ids...)
|
||||||
|
}
|
||||||
|
for _, link := range toInsert {
|
||||||
|
relinked = append(relinked, link.ClientId)
|
||||||
|
}
|
||||||
|
return relinked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepLinkOwnedFields undoes the merge of the record fields one inbound cannot decide
|
||||||
|
// alone, or syncing a client's inbounds in turn rewrites the record back and forth.
|
||||||
|
func keepLinkOwnedFields(row, before *model.ClientRecord, ownsTunnelPeer bool) {
|
||||||
|
row.Flow = before.Flow
|
||||||
|
if ownsTunnelPeer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row.PrivateKey = before.PrivateKey
|
||||||
|
row.PublicKey = before.PublicKey
|
||||||
|
row.AllowedIPs = before.AllowedIPs
|
||||||
|
row.PreSharedKey = before.PreSharedKey
|
||||||
|
row.KeepAlive = before.KeepAlive
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientsOnLowerTunnel returns the clients also attached to a WireGuard/AmneziaWG
|
||||||
|
// inbound with a lower id than inboundId: that tunnel owns their record's peer fields.
|
||||||
|
func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, clientIds []int) (map[int]struct{}, error) {
|
||||||
|
out := make(map[int]struct{})
|
||||||
|
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
||||||
|
var ids []int
|
||||||
|
if err := tx.Table("client_inbounds").
|
||||||
|
Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
|
||||||
|
Where("client_inbounds.client_id IN ? AND client_inbounds.inbound_id < ? AND inbounds.protocol IN ?",
|
||||||
|
batch, inboundId, []model.Protocol{model.WireGuard, model.AmneziaWG}).
|
||||||
|
Distinct().Pluck("client_inbounds.client_id", &ids).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
out[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleRecordFlows sets the record flow of each still-linked client to its
|
||||||
|
// effective flow (EffectiveFlow's rule); an unlinked record keeps its own.
|
||||||
|
func settleRecordFlows(tx *gorm.DB, clientIds []int) error {
|
||||||
|
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
||||||
|
var links []model.ClientInbound
|
||||||
|
if err := tx.Select("client_id", "flow_override").
|
||||||
|
Where("client_id IN ?", batch).
|
||||||
|
Order("inbound_id ASC").
|
||||||
|
Find(&links).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
effective := make(map[int]string, len(batch))
|
||||||
|
for _, link := range links {
|
||||||
|
if flow, seen := effective[link.ClientId]; !seen || (flow == "" && link.FlowOverride != "") {
|
||||||
|
effective[link.ClientId] = link.FlowOverride
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var records []model.ClientRecord
|
||||||
|
if err := tx.Select("id", "flow").Where("id IN ?", batch).Find(&records).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idsByFlow := make(map[string][]int)
|
||||||
|
for _, rec := range records {
|
||||||
|
if flow, linked := effective[rec.Id]; linked && flow != rec.Flow {
|
||||||
|
idsByFlow[flow] = append(idsByFlow[flow], rec.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for flow, ids := range idsByFlow {
|
||||||
|
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", ids).
|
||||||
|
UpdateColumn("flow", flow).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -418,12 +520,8 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
|
|||||||
|
|
||||||
// validateProtocolClients runs inside the write every path linking clients to an inbound
|
// validateProtocolClients runs inside the write every path linking clients to an inbound
|
||||||
// shares, so a client the protocol's core config would refuse never reaches it.
|
// shares, so a client the protocol's core config would refuse never reaches it.
|
||||||
func (s *ClientService) validateProtocolClients(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
|
func (s *ClientService) validateProtocolClients(tx *gorm.DB, protocol model.Protocol, inboundID int, changed []model.Client, detached []string, prune bool) error {
|
||||||
var inbound model.Inbound
|
switch protocol {
|
||||||
if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch inbound.Protocol {
|
|
||||||
case model.TUIC:
|
case model.TUIC:
|
||||||
return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
|
return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
|
||||||
case model.MASQUE:
|
case model.MASQUE:
|
||||||
|
|||||||
@@ -224,6 +224,43 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
|
|||||||
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeNodeClientTraffic folds one node-reported client into its central row.
|
||||||
|
// While frozen (a push pending or just landed) only the counters may move.
|
||||||
|
func (s *InboundService) mergeNodeClientTraffic(tx *gorm.DB, cs xray.ClientTraffic, frozen bool, deltaUp, deltaDown, now int64) error {
|
||||||
|
if frozen {
|
||||||
|
return tx.Exec(
|
||||||
|
fmt.Sprintf(
|
||||||
|
`UPDATE client_traffics
|
||||||
|
SET up = %s, down = %s, last_online = %s
|
||||||
|
WHERE email = ?`,
|
||||||
|
database.ClampedAddExpr("up"),
|
||||||
|
database.ClampedAddExpr("down"),
|
||||||
|
database.GreatestExpr("last_online", "?"),
|
||||||
|
),
|
||||||
|
deltaUp, deltaDown, cs.LastOnline, cs.Email,
|
||||||
|
).Error
|
||||||
|
}
|
||||||
|
return tx.Exec(
|
||||||
|
fmt.Sprintf(
|
||||||
|
`UPDATE client_traffics
|
||||||
|
SET up = %s, down = %s, enable = %s, total = ?,
|
||||||
|
expiry_time = %s,
|
||||||
|
reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
|
||||||
|
WHERE email = ?`,
|
||||||
|
database.ClampedAddExpr("up"),
|
||||||
|
database.ClampedAddExpr("down"),
|
||||||
|
database.ClientTrafficEnableMergeExpr(),
|
||||||
|
database.ClientTrafficExpiryMergeExpr(),
|
||||||
|
database.GreatestExpr("last_online", "?"),
|
||||||
|
),
|
||||||
|
deltaUp, deltaDown,
|
||||||
|
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
|
||||||
|
cs.Total,
|
||||||
|
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
|
||||||
|
cs.LastOnline, cs.Email,
|
||||||
|
).Error
|
||||||
|
}
|
||||||
|
|
||||||
// mergeActivationExpiry: master absolute wins; node may only activate when
|
// mergeActivationExpiry: master absolute wins; node may only activate when
|
||||||
// master is unset/duration. Node auto-renew goes through nodeClientRenewed.
|
// master is unset/duration. Node auto-renew goes through nodeClientRenewed.
|
||||||
func mergeActivationExpiry(existing, node int64) int64 {
|
func mergeActivationExpiry(existing, node int64) int64 {
|
||||||
@@ -634,22 +671,25 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Membership set for the rowExists checks below. Only the snapshot's emails
|
// The stored row of every reported email, wherever it is homed, so the merge
|
||||||
// are ever probed, so scope the lookup to those instead of plucking the whole
|
// can skip a write that would leave it unchanged. Homed rows share centralCS's pointer.
|
||||||
// client_traffics table (50k+ rows) on every node poll.
|
statByEmail := make(map[string]*xray.ClientTraffic, len(snapEmailsAll))
|
||||||
existingEmails := make(map[string]struct{}, len(snapEmailsAll))
|
|
||||||
if len(snapEmailsAll) > 0 {
|
if len(snapEmailsAll) > 0 {
|
||||||
snapEmailList := make([]string, 0, len(snapEmailsAll))
|
snapEmailList := make([]string, 0, len(snapEmailsAll))
|
||||||
for email := range snapEmailsAll {
|
for email := range snapEmailsAll {
|
||||||
snapEmailList = append(snapEmailList, email)
|
snapEmailList = append(snapEmailList, email)
|
||||||
}
|
}
|
||||||
for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
|
for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
|
||||||
var found []string
|
var found []xray.ClientTraffic
|
||||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Pluck("email", &found).Error; err != nil {
|
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&found).Error; err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
for _, e := range found {
|
for i := range found {
|
||||||
existingEmails[e] = struct{}{}
|
if homed, ok := centralCSByEmail[found[i].Email]; ok {
|
||||||
|
statByEmail[found[i].Email] = homed
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
statByEmail[found[i].Email] = &found[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,7 +1064,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
if _, rowExists := statByEmail[cs.Email]; !rowExists {
|
||||||
if dirty {
|
if dirty {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1062,7 +1102,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
centralCS[csKey{c.Id, cs.Email}] = row
|
centralCS[csKey{c.Id, cs.Email}] = row
|
||||||
centralCSByEmail[cs.Email] = row
|
centralCSByEmail[cs.Email] = row
|
||||||
existingEmails[cs.Email] = struct{}{}
|
statByEmail[cs.Email] = row
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
|
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -1121,64 +1161,34 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
existing.Total = cs.Total
|
existing.Total = cs.Total
|
||||||
existing.ExpiryTime = cs.ExpiryTime
|
existing.ExpiryTime = cs.ExpiryTime
|
||||||
existing.Reset = cs.Reset
|
existing.Reset = cs.Reset
|
||||||
|
existing.ResetDay = cs.ResetDay
|
||||||
existing.ResetWeekday = cs.ResetWeekday
|
existing.ResetWeekday = cs.ResetWeekday
|
||||||
existing.ResetCount = cs.ResetCount
|
existing.ResetCount = cs.ResetCount
|
||||||
|
existing.LastOnline = max(existing.LastOnline, cs.LastOnline)
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
} else if clientFrozen {
|
|
||||||
// Push pending or just landed: only counters may move, the master
|
|
||||||
// keeps expiry/enable/total/reset.
|
|
||||||
if err := tx.Exec(
|
|
||||||
fmt.Sprintf(
|
|
||||||
`UPDATE client_traffics
|
|
||||||
SET up = %s, down = %s, last_online = %s
|
|
||||||
WHERE email = ?`,
|
|
||||||
database.ClampedAddExpr("up"),
|
|
||||||
database.ClampedAddExpr("down"),
|
|
||||||
database.GreatestExpr("last_online", "?"),
|
|
||||||
),
|
|
||||||
deltaUp, deltaDown, cs.LastOnline, cs.Email,
|
|
||||||
).Error; err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
existing.Up = clampTrafficCounter(existing.Up + deltaUp)
|
|
||||||
existing.Down = clampTrafficCounter(existing.Down + deltaDown)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
enableExpr := database.ClientTrafficEnableMergeExpr()
|
// The row as the UPDATE below would leave it, computed from its
|
||||||
expiryExpr := database.ClientTrafficExpiryMergeExpr()
|
// pre-update values exactly as the SQL reads them.
|
||||||
if err := tx.Exec(
|
row := statByEmail[cs.Email]
|
||||||
fmt.Sprintf(
|
next := *row
|
||||||
`UPDATE client_traffics
|
next.Up = clampTrafficCounter(row.Up + deltaUp)
|
||||||
SET up = %s, down = %s, enable = %s, total = ?,
|
next.Down = clampTrafficCounter(row.Down + deltaDown)
|
||||||
expiry_time = %s,
|
next.LastOnline = max(row.LastOnline, cs.LastOnline)
|
||||||
reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
|
if !clientFrozen {
|
||||||
WHERE email = ?`,
|
if !cs.Enable && !nodeDisableIsStale(row, cs, now, deltaUp, deltaDown) {
|
||||||
database.ClampedAddExpr("up"),
|
next.Enable = false
|
||||||
database.ClampedAddExpr("down"),
|
|
||||||
enableExpr,
|
|
||||||
expiryExpr,
|
|
||||||
database.GreatestExpr("last_online", "?"),
|
|
||||||
),
|
|
||||||
deltaUp, deltaDown,
|
|
||||||
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
|
|
||||||
cs.Total,
|
|
||||||
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
|
|
||||||
cs.LastOnline, cs.Email,
|
|
||||||
).Error; err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
priorExpiry := existing.ExpiryTime
|
|
||||||
if !cs.Enable && !nodeDisableIsStale(existing, cs, now, deltaUp, deltaDown) {
|
|
||||||
existing.Enable = false
|
|
||||||
}
|
}
|
||||||
existing.ExpiryTime = mergeActivationExpiry(priorExpiry, cs.ExpiryTime)
|
next.ExpiryTime = mergeActivationExpiry(row.ExpiryTime, cs.ExpiryTime)
|
||||||
existing.Up = clampTrafficCounter(existing.Up + deltaUp)
|
next.Total = cs.Total
|
||||||
existing.Down = clampTrafficCounter(existing.Down + deltaDown)
|
next.Reset, next.ResetDay, next.ResetWeekday = cs.Reset, cs.ResetDay, cs.ResetWeekday
|
||||||
existing.Total = cs.Total
|
}
|
||||||
existing.Reset = cs.Reset
|
// A multi-attached client is reported once per inbound and an idle one
|
||||||
existing.ResetWeekday = cs.ResetWeekday
|
// every tick: rewriting an unchanged row only holds its lock longer.
|
||||||
|
if next != *row {
|
||||||
|
if err := s.mergeNodeClientTraffic(tx, cs, clientFrozen, deltaUp, deltaDown, now); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
*row = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A dip plus a lagging longer expiry mimics nodeClientRenewed and would
|
// A dip plus a lagging longer expiry mimics nodeClientRenewed and would
|
||||||
@@ -1186,6 +1196,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) {
|
if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if seen && base == canon {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
|
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -207,3 +208,76 @@ func TestNodeInboundUnchangedPushLeavesTheCoreAlone(t *testing.T) {
|
|||||||
t.Fatalf("core operations = %q, want none for an unchanged inbound", got)
|
t.Fatalf("core operations = %q, want none for an unchanged inbound", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ssHotSettings(t *testing.T, method, serverKey string, clients []model.Client) string {
|
||||||
|
t.Helper()
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(clientsSettings(t, clients)), &settings); err != nil {
|
||||||
|
t.Fatalf("unmarshal settings: %v", err)
|
||||||
|
}
|
||||||
|
settings["method"] = method
|
||||||
|
settings["network"] = "tcp,udp"
|
||||||
|
if serverKey != "" {
|
||||||
|
settings["password"] = serverKey
|
||||||
|
} else {
|
||||||
|
for _, c := range settings["clients"].([]any) {
|
||||||
|
c.(map[string]any)["method"] = method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal settings: %v", err)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-user shadowsocks has the same per-user API as vless, so a pushed client
|
||||||
|
// change must not rebuild the handler and drop every connection on the port.
|
||||||
|
func TestNodeShadowsocksPushChangingOnlyClientsKeepsTheHandler(t *testing.T) {
|
||||||
|
ciphers := []struct{ name, method, serverKey, keyA, keyB string }{
|
||||||
|
{"legacy aead", "aes-256-gcm", "", "a-pass", "b-pass"},
|
||||||
|
{"2022", "2022-blake3-aes-128-gcm", "AAECAwQFBgcICQoLDA0ODw==", "EBESExQVFhcYGRobHB0eHw==", "ICEiIyQlJicoKSorLC0uLw=="},
|
||||||
|
}
|
||||||
|
for _, cipher := range ciphers {
|
||||||
|
a := model.Client{Email: "ss-a", Password: cipher.keyA, Enable: true, CreatedAt: 1, UpdatedAt: 1}
|
||||||
|
b := model.Client{Email: "ss-b", Password: cipher.keyB, Enable: true, CreatedAt: 1, UpdatedAt: 1}
|
||||||
|
bOff := b
|
||||||
|
bOff.Enable = false
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
stored, pushed []model.Client
|
||||||
|
wantOps []string
|
||||||
|
}{
|
||||||
|
{"disabling one client removes only that user", []model.Client{a, b}, []model.Client{a, bOff}, []string{"remove-user ss-b"}},
|
||||||
|
{"renewing a disabled client adds only that user", []model.Client{a, bOff}, []model.Client{a, b}, []string{"add-user ss-b"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(cipher.name+"/"+tc.name, func(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
setRestartOnClientDisable(t, true)
|
||||||
|
probe := &inboundOpsProbe{}
|
||||||
|
useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
|
||||||
|
|
||||||
|
seedInboundConflict(t, "hot-ss", "0.0.0.0", 50083, model.Shadowsocks, `{"network":"tcp"}`,
|
||||||
|
ssHotSettings(t, cipher.method, cipher.serverKey, tc.stored))
|
||||||
|
stored := loadInboundByTag(t, "hot-ss")
|
||||||
|
if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil {
|
||||||
|
t.Fatalf("SyncInbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pushed := *stored
|
||||||
|
pushed.Settings = ssHotSettings(t, cipher.method, cipher.serverKey, tc.pushed)
|
||||||
|
_, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateInbound: %v", err)
|
||||||
|
}
|
||||||
|
if got := probe.recorded(); !slices.Equal(got, tc.wantOps) {
|
||||||
|
t.Fatalf("core operations = %q, want %q", got, tc.wantOps)
|
||||||
|
}
|
||||||
|
if needRestart {
|
||||||
|
t.Fatal("needRestart = true, want the push applied without restarting the core")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -471,6 +476,163 @@ func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
|
|||||||
assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
|
assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var clientWriteSQL = regexp.MustCompile(`(?i)^\s*(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+["` + "`" + `]?(clients|client_inbounds|client_traffics|node_client_traffics)\b`)
|
||||||
|
|
||||||
|
// countClientRowWrites returns, per client table, how many rows fn's
|
||||||
|
// INSERT/UPDATE/DELETE statements touched.
|
||||||
|
func countClientRowWrites(t *testing.T, fn func()) map[string]int64 {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
var mu sync.Mutex
|
||||||
|
rows := map[string]int64{}
|
||||||
|
record := func(tx *gorm.DB) {
|
||||||
|
if tx.Error != nil || tx.RowsAffected == 0 || tx.Statement == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m := clientWriteSQL.FindStringSubmatch(tx.Statement.SQL.String()); m != nil {
|
||||||
|
mu.Lock()
|
||||||
|
rows[m[1]] += tx.RowsAffected
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const name = "test:count_client_row_writes"
|
||||||
|
cb := db.Callback()
|
||||||
|
for _, err := range []error{
|
||||||
|
cb.Create().After("gorm:create").Register(name, record),
|
||||||
|
cb.Update().After("gorm:update").Register(name, record),
|
||||||
|
cb.Delete().After("gorm:delete").Register(name, record),
|
||||||
|
cb.Raw().After("gorm:raw").Register(name, record),
|
||||||
|
} {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register write callback: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, err := range []error{
|
||||||
|
cb.Create().Remove(name), cb.Update().Remove(name),
|
||||||
|
cb.Delete().Remove(name), cb.Raw().Remove(name),
|
||||||
|
} {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("remove write callback: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeMerge_WritesOnlyChangedClients(t *testing.T) {
|
||||||
|
const clientCount = 20
|
||||||
|
type snapInbound struct {
|
||||||
|
tag string
|
||||||
|
protocol model.Protocol
|
||||||
|
entry func(i int, email string) map[string]any
|
||||||
|
}
|
||||||
|
vlessEntry := func(flow string) func(int, string) map[string]any {
|
||||||
|
return func(i int, email string) map[string]any {
|
||||||
|
e := map[string]any{"email": email, "id": fmt.Sprintf("%08d-0000-4000-8000-000000000000", i), "subId": email, "enable": true}
|
||||||
|
if flow != "" {
|
||||||
|
e["flow"] = flow
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wgEntry := func(tunnel int) func(int, string) map[string]any {
|
||||||
|
return func(i int, email string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"email": email, "subId": email, "enable": true,
|
||||||
|
"privateKey": fmt.Sprintf("priv-%d-%d", tunnel, i),
|
||||||
|
"publicKey": fmt.Sprintf("pub-%d-%d", tunnel, i),
|
||||||
|
"allowedIPs": []string{fmt.Sprintf("10.%d.0.%d/32", tunnel, i+2)},
|
||||||
|
"preSharedKey": fmt.Sprintf("psk-%d-%d", tunnel, i),
|
||||||
|
"keepAlive": 25 * tunnel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
inbounds []snapInbound
|
||||||
|
}{
|
||||||
|
{"vless with and without flow", []snapInbound{
|
||||||
|
{"n1-reality", model.VLESS, vlessEntry("xtls-rprx-vision")},
|
||||||
|
{"n1-ws", model.VLESS, vlessEntry("")},
|
||||||
|
}},
|
||||||
|
{"wireguard and vless", []snapInbound{
|
||||||
|
{"n1-wg", model.WireGuard, wgEntry(1)},
|
||||||
|
{"n1-vless", model.VLESS, vlessEntry("")},
|
||||||
|
}},
|
||||||
|
{"two wireguard tunnels", []snapInbound{
|
||||||
|
{"n1-wg-a", model.WireGuard, wgEntry(1)},
|
||||||
|
{"n1-wg-b", model.WireGuard, wgEntry(2)},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
db := initTrafficTestDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
emails := make([]string, clientCount)
|
||||||
|
stats := make([]xray.ClientTraffic, clientCount)
|
||||||
|
for i := range emails {
|
||||||
|
emails[i] = fmt.Sprintf("multi-%d", i)
|
||||||
|
stats[i] = xray.ClientTraffic{Email: emails[i], Up: 1000, Down: 2000, Enable: true, LastOnline: 5000}
|
||||||
|
}
|
||||||
|
settings := make([]string, len(tc.inbounds))
|
||||||
|
for n, ib := range tc.inbounds {
|
||||||
|
entries := make([]map[string]any, clientCount)
|
||||||
|
for i, email := range emails {
|
||||||
|
entries[i] = ib.entry(i, email)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(map[string]any{"clients": entries})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal settings: %v", err)
|
||||||
|
}
|
||||||
|
settings[n] = string(raw)
|
||||||
|
nodeID := 1
|
||||||
|
if err := db.Create(&model.Inbound{
|
||||||
|
UserId: 1, NodeID: &nodeID, Tag: ib.tag, Enable: true,
|
||||||
|
Port: 42000 + n, Protocol: ib.protocol, Settings: settings[n],
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound %q: %v", ib.tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merge := func(stats []xray.ClientTraffic) {
|
||||||
|
t.Helper()
|
||||||
|
// A node copies a multi-attached client's one counter onto every inbound it is on.
|
||||||
|
snap := &runtime.TrafficSnapshot{}
|
||||||
|
for n, ib := range tc.inbounds {
|
||||||
|
snap.Inbounds = append(snap.Inbounds, &model.Inbound{
|
||||||
|
Tag: ib.tag, Enable: true, Port: 42000 + n, Protocol: ib.protocol,
|
||||||
|
Settings: settings[n], ClientStats: slices.Clone(stats),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||||
|
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merge(stats)
|
||||||
|
if got := countClientRowWrites(t, func() { merge(stats) }); len(got) != 0 {
|
||||||
|
t.Errorf("repeating an unchanged snapshot wrote client rows %v, want none", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
active := slices.Clone(stats)
|
||||||
|
active[0].Up, active[0].Down, active[0].LastOnline = 1300, 2600, 6000
|
||||||
|
got := countClientRowWrites(t, func() { merge(active) })
|
||||||
|
want := map[string]int64{"client_traffics": 1, "node_client_traffics": 1}
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Errorf("one client's traffic tick wrote client rows %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
ct := readTraffic(t, db, emails[0])
|
||||||
|
assertUpDown(t, ct, 300, 600, "active client after its tick")
|
||||||
|
if ct.LastOnline != 6000 {
|
||||||
|
t.Errorf("last_online = %d, want 6000", ct.LastOnline)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
|
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
|
||||||
db := initTrafficTestDB(t)
|
db := initTrafficTestDB(t)
|
||||||
svc := &InboundService{}
|
svc := &InboundService{}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ const (
|
|||||||
sponsorsErrTTL = 10 * time.Minute
|
sponsorsErrTTL = 10 * time.Minute
|
||||||
maxSponsorsBytes = 256 << 10
|
maxSponsorsBytes = 256 << 10
|
||||||
maxLogoBytes = 256 << 10
|
maxLogoBytes = 256 << 10
|
||||||
maxSidebarSlots = 3
|
|
||||||
localSponsorsDir = "../sponsors/3X"
|
localSponsorsDir = "../sponsors/3X"
|
||||||
sponsorLogoPath = "/sponsors/logo/"
|
sponsorLogoPath = "/sponsors/logo/"
|
||||||
)
|
)
|
||||||
@@ -61,13 +60,13 @@ type sponsorLogo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
sponsorsURL = "https://sponsors.sanaei.dev/3X/sponsors.json"
|
sponsorsURL = "https://raw.githubusercontent.com/MHSanaei/sponsors/refs/heads/main/3X/sponsors.json"
|
||||||
sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/"
|
sponsorLogoBase = "https://raw.githubusercontent.com/MHSanaei/sponsors/refs/heads/main/3X/logos/"
|
||||||
sponsorNow = time.Now
|
sponsorNow = time.Now
|
||||||
|
|
||||||
// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
|
// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
|
||||||
sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
|
sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
|
||||||
sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
|
sponsorSlots = map[string]bool{"dashboard": true, "page": true, "login": true}
|
||||||
|
|
||||||
sponsorsMu sync.Mutex
|
sponsorsMu sync.Mutex
|
||||||
sponsorsRaw *SponsorList
|
sponsorsRaw *SponsorList
|
||||||
@@ -217,7 +216,6 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
|||||||
if strings.HasPrefix(raw.Contact, "https://") {
|
if strings.HasPrefix(raw.Contact, "https://") {
|
||||||
out.Contact = raw.Contact
|
out.Contact = raw.Contact
|
||||||
}
|
}
|
||||||
sidebarTaken := 0
|
|
||||||
for _, sp := range raw.Sponsors {
|
for _, sp := range raw.Sponsors {
|
||||||
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
||||||
disabled := sp.Enable != nil && !*sp.Enable
|
disabled := sp.Enable != nil && !*sp.Enable
|
||||||
@@ -233,18 +231,13 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
|||||||
}
|
}
|
||||||
slots := make([]string, 0, len(sp.Slots))
|
slots := make([]string, 0, len(sp.Slots))
|
||||||
for _, slot := range sp.Slots {
|
for _, slot := range sp.Slots {
|
||||||
// The sidebar rotates, so capping it keeps each paid card on screen long enough.
|
if sponsorSlots[slot] {
|
||||||
if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
|
slots = append(slots, slot)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
slots = append(slots, slot)
|
|
||||||
}
|
}
|
||||||
if len(slots) == 0 {
|
if len(slots) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slices.Contains(slots, "sidebar") {
|
|
||||||
sidebarTaken++
|
|
||||||
}
|
|
||||||
sp.Slots = slots
|
sp.Slots = slots
|
||||||
out.Sponsors = append(out.Sponsors, sp)
|
out.Sponsors = append(out.Sponsors, sp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -22,7 +21,7 @@ func validSponsor() Sponsor {
|
|||||||
return Sponsor{
|
return Sponsor{
|
||||||
ID: "acme",
|
ID: "acme",
|
||||||
Name: "Acme",
|
Name: "Acme",
|
||||||
Slots: []string{"dashboard", "sidebar"},
|
Slots: []string{"dashboard"},
|
||||||
Until: sponsorTestNow.Add(24 * time.Hour),
|
Until: sponsorTestNow.Add(24 * time.Hour),
|
||||||
Logo: "acme.png",
|
Logo: "acme.png",
|
||||||
Link: "https://acme.example/",
|
Link: "https://acme.example/",
|
||||||
@@ -59,34 +58,6 @@ func TestActiveSponsorsFilters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) {
|
|
||||||
raw := &SponsorList{}
|
|
||||||
for _, id := range []string{"expired", "a", "b", "c", "d", "e"} {
|
|
||||||
sp := validSponsor()
|
|
||||||
sp.ID = id
|
|
||||||
sp.Slots = []string{"sidebar", "page"}
|
|
||||||
if id == "expired" {
|
|
||||||
sp.Until = sponsorTestNow
|
|
||||||
}
|
|
||||||
if id == "e" {
|
|
||||||
sp.Slots = []string{"sidebar"}
|
|
||||||
}
|
|
||||||
raw.Sponsors = append(raw.Sponsors, sp)
|
|
||||||
}
|
|
||||||
got := activeSponsors(raw, sponsorTestNow)
|
|
||||||
want := map[string][]string{
|
|
||||||
"a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"},
|
|
||||||
}
|
|
||||||
if len(got.Sponsors) != len(want) {
|
|
||||||
t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want))
|
|
||||||
}
|
|
||||||
for _, sp := range got.Sponsors {
|
|
||||||
if !slices.Equal(sp.Slots, want[sp.ID]) {
|
|
||||||
t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestActiveSponsorsLogoName(t *testing.T) {
|
func TestActiveSponsorsLogoName(t *testing.T) {
|
||||||
cases := []struct{ logo, want string }{
|
cases := []struct{ logo, want string }{
|
||||||
{"acme.png", "/sponsors/logo/acme.png"},
|
{"acme.png", "/sponsors/logo/acme.png"},
|
||||||
@@ -114,7 +85,7 @@ func TestActiveSponsorsLogoName(t *testing.T) {
|
|||||||
|
|
||||||
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
|
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
|
||||||
sp := validSponsor()
|
sp := validSponsor()
|
||||||
sp.Slots = []string{"subpage", "login"}
|
sp.Slots = []string{"subpage", "sidebar", "login"}
|
||||||
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
|
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
|
||||||
if len(got.Sponsors) != 1 {
|
if len(got.Sponsors) != 1 {
|
||||||
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
|
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
|
||||||
|
|||||||
@@ -94,12 +94,13 @@ func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *int) string {
|
|||||||
|
|
||||||
wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)}
|
wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)}
|
||||||
awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
|
awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
|
||||||
if err := db.Create(wgInbound).Error; err != nil {
|
// The lower-id tunnel owns the shared record's peer fields: AmneziaWG first.
|
||||||
t.Fatalf("create wg inbound: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Create(awgInbound).Error; err != nil {
|
if err := db.Create(awgInbound).Error; err != nil {
|
||||||
t.Fatalf("create awg inbound: %v", err)
|
t.Fatalf("create awg inbound: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := db.Create(wgInbound).Error; err != nil {
|
||||||
|
t.Fatalf("create wg inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
svc := ClientService{}
|
svc := ClientService{}
|
||||||
if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil {
|
if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil {
|
||||||
@@ -202,7 +203,7 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
|
|||||||
t.Fatalf("read shared client: %v", err)
|
t.Fatalf("read shared client: %v", err)
|
||||||
}
|
}
|
||||||
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
|
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
|
||||||
t.Fatalf("test setup did not persist AmneziaWG last: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
|
t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
|
||||||
}
|
}
|
||||||
|
|
||||||
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
|
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
|
||||||
|
|||||||
@@ -505,6 +505,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "نطاق FakeTLS الافتراضي المستخدم لإنشاء سر عميل جديد. يمكن لكل عميل استخدام نطاقه الخاص.",
|
"mtprotoFakeTlsDomainHint": "نطاق FakeTLS الافتراضي المستخدم لإنشاء سر عميل جديد. يمكن لكل عميل استخدام نطاقه الخاص.",
|
||||||
"mtgThrottleMaxConnections": "الحد الأقصى للاتصالات",
|
"mtgThrottleMaxConnections": "الحد الأقصى للاتصالات",
|
||||||
"mtgThrottleMaxConnectionsHint": "تحديد الاتصالات المتزامنة لجميع المستخدمين بتوزيع عادل. القيمة 0 تعطّل الميزة.",
|
"mtgThrottleMaxConnectionsHint": "تحديد الاتصالات المتزامنة لجميع المستخدمين بتوزيع عادل. القيمة 0 تعطّل الميزة.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "الحد الأقصى للمصافحات المعلقة لكل IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "تُغلق الاتصالات الجديدة من عنوان IP لديه هذا العدد من المصافحات غير المكتملة. يتشارك المستخدمون خلف عنوان IP مشترك (NAT لدى المشغّل) هذا الحد. القيمة 0 تعطّله.",
|
||||||
"mtgAdTagInvalid": "يجب أن تتكوّن العلامة الإعلانية من 32 حرفًا ست عشريًا بالضبط.",
|
"mtgAdTagInvalid": "يجب أن تتكوّن العلامة الإعلانية من 32 حرفًا ست عشريًا بالضبط.",
|
||||||
"mtgPublicIpv4": "عنوان IPv4 العام",
|
"mtgPublicIpv4": "عنوان IPv4 العام",
|
||||||
"mtgPublicIpv6": "عنوان IPv6 العام",
|
"mtgPublicIpv6": "عنوان IPv6 العام",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "راعٍ",
|
"tag": "راعٍ",
|
||||||
"visit": "زيارة",
|
"visit": "زيارة",
|
||||||
"yourBrand": "علامتك التجارية هنا",
|
"yourBrand": "علامتك التجارية هنا",
|
||||||
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم.",
|
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم."
|
||||||
"placements": "المواضع المتاحة",
|
|
||||||
"placementDashboard": "لافتة لوحة المعلومات",
|
|
||||||
"placementDashboardDesc": "موضع مميز أعلى لوحة المعلومات، راعٍ واحد في كل مرة.",
|
|
||||||
"placementSidebar": "بطاقة الشريط الجانبي",
|
|
||||||
"placementSidebarDesc": "تظهر في كل صفحات اللوحة وتتناوب بين الرعاة.",
|
|
||||||
"placementLogin": "صفحة تسجيل الدخول",
|
|
||||||
"placementLoginDesc": "يراها المسؤول عند كل تسجيل دخول.",
|
|
||||||
"placementPage": "صفحة الرعاة",
|
|
||||||
"placementPageDesc": "مدرجة هنا مع الشعار والوصف والرابط.",
|
|
||||||
"available": "متاح",
|
|
||||||
"takenUntil": "محجوز حتى {date}",
|
|
||||||
"activeCount": "{count} نشط"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -507,6 +507,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "Default FakeTLS domain used to generate a new client's secret. Each client can front its own domain.",
|
"mtprotoFakeTlsDomainHint": "Default FakeTLS domain used to generate a new client's secret. Each client can front its own domain.",
|
||||||
"mtgThrottleMaxConnections": "Max connections",
|
"mtgThrottleMaxConnections": "Max connections",
|
||||||
"mtgThrottleMaxConnectionsHint": "Cap concurrent connections across all users with a fair-share limit. 0 disables throttling.",
|
"mtgThrottleMaxConnectionsHint": "Cap concurrent connections across all users with a fair-share limit. 0 disables throttling.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "Max pending handshakes per IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "Close new connections from an IP that already has this many unfinished handshakes. Clients behind a shared IP (carrier NAT) share the limit. 0 disables it.",
|
||||||
"mtgAdTagInvalid": "Ad-tag must be exactly 32 hexadecimal characters.",
|
"mtgAdTagInvalid": "Ad-tag must be exactly 32 hexadecimal characters.",
|
||||||
"mtgPublicIpv4": "Public IPv4",
|
"mtgPublicIpv4": "Public IPv4",
|
||||||
"mtgPublicIpv6": "Public IPv6",
|
"mtgPublicIpv6": "Public IPv6",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "Sponsor",
|
"tag": "Sponsor",
|
||||||
"visit": "Visit",
|
"visit": "Visit",
|
||||||
"yourBrand": "Your brand here",
|
"yourBrand": "Your brand here",
|
||||||
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day.",
|
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day."
|
||||||
"placements": "Available placements",
|
|
||||||
"placementDashboard": "Overview banner",
|
|
||||||
"placementDashboardDesc": "Premium spot at the top of the dashboard, one sponsor at a time.",
|
|
||||||
"placementSidebar": "Sidebar card",
|
|
||||||
"placementSidebarDesc": "Shown on every panel page, rotating between sponsors.",
|
|
||||||
"placementLogin": "Login page",
|
|
||||||
"placementLoginDesc": "Seen by the admin at every sign-in.",
|
|
||||||
"placementPage": "Sponsors page",
|
|
||||||
"placementPageDesc": "Listed here with logo, description and link.",
|
|
||||||
"available": "Available",
|
|
||||||
"takenUntil": "Taken until {date}",
|
|
||||||
"activeCount": "{count} active"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -505,6 +505,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "Dominio FakeTLS predeterminado para generar el secreto de un nuevo cliente. Cada cliente puede usar su propio dominio.",
|
"mtprotoFakeTlsDomainHint": "Dominio FakeTLS predeterminado para generar el secreto de un nuevo cliente. Cada cliente puede usar su propio dominio.",
|
||||||
"mtgThrottleMaxConnections": "Conexiones máximas",
|
"mtgThrottleMaxConnections": "Conexiones máximas",
|
||||||
"mtgThrottleMaxConnectionsHint": "Limita las conexiones simultáneas de todos los usuarios con reparto equitativo. 0 desactiva el límite.",
|
"mtgThrottleMaxConnectionsHint": "Limita las conexiones simultáneas de todos los usuarios con reparto equitativo. 0 desactiva el límite.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "Máx. handshakes pendientes por IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "Cierra las conexiones nuevas de una IP que ya tiene esta cantidad de handshakes sin completar. Los clientes detrás de una IP compartida (NAT del operador) comparten el límite. 0 lo desactiva.",
|
||||||
"mtgAdTagInvalid": "El ad-tag debe tener exactamente 32 caracteres hexadecimales.",
|
"mtgAdTagInvalid": "El ad-tag debe tener exactamente 32 caracteres hexadecimales.",
|
||||||
"mtgPublicIpv4": "IPv4 pública",
|
"mtgPublicIpv4": "IPv4 pública",
|
||||||
"mtgPublicIpv6": "IPv6 pública",
|
"mtgPublicIpv6": "IPv6 pública",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "Patrocinador",
|
"tag": "Patrocinador",
|
||||||
"visit": "Visitar",
|
"visit": "Visitar",
|
||||||
"yourBrand": "Tu marca aquí",
|
"yourBrand": "Tu marca aquí",
|
||||||
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día.",
|
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día."
|
||||||
"placements": "Espacios disponibles",
|
|
||||||
"placementDashboard": "Banner del panel",
|
|
||||||
"placementDashboardDesc": "Espacio premium en la parte superior del panel, un patrocinador a la vez.",
|
|
||||||
"placementSidebar": "Tarjeta lateral",
|
|
||||||
"placementSidebarDesc": "Se muestra en todas las páginas del panel, rotando entre patrocinadores.",
|
|
||||||
"placementLogin": "Página de inicio de sesión",
|
|
||||||
"placementLoginDesc": "La ve el administrador en cada inicio de sesión.",
|
|
||||||
"placementPage": "Página de patrocinadores",
|
|
||||||
"placementPageDesc": "Aparece aquí con logo, descripción y enlace.",
|
|
||||||
"available": "Disponible",
|
|
||||||
"takenUntil": "Ocupado hasta {date}",
|
|
||||||
"activeCount": "{count} activos"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -505,6 +505,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "دامنه پیشفرض FakeTLS برای ساخت سکرت کلاینت جدید. هر کلاینت میتواند دامنه مخصوص خود را داشته باشد.",
|
"mtprotoFakeTlsDomainHint": "دامنه پیشفرض FakeTLS برای ساخت سکرت کلاینت جدید. هر کلاینت میتواند دامنه مخصوص خود را داشته باشد.",
|
||||||
"mtgThrottleMaxConnections": "حداکثر اتصالات",
|
"mtgThrottleMaxConnections": "حداکثر اتصالات",
|
||||||
"mtgThrottleMaxConnectionsHint": "محدود کردن اتصالات همزمان همه کاربران با تقسیم منصفانه. مقدار ۰ غیرفعال است.",
|
"mtgThrottleMaxConnectionsHint": "محدود کردن اتصالات همزمان همه کاربران با تقسیم منصفانه. مقدار ۰ غیرفعال است.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "حداکثر handshake معلق برای هر IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "اتصالهای جدید از IPای که این تعداد handshake ناتمام دارد بسته میشوند. کاربرانی که پشت یک IP مشترک (NAT اپراتور) هستند این سقف را با هم شریکاند. مقدار ۰ غیرفعال است.",
|
||||||
"mtgAdTagInvalid": "برچسب تبلیغاتی باید دقیقاً ۳۲ کاراکتر هگزادسیمال باشد.",
|
"mtgAdTagInvalid": "برچسب تبلیغاتی باید دقیقاً ۳۲ کاراکتر هگزادسیمال باشد.",
|
||||||
"mtgPublicIpv4": "IPv4 عمومی",
|
"mtgPublicIpv4": "IPv4 عمومی",
|
||||||
"mtgPublicIpv6": "IPv6 عمومی",
|
"mtgPublicIpv6": "IPv6 عمومی",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "اسپانسر",
|
"tag": "اسپانسر",
|
||||||
"visit": "مشاهده",
|
"visit": "مشاهده",
|
||||||
"yourBrand": "جای برند شما",
|
"yourBrand": "جای برند شما",
|
||||||
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز میکنند.",
|
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز میکنند."
|
||||||
"placements": "جایگاههای موجود",
|
|
||||||
"placementDashboard": "بنر داشبورد",
|
|
||||||
"placementDashboardDesc": "جایگاه ویژه بالای صفحهی داشبورد، هر بار فقط یک اسپانسر.",
|
|
||||||
"placementSidebar": "کارت سایدبار",
|
|
||||||
"placementSidebarDesc": "در همهی صفحات پنل نمایش داده میشود و بین اسپانسرها چرخش دارد.",
|
|
||||||
"placementLogin": "صفحهی ورود",
|
|
||||||
"placementLoginDesc": "ادمین در هر بار ورود آن را میبیند.",
|
|
||||||
"placementPage": "صفحهی اسپانسرها",
|
|
||||||
"placementPageDesc": "همراه با لوگو، توضیحات و لینک در همین صفحه.",
|
|
||||||
"available": "خالی",
|
|
||||||
"takenUntil": "پر شده تا {date}",
|
|
||||||
"activeCount": "{count} فعال"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -505,6 +505,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "Domain FakeTLS default untuk membuat secret klien baru. Setiap klien bisa memakai domainnya sendiri.",
|
"mtprotoFakeTlsDomainHint": "Domain FakeTLS default untuk membuat secret klien baru. Setiap klien bisa memakai domainnya sendiri.",
|
||||||
"mtgThrottleMaxConnections": "Koneksi maksimum",
|
"mtgThrottleMaxConnections": "Koneksi maksimum",
|
||||||
"mtgThrottleMaxConnectionsHint": "Batasi koneksi bersamaan semua pengguna dengan pembagian adil. 0 menonaktifkan.",
|
"mtgThrottleMaxConnectionsHint": "Batasi koneksi bersamaan semua pengguna dengan pembagian adil. 0 menonaktifkan.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "Maks. handshake tertunda per IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "Tutup koneksi baru dari IP yang sudah memiliki handshake belum selesai sebanyak ini. Klien di balik IP bersama (NAT operator) berbagi batas ini. 0 menonaktifkan.",
|
||||||
"mtgAdTagInvalid": "Ad-tag harus tepat 32 karakter heksadesimal.",
|
"mtgAdTagInvalid": "Ad-tag harus tepat 32 karakter heksadesimal.",
|
||||||
"mtgPublicIpv4": "IPv4 Publik",
|
"mtgPublicIpv4": "IPv4 Publik",
|
||||||
"mtgPublicIpv6": "IPv6 Publik",
|
"mtgPublicIpv6": "IPv6 Publik",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "Sponsor",
|
"tag": "Sponsor",
|
||||||
"visit": "Kunjungi",
|
"visit": "Kunjungi",
|
||||||
"yourBrand": "Merek Anda di sini",
|
"yourBrand": "Merek Anda di sini",
|
||||||
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari.",
|
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari."
|
||||||
"placements": "Slot yang tersedia",
|
|
||||||
"placementDashboard": "Banner dasbor",
|
|
||||||
"placementDashboardDesc": "Tempat premium di atas dasbor, satu sponsor dalam satu waktu.",
|
|
||||||
"placementSidebar": "Kartu sidebar",
|
|
||||||
"placementSidebarDesc": "Tampil di setiap halaman panel, bergantian antar sponsor.",
|
|
||||||
"placementLogin": "Halaman login",
|
|
||||||
"placementLoginDesc": "Dilihat admin setiap kali masuk.",
|
|
||||||
"placementPage": "Halaman sponsor",
|
|
||||||
"placementPageDesc": "Tercantum di sini dengan logo, deskripsi, dan tautan.",
|
|
||||||
"available": "Tersedia",
|
|
||||||
"takenUntil": "Terisi hingga {date}",
|
|
||||||
"activeCount": "{count} aktif"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -526,6 +526,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "新しいクライアントのシークレット生成に使う既定の FakeTLS ドメイン。クライアントごとに別のドメインを使用できます。",
|
"mtprotoFakeTlsDomainHint": "新しいクライアントのシークレット生成に使う既定の FakeTLS ドメイン。クライアントごとに別のドメインを使用できます。",
|
||||||
"mtgThrottleMaxConnections": "最大接続数",
|
"mtgThrottleMaxConnections": "最大接続数",
|
||||||
"mtgThrottleMaxConnectionsHint": "全ユーザーの同時接続数を公平配分で制限します。0 で無効。",
|
"mtgThrottleMaxConnectionsHint": "全ユーザーの同時接続数を公平配分で制限します。0 で無効。",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "IP あたりの未完了ハンドシェイク上限",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "未完了のハンドシェイクをこの数だけ保持している IP からの新規接続を閉じます。共有 IP(キャリア NAT)配下のクライアントはこの上限を共有します。0 で無効。",
|
||||||
"mtgAdTagInvalid": "広告タグは正確に 32 文字の 16 進数である必要があります。",
|
"mtgAdTagInvalid": "広告タグは正確に 32 文字の 16 進数である必要があります。",
|
||||||
"mtgPublicIpv4": "パブリック IPv4",
|
"mtgPublicIpv4": "パブリック IPv4",
|
||||||
"mtgPublicIpv6": "パブリック IPv6",
|
"mtgPublicIpv6": "パブリック IPv6",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "スポンサー",
|
"tag": "スポンサー",
|
||||||
"visit": "訪問",
|
"visit": "訪問",
|
||||||
"yourBrand": "ここにあなたのブランドを",
|
"yourBrand": "ここにあなたのブランドを",
|
||||||
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。",
|
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。"
|
||||||
"placements": "掲載枠",
|
|
||||||
"placementDashboard": "ダッシュボードバナー",
|
|
||||||
"placementDashboardDesc": "ダッシュボード上部のプレミアム枠。一度に 1 社のみ。",
|
|
||||||
"placementSidebar": "サイドバーカード",
|
|
||||||
"placementSidebarDesc": "パネルの全ページに表示され、スポンサー間でローテーションします。",
|
|
||||||
"placementLogin": "ログインページ",
|
|
||||||
"placementLoginDesc": "管理者がログインするたびに表示されます。",
|
|
||||||
"placementPage": "スポンサーページ",
|
|
||||||
"placementPageDesc": "ロゴ、説明、リンク付きでここに掲載されます。",
|
|
||||||
"available": "空きあり",
|
|
||||||
"takenUntil": "{date} まで予約済み",
|
|
||||||
"activeCount": "{count} 件掲載中"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -526,6 +526,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "Domínio FakeTLS padrão usado para gerar o segredo de um novo cliente. Cada cliente pode usar seu próprio domínio.",
|
"mtprotoFakeTlsDomainHint": "Domínio FakeTLS padrão usado para gerar o segredo de um novo cliente. Cada cliente pode usar seu próprio domínio.",
|
||||||
"mtgThrottleMaxConnections": "Conexões máximas",
|
"mtgThrottleMaxConnections": "Conexões máximas",
|
||||||
"mtgThrottleMaxConnectionsHint": "Limita conexões simultâneas de todos os usuários com distribuição justa. 0 desativa o limite.",
|
"mtgThrottleMaxConnectionsHint": "Limita conexões simultâneas de todos os usuários com distribuição justa. 0 desativa o limite.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "Máx. handshakes pendentes por IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "Fecha novas conexões de um IP que já tem esta quantidade de handshakes não concluídos. Clientes atrás de um IP compartilhado (NAT da operadora) dividem o limite. 0 desativa.",
|
||||||
"mtgAdTagInvalid": "A ad-tag deve ter exatamente 32 caracteres hexadecimais.",
|
"mtgAdTagInvalid": "A ad-tag deve ter exatamente 32 caracteres hexadecimais.",
|
||||||
"mtgPublicIpv4": "IPv4 público",
|
"mtgPublicIpv4": "IPv4 público",
|
||||||
"mtgPublicIpv6": "IPv6 público",
|
"mtgPublicIpv6": "IPv6 público",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "Patrocinador",
|
"tag": "Patrocinador",
|
||||||
"visit": "Visitar",
|
"visit": "Visitar",
|
||||||
"yourBrand": "Sua marca aqui",
|
"yourBrand": "Sua marca aqui",
|
||||||
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias.",
|
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias."
|
||||||
"placements": "Espaços disponíveis",
|
|
||||||
"placementDashboard": "Banner do painel",
|
|
||||||
"placementDashboardDesc": "Espaço premium no topo do painel, um patrocinador por vez.",
|
|
||||||
"placementSidebar": "Cartão lateral",
|
|
||||||
"placementSidebarDesc": "Exibido em todas as páginas do painel, alternando entre patrocinadores.",
|
|
||||||
"placementLogin": "Página de login",
|
|
||||||
"placementLoginDesc": "Visto pelo administrador a cada login.",
|
|
||||||
"placementPage": "Página de patrocinadores",
|
|
||||||
"placementPageDesc": "Listado aqui com logo, descrição e link.",
|
|
||||||
"available": "Disponível",
|
|
||||||
"takenUntil": "Ocupado até {date}",
|
|
||||||
"activeCount": "{count} ativos"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -528,6 +528,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "Домен FakeTLS по умолчанию для генерации секрета нового клиента. Каждый клиент может использовать свой домен.",
|
"mtprotoFakeTlsDomainHint": "Домен FakeTLS по умолчанию для генерации секрета нового клиента. Каждый клиент может использовать свой домен.",
|
||||||
"mtgThrottleMaxConnections": "Макс. подключений",
|
"mtgThrottleMaxConnections": "Макс. подключений",
|
||||||
"mtgThrottleMaxConnectionsHint": "Ограничение одновременных подключений всех пользователей по справедливому распределению. 0 — отключено.",
|
"mtgThrottleMaxConnectionsHint": "Ограничение одновременных подключений всех пользователей по справедливому распределению. 0 — отключено.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "Макс. незавершённых рукопожатий на IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "Новые подключения с IP, у которого уже столько незавершённых рукопожатий, закрываются. Клиенты за общим IP (NAT оператора) делят этот лимит. 0 — отключено.",
|
||||||
"mtgAdTagInvalid": "Рекламный тег должен содержать ровно 32 шестнадцатеричных символа.",
|
"mtgAdTagInvalid": "Рекламный тег должен содержать ровно 32 шестнадцатеричных символа.",
|
||||||
"mtgPublicIpv4": "Публичный IPv4",
|
"mtgPublicIpv4": "Публичный IPv4",
|
||||||
"mtgPublicIpv6": "Публичный IPv6",
|
"mtgPublicIpv6": "Публичный IPv6",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "Спонсор",
|
"tag": "Спонсор",
|
||||||
"visit": "Перейти",
|
"visit": "Перейти",
|
||||||
"yourBrand": "Здесь может быть ваш бренд",
|
"yourBrand": "Здесь может быть ваш бренд",
|
||||||
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI.",
|
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI."
|
||||||
"placements": "Доступные места",
|
|
||||||
"placementDashboard": "Баннер на главной",
|
|
||||||
"placementDashboardDesc": "Премиум-место вверху панели, один спонсор за раз.",
|
|
||||||
"placementSidebar": "Карточка в боковой панели",
|
|
||||||
"placementSidebarDesc": "Показывается на всех страницах панели, спонсоры чередуются.",
|
|
||||||
"placementLogin": "Страница входа",
|
|
||||||
"placementLoginDesc": "Администратор видит её при каждом входе.",
|
|
||||||
"placementPage": "Страница спонсоров",
|
|
||||||
"placementPageDesc": "Размещение здесь с логотипом, описанием и ссылкой.",
|
|
||||||
"available": "Свободно",
|
|
||||||
"takenUntil": "Занято до {date}",
|
|
||||||
"activeCount": "Активных: {count}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -505,6 +505,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "Yeni bir istemcinin sırrını oluştururken kullanılan varsayılan FakeTLS alan adı. Her istemci kendi alan adını kullanabilir.",
|
"mtprotoFakeTlsDomainHint": "Yeni bir istemcinin sırrını oluştururken kullanılan varsayılan FakeTLS alan adı. Her istemci kendi alan adını kullanabilir.",
|
||||||
"mtgThrottleMaxConnections": "Maks. bağlantı",
|
"mtgThrottleMaxConnections": "Maks. bağlantı",
|
||||||
"mtgThrottleMaxConnectionsHint": "Tüm kullanıcıların eşzamanlı bağlantılarını adil paylaşımla sınırlar. 0 devre dışı bırakır.",
|
"mtgThrottleMaxConnectionsHint": "Tüm kullanıcıların eşzamanlı bağlantılarını adil paylaşımla sınırlar. 0 devre dışı bırakır.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "IP başına maks. bekleyen el sıkışma",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "Bu sayıda tamamlanmamış el sıkışması olan bir IP'den gelen yeni bağlantıları kapatır. Paylaşılan IP arkasındaki istemciler (operatör NAT) sınırı paylaşır. 0 devre dışı bırakır.",
|
||||||
"mtgAdTagInvalid": "Reklam etiketi tam olarak 32 onaltılık karakter olmalıdır.",
|
"mtgAdTagInvalid": "Reklam etiketi tam olarak 32 onaltılık karakter olmalıdır.",
|
||||||
"mtgPublicIpv4": "Genel IPv4",
|
"mtgPublicIpv4": "Genel IPv4",
|
||||||
"mtgPublicIpv6": "Genel IPv6",
|
"mtgPublicIpv6": "Genel IPv6",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "Sponsor",
|
"tag": "Sponsor",
|
||||||
"visit": "Ziyaret et",
|
"visit": "Ziyaret et",
|
||||||
"yourBrand": "Markanız burada",
|
"yourBrand": "Markanız burada",
|
||||||
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın.",
|
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın."
|
||||||
"placements": "Mevcut yerleşimler",
|
|
||||||
"placementDashboard": "Genel bakış afişi",
|
|
||||||
"placementDashboardDesc": "Panonun üstünde premium alan, aynı anda tek sponsor.",
|
|
||||||
"placementSidebar": "Kenar çubuğu kartı",
|
|
||||||
"placementSidebarDesc": "Tüm panel sayfalarında gösterilir, sponsorlar arasında döner.",
|
|
||||||
"placementLogin": "Giriş sayfası",
|
|
||||||
"placementLoginDesc": "Yönetici her girişte görür.",
|
|
||||||
"placementPage": "Sponsorlar sayfası",
|
|
||||||
"placementPageDesc": "Logo, açıklama ve bağlantıyla burada listelenir.",
|
|
||||||
"available": "Müsait",
|
|
||||||
"takenUntil": "{date} tarihine kadar dolu",
|
|
||||||
"activeCount": "{count} aktif"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -505,6 +505,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "Домен FakeTLS за замовчуванням для генерації секрету нового клієнта. Кожен клієнт може використовувати власний домен.",
|
"mtprotoFakeTlsDomainHint": "Домен FakeTLS за замовчуванням для генерації секрету нового клієнта. Кожен клієнт може використовувати власний домен.",
|
||||||
"mtgThrottleMaxConnections": "Макс. з'єднань",
|
"mtgThrottleMaxConnections": "Макс. з'єднань",
|
||||||
"mtgThrottleMaxConnectionsHint": "Обмеження одночасних з'єднань усіх користувачів зі справедливим розподілом. 0 — вимкнено.",
|
"mtgThrottleMaxConnectionsHint": "Обмеження одночасних з'єднань усіх користувачів зі справедливим розподілом. 0 — вимкнено.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "Макс. незавершених рукостискань на IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "Нові з'єднання з IP, що вже має стільки незавершених рукостискань, закриваються. Клієнти за спільним IP (NAT оператора) ділять цей ліміт. 0 — вимкнено.",
|
||||||
"mtgAdTagInvalid": "Рекламний тег має містити рівно 32 шістнадцяткові символи.",
|
"mtgAdTagInvalid": "Рекламний тег має містити рівно 32 шістнадцяткові символи.",
|
||||||
"mtgPublicIpv4": "Публічний IPv4",
|
"mtgPublicIpv4": "Публічний IPv4",
|
||||||
"mtgPublicIpv6": "Публічний IPv6",
|
"mtgPublicIpv6": "Публічний IPv6",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "Спонсор",
|
"tag": "Спонсор",
|
||||||
"visit": "Перейти",
|
"visit": "Перейти",
|
||||||
"yourBrand": "Тут може бути ваш бренд",
|
"yourBrand": "Тут може бути ваш бренд",
|
||||||
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI.",
|
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI."
|
||||||
"placements": "Доступні місця",
|
|
||||||
"placementDashboard": "Банер на головній",
|
|
||||||
"placementDashboardDesc": "Преміум-місце вгорі панелі, один спонсор за раз.",
|
|
||||||
"placementSidebar": "Картка в бічній панелі",
|
|
||||||
"placementSidebarDesc": "Показується на всіх сторінках панелі, спонсори чергуються.",
|
|
||||||
"placementLogin": "Сторінка входу",
|
|
||||||
"placementLoginDesc": "Адміністратор бачить її під час кожного входу.",
|
|
||||||
"placementPage": "Сторінка спонсорів",
|
|
||||||
"placementPageDesc": "Розміщення тут із логотипом, описом і посиланням.",
|
|
||||||
"available": "Вільно",
|
|
||||||
"takenUntil": "Зайнято до {date}",
|
|
||||||
"activeCount": "Активних: {count}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -526,6 +526,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "Tên miền FakeTLS mặc định dùng để tạo secret cho client mới. Mỗi client có thể dùng tên miền riêng.",
|
"mtprotoFakeTlsDomainHint": "Tên miền FakeTLS mặc định dùng để tạo secret cho client mới. Mỗi client có thể dùng tên miền riêng.",
|
||||||
"mtgThrottleMaxConnections": "Số kết nối tối đa",
|
"mtgThrottleMaxConnections": "Số kết nối tối đa",
|
||||||
"mtgThrottleMaxConnectionsHint": "Giới hạn kết nối đồng thời của tất cả người dùng theo phân bổ công bằng. 0 để tắt.",
|
"mtgThrottleMaxConnectionsHint": "Giới hạn kết nối đồng thời của tất cả người dùng theo phân bổ công bằng. 0 để tắt.",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "Số bắt tay chờ tối đa mỗi IP",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "Đóng kết nối mới từ IP đang có số bắt tay chưa hoàn tất bằng mức này. Các máy khách dùng chung IP (NAT nhà mạng) chia sẻ giới hạn. 0 để tắt.",
|
||||||
"mtgAdTagInvalid": "Ad-tag phải có đúng 32 ký tự thập lục phân.",
|
"mtgAdTagInvalid": "Ad-tag phải có đúng 32 ký tự thập lục phân.",
|
||||||
"mtgPublicIpv4": "IPv4 công khai",
|
"mtgPublicIpv4": "IPv4 công khai",
|
||||||
"mtgPublicIpv6": "IPv6 công khai",
|
"mtgPublicIpv6": "IPv6 công khai",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "Tài trợ",
|
"tag": "Tài trợ",
|
||||||
"visit": "Truy cập",
|
"visit": "Truy cập",
|
||||||
"yourBrand": "Thương hiệu của bạn ở đây",
|
"yourBrand": "Thương hiệu của bạn ở đây",
|
||||||
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày.",
|
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày."
|
||||||
"placements": "Vị trí hiện có",
|
|
||||||
"placementDashboard": "Banner tổng quan",
|
|
||||||
"placementDashboardDesc": "Vị trí cao cấp ở đầu bảng điều khiển, mỗi lần một nhà tài trợ.",
|
|
||||||
"placementSidebar": "Thẻ thanh bên",
|
|
||||||
"placementSidebarDesc": "Hiển thị trên mọi trang của panel, luân phiên giữa các nhà tài trợ.",
|
|
||||||
"placementLogin": "Trang đăng nhập",
|
|
||||||
"placementLoginDesc": "Quản trị viên thấy mỗi lần đăng nhập.",
|
|
||||||
"placementPage": "Trang nhà tài trợ",
|
|
||||||
"placementPageDesc": "Được liệt kê tại đây với logo, mô tả và liên kết.",
|
|
||||||
"available": "Còn trống",
|
|
||||||
"takenUntil": "Đã đặt đến {date}",
|
|
||||||
"activeCount": "{count} đang hoạt động"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -525,6 +525,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "生成新客户端密钥时使用的默认 FakeTLS 域名。每个客户端可使用各自的域名。",
|
"mtprotoFakeTlsDomainHint": "生成新客户端密钥时使用的默认 FakeTLS 域名。每个客户端可使用各自的域名。",
|
||||||
"mtgThrottleMaxConnections": "最大连接数",
|
"mtgThrottleMaxConnections": "最大连接数",
|
||||||
"mtgThrottleMaxConnectionsHint": "按公平分配限制所有用户的并发连接数。0 表示不限制。",
|
"mtgThrottleMaxConnectionsHint": "按公平分配限制所有用户的并发连接数。0 表示不限制。",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "每 IP 最大未完成握手数",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "当某个 IP 的未完成握手达到此数量时,关闭其新连接。共享 IP(运营商 NAT)后的客户端共用此上限。0 表示禁用。",
|
||||||
"mtgAdTagInvalid": "广告标签必须为 32 位十六进制字符。",
|
"mtgAdTagInvalid": "广告标签必须为 32 位十六进制字符。",
|
||||||
"mtgPublicIpv4": "公网 IPv4",
|
"mtgPublicIpv4": "公网 IPv4",
|
||||||
"mtgPublicIpv6": "公网 IPv6",
|
"mtgPublicIpv6": "公网 IPv6",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "赞助",
|
"tag": "赞助",
|
||||||
"visit": "访问",
|
"visit": "访问",
|
||||||
"yourBrand": "您的品牌展示位",
|
"yourBrand": "您的品牌展示位",
|
||||||
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。",
|
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。"
|
||||||
"placements": "可用展示位",
|
|
||||||
"placementDashboard": "概览横幅",
|
|
||||||
"placementDashboardDesc": "仪表盘顶部的高级位置,每次仅一家赞助商。",
|
|
||||||
"placementSidebar": "侧边栏卡片",
|
|
||||||
"placementSidebarDesc": "在面板所有页面显示,并在赞助商之间轮换。",
|
|
||||||
"placementLogin": "登录页",
|
|
||||||
"placementLoginDesc": "管理员每次登录时都能看到。",
|
|
||||||
"placementPage": "赞助商页面",
|
|
||||||
"placementPageDesc": "在此页面展示徽标、简介和链接。",
|
|
||||||
"available": "可预订",
|
|
||||||
"takenUntil": "已预订至 {date}",
|
|
||||||
"activeCount": "{count} 个进行中"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -505,6 +505,8 @@
|
|||||||
"mtprotoFakeTlsDomainHint": "產生新用戶端金鑰時使用的預設 FakeTLS 網域。每個用戶端可使用各自的網域。",
|
"mtprotoFakeTlsDomainHint": "產生新用戶端金鑰時使用的預設 FakeTLS 網域。每個用戶端可使用各自的網域。",
|
||||||
"mtgThrottleMaxConnections": "最大連線數",
|
"mtgThrottleMaxConnections": "最大連線數",
|
||||||
"mtgThrottleMaxConnectionsHint": "以公平分配限制所有使用者的並行連線數。0 表示不限制。",
|
"mtgThrottleMaxConnectionsHint": "以公平分配限制所有使用者的並行連線數。0 表示不限制。",
|
||||||
|
"mtgPendingHandshakesMaxPerIp": "每 IP 最大未完成交握數",
|
||||||
|
"mtgPendingHandshakesMaxPerIpHint": "當某個 IP 的未完成交握達到此數量時,關閉其新連線。共用 IP(電信 NAT)後的用戶端共用此上限。0 表示停用。",
|
||||||
"mtgAdTagInvalid": "廣告標籤必須為 32 位十六進位字元。",
|
"mtgAdTagInvalid": "廣告標籤必須為 32 位十六進位字元。",
|
||||||
"mtgPublicIpv4": "公網 IPv4",
|
"mtgPublicIpv4": "公網 IPv4",
|
||||||
"mtgPublicIpv6": "公網 IPv6",
|
"mtgPublicIpv6": "公網 IPv6",
|
||||||
@@ -2423,19 +2425,7 @@
|
|||||||
"tag": "贊助",
|
"tag": "贊助",
|
||||||
"visit": "造訪",
|
"visit": "造訪",
|
||||||
"yourBrand": "您的品牌展示位",
|
"yourBrand": "您的品牌展示位",
|
||||||
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。",
|
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。"
|
||||||
"placements": "可用展示位",
|
|
||||||
"placementDashboard": "總覽橫幅",
|
|
||||||
"placementDashboardDesc": "儀表板頂部的高級位置,每次僅一家贊助商。",
|
|
||||||
"placementSidebar": "側邊欄卡片",
|
|
||||||
"placementSidebarDesc": "在面板所有頁面顯示,並在贊助商之間輪換。",
|
|
||||||
"placementLogin": "登入頁",
|
|
||||||
"placementLoginDesc": "管理員每次登入時都能看到。",
|
|
||||||
"placementPage": "贊助商頁面",
|
|
||||||
"placementPageDesc": "在此頁面展示標誌、簡介與連結。",
|
|
||||||
"available": "可預訂",
|
|
||||||
"takenUntil": "已預訂至 {date}",
|
|
||||||
"activeCount": "{count} 個進行中"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package xray
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"maps"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
@@ -234,7 +235,7 @@ func InboundUserOps(oldIb, newIb *InboundConfig) (*HotDiff, bool) {
|
|||||||
return diff, true
|
return diff, true
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}}
|
var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}, "shadowsocks": {}}
|
||||||
|
|
||||||
// diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
|
// diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
|
||||||
// differ only in settings.clients, so the handler (and its listener) survives.
|
// differ only in settings.clients, so the handler (and its listener) survives.
|
||||||
@@ -261,6 +262,10 @@ func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
|
|||||||
if !bytes.Equal(oldRest, newRest) {
|
if !bytes.Equal(oldRest, newRest) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
cipher := ""
|
||||||
|
if newIb.Protocol == "shadowsocks" {
|
||||||
|
cipher = shadowsocksSettingsMethod(newRest)
|
||||||
|
}
|
||||||
for email, oldC := range oldClients {
|
for email, oldC := range oldClients {
|
||||||
newC, exists := newClients[email]
|
newC, exists := newClients[email]
|
||||||
if exists && bytes.Equal(oldC.norm, newC.norm) {
|
if exists && bytes.Equal(oldC.norm, newC.norm) {
|
||||||
@@ -268,17 +273,38 @@ func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
|
|||||||
}
|
}
|
||||||
diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
|
diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
|
||||||
if exists {
|
if exists {
|
||||||
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
|
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: userWithCipher(newC.user, cipher)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for email, newC := range newClients {
|
for email, newC := range newClients {
|
||||||
if _, exists := oldClients[email]; !exists {
|
if _, exists := oldClients[email]; !exists {
|
||||||
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
|
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: userWithCipher(newC.user, cipher)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shadowsocksSettingsMethod reads the inbound-level method: 2022 clients carry
|
||||||
|
// none, and AddUser needs it to pick the account type the running inbound expects.
|
||||||
|
func shadowsocksSettingsMethod(rest []byte) string {
|
||||||
|
var settings struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(rest, &settings) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return settings.Method
|
||||||
|
}
|
||||||
|
|
||||||
|
func userWithCipher(user map[string]any, cipher string) map[string]any {
|
||||||
|
if cipher == "" {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
withCipher := maps.Clone(user)
|
||||||
|
withCipher["cipher"] = cipher
|
||||||
|
return withCipher
|
||||||
|
}
|
||||||
|
|
||||||
type clientEntry struct {
|
type clientEntry struct {
|
||||||
user map[string]any
|
user map[string]any
|
||||||
norm []byte
|
norm []byte
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
|
|
||||||
"github.com/op/go-logging"
|
"github.com/op/go-logging"
|
||||||
|
"github.com/xtls/xray-core/proxy/shadowsocks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@@ -242,7 +243,7 @@ func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "unsupported protocol",
|
name: "unsupported protocol",
|
||||||
mutate: func(cfg *Config) {
|
mutate: func(cfg *Config) {
|
||||||
cfg.InboundConfigs[1].Protocol = "shadowsocks"
|
cfg.InboundConfigs[1].Protocol = "http"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -559,3 +560,43 @@ func TestComputeHotDiff_HysteriaClientOnlyChangeKeepsListener(t *testing.T) {
|
|||||||
t.Fatalf("expected a single AddUser op for b, got %+v", diff)
|
t.Fatalf("expected a single AddUser op for b, got %+v", diff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2022 clients carry no method of their own, so a user op built from the client
|
||||||
|
// object alone has no cipher and its account cannot be built for the core.
|
||||||
|
func TestInboundUserOps_ShadowsocksAddedUserBuildsTheInboundAccount(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, settingsHead, clientMethod string
|
||||||
|
wantLegacy bool
|
||||||
|
}{
|
||||||
|
{"legacy aead", `"method":"aes-256-gcm"`, `"method":"aes-256-gcm",`, true},
|
||||||
|
{"2022", `"method":"2022-blake3-aes-128-gcm","password":"AAECAwQFBgcICQoLDA0ODw=="`, "", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
inbound := func(clients ...string) *InboundConfig {
|
||||||
|
return &InboundConfig{
|
||||||
|
Port: 8388, Protocol: "shadowsocks", Tag: "inbound-8388",
|
||||||
|
Listen: json_util.RawMessage(`"0.0.0.0"`),
|
||||||
|
Settings: json_util.RawMessage(`{` + tc.settingsHead + `,"network":"tcp,udp","clients":[` + strings.Join(clients, ",") + `]}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientA := `{` + tc.clientMethod + `"email":"a","password":"EBESExQVFhcYGRobHB0eHw=="}`
|
||||||
|
clientB := `{` + tc.clientMethod + `"email":"b","password":"ICEiIyQlJicoKSorLC0uLw=="}`
|
||||||
|
|
||||||
|
diff, ok := InboundUserOps(inbound(clientA), inbound(clientA, clientB))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a client-only shadowsocks change must apply through per-user ops")
|
||||||
|
}
|
||||||
|
if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" {
|
||||||
|
t.Fatalf("expected only user b added, got added %+v removed %+v", diff.AddedUsers, diff.RemovedUsers)
|
||||||
|
}
|
||||||
|
_, legacy, modern := decodeSSAccount(t, diff.AddedUsers[0].User)
|
||||||
|
if tc.wantLegacy && (legacy == nil || legacy.CipherType != shadowsocks.CipherType_AES_256_GCM) {
|
||||||
|
t.Fatalf("want a legacy aes-256-gcm account, got legacy %v modern %v", legacy, modern)
|
||||||
|
}
|
||||||
|
if !tc.wantLegacy && (modern == nil || modern.Key != "ICEiIyQlJicoKSorLC0uLw==") {
|
||||||
|
t.Fatalf("want a 2022 account keyed by b's password, got legacy %v modern %v", legacy, modern)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+117
-78
@@ -12,8 +12,8 @@ import (
|
|||||||
// Compiled once at package load: Write runs on every line Xray emits, so
|
// Compiled once at package load: Write runs on every line Xray emits, so
|
||||||
// recompiling these per write is wasted work.
|
// recompiling these per write is wasted work.
|
||||||
var (
|
var (
|
||||||
crashRegex = regexp.MustCompile(`(?i)(panic|exception|stack trace|fatal error)`)
|
crashStartRegex = regexp.MustCompile(`^(panic|fatal error): `)
|
||||||
logLineRegex = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
|
logLineRegex = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewLogWriter returns a new LogWriter for processing Xray log output.
|
// NewLogWriter returns a new LogWriter for processing Xray log output.
|
||||||
@@ -21,9 +21,12 @@ func NewLogWriter() *LogWriter {
|
|||||||
return &LogWriter{}
|
return &LogWriter{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogWriter processes and filters log output from the Xray process, handling crash detection and message filtering.
|
// LogWriter splits Xray's stdout/stderr into lines and forwards them to the
|
||||||
|
// panel log; once a Go panic or fatal error starts, all output is kept for one crash report.
|
||||||
type LogWriter struct {
|
type LogWriter struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
pending string
|
||||||
|
crash strings.Builder
|
||||||
lastLine string
|
lastLine string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,86 +39,122 @@ func (lw *LogWriter) LastLine() string {
|
|||||||
return lw.lastLine
|
return lw.lastLine
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lw *LogWriter) setLastLine(line string) {
|
// Write buffers chunks into whole lines: the pipe splits output at arbitrary
|
||||||
lw.mu.Lock()
|
// points, e.g. the Go runtime writes "panic: " on its own before the message.
|
||||||
lw.lastLine = line
|
|
||||||
lw.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write processes and filters log output from the Xray process, handling crash detection and message filtering.
|
|
||||||
func (lw *LogWriter) Write(m []byte) (n int, err error) {
|
func (lw *LogWriter) Write(m []byte) (n int, err error) {
|
||||||
// Convert the data to a string
|
lw.mu.Lock()
|
||||||
message := strings.TrimSpace(string(m))
|
defer lw.mu.Unlock()
|
||||||
msgLowerAll := strings.ToLower(message)
|
if lw.crash.Len() > 0 {
|
||||||
|
lw.crash.Write(m)
|
||||||
// Suppress noisy Windows process-kill signal that surfaces as exit status 1
|
|
||||||
if runtime.GOOS == "windows" && strings.Contains(msgLowerAll, "exit status 1") {
|
|
||||||
return len(m), nil
|
return len(m), nil
|
||||||
}
|
}
|
||||||
|
lw.pending += string(m)
|
||||||
// Check if the message contains a crash
|
for {
|
||||||
if crashRegex.MatchString(message) {
|
i := strings.IndexByte(lw.pending, '\n')
|
||||||
logger.Debug("Core crash detected:\n", message)
|
if i < 0 {
|
||||||
lw.setLastLine(message)
|
break
|
||||||
err1 := writeCrashReport(m)
|
|
||||||
if err1 != nil {
|
|
||||||
logger.Error("Unable to write crash report:", err1)
|
|
||||||
}
|
}
|
||||||
return len(m), nil
|
line := lw.pending[:i]
|
||||||
}
|
lw.pending = lw.pending[i+1:]
|
||||||
|
if lw.startCrashLocked(line + "\n" + lw.pending) {
|
||||||
messages := strings.SplitSeq(message, "\n")
|
lw.pending = ""
|
||||||
|
break
|
||||||
for msg := range messages {
|
|
||||||
matches := logLineRegex.FindStringSubmatch(msg)
|
|
||||||
|
|
||||||
if len(matches) > 3 {
|
|
||||||
level := matches[2]
|
|
||||||
msgBody := matches[3]
|
|
||||||
msgBodyLower := strings.ToLower(msgBody)
|
|
||||||
|
|
||||||
if strings.Contains(msgBodyLower, "tls handshake error") ||
|
|
||||||
strings.Contains(msgBodyLower, "connection ends") {
|
|
||||||
logger.Debug("XRAY: " + msgBody)
|
|
||||||
lw.setLastLine("")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(msgBodyLower, "failed") {
|
|
||||||
logger.Error("XRAY: " + msgBody)
|
|
||||||
} else {
|
|
||||||
switch level {
|
|
||||||
case "Debug":
|
|
||||||
logger.Debug("XRAY: " + msgBody)
|
|
||||||
case "Info":
|
|
||||||
logger.Info("XRAY: " + msgBody)
|
|
||||||
case "Warning":
|
|
||||||
logger.Warning("XRAY: " + msgBody)
|
|
||||||
case "Error":
|
|
||||||
logger.Error("XRAY: " + msgBody)
|
|
||||||
default:
|
|
||||||
logger.Debug("XRAY: " + msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lw.setLastLine("")
|
|
||||||
} else if msg != "" {
|
|
||||||
msgLower := strings.ToLower(msg)
|
|
||||||
|
|
||||||
if strings.Contains(msgLower, "tls handshake error") ||
|
|
||||||
strings.Contains(msgLower, "connection ends") {
|
|
||||||
logger.Debug("XRAY: " + msg)
|
|
||||||
lw.setLastLine(msg)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(msgLower, "failed") {
|
|
||||||
logger.Error("XRAY: " + msg)
|
|
||||||
} else {
|
|
||||||
logger.Debug("XRAY: " + msg)
|
|
||||||
}
|
|
||||||
lw.setLastLine(msg)
|
|
||||||
}
|
}
|
||||||
|
lw.processLineLocked(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(m), nil
|
return len(m), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush handles an unterminated final line and writes the crash report, if any;
|
||||||
|
// it must run after cmd.Wait, which guarantees every output chunk was delivered.
|
||||||
|
func (lw *LogWriter) Flush() {
|
||||||
|
lw.mu.Lock()
|
||||||
|
defer lw.mu.Unlock()
|
||||||
|
if rest := lw.pending; rest != "" {
|
||||||
|
lw.pending = ""
|
||||||
|
if !lw.startCrashLocked(rest) {
|
||||||
|
lw.processLineLocked(rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lw.crash.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report := lw.crash.String()
|
||||||
|
lw.crash.Reset()
|
||||||
|
logger.Debug("Core crash detected:\n", report)
|
||||||
|
if err := writeCrashReport([]byte(report)); err != nil {
|
||||||
|
logger.Error("Unable to write crash report:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCrashLocked begins capturing a crash when output opens with a Go runtime
|
||||||
|
// crash header; the header line becomes LastLine so GetResult shows the cause.
|
||||||
|
func (lw *LogWriter) startCrashLocked(output string) bool {
|
||||||
|
if lw.crash.Len() > 0 || !crashStartRegex.MatchString(output) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
header, _, _ := strings.Cut(output, "\n")
|
||||||
|
lw.lastLine = strings.TrimSpace(header)
|
||||||
|
lw.crash.WriteString(output)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lw *LogWriter) processLineLocked(line string) {
|
||||||
|
msg := strings.TrimSpace(line)
|
||||||
|
if msg == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgLower := strings.ToLower(msg)
|
||||||
|
|
||||||
|
// Suppress noisy Windows process-kill signal that surfaces as exit status 1
|
||||||
|
if runtime.GOOS == "windows" && strings.Contains(msgLower, "exit status 1") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := logLineRegex.FindStringSubmatch(msg)
|
||||||
|
if len(matches) > 3 {
|
||||||
|
level := matches[2]
|
||||||
|
msgBody := matches[3]
|
||||||
|
msgBodyLower := strings.ToLower(msgBody)
|
||||||
|
|
||||||
|
if strings.Contains(msgBodyLower, "tls handshake error") ||
|
||||||
|
strings.Contains(msgBodyLower, "connection ends") {
|
||||||
|
logger.Debug("XRAY: " + msgBody)
|
||||||
|
lw.lastLine = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(msgBodyLower, "failed") {
|
||||||
|
logger.Error("XRAY: " + msgBody)
|
||||||
|
} else {
|
||||||
|
switch level {
|
||||||
|
case "Debug":
|
||||||
|
logger.Debug("XRAY: " + msgBody)
|
||||||
|
case "Info":
|
||||||
|
logger.Info("XRAY: " + msgBody)
|
||||||
|
case "Warning":
|
||||||
|
logger.Warning("XRAY: " + msgBody)
|
||||||
|
case "Error":
|
||||||
|
logger.Error("XRAY: " + msgBody)
|
||||||
|
default:
|
||||||
|
logger.Debug("XRAY: " + msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lw.lastLine = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(msgLower, "tls handshake error") ||
|
||||||
|
strings.Contains(msgLower, "connection ends") {
|
||||||
|
logger.Debug("XRAY: " + msg)
|
||||||
|
lw.lastLine = msg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(msgLower, "failed") {
|
||||||
|
logger.Error("XRAY: " + msg)
|
||||||
|
} else {
|
||||||
|
logger.Debug("XRAY: " + msg)
|
||||||
|
}
|
||||||
|
lw.lastLine = msg
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package xray
|
package xray
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -20,7 +24,7 @@ func TestLogWriterLastLineConcurrent(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for range iterations {
|
for range iterations {
|
||||||
_, _ = lw.Write([]byte("2024/01/01 00:00:00.000000 [Info] connection accepted"))
|
_, _ = lw.Write([]byte("2024/01/01 00:00:00.000000 [Info] connection accepted\n"))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -34,3 +38,76 @@ func TestLogWriterLastLineConcurrent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func crashReportsIn(t *testing.T, dir string) []string {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var contents []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasPrefix(e.Name(), crashReportPrefix) {
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contents = append(contents, string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Go runtime prints "panic: " with its own write(2), so the pipe hands it
|
||||||
|
// over as a chunk of its own; the report must still hold the whole traceback.
|
||||||
|
func TestLogWriterCrashReportHoldsWholePanic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", dir)
|
||||||
|
chunks := []string{
|
||||||
|
"panic: ",
|
||||||
|
"runtime error: index out of range [3] with length 1\n",
|
||||||
|
"\ngoroutine 1 [running]:\nmain.main()\n\t/src/main.go:5 +0x1d\n",
|
||||||
|
"exit status 2",
|
||||||
|
}
|
||||||
|
lw := NewLogWriter()
|
||||||
|
for _, c := range chunks {
|
||||||
|
_, _ = lw.Write([]byte(c))
|
||||||
|
}
|
||||||
|
lw.Flush()
|
||||||
|
|
||||||
|
got := crashReportsIn(t, dir)
|
||||||
|
want := strings.Join(chunks, "")
|
||||||
|
if len(got) != 1 || got[0] != want {
|
||||||
|
t.Fatalf("crash reports = %q, want exactly [%q]", got, want)
|
||||||
|
}
|
||||||
|
if line := lw.LastLine(); line != "panic: runtime error: index out of range [3] with length 1" {
|
||||||
|
t.Fatalf("LastLine = %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterOrdinaryLineMentioningPanicIsNotACrash(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", dir)
|
||||||
|
lw := NewLogWriter()
|
||||||
|
_, _ = lw.Write([]byte("2026/07/12 15:16:00.000000 [Info] [42] proxy/vless: sniffed domain panic.example.com\n"))
|
||||||
|
lw.Flush()
|
||||||
|
|
||||||
|
if got := crashReportsIn(t, dir); len(got) != 0 {
|
||||||
|
t.Fatalf("ordinary log line produced crash reports %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteCrashReportKeepsReportsFromTheSameSecond(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", dir)
|
||||||
|
for _, m := range []string{"panic: first", "panic: second"} {
|
||||||
|
if err := writeCrashReport([]byte(m)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := crashReportsIn(t, dir)
|
||||||
|
slices.Sort(got)
|
||||||
|
if !slices.Equal(got, []string{"panic: first", "panic: second"}) {
|
||||||
|
t.Fatalf("crash reports = %q, want both", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -280,6 +281,33 @@ func TestWaitForCommand_CrashExitRecordsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A real child panic reaches the LogWriter in several pipe chunks; the crash
|
||||||
|
// report is only whole if waitForCommand flushes the writer after cmd.Wait.
|
||||||
|
func TestWaitForCommand_PanicWritesWholeCrashReport(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", dir)
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "panic")
|
||||||
|
cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
|
||||||
|
|
||||||
|
p := newProcess(nil)
|
||||||
|
cmd.Stdout = p.logWriter
|
||||||
|
cmd.Stderr = p.logWriter
|
||||||
|
if err := p.startCommand(cmd); err != nil {
|
||||||
|
t.Fatalf("startCommand: %v", err)
|
||||||
|
}
|
||||||
|
if err := p.waitForExit(5 * time.Second); err != nil {
|
||||||
|
t.Fatalf("child did not exit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reports := crashReportsIn(t, dir)
|
||||||
|
if len(reports) != 1 || !strings.HasPrefix(reports[0], "panic: xray helper panic\n\ngoroutine ") {
|
||||||
|
t.Fatalf("crash reports = %q, want one holding the whole panic traceback", reports)
|
||||||
|
}
|
||||||
|
if got := p.GetResult(); got != "panic: xray helper panic" {
|
||||||
|
t.Fatalf("GetResult = %q, want the panic header", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestStop_RemovesTempConfigFile pins process.go:579 — the
|
// TestStop_RemovesTempConfigFile pins process.go:579 — the
|
||||||
// `if p.configPath != ""` guard that removes the per-run temp config file on
|
// `if p.configPath != ""` guard that removes the per-run temp config file on
|
||||||
// Stop (so test runs never disturb the main config.json). A mutated guard
|
// Stop (so test runs never disturb the main config.json). A mutated guard
|
||||||
@@ -337,5 +365,8 @@ func TestMutationAuditHelper(t *testing.T) {
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
case "block":
|
case "block":
|
||||||
select {}
|
select {}
|
||||||
|
case "panic":
|
||||||
|
go func() { panic("xray helper panic") }()
|
||||||
|
select {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,6 +734,7 @@ func (p *process) waitForCommand(cmd *exec.Cmd, done chan struct{}) {
|
|||||||
defer close(done)
|
defer close(done)
|
||||||
|
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
|
p.logWriter.Flush()
|
||||||
if err == nil || p.intentionalStop.Load() {
|
if err == nil || p.intentionalStop.Load() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -830,17 +831,27 @@ const (
|
|||||||
maxCrashReports = 10
|
maxCrashReports = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
// writeCrashReport persists a captured xray crash chunk to the log folder
|
// writeCrashReport persists a captured xray crash to the log folder; CreateTemp's
|
||||||
// with nanosecond-precision filename so restart-loop bursts don't overwrite
|
// random suffix keeps restart-loop bursts within one second from overwriting each other.
|
||||||
// each other, and prunes old reports to keep the folder bounded.
|
|
||||||
func writeCrashReport(m []byte) error {
|
func writeCrashReport(m []byte) error {
|
||||||
dir := config.GetLogFolder()
|
dir := config.GetLogFolder()
|
||||||
if err := os.MkdirAll(dir, 0o770); err != nil {
|
if err := os.MkdirAll(dir, 0o770); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pruneOldCrashReports(dir, maxCrashReports-1)
|
pruneOldCrashReports(dir, maxCrashReports-1)
|
||||||
name := crashReportPrefix + time.Now().Format("20060102_150405_000000000") + crashReportSuffix
|
pattern := crashReportPrefix + time.Now().Format("20060102_150405") + "_*" + crashReportSuffix
|
||||||
return os.WriteFile(filepath.Join(dir, name), m, 0o640)
|
f, err := os.CreateTemp(dir, pattern)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(m)
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.Chmod(f.Name(), 0o640)
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func pruneOldCrashReports(dir string, keep int) {
|
func pruneOldCrashReports(dir string, keep int) {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"RT_GROUP_ICON": {
|
||||||
|
"APP": {
|
||||||
|
"0000": "icon.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RT_MANIFEST": {
|
||||||
|
"#1": {
|
||||||
|
"0409": {
|
||||||
|
"identity": {
|
||||||
|
"name": "3x-ui",
|
||||||
|
"version": ""
|
||||||
|
},
|
||||||
|
"description": "3X-UI web panel for Xray-core",
|
||||||
|
"minimum-os": "win7",
|
||||||
|
"execution-level": "as invoker",
|
||||||
|
"ui-access": false,
|
||||||
|
"auto-elevate": false,
|
||||||
|
"dpi-awareness": "system",
|
||||||
|
"disable-theming": false,
|
||||||
|
"disable-window-filtering": false,
|
||||||
|
"high-resolution-scrolling-aware": false,
|
||||||
|
"ultra-high-resolution-scrolling-aware": false,
|
||||||
|
"long-path-aware": false,
|
||||||
|
"printer-driver-isolation": false,
|
||||||
|
"gdi-scaling": false,
|
||||||
|
"segment-heap": false,
|
||||||
|
"use-common-controls-v6": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RT_VERSION": {
|
||||||
|
"#1": {
|
||||||
|
"0000": {
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "0.0.0.0",
|
||||||
|
"product_version": "0.0.0.0"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0409": {
|
||||||
|
"CompanyName": "MHSanaei",
|
||||||
|
"FileDescription": "3X-UI web panel for Xray-core",
|
||||||
|
"FileVersion": "",
|
||||||
|
"InternalName": "3x-ui",
|
||||||
|
"LegalCopyright": "3X-UI contributors, licensed under GPL-3.0",
|
||||||
|
"OriginalFilename": "3x-ui.exe",
|
||||||
|
"ProductName": "3X-UI",
|
||||||
|
"ProductVersion": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user