精简版P9(4b): 删除本地核心整条路径(进程管理/buildXrayConfig本地分支/local-only注入/本地核心接口与任务)
- service/xray.go 1741->573行: 只剩 agent 配置构建(GetXrayConfigForAgent/buildAgentXrayConfig=模板+节点入站+egress+转发编译); 删进程生命周期/状态机/RestartXray/StopXray/SetToNeedRestart/GetXrayTraffic/GetOnlineUsers/balancer/TestRoute 与 injectPanelEgress/injectNodeEgresses/injectMtproto/injectAmneziawg/mergeSubscriptionOutbounds
- controller: 43处 SetToNeedRestart 与各 RestartXray 调用清除; 删 balancerStatus/balancerOverride/routeTest/getXrayResult 路由与handler(纯本地核心操作); 前端删 RouteTester 页签与组件
- server service: GetConfigJson 改模板直读; 状态Xray块恒 stopped/空版本; Stop/RestartXrayService 改 no-op(DB导入/恢复流程保留形状)
- setting service: PanelEgressProxyURL/NodeEgressProxyURL 恒直连(桥在本地核心内,已不存在); NodeService.withOutboundBridge 恒直连回退
- runtime: Local 改为全 no-op 桩(DB即真源); NewManager() 去 LocalDeps(APIPort/SetNeedRestart)
- job: 删 XrayTrafficJob/CheckXrayRunningJob/CheckClientIpJob 三文件(共享声明 EventBus/IPWithTimestamp/clientStatsSnapshotMaxClients 迁 shared.go); web.go 不再调度 MtprotoJob/AmneziaWGJob, 删 OnCrash 接线与 stop() 的 sidecar StopAll
- MTProto/AmneziaWG 下线: AddInbound/UpdateInbound 对两协议直接拒绝(存量行保留可删); TG bot 的 restart 命令与在线查询按无本地核心收口
- 测试: GetXrayConfig 系列移植到 agent 构建(finalmask/clients/wireguard/scale); 删退役行为测试(lifecycle/restart/mtproto本地应用/client-ip job/inject系列); 共享测试helper迁 mtproto_test_helpers_test.go 与 job/integration_helpers_test.go
- 既有红(非本轮引入,已在 f6d6520 复现): TestGetXrayConfig_EnabledClientsStillEmitted、TestRouteRegistryContract(P6删 endpoints.ts 遗留)
This commit is contained in:
@@ -1,165 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Alert, Button, Col, Input, InputNumber, Row, Select, Space, Tag } from 'antd';
|
|
||||||
import { AimOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
|
||||||
import { buildRemarkByTag, formatInboundTag } from './helpers';
|
|
||||||
|
|
||||||
interface RouteTesterProps {
|
|
||||||
inboundTags: string[];
|
|
||||||
isMobile: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirror of the /xray/routeTest response (RoutingService.TestRoute).
|
|
||||||
interface RouteTestResult {
|
|
||||||
matched: boolean;
|
|
||||||
outboundTag: string;
|
|
||||||
groupTags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROTOCOL_OPTIONS = ['http', 'tls', 'quic', 'bittorrent'].map((p) => ({ label: p, value: p }));
|
|
||||||
|
|
||||||
export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: inboundOptions } = useInboundOptions();
|
|
||||||
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
|
|
||||||
const [dest, setDest] = useState('');
|
|
||||||
const [port, setPort] = useState<number | null>(443);
|
|
||||||
const [network, setNetwork] = useState('tcp');
|
|
||||||
const [inboundTag, setInboundTag] = useState<string | undefined>(undefined);
|
|
||||||
const [protocol, setProtocol] = useState<string | undefined>(undefined);
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [result, setResult] = useState<RouteTestResult | null>(null);
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const value = dest.trim();
|
|
||||||
if (!value) return;
|
|
||||||
// Domains never contain ':' and a pure dotted-quad is an IPv4 address;
|
|
||||||
// everything else is treated as a domain.
|
|
||||||
const isIp = /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
|
|
||||||
setTesting(true);
|
|
||||||
setResult(null);
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/routeTest', {
|
|
||||||
domain: isIp ? '' : value,
|
|
||||||
ip: isIp ? value : '',
|
|
||||||
port: port ?? 0,
|
|
||||||
network,
|
|
||||||
inboundTag: inboundTag || '',
|
|
||||||
protocol: protocol || '',
|
|
||||||
});
|
|
||||||
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
|
|
||||||
setResult(msg.obj as RouteTestResult);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldSpan = isMobile ? 24 : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
|
||||||
<Alert type="info" showIcon title={t('pages.xray.routeTesterDesc')} />
|
|
||||||
<Row gutter={[8, 8]} align="bottom">
|
|
||||||
<Col xs={fieldSpan} sm={7}>
|
|
||||||
<Input
|
|
||||||
aria-label={t('pages.xray.routeTesterDest')}
|
|
||||||
placeholder={t('pages.xray.routeTesterDest')}
|
|
||||||
value={dest}
|
|
||||||
onChange={(e) => setDest(e.target.value)}
|
|
||||||
onPressEnter={run}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={3}>
|
|
||||||
<InputNumber
|
|
||||||
aria-label={t('pages.xray.routeTesterPort')}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
min={0}
|
|
||||||
max={65535}
|
|
||||||
placeholder={t('pages.xray.routeTesterPort')}
|
|
||||||
value={port}
|
|
||||||
onChange={(v) => setPort(v)}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={3}>
|
|
||||||
<Select
|
|
||||||
aria-label={t('pages.inbounds.network')}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={network}
|
|
||||||
onChange={setNetwork}
|
|
||||||
options={[
|
|
||||||
{ label: 'TCP', value: 'tcp' },
|
|
||||||
{ label: 'UDP', value: 'udp' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={4}>
|
|
||||||
<Select
|
|
||||||
aria-label={t('pages.xray.routeTesterInbound')}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
placeholder={t('pages.xray.routeTesterInbound')}
|
|
||||||
allowClear
|
|
||||||
value={inboundTag}
|
|
||||||
onChange={setInboundTag}
|
|
||||||
options={inboundTags
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={4}>
|
|
||||||
<Select
|
|
||||||
aria-label={t('pages.xray.routeTesterProtocol')}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
placeholder={t('pages.xray.routeTesterProtocol')}
|
|
||||||
allowClear
|
|
||||||
value={protocol}
|
|
||||||
onChange={setProtocol}
|
|
||||||
options={PROTOCOL_OPTIONS}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={fieldSpan} sm={3}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<AimOutlined />}
|
|
||||||
loading={testing}
|
|
||||||
disabled={!dest.trim()}
|
|
||||||
onClick={run}
|
|
||||||
block
|
|
||||||
>
|
|
||||||
{t('pages.xray.routeTesterTest')}
|
|
||||||
</Button>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{result &&
|
|
||||||
(result.matched ? (
|
|
||||||
<Alert
|
|
||||||
type="success"
|
|
||||||
showIcon
|
|
||||||
title={
|
|
||||||
<Space wrap>
|
|
||||||
<span>{t('pages.xray.routeTesterMatchedOutbound')}:</span>
|
|
||||||
<Tag color="blue">{result.outboundTag || '—'}</Tag>
|
|
||||||
{(result.groupTags || []).length > 0 && (
|
|
||||||
<>
|
|
||||||
<span>{t('pages.xray.routeTesterViaBalancer')}:</span>
|
|
||||||
{(result.groupTags || []).map((tag) => (
|
|
||||||
<Tag key={tag} color="orange">
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Alert type="warning" showIcon title={t('pages.xray.routeTesterDefaultOutbound')} />
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
|
import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
AimOutlined,
|
|
||||||
ControlOutlined,
|
ControlOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
@@ -16,7 +15,6 @@ import PromptModal from '@/components/feedback/PromptModal';
|
|||||||
import TextModal from '@/components/feedback/TextModal';
|
import TextModal from '@/components/feedback/TextModal';
|
||||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||||
import RoutingBasic from './RoutingBasic';
|
import RoutingBasic from './RoutingBasic';
|
||||||
import RouteTester from './RouteTester';
|
|
||||||
import RuleFormModal from './RuleFormModal';
|
import RuleFormModal from './RuleFormModal';
|
||||||
import type { RoutingRule } from './RuleFormModal';
|
import type { RoutingRule } from './RuleFormModal';
|
||||||
import RuleCardList from './RuleCardList';
|
import RuleCardList from './RuleCardList';
|
||||||
@@ -430,11 +428,6 @@ export default function RoutingTab({
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'tester',
|
|
||||||
label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
|
|
||||||
children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<RuleFormModal
|
<RuleFormModal
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ func (a *ClientController) create(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -215,7 +214,6 @@ func (a *ClientController) update(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -230,7 +228,6 @@ func (a *ClientController) delete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -257,7 +254,6 @@ func (a *ClientController) attach(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -285,7 +281,6 @@ func (a *ClientController) resetAllTraffics(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -310,7 +305,6 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -338,7 +332,6 @@ func (a *ClientController) bulkAttach(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -361,7 +354,6 @@ func (a *ClientController) bulkDetach(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -379,7 +371,6 @@ func (a *ClientController) bulkDelete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -409,7 +400,6 @@ func (a *ClientController) bulkSetEnable(c *gin.Context, enable bool) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -427,7 +417,6 @@ func (a *ClientController) bulkCreate(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -440,7 +429,6 @@ func (a *ClientController) delDepleted(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, gin.H{"deleted": deleted}, nil)
|
jsonObj(c, gin.H{"deleted": deleted}, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -482,7 +470,6 @@ func (a *ClientController) importClients(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -506,7 +493,6 @@ func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -636,7 +622,6 @@ func (a *ClientController) detach(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
@@ -657,6 +642,5 @@ func (a *ClientController) bulkResetTraffic(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,6 @@ func (a *InboundController) addInbound(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
a.broadcastInboundsUpdate(user.Id)
|
a.broadcastInboundsUpdate(user.Id)
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
@@ -248,7 +247,6 @@ func (a *InboundController) delInbound(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), id, nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), id, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
a.broadcastInboundsUpdate(user.Id)
|
a.broadcastInboundsUpdate(user.Id)
|
||||||
@@ -274,7 +272,6 @@ func (a *InboundController) bulkDelInbounds(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
a.broadcastInboundsUpdate(user.Id)
|
a.broadcastInboundsUpdate(user.Id)
|
||||||
@@ -357,7 +354,6 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
a.broadcastInboundsUpdate(user.Id)
|
a.broadcastInboundsUpdate(user.Id)
|
||||||
@@ -409,7 +405,6 @@ func (a *InboundController) setInboundEnable(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
|
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
|
||||||
// instead of fetching + serialising the whole inbound list. Other open
|
// instead of fetching + serialising the whole inbound list. Other open
|
||||||
@@ -431,7 +426,6 @@ func (a *InboundController) resetInboundTraffic(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
|
||||||
}
|
}
|
||||||
@@ -463,7 +457,6 @@ func (a *InboundController) delAllInboundClients(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
a.broadcastInboundsUpdate(user.Id)
|
a.broadcastInboundsUpdate(user.Id)
|
||||||
@@ -477,7 +470,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
||||||
}
|
}
|
||||||
@@ -535,7 +527,6 @@ func (a *InboundController) importInbound(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
a.broadcastInboundsUpdate(user.Id)
|
a.broadcastInboundsUpdate(user.Id)
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
@@ -602,6 +593,5 @@ func (a *InboundController) setFallbacks(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
|
|
||||||
@@ -158,9 +157,6 @@ func (a *NodeController) add(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n.OutboundTag != "" {
|
if n.OutboundTag != "" {
|
||||||
if err := a.xrayService.RestartXray(false); err != nil {
|
|
||||||
logger.Warning("apply node outbound bridge failed:", err)
|
|
||||||
}
|
|
||||||
if err := a.ensureReachable(c, n, view.Id); err != nil {
|
if err := a.ensureReachable(c, n, view.Id); err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||||
return
|
return
|
||||||
@@ -215,9 +211,6 @@ func (a *NodeController) update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n.OutboundTag != old.OutboundTag {
|
if n.OutboundTag != old.OutboundTag {
|
||||||
if err := a.xrayService.RestartXray(false); err != nil {
|
|
||||||
logger.Warning("apply node outbound bridge change failed:", err)
|
|
||||||
}
|
|
||||||
if err := a.ensureReachable(c, n, id); err != nil {
|
if err := a.ensureReachable(c, n, id); err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||||
return
|
return
|
||||||
@@ -262,9 +255,6 @@ func (a *NodeController) setEnable(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n.OutboundTag != "" {
|
if n.OutboundTag != "" {
|
||||||
if err := a.xrayService.RestartXray(false); err != nil {
|
|
||||||
logger.Warning("apply node enable change failed:", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||||
@@ -118,20 +117,11 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
allSetting := &form.AllSetting
|
allSetting := &form.AllSetting
|
||||||
oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
|
|
||||||
oldTgEnable, _ := a.settingService.GetTgbotEnabled()
|
oldTgEnable, _ := a.settingService.GetTgbotEnabled()
|
||||||
oldTgToken, _ := a.settingService.GetTgBotToken()
|
oldTgToken, _ := a.settingService.GetTgBotToken()
|
||||||
oldTgChatId, _ := a.settingService.GetTgBotChatId()
|
oldTgChatId, _ := a.settingService.GetTgBotChatId()
|
||||||
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
|
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
|
||||||
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{TgBotToken: form.ClearTgBotToken})
|
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{TgBotToken: form.ClearTgBotToken})
|
||||||
if err == nil && form.PanelOutbound != oldPanelOutbound {
|
|
||||||
// The egress bridge lives in the generated config; reconcile the
|
|
||||||
// running core. One SOCKS inbound plus one routing rule — both
|
|
||||||
// hot-appliable, so this normally does not restart Xray.
|
|
||||||
if applyErr := a.xrayService.RestartXray(false); applyErr != nil {
|
|
||||||
logger.Warning("apply panel outbound change failed:", applyErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare.
|
// UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare.
|
||||||
if err == nil && reloadTgbotFunc != nil {
|
if err == nil && reloadTgbotFunc != nil {
|
||||||
tgChanged := oldTgEnable != allSetting.TgBotEnable ||
|
tgChanged := oldTgEnable != allSetting.TgBotEnable ||
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -44,7 +43,6 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
g = g.Group("/xray")
|
g = g.Group("/xray")
|
||||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||||
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
|
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
|
||||||
g.GET("/getXrayResult", a.getXrayResult)
|
|
||||||
|
|
||||||
g.POST("/", a.getXraySetting)
|
g.POST("/", a.getXraySetting)
|
||||||
g.POST("/warp/:action", a.warp)
|
g.POST("/warp/:action", a.warp)
|
||||||
@@ -52,9 +50,6 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/pia/:action", a.pia)
|
g.POST("/pia/:action", a.pia)
|
||||||
g.POST("/update", a.updateSetting)
|
g.POST("/update", a.updateSetting)
|
||||||
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
||||||
g.POST("/balancerStatus", a.balancerStatus)
|
|
||||||
g.POST("/balancerOverride", a.balancerOverride)
|
|
||||||
g.POST("/routeTest", a.routeTest)
|
|
||||||
|
|
||||||
g.GET("/geodata/files", a.geodataFiles)
|
g.GET("/geodata/files", a.geodataFiles)
|
||||||
g.GET("/geodata/categories", a.geodataCategories)
|
g.GET("/geodata/categories", a.geodataCategories)
|
||||||
@@ -129,22 +124,15 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
|||||||
jsonObj(c, string(result), nil)
|
jsonObj(c, string(result), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSetting updates the Xray configuration settings and applies them to
|
// updateSetting updates the Xray configuration settings. Agent nodes pick
|
||||||
// the running core right away — through the gRPC API when only inbounds,
|
// the new template up on their next config pull; there is no local core to
|
||||||
// outbounds or routing rules changed, with a process restart otherwise.
|
// apply it to.
|
||||||
func (a *XraySettingController) updateSetting(c *gin.Context) {
|
func (a *XraySettingController) updateSetting(c *gin.Context) {
|
||||||
xraySetting := c.PostForm("xraySetting")
|
xraySetting := c.PostForm("xraySetting")
|
||||||
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
|
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only reconcile a running core; a manually stopped xray stays stopped.
|
|
||||||
if a.XrayService.IsXrayRunning() {
|
|
||||||
if err := a.XrayService.RestartXray(false); err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,10 +146,6 @@ func (a *XraySettingController) getDefaultXrayConfig(c *gin.Context) {
|
|||||||
jsonObj(c, defaultJsonConfig, nil)
|
jsonObj(c, defaultJsonConfig, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getXrayResult retrieves the current Xray service result.
|
|
||||||
func (a *XraySettingController) getXrayResult(c *gin.Context) {
|
|
||||||
jsonObj(c, a.XrayService.GetXrayResult(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// warp handles Warp-related operations based on the action parameter.
|
// warp handles Warp-related operations based on the action parameter.
|
||||||
func (a *XraySettingController) warp(c *gin.Context) {
|
func (a *XraySettingController) warp(c *gin.Context) {
|
||||||
@@ -182,7 +166,6 @@ func (a *XraySettingController) warp(c *gin.Context) {
|
|||||||
case "changeIp":
|
case "changeIp":
|
||||||
resp, err = a.WarpService.ChangeWarpIP()
|
resp, err = a.WarpService.ChangeWarpIP()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
a.XrayService.SetToNeedRestart()
|
|
||||||
// Restart the auto-update clock so a scheduled rotation
|
// Restart the auto-update clock so a scheduled rotation
|
||||||
// doesn't fire right after this manual one.
|
// doesn't fire right after this manual one.
|
||||||
err = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
|
err = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
|
||||||
@@ -284,76 +267,8 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
|||||||
jsonObj(c, "", nil)
|
jsonObj(c, "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// balancerStatus reports the live state (override + strategy picks) of the
|
|
||||||
// balancer tags given as a comma-separated "tags" form field.
|
|
||||||
func (a *XraySettingController) balancerStatus(c *gin.Context) {
|
|
||||||
raw := c.PostForm("tags")
|
|
||||||
var tags []string
|
|
||||||
for tag := range strings.SplitSeq(raw, ",") {
|
|
||||||
if tag = strings.TrimSpace(tag); tag != "" {
|
|
||||||
tags = append(tags, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
statuses, err := a.XrayService.GetBalancersStatus(tags)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
byTag := make(map[string]service.BalancerStatus, len(statuses))
|
|
||||||
for _, status := range statuses {
|
|
||||||
byTag[status.Tag] = status
|
|
||||||
}
|
|
||||||
jsonObj(c, byTag, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// balancerOverride forces a balancer to a specific outbound tag; an empty
|
|
||||||
// "target" clears the override.
|
|
||||||
func (a *XraySettingController) balancerOverride(c *gin.Context) {
|
|
||||||
tag := c.PostForm("tag")
|
|
||||||
if tag == "" {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("tag is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target := c.PostForm("target")
|
|
||||||
if err := a.XrayService.OverrideBalancer(tag, target); err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, "", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeTest asks the running core which outbound it would route a synthetic
|
|
||||||
// connection to.
|
|
||||||
func (a *XraySettingController) routeTest(c *gin.Context) {
|
|
||||||
port := 0
|
|
||||||
if portStr := c.PostForm("port"); portStr != "" {
|
|
||||||
parsed, err := strconv.Atoi(portStr)
|
|
||||||
if err != nil || parsed < 0 || parsed > 65535 {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("invalid port"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
port = parsed
|
|
||||||
}
|
|
||||||
req := xray.RouteTestRequest{
|
|
||||||
InboundTag: c.PostForm("inboundTag"),
|
|
||||||
Domain: c.PostForm("domain"),
|
|
||||||
IP: c.PostForm("ip"),
|
|
||||||
Port: port,
|
|
||||||
Network: c.PostForm("network"),
|
|
||||||
Protocol: c.PostForm("protocol"),
|
|
||||||
Email: c.PostForm("email"),
|
|
||||||
}
|
|
||||||
if req.Domain == "" && req.IP == "" {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("domain or ip is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := a.XrayService.TestRoute(req)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, result, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// maxGeodataTokens bounds one validation request; a routing rule listing more
|
// maxGeodataTokens bounds one validation request; a routing rule listing more
|
||||||
// categories than this is not something the panel needs to answer for.
|
// categories than this is not something the panel needs to answer for.
|
||||||
@@ -496,7 +411,6 @@ func (a *XraySettingController) deleteOutboundSub(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Signal that xray should drop this subscription's outbounds on next reload.
|
// Signal that xray should drop this subscription's outbounds on next reload.
|
||||||
a.XrayService.SetToNeedRestart()
|
|
||||||
jsonObj(c, "", nil)
|
jsonObj(c, "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +427,6 @@ func (a *XraySettingController) refreshOutboundSub(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Signal that xray should pick up the new outbounds on next restart/reload
|
// Signal that xray should pick up the new outbounds on next restart/reload
|
||||||
a.XrayService.SetToNeedRestart()
|
|
||||||
jsonObj(c, obs, nil)
|
jsonObj(c, obs, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +443,6 @@ func (a *XraySettingController) moveOutboundSub(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Order affects the merged outbounds, so xray needs a reload.
|
// Order affects the merged outbounds, so xray needs a reload.
|
||||||
a.XrayService.SetToNeedRestart()
|
|
||||||
jsonObj(c, "", nil)
|
jsonObj(c, "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
func banLineCount(t *testing.T, email string) int {
|
|
||||||
t.Helper()
|
|
||||||
body, err := os.ReadFile(readIpLimitLogPath())
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
t.Fatalf("read 3xipl.log: %v", err)
|
|
||||||
}
|
|
||||||
return strings.Count(string(body), "[LIMIT_IP] Email = "+email)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
const email = "frozen-ban"
|
|
||||||
seedInboundWithClient(t, "inbound-frozen-ban", email, 1)
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
deadStart := now - 300
|
|
||||||
live := []IPWithTimestamp{
|
|
||||||
{IP: "10.2.0.1", Timestamp: deadStart},
|
|
||||||
{IP: "192.0.2.7", Timestamp: now},
|
|
||||||
}
|
|
||||||
|
|
||||||
j := NewCheckClientIpJob()
|
|
||||||
inbound, err := j.getInboundByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getInboundByEmail: %v", err)
|
|
||||||
}
|
|
||||||
row := seedClientIps(t, email, nil)
|
|
||||||
|
|
||||||
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); !banned {
|
|
||||||
t.Fatalf("first scan: the over-limit stale IP must be banned")
|
|
||||||
}
|
|
||||||
if got := banLineCount(t, email); got != 1 {
|
|
||||||
t.Fatalf("ban lines after first scan = %d, want 1", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); banned {
|
|
||||||
t.Fatalf("second scan with a frozen lastSeen must not re-ban a dead connection")
|
|
||||||
}
|
|
||||||
if got := banLineCount(t, email); got != 1 {
|
|
||||||
t.Fatalf("ban lines after frozen rescan = %d, want still 1", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnected := []IPWithTimestamp{
|
|
||||||
{IP: "10.2.0.1", Timestamp: now + 30},
|
|
||||||
{IP: "192.0.2.7", Timestamp: now + 60},
|
|
||||||
}
|
|
||||||
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, reconnected, true, true); !banned {
|
|
||||||
t.Fatalf("a reconnect (advanced lastSeen) must be banned again")
|
|
||||||
}
|
|
||||||
if got := banLineCount(t, email); got != 2 {
|
|
||||||
t.Fatalf("ban lines after reconnect = %d, want 2", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,789 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IPWithTimestamp tracks an IP address with its last seen timestamp
|
|
||||||
type IPWithTimestamp struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckClientIpJob monitors client IP addresses and manages IP blocking based
|
|
||||||
// on configured limits. The per-client IPs come from the core's online-stats
|
|
||||||
// API; no access log is involved. On a core too old to expose that API the job
|
|
||||||
// simply skips the run (the bundled core always supports it).
|
|
||||||
type CheckClientIpJob struct {
|
|
||||||
disAllowedIps []string
|
|
||||||
bannedSeen map[string]int64
|
|
||||||
xrayService service.XrayService
|
|
||||||
allowlist ipLimitAllowlist
|
|
||||||
lastIpPrune int64
|
|
||||||
}
|
|
||||||
|
|
||||||
var job *CheckClientIpJob
|
|
||||||
|
|
||||||
const defaultXrayAPIPort = 62789
|
|
||||||
|
|
||||||
const ipStaleAfterSeconds = int64(30 * 60)
|
|
||||||
|
|
||||||
// pruneStaleIpRows cadence; the scan itself cannot prune offline clients' rows.
|
|
||||||
const ipPruneIntervalSeconds = int64(5 * 60)
|
|
||||||
|
|
||||||
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
|
||||||
func NewCheckClientIpJob() *CheckClientIpJob {
|
|
||||||
job = new(CheckClientIpJob)
|
|
||||||
return job
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *CheckClientIpJob) Run() {
|
|
||||||
j.pruneStaleIpRows()
|
|
||||||
observed, apiMode := j.collectFromOnlineAPI()
|
|
||||||
if !apiMode {
|
|
||||||
// xray is down or predates the online-stats API. There is no access-log
|
|
||||||
// fallback anymore, so there is nothing to do this run.
|
|
||||||
logger.Debug("[LimitIP] online-stats API unavailable this run; skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isFail2BanEnabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hasLimit := j.hasLimitIp()
|
|
||||||
f2bInstalled := false
|
|
||||||
if hasLimit {
|
|
||||||
f2bInstalled = j.checkFail2BanInstalled()
|
|
||||||
}
|
|
||||||
// Read only when the limit is actually applied: this runs every 10s and
|
|
||||||
// most panels carry no IP limit at all.
|
|
||||||
enforce := j.resolveEnforce(hasLimit, f2bInstalled)
|
|
||||||
if enforce {
|
|
||||||
j.allowlist = j.loadAllowlist()
|
|
||||||
}
|
|
||||||
j.processObserved(observed, enforce, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveEnforce decides whether limits can actually be enforced this run.
|
|
||||||
// Without fail2ban on a platform that needs it the limit can't be applied, so
|
|
||||||
// enforcement is skipped (the panel resets these limits to 0 on upgrade and
|
|
||||||
// disables the field, so this is normally a no-op).
|
|
||||||
func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
|
|
||||||
if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return hasLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectFromOnlineAPI builds per-email IP observations (email -> ip ->
|
|
||||||
// last-seen unix seconds) from the core's online-stats API. ok=false means the
|
|
||||||
// API is unavailable — xray not running, an older core, or a transient gRPC
|
|
||||||
// failure — and the caller skips the run (there is no access-log fallback).
|
|
||||||
func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) {
|
|
||||||
onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
|
|
||||||
if err != nil {
|
|
||||||
logger.Debug("[LimitIP] online-stats API unavailable this run:", err)
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
now := time.Now().Unix()
|
|
||||||
observed := make(map[string]map[string]int64, len(onlineUsers))
|
|
||||||
for _, user := range onlineUsers {
|
|
||||||
for _, entry := range user.IPs {
|
|
||||||
// No localhost guard needed here: the core's OnlineMap.AddIP drops
|
|
||||||
// 127.0.0.1/[::1] itself, so they never reach this list.
|
|
||||||
ts := entry.LastSeen
|
|
||||||
if ts <= 0 {
|
|
||||||
ts = now
|
|
||||||
}
|
|
||||||
if _, exists := observed[user.Email]; !exists {
|
|
||||||
observed[user.Email] = make(map[string]int64)
|
|
||||||
}
|
|
||||||
if existing, seen := observed[user.Email][entry.IP]; !seen || ts > existing {
|
|
||||||
observed[user.Email][entry.IP] = ts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return observed, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasLimitIp reports whether any client carries an IP limit. It probes the
|
|
||||||
// normalized clients table (limit_ip is synced there by SyncInbound and the
|
|
||||||
// legacy seeder), replacing the old `settings LIKE '%limitIp%'` scan that
|
|
||||||
// loaded and JSON-parsed every inbound's settings blob on each 10s run.
|
|
||||||
func (j *CheckClientIpJob) hasLimitIp() bool {
|
|
||||||
db := database.GetDB()
|
|
||||||
var probe int64
|
|
||||||
err := db.Model(&model.ClientRecord{}).Where("limit_ip > 0").Limit(1).Count(&probe).Error
|
|
||||||
return err == nil && probe > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadAllowlist reads the operator's trusted addresses once per scan; a bad
|
|
||||||
// read leaves the list empty, which enforces the limit as before rather than
|
|
||||||
// silently exempting everyone.
|
|
||||||
func (j *CheckClientIpJob) loadAllowlist() ipLimitAllowlist {
|
|
||||||
raw, err := (&service.SettingService{}).GetIpLimitAllowlist()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("[LimitIP] could not read the allowlist, enforcing without it:", err)
|
|
||||||
return ipLimitAllowlist{}
|
|
||||||
}
|
|
||||||
return parseIpLimitAllowlist(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ipScanChunk = 400
|
|
||||||
|
|
||||||
func chunkEmails(s []string, size int) [][]string {
|
|
||||||
if len(s) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
chunks := make([][]string, 0, (len(s)+size-1)/size)
|
|
||||||
for size < len(s) {
|
|
||||||
s, chunks = s[size:], append(chunks, s[:size])
|
|
||||||
}
|
|
||||||
return append(chunks, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadClientLimits maps each observed email to its clients.limit_ip in a few
|
|
||||||
// chunked queries, replacing the per-email settings-JSON parse that previously
|
|
||||||
// resolved the limit.
|
|
||||||
func (j *CheckClientIpJob) loadClientLimits(emails []string) map[string]int {
|
|
||||||
db := database.GetDB()
|
|
||||||
out := make(map[string]int, len(emails))
|
|
||||||
for _, batch := range chunkEmails(emails, ipScanChunk) {
|
|
||||||
var rows []struct {
|
|
||||||
Email string
|
|
||||||
LimitIp int
|
|
||||||
}
|
|
||||||
if err := db.Model(&model.ClientRecord{}).
|
|
||||||
Select("email, limit_ip").
|
|
||||||
Where("email IN ?", batch).
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, r := range rows {
|
|
||||||
out[r.Email] = r.LimitIp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadInboundsByEmails resolves each email's owning inbound through the
|
|
||||||
// clients/client_inbounds relation in chunked queries. Like the old per-email
|
|
||||||
// First() it keeps the lowest inbound id when a client spans several inbounds.
|
|
||||||
func (j *CheckClientIpJob) loadInboundsByEmails(emails []string) map[string]*model.Inbound {
|
|
||||||
db := database.GetDB()
|
|
||||||
minInboundByEmail := make(map[string]int, len(emails))
|
|
||||||
for _, batch := range chunkEmails(emails, ipScanChunk) {
|
|
||||||
var pairs []struct {
|
|
||||||
Email string
|
|
||||||
InboundId int
|
|
||||||
}
|
|
||||||
if err := db.Table("client_inbounds").
|
|
||||||
Select("clients.email AS email, client_inbounds.inbound_id AS inbound_id").
|
|
||||||
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
|
||||||
Where("clients.email IN ?", batch).
|
|
||||||
Scan(&pairs).Error; err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, p := range pairs {
|
|
||||||
if cur, ok := minInboundByEmail[p.Email]; !ok || p.InboundId < cur {
|
|
||||||
minInboundByEmail[p.Email] = p.InboundId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(minInboundByEmail) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
idSet := make(map[int]struct{}, len(minInboundByEmail))
|
|
||||||
ids := make([]int, 0, len(minInboundByEmail))
|
|
||||||
for _, id := range minInboundByEmail {
|
|
||||||
if _, seen := idSet[id]; !seen {
|
|
||||||
idSet[id] = struct{}{}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Ints(ids)
|
|
||||||
inboundsById := make(map[int]*model.Inbound, len(ids))
|
|
||||||
for lo := 0; lo < len(ids); lo += ipScanChunk {
|
|
||||||
hi := min(lo+ipScanChunk, len(ids))
|
|
||||||
var page []*model.Inbound
|
|
||||||
if err := db.Model(&model.Inbound{}).Where("id IN ?", ids[lo:hi]).Find(&page).Error; err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, ib := range page {
|
|
||||||
inboundsById[ib.Id] = ib
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(map[string]*model.Inbound, len(minInboundByEmail))
|
|
||||||
for email, id := range minInboundByEmail {
|
|
||||||
if ib, ok := inboundsById[id]; ok {
|
|
||||||
out[email] = ib
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *CheckClientIpJob) loadClientIpRows(emails []string) map[string]*model.InboundClientIps {
|
|
||||||
db := database.GetDB()
|
|
||||||
out := make(map[string]*model.InboundClientIps, len(emails))
|
|
||||||
for _, batch := range chunkEmails(emails, ipScanChunk) {
|
|
||||||
var rows []model.InboundClientIps
|
|
||||||
if err := db.Where("client_email IN ?", batch).Find(&rows).Error; err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for i := range rows {
|
|
||||||
out[rows[i].ClientEmail] = &rows[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// processObserved runs collection + enforcement for one scan's observations
|
|
||||||
// (email -> ip -> last-seen unix seconds). observedAreLive marks the
|
|
||||||
// observations as live connections, which bypass the stale cutoff: a connection
|
|
||||||
// that opened hours ago is still live even though its timestamp is old. The
|
|
||||||
// online-stats API always reports live connections, so the job passes true.
|
|
||||||
// Lookups are batched up front and all inbound_client_ips writes share one
|
|
||||||
// transaction, so a scan costs a handful of queries and one fsync instead of
|
|
||||||
// several per observed email.
|
|
||||||
func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
|
|
||||||
shouldCleanLog := false
|
|
||||||
now := time.Now().Unix()
|
|
||||||
|
|
||||||
emails := make([]string, 0, len(observed))
|
|
||||||
for email := range observed {
|
|
||||||
emails = append(emails, email)
|
|
||||||
}
|
|
||||||
sort.Strings(emails)
|
|
||||||
|
|
||||||
limitByEmail := j.loadClientLimits(emails)
|
|
||||||
inboundByEmail := j.loadInboundsByEmails(emails)
|
|
||||||
ipRowByEmail := j.loadClientIpRows(emails)
|
|
||||||
|
|
||||||
// attribution accumulates this scan's local observations per email so they can
|
|
||||||
// be recorded under this panel's own guid for cross-node IP attribution.
|
|
||||||
attribution := make(map[string][]model.ClientIpEntry, len(observed))
|
|
||||||
|
|
||||||
type pendingDisconnect struct {
|
|
||||||
inbound *model.Inbound
|
|
||||||
email string
|
|
||||||
}
|
|
||||||
var disconnects []pendingDisconnect
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
tx := db.Begin()
|
|
||||||
if tx.Error != nil {
|
|
||||||
j.checkError(tx.Error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
committed := false
|
|
||||||
defer func() {
|
|
||||||
if !committed {
|
|
||||||
tx.Rollback()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for _, email := range emails {
|
|
||||||
ipTimestamps := observed[email]
|
|
||||||
|
|
||||||
// The observations can still reference a client that was just renamed
|
|
||||||
// or deleted; its email no longer matches any inbound. Skip it (and
|
|
||||||
// drop any orphaned tracking row) instead of recreating a row and
|
|
||||||
// logging an ERROR every run (#4963). The batch map resolves through
|
|
||||||
// the clients relation; the per-email fallback keeps its settings LIKE
|
|
||||||
// net for clients not yet present there.
|
|
||||||
inbound, ok := inboundByEmail[email]
|
|
||||||
if !ok {
|
|
||||||
var err error
|
|
||||||
inbound, err = j.getInboundByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
|
|
||||||
j.delInboundClientIps(tx, email)
|
|
||||||
} else {
|
|
||||||
j.checkError(err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to IPWithTimestamp slice
|
|
||||||
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
|
||||||
attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps))
|
|
||||||
for ip, timestamp := range ipTimestamps {
|
|
||||||
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
|
||||||
// Live API observations may carry an old lastSeen (connection start),
|
|
||||||
// so stamp attribution with now; otherwise the stale cutoff would evict
|
|
||||||
// an IP that is connected right now.
|
|
||||||
attrTs := timestamp
|
|
||||||
if observedAreLive {
|
|
||||||
attrTs = now
|
|
||||||
}
|
|
||||||
attrEntries = append(attrEntries, model.ClientIpEntry{IP: ip, Timestamp: attrTs})
|
|
||||||
}
|
|
||||||
if len(attrEntries) > 0 {
|
|
||||||
attribution[email] = attrEntries
|
|
||||||
}
|
|
||||||
|
|
||||||
clientIpsRecord, ok := ipRowByEmail[email]
|
|
||||||
if !ok {
|
|
||||||
jsonIps, err := json.Marshal(ipsWithTime)
|
|
||||||
if err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := tx.Save(&model.InboundClientIps{ClientEmail: email, Ips: string(jsonIps)}).Error; err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned, banned := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
|
|
||||||
shouldCleanLog = cleaned || shouldCleanLog
|
|
||||||
if banned {
|
|
||||||
disconnects = append(disconnects, pendingDisconnect{inbound: inbound, email: email})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
return shouldCleanLog
|
|
||||||
}
|
|
||||||
committed = true
|
|
||||||
|
|
||||||
// Xray disconnects run after the commit so their network round-trips never
|
|
||||||
// extend the scan's write transaction (node syncs upsert the same table).
|
|
||||||
clientsCache := make(map[int][]model.Client)
|
|
||||||
for _, d := range disconnects {
|
|
||||||
clients, cached := clientsCache[d.inbound.Id]
|
|
||||||
if !cached {
|
|
||||||
clients, _ = service.ParseInboundSettingsClients(d.inbound.Settings)
|
|
||||||
clientsCache[d.inbound.Id] = clients
|
|
||||||
}
|
|
||||||
j.disconnectClientTemporarily(d.inbound, d.email, clients)
|
|
||||||
}
|
|
||||||
|
|
||||||
j.recordLocalAttribution(attribution)
|
|
||||||
|
|
||||||
return shouldCleanLog
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordLocalAttribution stores this scan's local observations under this panel's
|
|
||||||
// own guid so a parent panel can attribute each IP to the node it is on.
|
|
||||||
// Best-effort: attribution is advisory and must never block IP-limit enforcement.
|
|
||||||
func (j *CheckClientIpJob) recordLocalAttribution(attribution map[string][]model.ClientIpEntry) {
|
|
||||||
if len(attribution) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guid, err := (&service.SettingService{}).GetPanelGuid()
|
|
||||||
if err != nil || guid == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := (&service.InboundService{}).RecordLocalClientIps(guid, attribution); err != nil {
|
|
||||||
logger.Debug("[LimitIP] record local ip attribution failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeClientIps folds this scan's observations into the persisted set,
|
|
||||||
// dropping entries older than staleCutoff. newAlwaysLive exempts the new
|
|
||||||
// entries from that cutoff: an API-observed IP is a live connection by
|
|
||||||
// definition, even when its lastSeen (set at dispatch time) is hours old.
|
|
||||||
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
|
|
||||||
ipMap := make(map[string]int64, len(old)+len(new))
|
|
||||||
for _, ipTime := range old {
|
|
||||||
if ipTime.Timestamp < staleCutoff {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
|
||||||
}
|
|
||||||
for _, ipTime := range new {
|
|
||||||
if !newAlwaysLive && ipTime.Timestamp < staleCutoff {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
|
|
||||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ipMap
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
|
|
||||||
// into the newest `limit` entries to keep and the older remainder to ban.
|
|
||||||
func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTimestamp) {
|
|
||||||
if limit <= 0 || len(live) <= limit {
|
|
||||||
return live, nil
|
|
||||||
}
|
|
||||||
cutoff := len(live) - limit
|
|
||||||
return live[cutoff:], live[:cutoff]
|
|
||||||
}
|
|
||||||
|
|
||||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
|
||||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
|
||||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
|
||||||
now := time.Now().Unix()
|
|
||||||
for ip, ts := range ipMap {
|
|
||||||
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
|
|
||||||
// Consider an IP "live" if it was seen locally in this scan, OR if its
|
|
||||||
// timestamp from the synced database is very recent (e.g. within 2 minutes).
|
|
||||||
// This ensures cluster-wide limits work even if the IP was seen on another node.
|
|
||||||
if observedThisScan[ip] || now-ts < 120 {
|
|
||||||
live = append(live, entry)
|
|
||||||
} else {
|
|
||||||
historical = append(historical, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(live, func(i, j int) bool { return live[i].Timestamp < live[j].Timestamp })
|
|
||||||
sort.Slice(historical, func(i, j int) bool { return historical[i].Timestamp < historical[j].Timestamp })
|
|
||||||
return live, historical
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
|
|
||||||
if !isFail2BanEnabled() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := "fail2ban-client"
|
|
||||||
args := []string{"-h"}
|
|
||||||
err := exec.CommandContext(context.Background(), cmd, args...).Run()
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFail2BanEnabled() bool {
|
|
||||||
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
|
|
||||||
return !ok || value == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *CheckClientIpJob) checkError(e error) {
|
|
||||||
if e != nil {
|
|
||||||
logger.Warning("client ip job err:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// delInboundClientIps drops the inbound_client_ips tracking row for an email
|
|
||||||
// that no longer maps to any inbound (a renamed or deleted client), so stale
|
|
||||||
// access-log entries don't keep a ghost row alive (#4963).
|
|
||||||
func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string) {
|
|
||||||
if err := tx.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateInboundClientIps merges one email's observed IPs into its tracking row
|
|
||||||
// and applies the IP limit. limitIp comes from the caller (the clients table);
|
|
||||||
// writes go through the caller's transaction. banned=true asks the caller to
|
|
||||||
// disconnect the client after the transaction commits.
|
|
||||||
func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) (shouldCleanLog, banned bool) {
|
|
||||||
if inbound.Settings == "" {
|
|
||||||
logger.Debug("wrong data:", inbound)
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !enforce || limitIp <= 0 || !inbound.Enable {
|
|
||||||
// Nothing to enforce (collection-only run, no limit on the clients row,
|
|
||||||
// or inbound disabled): record the observed IPs for the panel and return.
|
|
||||||
jsonIps, _ := json.Marshal(newIpsWithTime)
|
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
|
||||||
if err := tx.Save(inboundClientIps).Error; err != nil {
|
|
||||||
logger.Error("failed to save inboundClientIps:", err)
|
|
||||||
}
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse old IPs from database
|
|
||||||
var oldIpsWithTime []IPWithTimestamp
|
|
||||||
if inboundClientIps.Ips != "" {
|
|
||||||
_ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
|
|
||||||
|
|
||||||
// only ips seen in this scan count toward the limit. see
|
|
||||||
// partitionLiveIps.
|
|
||||||
observedThisScan := make(map[string]bool, len(newIpsWithTime))
|
|
||||||
for _, ipTime := range newIpsWithTime {
|
|
||||||
observedThisScan[ipTime.IP] = true
|
|
||||||
}
|
|
||||||
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
|
||||||
|
|
||||||
j.disAllowedIps = []string{}
|
|
||||||
|
|
||||||
// historical db-only ips are excluded from this count on purpose.
|
|
||||||
limitedIps, allowedIps := j.allowlist.split(liveIps)
|
|
||||||
keptLive, bannedLive := selectIpsToBan(limitedIps, limitIp)
|
|
||||||
// Allowlisted addresses stay connected and out of the count: charging them
|
|
||||||
// against the limit would still cut the shared network the entry protects.
|
|
||||||
keptLive = append(keptLive, allowedIps...)
|
|
||||||
actionable := j.filterAdvancedSinceLastBan(clientEmail, bannedLive)
|
|
||||||
if len(actionable) > 0 {
|
|
||||||
shouldCleanLog = true
|
|
||||||
banned = true
|
|
||||||
|
|
||||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
defer logIpFile.Close()
|
|
||||||
ipLogger := log.New(logIpFile, "", log.LstdFlags)
|
|
||||||
|
|
||||||
// log format is load-bearing: x-ui.sh create_iplimit_jails builds
|
|
||||||
// filter.d/3x-ipl.conf with
|
|
||||||
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
|
||||||
// don't change the wording.
|
|
||||||
for _, ipTime := range actionable {
|
|
||||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
|
||||||
ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep kept-live + historical in the blob so the panel keeps showing
|
|
||||||
// recently seen ips. banned live ips are already in the fail2ban log
|
|
||||||
// and will reappear in the next scan if they reconnect.
|
|
||||||
dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
|
|
||||||
dbIps = append(dbIps, keptLive...)
|
|
||||||
dbIps = append(dbIps, historicalIps...)
|
|
||||||
jsonIps, _ := json.Marshal(dbIps)
|
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
|
||||||
|
|
||||||
if err := tx.Save(inboundClientIps).Error; err != nil {
|
|
||||||
logger.Error("failed to save inboundClientIps:", err)
|
|
||||||
return false, banned
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(j.disAllowedIps) > 0 {
|
|
||||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
|
|
||||||
}
|
|
||||||
|
|
||||||
return shouldCleanLog, banned
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterAdvancedSinceLastBan keeps only banned pairs whose lastSeen advanced since
|
|
||||||
// the previous ban: the core refreshes lastSeen solely on a new dispatch, so a
|
|
||||||
// frozen value is a dead connection it hasn't reaped yet, not a reconnect.
|
|
||||||
func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
|
|
||||||
if j.bannedSeen == nil {
|
|
||||||
j.bannedSeen = make(map[string]int64)
|
|
||||||
}
|
|
||||||
current := make(map[string]struct{}, len(banned))
|
|
||||||
actionable := make([]IPWithTimestamp, 0, len(banned))
|
|
||||||
for _, ipTime := range banned {
|
|
||||||
key := email + "|" + ipTime.IP
|
|
||||||
current[key] = struct{}{}
|
|
||||||
if last, ok := j.bannedSeen[key]; ok && ipTime.Timestamp <= last {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
j.bannedSeen[key] = ipTime.Timestamp
|
|
||||||
actionable = append(actionable, ipTime)
|
|
||||||
}
|
|
||||||
prefix := email + "|"
|
|
||||||
for key := range j.bannedSeen {
|
|
||||||
if strings.HasPrefix(key, prefix) {
|
|
||||||
if _, still := current[key]; !still {
|
|
||||||
delete(j.bannedSeen, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return actionable
|
|
||||||
}
|
|
||||||
|
|
||||||
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
|
|
||||||
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
|
||||||
var xrayAPI xray.XrayAPI
|
|
||||||
apiPort := j.resolveXrayAPIPort()
|
|
||||||
|
|
||||||
err := xrayAPI.Init(apiPort)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer xrayAPI.Close()
|
|
||||||
|
|
||||||
// Find the client config
|
|
||||||
var clientConfig map[string]any
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.Email == clientEmail {
|
|
||||||
// Convert client to map for API
|
|
||||||
clientBytes, _ := json.Marshal(client)
|
|
||||||
_ = json.Unmarshal(clientBytes, &clientConfig)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientConfig == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protocols XrayAPI can remove and re-add from a marshaled model.Client.
|
|
||||||
// wireguard stays out: keepAlive marshals as a number, AddUser wants a string.
|
|
||||||
protocol := string(inbound.Protocol)
|
|
||||||
switch protocol {
|
|
||||||
case "vmess", "vless", "trojan", "shadowsocks", "hysteria":
|
|
||||||
// supported protocols, continue
|
|
||||||
default:
|
|
||||||
logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Shadowsocks, ensure the required "cipher" field is present by
|
|
||||||
// reading it from the inbound settings (e.g., settings["method"]).
|
|
||||||
if string(inbound.Protocol) == "shadowsocks" {
|
|
||||||
var inboundSettings map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil {
|
|
||||||
logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err)
|
|
||||||
} else {
|
|
||||||
if method, ok := inboundSettings["method"].(string); ok && method != "" {
|
|
||||||
clientConfig["cipher"] = method
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove user to disconnect all connections
|
|
||||||
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a moment for disconnection to take effect
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
// Re-add user to allow new connections
|
|
||||||
err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveXrayAPIPort returns the API inbound port from running config, then template config, then default.
|
|
||||||
func (j *CheckClientIpJob) resolveXrayAPIPort() int {
|
|
||||||
var configErr error
|
|
||||||
var templateErr error
|
|
||||||
|
|
||||||
if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil {
|
|
||||||
return port
|
|
||||||
} else {
|
|
||||||
configErr = err
|
|
||||||
}
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
var template model.Setting
|
|
||||||
if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil {
|
|
||||||
if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil {
|
|
||||||
return port
|
|
||||||
} else {
|
|
||||||
templateErr = parseErr
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templateErr = err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Warningf(
|
|
||||||
"[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)",
|
|
||||||
defaultXrayAPIPort,
|
|
||||||
configErr,
|
|
||||||
templateErr,
|
|
||||||
)
|
|
||||||
|
|
||||||
return defaultXrayAPIPort
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAPIPortFromConfigPath(configPath string) (int, error) {
|
|
||||||
configData, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return getAPIPortFromConfigData(configData)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAPIPortFromConfigData(configData []byte) (int, error) {
|
|
||||||
xrayConfig := &xray.Config{}
|
|
||||||
if err := json.Unmarshal(configData, xrayConfig); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, inboundConfig := range xrayConfig.InboundConfigs {
|
|
||||||
if inboundConfig.Tag == "api" && inboundConfig.Port > 0 {
|
|
||||||
return inboundConfig.Port, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, errors.New("api inbound port not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInboundByEmail resolves the inbound that owns a client email. It prefers
|
|
||||||
// the exact clients/client_inbounds relation; a substring "settings LIKE
|
|
||||||
// %email%" can match the wrong inbound (an email that is a substring of another,
|
|
||||||
// or text that merely appears elsewhere in the settings JSON). The LIKE + JSON
|
|
||||||
// scan stays only as a fallback for clients not yet present in the relation, so
|
|
||||||
// nothing regresses when the join finds no row.
|
|
||||||
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
|
|
||||||
db := database.GetDB()
|
|
||||||
inbound := &model.Inbound{}
|
|
||||||
|
|
||||||
err := db.Model(&model.Inbound{}).
|
|
||||||
Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
|
|
||||||
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
|
||||||
Where("clients.email = ?", clientEmail).
|
|
||||||
First(inbound).Error
|
|
||||||
if err == nil {
|
|
||||||
return inbound, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidates []model.Inbound
|
|
||||||
if listErr := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").Find(&candidates).Error; listErr != nil {
|
|
||||||
return nil, listErr
|
|
||||||
}
|
|
||||||
for i := range candidates {
|
|
||||||
clients, jsonErr := service.ParseInboundSettingsClients(candidates[i].Settings)
|
|
||||||
if jsonErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.Email == clientEmail {
|
|
||||||
return &candidates[i], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs before the fail2ban/apiMode gates: retention must hold for stored rows
|
|
||||||
// even while nothing is being collected.
|
|
||||||
func (j *CheckClientIpJob) pruneStaleIpRows() {
|
|
||||||
now := time.Now().Unix()
|
|
||||||
if now-j.lastIpPrune < ipPruneIntervalSeconds {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
j.lastIpPrune = now
|
|
||||||
if err := (&service.InboundService{}).PruneStaleClientIps(); err != nil {
|
|
||||||
logger.Warning("prune stale client ip rows failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/op/go-logging"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 3x-ui logger must be initialised once before any code path that can
|
|
||||||
// log a warning. otherwise log.Warningf panics on a nil logger.
|
|
||||||
var loggerInitOnce sync.Once
|
|
||||||
|
|
||||||
// setupIntegrationDB wires a temp sqlite db and log folder so
|
|
||||||
// updateInboundClientIps can run end to end. closes the db before
|
|
||||||
// TempDir cleanup so windows doesn't complain about the file being in
|
|
||||||
// use.
|
|
||||||
func setupIntegrationDB(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
loggerInitOnce.Do(func() {
|
|
||||||
xuilogger.InitLogger(logging.ERROR)
|
|
||||||
})
|
|
||||||
|
|
||||||
dbDir := t.TempDir()
|
|
||||||
logDir := t.TempDir()
|
|
||||||
|
|
||||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
||||||
t.Setenv("XUI_LOG_FOLDER", logDir)
|
|
||||||
|
|
||||||
// updateInboundClientIps calls log.SetOutput on the package global,
|
|
||||||
// which would leak to other tests in the same binary.
|
|
||||||
origLogWriter := log.Writer()
|
|
||||||
origLogFlags := log.Flags()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
log.SetOutput(origLogWriter)
|
|
||||||
log.SetFlags(origLogFlags)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
||||||
t.Fatalf("database.InitDB failed: %v", err)
|
|
||||||
}
|
|
||||||
// LIFO cleanup order: this runs before t.TempDir's own cleanup.
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if err := database.CloseDB(); err != nil {
|
|
||||||
t.Logf("database.CloseDB warning: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// seed an inbound whose settings json has a single client with the
|
|
||||||
// given email and ip limit.
|
|
||||||
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
|
||||||
t.Helper()
|
|
||||||
seedInboundOnlyWithClient(t, tag, email, limitIp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedInboundOnlyWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
|
|
||||||
t.Helper()
|
|
||||||
settings := map[string]any{
|
|
||||||
"clients": []map[string]any{
|
|
||||||
{
|
|
||||||
"email": email,
|
|
||||||
"limitIp": limitIp,
|
|
||||||
"enable": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal settings: %v", err)
|
|
||||||
}
|
|
||||||
inbound := &model.Inbound{
|
|
||||||
Tag: tag,
|
|
||||||
Enable: true,
|
|
||||||
Protocol: model.VLESS,
|
|
||||||
Port: 4321,
|
|
||||||
Settings: string(settingsJSON),
|
|
||||||
}
|
|
||||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
|
||||||
t.Fatalf("seed inbound: %v", err)
|
|
||||||
}
|
|
||||||
return inbound
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
|
|
||||||
t.Helper()
|
|
||||||
inbound := seedInboundOnlyWithClient(t, tag, email, limitIp)
|
|
||||||
client := &model.ClientRecord{Email: email, LimitIP: limitIp}
|
|
||||||
if err := database.GetDB().Create(client).Error; err != nil {
|
|
||||||
t.Fatalf("seed client record: %v", err)
|
|
||||||
}
|
|
||||||
link := &model.ClientInbound{ClientId: client.Id, InboundId: inbound.Id}
|
|
||||||
if err := database.GetDB().Create(link).Error; err != nil {
|
|
||||||
t.Fatalf("seed client inbound link: %v", err)
|
|
||||||
}
|
|
||||||
return inbound
|
|
||||||
}
|
|
||||||
|
|
||||||
// seed an InboundClientIps row with the given blob.
|
|
||||||
func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
|
|
||||||
t.Helper()
|
|
||||||
blob, err := json.Marshal(ips)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal ips: %v", err)
|
|
||||||
}
|
|
||||||
row := &model.InboundClientIps{
|
|
||||||
ClientEmail: email,
|
|
||||||
Ips: string(blob),
|
|
||||||
}
|
|
||||||
if err := database.GetDB().Create(row).Error; err != nil {
|
|
||||||
t.Fatalf("seed InboundClientIps: %v", err)
|
|
||||||
}
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
// read the persisted blob and parse it back.
|
|
||||||
func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
|
||||||
t.Helper()
|
|
||||||
row := &model.InboundClientIps{}
|
|
||||||
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
|
|
||||||
t.Fatalf("read InboundClientIps for %s: %v", email, err)
|
|
||||||
}
|
|
||||||
if row.Ips == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []IPWithTimestamp
|
|
||||||
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
|
|
||||||
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// make a lookup map so asserts don't depend on slice order.
|
|
||||||
func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
|
||||||
out := make(map[string]int64, len(entries))
|
|
||||||
for _, e := range entries {
|
|
||||||
out[e.IP] = e.Timestamp
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// With the access-log fallback removed, an unavailable online-stats API (xray
|
|
||||||
// down, as in this unit test) must make Run a clean no-op: no fail2ban probe, no
|
|
||||||
// ban log, and no inbound_client_ips rows — never a crash or partial work.
|
|
||||||
func TestRun_NoOpWhenOnlineApiUnavailable(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
|
||||||
marker := fakeFail2BanClient(t)
|
|
||||||
|
|
||||||
const email = "no-api-user"
|
|
||||||
seedInboundWithClient(t, "inbound-no-api", email, 1)
|
|
||||||
|
|
||||||
NewCheckClientIpJob().Run()
|
|
||||||
|
|
||||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("fail2ban-client should not have been probed when the online API is unavailable, stat error: %v", err)
|
|
||||||
}
|
|
||||||
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
|
||||||
body, _ := os.ReadFile(readIpLimitLogPath())
|
|
||||||
t.Fatalf("3xipl.log should be empty when Run no-ops, got:\n%s", body)
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
|
|
||||||
t.Fatalf("count InboundClientIps: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("no IP-limit rows should be persisted when Run no-ops, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #4091 repro: client has limit=3, db still holds 3 idle ips from a
|
|
||||||
// few minutes ago, only one live ip is actually connecting. pre-fix:
|
|
||||||
// live ip got banned every tick and never appeared in the panel.
|
|
||||||
// post-fix: no ban, live ip persisted, historical ips still visible.
|
|
||||||
func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
const email = "pr4091-repro"
|
|
||||||
seedInboundWithClient(t, "inbound-pr4091", email, 3)
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
// idle but still within the 30min staleness window.
|
|
||||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
|
||||||
{IP: "10.0.0.1", Timestamp: now - 20*60},
|
|
||||||
{IP: "10.0.0.2", Timestamp: now - 15*60},
|
|
||||||
{IP: "10.0.0.3", Timestamp: now - 10*60},
|
|
||||||
})
|
|
||||||
|
|
||||||
j := NewCheckClientIpJob()
|
|
||||||
// the one that's actually connecting (user's 128.71.x.x).
|
|
||||||
live := []IPWithTimestamp{
|
|
||||||
{IP: "128.71.1.1", Timestamp: now},
|
|
||||||
}
|
|
||||||
|
|
||||||
inbound, err := j.getInboundByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getInboundByEmail: %v", err)
|
|
||||||
}
|
|
||||||
shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 3, live, true, false)
|
|
||||||
|
|
||||||
if shouldCleanLog {
|
|
||||||
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
|
||||||
}
|
|
||||||
if banned {
|
|
||||||
t.Fatalf("banned must be false with 1 live ip under limit 3")
|
|
||||||
}
|
|
||||||
if len(j.disAllowedIps) != 0 {
|
|
||||||
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
|
|
||||||
}
|
|
||||||
|
|
||||||
persisted := ipSet(readClientIps(t, email))
|
|
||||||
for _, want := range []string{"128.71.1.1", "10.0.0.1", "10.0.0.2", "10.0.0.3"} {
|
|
||||||
if _, ok := persisted[want]; !ok {
|
|
||||||
t.Errorf("expected %s to be persisted in inbound_client_ips.ips; got %v", want, persisted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := persisted["128.71.1.1"]; got != now {
|
|
||||||
t.Errorf("live ip timestamp should match the scan timestamp %d, got %d", now, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3xipl.log must not contain a ban line.
|
|
||||||
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
|
||||||
body, _ := os.ReadFile(readIpLimitLogPath())
|
|
||||||
t.Fatalf("3xipl.log should be empty when no ips are banned, got:\n%s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// opposite invariant: when several ips are actually live and exceed
|
|
||||||
// the limit, the oldest connection is dropped and the most recent one
|
|
||||||
// keeps the slot (last-IP-wins policy from #3735, restored in #4699).
|
|
||||||
func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
const email = "pr4091-abuse"
|
|
||||||
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
|
||||||
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
|
|
||||||
})
|
|
||||||
|
|
||||||
j := NewCheckClientIpJob()
|
|
||||||
// both live, limit=1. use distinct timestamps so sort-by-timestamp
|
|
||||||
// is deterministic: 10.1.0.1 is the original (older) and must get
|
|
||||||
// banned; 192.0.2.9 joined later and keeps the slot (last IP wins).
|
|
||||||
live := []IPWithTimestamp{
|
|
||||||
{IP: "10.1.0.1", Timestamp: now - 5},
|
|
||||||
{IP: "192.0.2.9", Timestamp: now},
|
|
||||||
}
|
|
||||||
|
|
||||||
inbound, err := j.getInboundByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getInboundByEmail: %v", err)
|
|
||||||
}
|
|
||||||
shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
|
|
||||||
|
|
||||||
if !shouldCleanLog {
|
|
||||||
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
|
||||||
}
|
|
||||||
if !banned {
|
|
||||||
t.Fatalf("banned must be true when the live set exceeds the limit")
|
|
||||||
}
|
|
||||||
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
|
|
||||||
t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
|
|
||||||
}
|
|
||||||
|
|
||||||
persisted := ipSet(readClientIps(t, email))
|
|
||||||
if _, ok := persisted["192.0.2.9"]; !ok {
|
|
||||||
t.Errorf("newest IP 192.0.2.9 must still be persisted; got %v", persisted)
|
|
||||||
}
|
|
||||||
if _, ok := persisted["10.1.0.1"]; ok {
|
|
||||||
t.Errorf("banned IP 10.1.0.1 must NOT be persisted; got %v", persisted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3xipl.log must contain the ban line in the exact fail2ban format.
|
|
||||||
body, err := os.ReadFile(readIpLimitLogPath())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read 3xipl.log: %v", err)
|
|
||||||
}
|
|
||||||
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 10.1.0.1"
|
|
||||||
if !contains(string(body), wantSubstr) {
|
|
||||||
t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #4800: per-client IP tracking must populate even when no client has an IP
|
|
||||||
// limit. processObserved records observed IPs for the panel regardless of any
|
|
||||||
// limit; only enforcement is gated, so a limit-free install still shows IPs. No
|
|
||||||
// ban may be written since there's no limit.
|
|
||||||
func TestProcessObserved_CollectsIpsWithoutLimit(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
const email = "no-limit-user"
|
|
||||||
seedInboundWithClient(t, "inbound-no-limit", email, 0) // limitIp = 0
|
|
||||||
|
|
||||||
observed := map[string]map[string]int64{
|
|
||||||
email: {"203.0.113.10": time.Now().Unix()},
|
|
||||||
}
|
|
||||||
NewCheckClientIpJob().processObserved(observed, true, true)
|
|
||||||
|
|
||||||
ips := readClientIps(t, email)
|
|
||||||
if len(ips) != 1 || ips[0].IP != "203.0.113.10" {
|
|
||||||
t.Fatalf("expected the observed IP to be collected without a limit, got %v", ips)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
|
||||||
body, _ := os.ReadFile(readIpLimitLogPath())
|
|
||||||
t.Fatalf("3xipl.log should be empty with no limit set, got:\n%s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #4963: an observed IP for a renamed/deleted client (its email no longer maps
|
|
||||||
// to any inbound) must not create or resurrect an inbound_client_ips row, and
|
|
||||||
// must drop any orphan left behind — instead of erroring every run.
|
|
||||||
func TestProcessObserved_StaleEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
const staleEmail = "renamed-away"
|
|
||||||
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
|
||||||
// confirm the job removes it rather than leaving it to error forever.
|
|
||||||
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
|
||||||
|
|
||||||
observed := map[string]map[string]int64{
|
|
||||||
staleEmail: {"203.0.113.5": time.Now().Unix()},
|
|
||||||
}
|
|
||||||
NewCheckClientIpJob().processObserved(observed, true, true)
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
|
|
||||||
t.Fatalf("count InboundClientIps: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("stale-email orphan row should be deleted, got %d row(s)", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readIpLimitLogPath reads the 3xipl.log path the same way the job
|
|
||||||
// does via xray.GetIPLimitLogPath but without importing xray here
|
|
||||||
// just for the path helper (which would pull a lot more deps into the
|
|
||||||
// test binary). The env-derived log folder is deterministic.
|
|
||||||
func readIpLimitLogPath() string {
|
|
||||||
folder := os.Getenv("XUI_LOG_FOLDER")
|
|
||||||
if folder == "" {
|
|
||||||
folder = filepath.Join(".", "log")
|
|
||||||
}
|
|
||||||
return filepath.Join(folder, "3xipl.log")
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(haystack, needle string) bool {
|
|
||||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
||||||
if haystack[i:i+len(needle)] == needle {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// the exact clients/client_inbounds relation must win over the substring scan,
|
|
||||||
// so a client is resolved to its own inbound even when another inbound holds a
|
|
||||||
// superstring email.
|
|
||||||
func TestGetInboundByEmailUsesClientInboundLink(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
want := seedLinkedInboundWithClient(t, "linked-inbound", "exact@example.com", 1)
|
|
||||||
seedInboundOnlyWithClient(t, "other-inbound", "not-exact@example.com", 1)
|
|
||||||
|
|
||||||
got, err := (&CheckClientIpJob{}).getInboundByEmail("exact@example.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getInboundByEmail returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got.Id != want.Id {
|
|
||||||
t.Fatalf("getInboundByEmail returned inbound %d, want %d", got.Id, want.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// the substring fallback must still verify the exact email inside settings, so
|
|
||||||
// "ann@example.com" does not match an inbound holding "joann@example.com".
|
|
||||||
func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
seedInboundOnlyWithClient(t, "substring-only", "joann@example.com", 1)
|
|
||||||
|
|
||||||
if got, err := (&CheckClientIpJob{}).getInboundByEmail("ann@example.com"); err == nil {
|
|
||||||
t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasLimitIp gates every 10s scan on the normalized clients table: a bare
|
|
||||||
// "limitIp":0 in settings JSON (which the old LIKE scan matched and parsed)
|
|
||||||
// must not enable enforcement, while a single clients.limit_ip > 0 row must.
|
|
||||||
func TestHasLimitIp_ProbesClientRecords(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
j := &CheckClientIpJob{}
|
|
||||||
|
|
||||||
if j.hasLimitIp() {
|
|
||||||
t.Fatal("hasLimitIp = true on an empty database")
|
|
||||||
}
|
|
||||||
|
|
||||||
seedLinkedInboundWithClient(t, "no-limit", "nolimit@example.com", 0)
|
|
||||||
if j.hasLimitIp() {
|
|
||||||
t.Fatal("hasLimitIp = true with only limit_ip=0 clients")
|
|
||||||
}
|
|
||||||
|
|
||||||
limited := &model.ClientRecord{Email: "limited@example.com", LimitIP: 2}
|
|
||||||
if err := database.GetDB().Create(limited).Error; err != nil {
|
|
||||||
t.Fatalf("seed limited client: %v", err)
|
|
||||||
}
|
|
||||||
if !j.hasLimitIp() {
|
|
||||||
t.Fatal("hasLimitIp = false with a limit_ip=2 client present")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mirror of TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned: with the
|
|
||||||
// older address on the operator's allowlist nothing may be banned, it must not
|
|
||||||
// consume the limit, and no fail2ban line may be written for it (#5378).
|
|
||||||
func TestUpdateInboundClientIps_AllowlistedIpIsNeitherCountedNorBanned(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
const email = "issue5378-office"
|
|
||||||
seedInboundWithClient(t, "inbound-issue5378", email, 1)
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
|
||||||
{IP: "203.0.113.10", Timestamp: now - 60},
|
|
||||||
})
|
|
||||||
|
|
||||||
j := NewCheckClientIpJob()
|
|
||||||
j.allowlist = parseIpLimitAllowlist("203.0.113.0/24")
|
|
||||||
|
|
||||||
live := []IPWithTimestamp{
|
|
||||||
{IP: "203.0.113.10", Timestamp: now - 5},
|
|
||||||
{IP: "192.0.2.9", Timestamp: now},
|
|
||||||
}
|
|
||||||
|
|
||||||
inbound, err := j.getInboundByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getInboundByEmail: %v", err)
|
|
||||||
}
|
|
||||||
_, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
|
|
||||||
|
|
||||||
if banned {
|
|
||||||
t.Fatal("an allowlisted address pushed the client over its limit and something was banned")
|
|
||||||
}
|
|
||||||
if len(j.disAllowedIps) != 0 {
|
|
||||||
t.Fatalf("disAllowedIps = %v, want none", j.disAllowedIps)
|
|
||||||
}
|
|
||||||
|
|
||||||
persisted := ipSet(readClientIps(t, email))
|
|
||||||
for _, ip := range []string{"203.0.113.10", "192.0.2.9"} {
|
|
||||||
if _, ok := persisted[ip]; !ok {
|
|
||||||
t.Errorf("%s must still be persisted; got %v", ip, persisted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if body, err := os.ReadFile(readIpLimitLogPath()); err == nil {
|
|
||||||
if contains(string(body), "203.0.113.10") {
|
|
||||||
t.Fatalf("an allowlisted address reached the fail2ban log:\n%s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
|
||||||
// #4077: after a ban expires, a single IP that reconnects used to get
|
|
||||||
// banned again immediately because a long-disconnected IP stayed in the
|
|
||||||
// DB with an ancient timestamp and kept "protecting" itself against
|
|
||||||
// eviction. Guard against that regression here.
|
|
||||||
old := []IPWithTimestamp{
|
|
||||||
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
|
|
||||||
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
|
|
||||||
}
|
|
||||||
new := []IPWithTimestamp{
|
|
||||||
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
|
||||||
}
|
|
||||||
|
|
||||||
got := mergeClientIps(old, new, 1000, false)
|
|
||||||
|
|
||||||
want := map[string]int64{"2.2.2.2": 2000}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("stale 1.1.1.1 should have been dropped\ngot: %v\nwant: %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
|
||||||
// Backwards-compat: entries that aren't stale are still carried forward,
|
|
||||||
// so enforcement survives access-log rotation.
|
|
||||||
old := []IPWithTimestamp{
|
|
||||||
{IP: "1.1.1.1", Timestamp: 1500},
|
|
||||||
}
|
|
||||||
got := mergeClientIps(old, nil, 1000, false)
|
|
||||||
|
|
||||||
want := map[string]int64{"1.1.1.1": 1500}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("fresh old IP should have been retained\ngot: %v\nwant: %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
|
||||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
|
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
|
|
||||||
|
|
||||||
got := mergeClientIps(old, new, 1000, false)
|
|
||||||
|
|
||||||
if got["1.1.1.1"] != 1700 {
|
|
||||||
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
|
||||||
// A log line with a clock-skewed old timestamp must not resurrect a
|
|
||||||
// stale IP past the cutoff.
|
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
|
|
||||||
got := mergeClientIps(nil, new, 1000, false)
|
|
||||||
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Fatalf("stale new IP should have been dropped, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
|
||||||
// Defensive: a zero cutoff (e.g. during very first run on a fresh
|
|
||||||
// install) must not over-evict.
|
|
||||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
|
|
||||||
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
|
|
||||||
|
|
||||||
got := mergeClientIps(old, new, 0, false)
|
|
||||||
|
|
||||||
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("zero cutoff should keep everything\ngot: %v\nwant: %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
|
|
||||||
// online-API mode: lastSeen is set when the connection was dispatched, so
|
|
||||||
// a connection held open for hours has an "old" timestamp while being live
|
|
||||||
// by definition. It must survive the stale cutoff.
|
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
|
|
||||||
got := mergeClientIps(nil, new, 1000, true)
|
|
||||||
|
|
||||||
want := map[string]int64{"1.1.1.1": 500}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("live observation must bypass the stale cutoff\ngot: %v\nwant: %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
|
|
||||||
// the bypass applies only to this scan's observations — persisted entries
|
|
||||||
// from past scans still age out as before.
|
|
||||||
old := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 100}}
|
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 2000}}
|
|
||||||
got := mergeClientIps(old, new, 1000, true)
|
|
||||||
|
|
||||||
want := map[string]int64{"1.1.1.1": 2000}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("stale db entry must still be evicted in live mode\ngot: %v\nwant: %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectIpsToBan(t *testing.T) {
|
|
||||||
live := []IPWithTimestamp{ // sorted oldest-first, as partitionLiveIps returns
|
|
||||||
{IP: "A", Timestamp: 100},
|
|
||||||
{IP: "B", Timestamp: 200},
|
|
||||||
{IP: "C", Timestamp: 300},
|
|
||||||
}
|
|
||||||
|
|
||||||
// over the limit: oldest connections are banned, newest keep the slots
|
|
||||||
kept, banned := selectIpsToBan(live, 1)
|
|
||||||
if got := collectIps(kept); !reflect.DeepEqual(got, []string{"C"}) {
|
|
||||||
t.Fatalf("newest ip must keep the slot, got %v", got)
|
|
||||||
}
|
|
||||||
if got := collectIps(banned); !reflect.DeepEqual(got, []string{"A", "B"}) {
|
|
||||||
t.Fatalf("older ips must be banned oldest-first, got %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// at the limit: nothing banned
|
|
||||||
kept, banned = selectIpsToBan(live, 3)
|
|
||||||
if len(banned) != 0 || len(kept) != 3 {
|
|
||||||
t.Fatalf("at-limit set must not ban, kept=%v banned=%v", kept, banned)
|
|
||||||
}
|
|
||||||
|
|
||||||
// under the limit: nothing banned
|
|
||||||
kept, banned = selectIpsToBan(live[:1], 3)
|
|
||||||
if len(banned) != 0 || len(kept) != 1 {
|
|
||||||
t.Fatalf("under-limit set must not ban, kept=%v banned=%v", kept, banned)
|
|
||||||
}
|
|
||||||
|
|
||||||
// defensive: non-positive limit never reaches enforcement, but must not panic
|
|
||||||
if _, banned := selectIpsToBan(live, 0); banned != nil {
|
|
||||||
t.Fatalf("zero limit must not ban, got %v", banned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectIps(entries []IPWithTimestamp) []string {
|
|
||||||
out := make([]string, 0, len(entries))
|
|
||||||
for _, e := range entries {
|
|
||||||
out = append(out, e.IP)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing.T) {
|
|
||||||
// #4091: db holds A, B, C from minutes ago (still in the 30min
|
|
||||||
// window) but they're not connecting anymore. only D is. old code
|
|
||||||
// merged all four, sorted ascending, kept [A,B,C] and banned D
|
|
||||||
// every tick. pin the new rule: only live ips count toward the limit.
|
|
||||||
ipMap := map[string]int64{
|
|
||||||
"A": 1000,
|
|
||||||
"B": 1100,
|
|
||||||
"C": 1200,
|
|
||||||
"D": 2000,
|
|
||||||
}
|
|
||||||
observed := map[string]bool{"D": true}
|
|
||||||
|
|
||||||
live, historical := partitionLiveIps(ipMap, observed)
|
|
||||||
|
|
||||||
if got := collectIps(live); !reflect.DeepEqual(got, []string{"D"}) {
|
|
||||||
t.Fatalf("live set should only contain the ip observed this scan\ngot: %v\nwant: [D]", got)
|
|
||||||
}
|
|
||||||
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B", "C"}) {
|
|
||||||
t.Fatalf("historical set should contain db-only ips in ascending order\ngot: %v\nwant: [A B C]", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPartitionLiveIps_ConcurrentLiveIpsSortedAscending(t *testing.T) {
|
|
||||||
// when several ips are really live, partition returns them all in the
|
|
||||||
// live set sorted ascending by timestamp. updateInboundClientIps then
|
|
||||||
// keeps the newest and bans the oldest (last-IP-wins, #4699).
|
|
||||||
ipMap := map[string]int64{
|
|
||||||
"A": 5000,
|
|
||||||
"B": 5500,
|
|
||||||
}
|
|
||||||
observed := map[string]bool{"A": true, "B": true}
|
|
||||||
|
|
||||||
live, historical := partitionLiveIps(ipMap, observed)
|
|
||||||
|
|
||||||
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A", "B"}) {
|
|
||||||
t.Fatalf("both live ips should be in the live set, ascending\ngot: %v\nwant: [A B]", got)
|
|
||||||
}
|
|
||||||
if len(historical) != 0 {
|
|
||||||
t.Fatalf("no historical ips expected, got %v", historical)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetInboundByEmailFallbackIgnoresProtocolScalarFields(t *testing.T) {
|
|
||||||
dbDir := t.TempDir()
|
|
||||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
||||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
||||||
t.Fatalf("InitDB: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
|
|
||||||
inbound := &model.Inbound{
|
|
||||||
UserId: 1,
|
|
||||||
Tag: "vless-limit-fallback",
|
|
||||||
Enable: true,
|
|
||||||
Port: 43002,
|
|
||||||
Protocol: model.VLESS,
|
|
||||||
Settings: `{
|
|
||||||
"clients": [{"email": "alice@example.test", "id": "11111111-1111-1111-1111-111111111111", "limitIp": 2}],
|
|
||||||
"decryption": "none",
|
|
||||||
"encryption": "none",
|
|
||||||
"fallbacks": []
|
|
||||||
}`,
|
|
||||||
}
|
|
||||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
|
||||||
t.Fatalf("create inbound: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := (&CheckClientIpJob{}).getInboundByEmail("alice@example.test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getInboundByEmail: %v", err)
|
|
||||||
}
|
|
||||||
if got.Id != inbound.Id {
|
|
||||||
t.Fatalf("inbound id = %d, want %d", got.Id, inbound.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
|
|
||||||
// quiet tick: nothing observed => nothing live. everything merged
|
|
||||||
// is historical. keeps the panel from wiping recent-but-idle ips.
|
|
||||||
ipMap := map[string]int64{
|
|
||||||
"A": 1000,
|
|
||||||
"B": 1100,
|
|
||||||
}
|
|
||||||
observed := map[string]bool{}
|
|
||||||
|
|
||||||
live, historical := partitionLiveIps(ipMap, observed)
|
|
||||||
|
|
||||||
if len(live) != 0 {
|
|
||||||
t.Fatalf("no live ips expected, got %v", live)
|
|
||||||
}
|
|
||||||
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B"}) {
|
|
||||||
t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPartitionLiveIps_RecentSyncedIpIsLive(t *testing.T) {
|
|
||||||
// Synced IPs from other nodes within 2 minutes should be counted as live
|
|
||||||
// even if they weren't observed in the local scan.
|
|
||||||
now := time.Now().Unix()
|
|
||||||
ipMap := map[string]int64{
|
|
||||||
"A": now - 30, // synced 30s ago -> live
|
|
||||||
"B": now - 150, // synced 2m30s ago -> historical
|
|
||||||
}
|
|
||||||
observed := map[string]bool{}
|
|
||||||
|
|
||||||
live, historical := partitionLiveIps(ipMap, observed)
|
|
||||||
|
|
||||||
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A"}) {
|
|
||||||
t.Fatalf("recent IP should be live\ngot: %v\nwant: [A]", got)
|
|
||||||
}
|
|
||||||
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"B"}) {
|
|
||||||
t.Fatalf("older IP should be historical\ngot: %v\nwant: [B]", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
|
|
||||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
|
|
||||||
marker := fakeFail2BanClient(t)
|
|
||||||
|
|
||||||
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
|
|
||||||
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN=false")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
|
|
||||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "")
|
|
||||||
marker := fakeFail2BanClient(t)
|
|
||||||
|
|
||||||
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
|
|
||||||
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN is empty")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsFail2BanEnabled_DefaultsToEnabledWhenUnset(t *testing.T) {
|
|
||||||
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
|
|
||||||
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if ok {
|
|
||||||
os.Setenv("XUI_ENABLE_FAIL2BAN", value)
|
|
||||||
} else {
|
|
||||||
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if !isFail2BanEnabled() {
|
|
||||||
t.Fatal("fail2ban should default to enabled when XUI_ENABLE_FAIL2BAN is unset")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckFail2BanInstalled_EnabledEnvProbesClient(t *testing.T) {
|
|
||||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
|
||||||
marker := fakeFail2BanClient(t)
|
|
||||||
|
|
||||||
if !(&CheckClientIpJob{}).checkFail2BanInstalled() {
|
|
||||||
t.Fatal("fail2ban should be available when the client probe succeeds")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(marker); err != nil {
|
|
||||||
t.Fatalf("fail2ban-client should have been executed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fakeFail2BanClient(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
marker := filepath.Join(dir, "probe-called")
|
|
||||||
fakeClient := filepath.Join(dir, "fail2ban-client")
|
|
||||||
script := "#!/bin/sh\n: > \"$FAIL2BAN_PROBE_MARKER\"\nexit 0\n"
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
fakeClient += ".bat"
|
|
||||||
script = "@echo off\ntype nul > \"%FAIL2BAN_PROBE_MARKER%\"\nexit /b 0\n"
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(fakeClient, []byte(script), 0o755); err != nil {
|
|
||||||
t.Fatalf("write fake fail2ban-client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("FAIL2BAN_PROBE_MARKER", marker)
|
|
||||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
return marker
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/op/go-logging"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupScaleJobDB mirrors the service package's scale gating: Postgres via
|
|
||||||
// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
|
|
||||||
func setupScaleJobDB(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
loggerInitOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
|
||||||
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
|
||||||
|
|
||||||
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
|
|
||||||
if err := database.InitDB(""); err != nil {
|
|
||||||
t.Fatalf("InitDB(postgres): %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
|
||||||
case "1", "true", "yes":
|
|
||||||
if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
|
|
||||||
t.Fatalf("InitDB(sqlite): %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
|
|
||||||
}
|
|
||||||
|
|
||||||
func scaleJobSizes(t *testing.T, def ...int) []int {
|
|
||||||
t.Helper()
|
|
||||||
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
|
|
||||||
if raw == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
var out []int
|
|
||||||
for part := range strings.SplitSeq(raw, ",") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(part)
|
|
||||||
if err != nil || n <= 0 {
|
|
||||||
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
|
|
||||||
}
|
|
||||||
out = append(out, n)
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetScaleJobTables(t *testing.T, db *gorm.DB) {
|
|
||||||
t.Helper()
|
|
||||||
if config.GetDBKind() == "postgres" {
|
|
||||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
|
|
||||||
t.Fatalf("truncate: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, tbl := range []string{"inbounds", "clients", "client_inbounds"} {
|
|
||||||
if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
|
|
||||||
t.Fatalf("delete %s: %v", tbl, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.Exec("DELETE FROM sqlite_sequence")
|
|
||||||
}
|
|
||||||
if err := db.Where("1 = 1").Delete(&model.InboundClientIps{}).Error; err != nil {
|
|
||||||
t.Fatalf("clear inbound client ips: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Where("1 = 1").Delete(&model.NodeClientIp{}).Error; err != nil {
|
|
||||||
t.Fatalf("clear node client ips: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedScaleIPDataset seeds n clients across numInbounds inbounds. Every client
|
|
||||||
// in the LAST inbound carries limitIp=3 (and 0 elsewhere), so hasLimitIp pays
|
|
||||||
// its full scan cost before finding a hit, and the returned emails all resolve
|
|
||||||
// to that last inbound for the processObserved measurement.
|
|
||||||
func seedScaleIPDataset(t *testing.T, n, numInbounds int) []string {
|
|
||||||
t.Helper()
|
|
||||||
db := database.GetDB()
|
|
||||||
resetScaleJobTables(t, db)
|
|
||||||
|
|
||||||
tx := db.Begin()
|
|
||||||
if tx.Error != nil {
|
|
||||||
t.Fatalf("begin seed tx: %v", tx.Error)
|
|
||||||
}
|
|
||||||
committed := false
|
|
||||||
defer func() {
|
|
||||||
if !committed {
|
|
||||||
tx.Rollback()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var limitedEmails []string
|
|
||||||
per := n / numInbounds
|
|
||||||
for i := range numInbounds {
|
|
||||||
lo, hi := i*per, (i+1)*per
|
|
||||||
if i == numInbounds-1 {
|
|
||||||
hi = n
|
|
||||||
}
|
|
||||||
limitIp := 0
|
|
||||||
if i == numInbounds-1 {
|
|
||||||
limitIp = 3
|
|
||||||
}
|
|
||||||
clients := make([]model.Client, 0, hi-lo)
|
|
||||||
records := make([]*model.ClientRecord, 0, hi-lo)
|
|
||||||
for j := lo; j < hi; j++ {
|
|
||||||
email := fmt.Sprintf("user-%07d@ipscale", j)
|
|
||||||
clients = append(clients, model.Client{Email: email, LimitIP: limitIp, Enable: true})
|
|
||||||
records = append(records, &model.ClientRecord{Email: email, LimitIP: limitIp, Enable: true})
|
|
||||||
if limitIp > 0 {
|
|
||||||
limitedEmails = append(limitedEmails, email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
settings, err := json.Marshal(map[string][]model.Client{"clients": clients})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal settings: %v", err)
|
|
||||||
}
|
|
||||||
ib := &model.Inbound{
|
|
||||||
UserId: 1,
|
|
||||||
Tag: fmt.Sprintf("ipscale-%d-%d", n, i),
|
|
||||||
Enable: true,
|
|
||||||
Port: 42000 + i,
|
|
||||||
Protocol: model.VLESS,
|
|
||||||
Settings: string(settings),
|
|
||||||
}
|
|
||||||
if err := tx.Create(ib).Error; err != nil {
|
|
||||||
t.Fatalf("seed inbound %d: %v", i, err)
|
|
||||||
}
|
|
||||||
if err := tx.CreateInBatches(records, 500).Error; err != nil {
|
|
||||||
t.Fatalf("seed clients %d: %v", i, err)
|
|
||||||
}
|
|
||||||
links := make([]model.ClientInbound, len(records))
|
|
||||||
for j := range records {
|
|
||||||
links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
|
|
||||||
}
|
|
||||||
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
|
|
||||||
t.Fatalf("seed client_inbounds %d: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
|
||||||
t.Fatalf("commit seed tx: %v", err)
|
|
||||||
}
|
|
||||||
committed = true
|
|
||||||
db.Exec("ANALYZE")
|
|
||||||
return limitedEmails
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCheckClientIpScale measures the @every 10s ip-limit job pieces: the
|
|
||||||
// hasLimitIp gate (settings LIKE scan + full JSON parse of every matching
|
|
||||||
// inbound) and processObserved with M online users (per-email inbound lookup,
|
|
||||||
// settings parse and autocommit save). Run twice: first scan half add / half
|
|
||||||
// update, second scan all update path.
|
|
||||||
func TestCheckClientIpScale(t *testing.T) {
|
|
||||||
shapes := []struct {
|
|
||||||
name string
|
|
||||||
inbounds int
|
|
||||||
observed int
|
|
||||||
}{{"single", 1, 50}, {"spread50", 50, 1000}}
|
|
||||||
|
|
||||||
for _, n := range scaleJobSizes(t, 10000, 100000) {
|
|
||||||
for _, shape := range shapes {
|
|
||||||
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
|
||||||
setupScaleJobDB(t)
|
|
||||||
limited := seedScaleIPDataset(t, n, shape.inbounds)
|
|
||||||
m := min(shape.observed, len(limited))
|
|
||||||
|
|
||||||
j := NewCheckClientIpJob()
|
|
||||||
const reps = 3
|
|
||||||
start := time.Now()
|
|
||||||
for range reps {
|
|
||||||
if !j.hasLimitIp() {
|
|
||||||
t.Fatal("hasLimitIp = false, want true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("N=%-7d shape=%-8s hasLimitIp=%v/call", n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
observed := make(map[string]map[string]int64, m)
|
|
||||||
for i := range m {
|
|
||||||
observed[limited[i]] = map[string]int64{
|
|
||||||
fmt.Sprintf("10.0.%d.%d", i/250, i%250+1): now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range m / 2 {
|
|
||||||
seedClientIps(t, limited[i], []IPWithTimestamp{{IP: "10.99.0.1", Timestamp: now - 60}})
|
|
||||||
}
|
|
||||||
|
|
||||||
start = time.Now()
|
|
||||||
j.processObserved(observed, true, true)
|
|
||||||
firstScan := time.Since(start)
|
|
||||||
start = time.Now()
|
|
||||||
j.processObserved(observed, true, true)
|
|
||||||
secondScan := time.Since(start)
|
|
||||||
t.Logf("N=%-7d shape=%-8s processObserved M=%-5d first=%-10v second=%-10v (%.1fms/email)",
|
|
||||||
n, shape.name, m, firstScan.Round(time.Millisecond), secondScan.Round(time.Millisecond),
|
|
||||||
float64(secondScan.Milliseconds())/float64(m))
|
|
||||||
|
|
||||||
var rows int64
|
|
||||||
if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&rows).Error; err != nil {
|
|
||||||
t.Fatalf("count ip rows: %v", err)
|
|
||||||
}
|
|
||||||
if rows != int64(m) {
|
|
||||||
t.Fatalf("inbound_client_ips rows = %d, want %d", rows, m)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// Package job provides background job implementations for the 3x-ui web panel,
|
|
||||||
// including traffic monitoring, system checks, and periodic maintenance tasks.
|
|
||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EventBus is set from web layer to publish events.
|
|
||||||
var EventBus *eventbus.Bus
|
|
||||||
|
|
||||||
// CheckXrayRunningJob monitors Xray process health and restarts it if it crashes.
|
|
||||||
type CheckXrayRunningJob struct {
|
|
||||||
xrayService service.XrayService
|
|
||||||
checkTime int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCheckXrayRunningJob creates a new Xray health check job instance.
|
|
||||||
func NewCheckXrayRunningJob() *CheckXrayRunningJob {
|
|
||||||
return new(CheckXrayRunningJob)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run checks if Xray has crashed and restarts it after confirming it's down for 2 consecutive checks.
|
|
||||||
func (j *CheckXrayRunningJob) Run() {
|
|
||||||
if !j.xrayService.DidXrayCrash() {
|
|
||||||
j.checkTime = 0
|
|
||||||
} else {
|
|
||||||
j.checkTime++
|
|
||||||
// only restart if it's down 2 times in a row
|
|
||||||
if j.checkTime > 1 {
|
|
||||||
err := j.xrayService.RestartXray(false)
|
|
||||||
j.checkTime = 0
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Restart xray failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3x-ui logger must be initialised once before any code path that can
|
||||||
|
// log a warning, otherwise log.Warningf panics on a nil logger. These
|
||||||
|
// helpers used to live in the CheckClientIpJob integration tests; they
|
||||||
|
// moved here when that job was retired in P9(4b), because the node
|
||||||
|
// heartbeat and traffic-sync integration tests share them.
|
||||||
|
var loggerInitOnce sync.Once
|
||||||
|
|
||||||
|
// setupIntegrationDB wires a temp sqlite db and log folder so job code
|
||||||
|
// can run end to end. Closes the db before TempDir cleanup so windows
|
||||||
|
// doesn't complain about the file being in use.
|
||||||
|
func setupIntegrationDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
loggerInitOnce.Do(func() {
|
||||||
|
xuilogger.InitLogger(logging.ERROR)
|
||||||
|
})
|
||||||
|
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
logDir := t.TempDir()
|
||||||
|
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", logDir)
|
||||||
|
|
||||||
|
origLogWriter := log.Writer()
|
||||||
|
origLogFlags := log.Flags()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetOutput(origLogWriter)
|
||||||
|
log.SetFlags(origLogFlags)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("database.InitDB failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := database.CloseDB(); err != nil {
|
||||||
|
t.Logf("database.CloseDB warning: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// Addresses in the examples below come from the documentation ranges reserved
|
|
||||||
// by RFC 5737 and RFC 3849.
|
|
||||||
func TestIpLimitAllowlistMatchesAddressesAndNetworks(t *testing.T) {
|
|
||||||
list := parseIpLimitAllowlist("203.0.113.10, 198.51.100.0/24 , 2001:db8::/32, not-an-ip")
|
|
||||||
|
|
||||||
for _, ip := range []string{"203.0.113.10", "198.51.100.7", "2001:db8::1"} {
|
|
||||||
if !list.contains(ip) {
|
|
||||||
t.Fatalf("%s should be allowlisted", ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, ip := range []string{"203.0.113.11", "192.0.2.5", "2001:db9::1", ""} {
|
|
||||||
if list.contains(ip) {
|
|
||||||
t.Fatalf("%s must not be allowlisted", ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A typo must not disable the limit for everybody, so an unparsable entry is
|
|
||||||
// dropped and the rest of the list keeps working.
|
|
||||||
func TestIpLimitAllowlistIgnoresUnparsableEntries(t *testing.T) {
|
|
||||||
list := parseIpLimitAllowlist("nonsense, 203.0.113.0/24")
|
|
||||||
if !list.contains("203.0.113.5") {
|
|
||||||
t.Fatal("a valid entry stopped working because a neighbouring one was malformed")
|
|
||||||
}
|
|
||||||
if list.contains("192.0.2.1") {
|
|
||||||
t.Fatal("a malformed entry must not widen the allowlist")
|
|
||||||
}
|
|
||||||
if parseIpLimitAllowlist("nonsense").empty() != true {
|
|
||||||
t.Fatal("a list of only malformed entries must be empty, not permissive")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The point of the setting: a shared address is neither banned nor counted, so
|
|
||||||
// the office NAT it protects does not consume the client's limit either.
|
|
||||||
func TestIpLimitAllowlistSplitKeepsAllowedOutOfTheCount(t *testing.T) {
|
|
||||||
live := []IPWithTimestamp{
|
|
||||||
{IP: "203.0.113.10", Timestamp: 1},
|
|
||||||
{IP: "192.0.2.1", Timestamp: 2},
|
|
||||||
{IP: "192.0.2.2", Timestamp: 3},
|
|
||||||
}
|
|
||||||
list := parseIpLimitAllowlist("203.0.113.10")
|
|
||||||
|
|
||||||
limited, allowed := list.split(live)
|
|
||||||
if len(allowed) != 1 || allowed[0].IP != "203.0.113.10" {
|
|
||||||
t.Fatalf("allowed = %v, want the allowlisted address alone", allowed)
|
|
||||||
}
|
|
||||||
if len(limited) != 2 {
|
|
||||||
t.Fatalf("limited = %v, want the two ordinary addresses", limited)
|
|
||||||
}
|
|
||||||
|
|
||||||
kept, banned := selectIpsToBan(limited, 2)
|
|
||||||
if len(banned) != 0 {
|
|
||||||
t.Fatalf("banned = %v, want none: the allowlisted address must not push an ordinary one over the limit", banned)
|
|
||||||
}
|
|
||||||
if len(kept) != 2 {
|
|
||||||
t.Fatalf("kept = %v, want both ordinary addresses", kept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIpLimitAllowlistEmptyListChangesNothing(t *testing.T) {
|
|
||||||
live := []IPWithTimestamp{{IP: "192.0.2.1", Timestamp: 1}, {IP: "192.0.2.2", Timestamp: 2}}
|
|
||||||
limited, allowed := parseIpLimitAllowlist("").split(live)
|
|
||||||
if allowed != nil || len(limited) != 2 {
|
|
||||||
t.Fatalf("empty allowlist changed the input: limited=%v allowed=%v", limited, allowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The protocol gate must let hysteria through: XrayAPI supports it, and the
|
|
||||||
// skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
|
|
||||||
// captureLogLines installs a logger hook collecting "LEVEL message" lines
|
|
||||||
// for the rest of the test. It replaces reads of the in-memory log buffer,
|
|
||||||
// which was removed together with the web log viewer.
|
|
||||||
func captureLogLines(t *testing.T) *[]string {
|
|
||||||
t.Helper()
|
|
||||||
var mu sync.Mutex
|
|
||||||
lines := &[]string{}
|
|
||||||
prev := logger.SetLogHook(func(level, msg string) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
*lines = append(*lines, level+" "+msg)
|
|
||||||
})
|
|
||||||
t.Cleanup(func() { logger.SetLogHook(prev) })
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
|
||||||
|
|
||||||
const email = "hy2-limit-probe"
|
|
||||||
inbound := &model.Inbound{
|
|
||||||
Id: 1,
|
|
||||||
Protocol: model.Hysteria,
|
|
||||||
Tag: "hy2-limit-probe-tag",
|
|
||||||
Settings: `{"clients":[]}`,
|
|
||||||
}
|
|
||||||
clients := []model.Client{{Email: email, Auth: "secret"}}
|
|
||||||
|
|
||||||
logLines := captureLogLines(t)
|
|
||||||
(&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients)
|
|
||||||
|
|
||||||
var unsupported, attempted bool
|
|
||||||
for _, line := range *logLines {
|
|
||||||
if strings.Contains(line, "Temporary disconnect is not supported for protocol hysteria") {
|
|
||||||
unsupported = true
|
|
||||||
}
|
|
||||||
if strings.Contains(line, "Failed to remove user "+email) {
|
|
||||||
attempted = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if unsupported {
|
|
||||||
t.Fatal("hysteria was rejected by the protocol gate")
|
|
||||||
}
|
|
||||||
if !attempted {
|
|
||||||
t.Fatal("expected a remove attempt against the Xray API for hysteria")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -136,14 +136,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
|||||||
logger.Warning("node traffic sync: depletion check failed:", err)
|
logger.Warning("node traffic sync: depletion check failed:", err)
|
||||||
}
|
}
|
||||||
if clientsDisabled {
|
if clientsDisabled {
|
||||||
if restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable(); settingErr == nil && restartOnDisable {
|
|
||||||
if err := j.xrayService.RestartXray(true); err != nil {
|
|
||||||
logger.Warning("node traffic sync: restart xray after disabling clients failed:", err)
|
|
||||||
j.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
} else if settingErr != nil {
|
|
||||||
logger.Warning("node traffic sync: get RestartXrayOnClientDisable failed:", settingErr)
|
|
||||||
}
|
|
||||||
j.structural.set()
|
j.structural.set()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ func (j *OutboundSubscriptionJob) Run() {
|
|||||||
if count > 0 {
|
if count > 0 {
|
||||||
logger.Infof("Refreshed %d outbound subscription(s)", count)
|
logger.Infof("Refreshed %d outbound subscription(s)", count)
|
||||||
// Ask the xray manager to restart/reload on the next 30s check.
|
// Ask the xray manager to restart/reload on the next 30s check.
|
||||||
j.xraySvc.SetToNeedRestart()
|
|
||||||
// Also broadcast an invalidate so the UI can refresh the xray setting
|
// Also broadcast an invalidate so the UI can refresh the xray setting
|
||||||
// view (new outbounds will be visible after the reload cycle).
|
// view (new outbounds will be visible after the reload cycle).
|
||||||
websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
|
websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ func (j *PeriodicTrafficResetJob) resetClientsOnTheirOwnCycle() {
|
|||||||
// Dropping this leaves a re-enabled client absent from the running core until
|
// Dropping this leaves a re-enabled client absent from the running core until
|
||||||
// something unrelated restarts it.
|
// something unrelated restarts it.
|
||||||
if needRestart {
|
if needRestart {
|
||||||
j.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if resetCount > 0 {
|
if resetCount > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventBus is set from the web layer to publish events. It lived in the
|
||||||
|
// local-core watchdog job file until P9(4b) removed the local-core jobs;
|
||||||
|
// the CPU/memory/node jobs still publish through it.
|
||||||
|
var EventBus *eventbus.Bus
|
||||||
|
|
||||||
|
// clientStatsSnapshotMaxClients caps how many client_traffics rows a
|
||||||
|
// traffic snapshot folds into a single stats event.
|
||||||
|
const clientStatsSnapshotMaxClients = 5000
|
||||||
|
|
||||||
|
// IPWithTimestamp tracks an IP address with its last seen timestamp.
|
||||||
|
type IPWithTimestamp struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
@@ -55,7 +55,6 @@ func (j *WarpIpJob) Run() {
|
|||||||
if err := j.settingService.SetWarpLastUpdate(now); err != nil {
|
if err := j.settingService.SetWarpLastUpdate(now); err != nil {
|
||||||
logger.Warning("WARP IP changed but the next-update time was not saved: ", err)
|
logger.Warning("WARP IP changed but the next-update time was not saved: ", err)
|
||||||
}
|
}
|
||||||
j.xrayService.SetToNeedRestart()
|
|
||||||
logger.Info("Successfully updated WARP IP and scheduled Xray restart")
|
logger.Info("Successfully updated WARP IP and scheduled Xray restart")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
)
|
|
||||||
|
|
||||||
// XrayTrafficJob collects and processes traffic statistics from Xray, updating the database.
|
|
||||||
type XrayTrafficJob struct {
|
|
||||||
settingService service.SettingService
|
|
||||||
xrayService service.XrayService
|
|
||||||
inboundService service.InboundService
|
|
||||||
outboundService outbound.OutboundService
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientStatsSnapshotMaxClients caps how many client_traffics rows the job
|
|
||||||
// ships as a full websocket snapshot per poll (same spirit as the
|
|
||||||
// controller's broadcastInboundsUpdateClientLimit). Above it, a snapshot
|
|
||||||
// would blow past the hub's payload cap and be dropped wholesale, so the job
|
|
||||||
// broadcasts only this poll's active rows and the UI leans on its 5s REST
|
|
||||||
// refetch for the rest.
|
|
||||||
const clientStatsSnapshotMaxClients = 5000
|
|
||||||
|
|
||||||
// splitMovedClientTraffics keeps the rows that actually moved bytes this poll,
|
|
||||||
// alongside the active-email list and set derived from the same pass.
|
|
||||||
//
|
|
||||||
// Xray reports a row for every known email whether or not it transferred
|
|
||||||
// anything, so on a large panel nearly every delta is zero. The database writes
|
|
||||||
// and the external-API inform consume the full slice before this point; the
|
|
||||||
// WebSocket frame only feeds the dashboard's live speed column, where an absent
|
|
||||||
// row and a zero row render identically. Broadcasting just the movers keeps that
|
|
||||||
// frame from growing with the client count — at 5k clients it was carrying about
|
|
||||||
// a megabyte of zeros every five seconds.
|
|
||||||
func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, []string, map[string]bool) {
|
|
||||||
moved := make([]*xray.ClientTraffic, 0, len(clientTraffics))
|
|
||||||
emails := make([]string, 0, len(clientTraffics))
|
|
||||||
active := make(map[string]bool, len(clientTraffics))
|
|
||||||
for _, ct := range clientTraffics {
|
|
||||||
if ct == nil || ct.Up+ct.Down <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
moved = append(moved, ct)
|
|
||||||
emails = append(emails, ct.Email)
|
|
||||||
active[ct.Email] = true
|
|
||||||
}
|
|
||||||
return moved, emails, active
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewXrayTrafficJob creates a new traffic collection job instance.
|
|
||||||
func NewXrayTrafficJob() *XrayTrafficJob {
|
|
||||||
return new(XrayTrafficJob)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run collects traffic statistics from Xray, updates the database, and pushes
|
|
||||||
// real-time updates over WebSocket using compact delta payloads — no REST
|
|
||||||
// fallback, scales to 10k–20k+ clients per inbound.
|
|
||||||
func (j *XrayTrafficJob) Run() {
|
|
||||||
if !j.xrayService.IsXrayRunning() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
traffics, clientTraffics, err := j.xrayService.GetXrayTraffic()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
needRestart0, clientsDisabled, err := j.inboundService.AddTraffic(traffics, clientTraffics)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("add inbound traffic failed:", err)
|
|
||||||
}
|
|
||||||
err, needRestart1 := j.outboundService.AddTraffic(traffics, clientTraffics)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("add outbound traffic failed:", err)
|
|
||||||
}
|
|
||||||
if clientsDisabled {
|
|
||||||
restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable()
|
|
||||||
if settingErr != nil {
|
|
||||||
logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
|
|
||||||
}
|
|
||||||
if restartOnDisable {
|
|
||||||
if err := j.xrayService.RestartXray(false); err != nil {
|
|
||||||
logger.Warning("reconcile xray after disabling clients failed:", err)
|
|
||||||
j.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
|
||||||
}
|
|
||||||
if needRestart0 || needRestart1 {
|
|
||||||
j.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the local online set from this poll's per-email deltas rather
|
|
||||||
// than the shared last_online column, which remote-node syncs also bump
|
|
||||||
// and would otherwise make a client active only on a remote node appear
|
|
||||||
// online on local inbounds.
|
|
||||||
movedTraffics, activeEmails, deltaActive := splitMovedClientTraffics(clientTraffics)
|
|
||||||
// When the core supports the online-stats API, union in connection-based
|
|
||||||
// onlines. Neither signal alone covers everything: an idle-but-connected
|
|
||||||
// client moves no bytes between polls (the delta heuristic's blind spot),
|
|
||||||
// while a short-lived connection can close before this poll yet still show
|
|
||||||
// in the delta. Older cores fall back to deltas alone.
|
|
||||||
if onlineUsers, apiMode, ouErr := j.xrayService.GetOnlineUsers(); ouErr != nil {
|
|
||||||
logger.Debug("get online users from xray api failed:", ouErr)
|
|
||||||
} else if apiMode {
|
|
||||||
idleOnline := make([]string, 0, len(onlineUsers))
|
|
||||||
for _, u := range onlineUsers {
|
|
||||||
if !deltaActive[u.Email] {
|
|
||||||
activeEmails = append(activeEmails, u.Email)
|
|
||||||
idleOnline = append(idleOnline, u.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The traffic path only bumps last_online on a non-zero delta; keep the
|
|
||||||
// column fresh for clients kept online purely by a live connection.
|
|
||||||
if err := j.inboundService.BumpClientsLastOnline(idleOnline); err != nil {
|
|
||||||
logger.Warning("bump last online for connected clients failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Pair the email signal with the inbound tags that moved bytes this poll.
|
|
||||||
// Xray's user>>>email counter aggregates across every inbound a client is
|
|
||||||
// attached to, so an online email alone can't say which inbound it used —
|
|
||||||
// gating the per-inbound view on these tags keeps a multi-inbound client
|
|
||||||
// off inbounds that saw no traffic. See issue #4859.
|
|
||||||
activeInboundTags := make([]string, 0, len(traffics))
|
|
||||||
for _, tr := range traffics {
|
|
||||||
if tr != nil && tr.IsInbound && tr.Up+tr.Down > 0 {
|
|
||||||
activeInboundTags = append(activeInboundTags, tr.Tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
j.inboundService.RefreshLocalOnlineClients(activeEmails, activeInboundTags)
|
|
||||||
|
|
||||||
if !websocket.HasClients() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small installs broadcast the full snapshot (see GetAllClientTraffics for
|
|
||||||
// why deltas alone left UI rows stale). Above the threshold the snapshot
|
|
||||||
// would be dropped by the hub's payload cap anyway, so ship this poll's
|
|
||||||
// active rows instead and scope last-online to them; the initial full map
|
|
||||||
// still arrives over REST.
|
|
||||||
snapshot := true
|
|
||||||
if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
|
|
||||||
logger.Warning("count client traffics for websocket failed:", countErr)
|
|
||||||
} else if total > clientStatsSnapshotMaxClients {
|
|
||||||
snapshot = false
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats []*xray.ClientTraffic
|
|
||||||
var statsErr error
|
|
||||||
if snapshot {
|
|
||||||
stats, statsErr = j.inboundService.GetAllClientTraffics()
|
|
||||||
} else {
|
|
||||||
stats, statsErr = j.inboundService.GetActiveClientTraffics(activeEmails)
|
|
||||||
}
|
|
||||||
if statsErr != nil {
|
|
||||||
logger.Warning("get client traffics for websocket failed:", statsErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastOnlineMap map[string]int64
|
|
||||||
if snapshot {
|
|
||||||
if lastOnlineMap, err = j.inboundService.GetClientsLastOnline(); err != nil {
|
|
||||||
logger.Warning("get clients last online failed:", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastOnlineMap = make(map[string]int64, len(stats))
|
|
||||||
for _, ct := range stats {
|
|
||||||
if ct != nil {
|
|
||||||
lastOnlineMap[ct.Email] = ct.LastOnline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if lastOnlineMap == nil {
|
|
||||||
lastOnlineMap = make(map[string]int64)
|
|
||||||
}
|
|
||||||
onlineClients := j.inboundService.GetOnlineClients()
|
|
||||||
if onlineClients == nil {
|
|
||||||
onlineClients = []string{}
|
|
||||||
}
|
|
||||||
websocket.BroadcastTraffic(map[string]any{
|
|
||||||
"traffics": traffics,
|
|
||||||
"clientTraffics": movedTraffics,
|
|
||||||
"onlineClients": onlineClients,
|
|
||||||
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
|
|
||||||
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
|
|
||||||
"lastOnlineMap": lastOnlineMap,
|
|
||||||
})
|
|
||||||
|
|
||||||
clientStatsPayload := map[string]any{"snapshot": snapshot}
|
|
||||||
if len(stats) > 0 {
|
|
||||||
clientStatsPayload["clients"] = stats
|
|
||||||
}
|
|
||||||
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
|
||||||
logger.Warning("get inbounds traffic summary for websocket failed:", err)
|
|
||||||
} else if len(inboundSummary) > 0 {
|
|
||||||
clientStatsPayload["inbounds"] = inboundSummary
|
|
||||||
}
|
|
||||||
if len(clientStatsPayload) > 1 {
|
|
||||||
websocket.BroadcastClientStats(clientStatsPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
if updatedOutbounds, err := j.outboundService.GetOutboundsTraffic(); err == nil && updatedOutbounds != nil {
|
|
||||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
|
||||||
} else if err != nil {
|
|
||||||
logger.Warning("get all outbounds for websocket failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSplitMovedClientTraffics(t *testing.T) {
|
|
||||||
rows := []*xray.ClientTraffic{
|
|
||||||
{Email: "idle@x", Up: 0, Down: 0},
|
|
||||||
{Email: "up@x", Up: 1024, Down: 0},
|
|
||||||
{Email: "down@x", Up: 0, Down: 2048},
|
|
||||||
nil,
|
|
||||||
{Email: "both@x", Up: 512, Down: 512},
|
|
||||||
{Email: "alsoidle@x", Up: 0, Down: 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
moved, emails, active := splitMovedClientTraffics(rows)
|
|
||||||
|
|
||||||
t.Run("only the rows that moved bytes are broadcast", func(t *testing.T) {
|
|
||||||
got := make([]string, 0, len(moved))
|
|
||||||
for _, ct := range moved {
|
|
||||||
got = append(got, ct.Email)
|
|
||||||
}
|
|
||||||
want := []string{"up@x", "down@x", "both@x"}
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Fatalf("moved = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("the active list and set agree with the broadcast rows", func(t *testing.T) {
|
|
||||||
want := []string{"up@x", "down@x", "both@x"}
|
|
||||||
if !slices.Equal(emails, want) {
|
|
||||||
t.Fatalf("activeEmails = %v, want %v", emails, want)
|
|
||||||
}
|
|
||||||
if len(active) != len(want) {
|
|
||||||
t.Fatalf("deltaActive has %d entries, want %d", len(active), len(want))
|
|
||||||
}
|
|
||||||
for _, e := range want {
|
|
||||||
if !active[e] {
|
|
||||||
t.Fatalf("deltaActive missing %q", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if active["idle@x"] {
|
|
||||||
t.Fatal("an idle client must not count as active")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("an all-idle poll broadcasts nothing", func(t *testing.T) {
|
|
||||||
moved, emails, active := splitMovedClientTraffics([]*xray.ClientTraffic{
|
|
||||||
{Email: "a@x"}, {Email: "b@x"},
|
|
||||||
})
|
|
||||||
if len(moved) != 0 || len(emails) != 0 || len(active) != 0 {
|
|
||||||
t.Fatalf("expected an empty split, got %d/%d/%d", len(moved), len(emails), len(active))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+32
-286
@@ -2,313 +2,59 @@ package runtime
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LocalDeps struct {
|
// Local is the runtime for inbounds with no node assignment. The lite
|
||||||
APIPort func() int
|
// master is a pure control plane — it runs no local xray core and no
|
||||||
SetNeedRestart func()
|
// MTProto/AmneziaWG sidecars (removed in the P9(4b) round along with the
|
||||||
}
|
// whole local-core path) — so every operation is a no-op success: the
|
||||||
|
// database is the only state a node-less inbound has, and the service
|
||||||
|
// layer has already written it by the time these are called. Inbounds
|
||||||
|
// only become live once they are homed on an agent node (Remote).
|
||||||
|
type Local struct{}
|
||||||
|
|
||||||
type Local struct {
|
func NewLocal() *Local { return &Local{} }
|
||||||
deps LocalDeps
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLocal(deps LocalDeps) *Local {
|
|
||||||
return &Local{deps: deps}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) Name() string { return "local" }
|
func (l *Local) Name() string { return "local" }
|
||||||
|
|
||||||
func (l *Local) withAPI(fn func(api *xray.XrayAPI) error) error {
|
func (l *Local) AddInbound(_ context.Context, _ *model.Inbound) error { return nil }
|
||||||
l.mu.Lock()
|
func (l *Local) DelInbound(_ context.Context, _ *model.Inbound) error { return nil }
|
||||||
defer l.mu.Unlock()
|
func (l *Local) UpdateInbound(_ context.Context, _, _ *model.Inbound) error {
|
||||||
|
|
||||||
port := l.deps.APIPort()
|
|
||||||
if port <= 0 {
|
|
||||||
return errors.New("local xray is not running")
|
|
||||||
}
|
|
||||||
var api xray.XrayAPI
|
|
||||||
if err := api.Init(port); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer api.Close()
|
|
||||||
return fn(&api)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
|
|
||||||
if ib.Protocol == model.MTProto {
|
|
||||||
inst, ok := mtproto.InstanceFromInbound(ib)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return mtproto.GetManager().Ensure(inst)
|
|
||||||
}
|
|
||||||
if ib.Protocol == model.AmneziaWG {
|
|
||||||
inst, ok := amneziawg.InstanceFromInbound(ib)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
err := amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
|
|
||||||
Instance: inst,
|
|
||||||
Options: amneziawgnet.DeviceOptions{
|
|
||||||
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
|
|
||||||
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
|
|
||||||
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
|
|
||||||
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
|
|
||||||
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
|
|
||||||
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
|
|
||||||
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
|
|
||||||
RandomTrailers: inst.Obfuscation.RandomTrailers,
|
|
||||||
DisableCookies: inst.Obfuscation.DisableCookies,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// A brand new inbound can be the first one to qualify for
|
|
||||||
// injectAmneziawgnetSocks's Xray-side relay inbound (e.g. its first
|
|
||||||
// valid peer). Ensure only updates the embedded Device -- flag Xray
|
|
||||||
// for a resync so the relay actually gets created within the next
|
|
||||||
// ApplyPendingRestart tick instead of only at the next full restart.
|
|
||||||
if l.deps.SetNeedRestart != nil {
|
|
||||||
l.deps.SetNeedRestart()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return l.withAPI(func(api *xray.XrayAPI) error {
|
|
||||||
return api.AddInbound(body)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
|
|
||||||
if ib.Protocol == model.MTProto {
|
|
||||||
mtproto.GetManager().Remove(ib.Id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if ib.Protocol == model.AmneziaWG {
|
|
||||||
amneziawgnet.GetManager().Remove(ib.Id)
|
|
||||||
// The removed inbound may have been the only one backing Xray's
|
|
||||||
// injectAmneziawgnetSocks relay inbound for this tag -- flag a
|
|
||||||
// resync so the now-stale relay gets torn down promptly.
|
|
||||||
if l.deps.SetNeedRestart != nil {
|
|
||||||
l.deps.SetNeedRestart()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return l.withAPI(func(api *xray.XrayAPI) error {
|
|
||||||
return api.DelInbound(ib.Tag)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
|
||||||
if oldIb.Protocol == model.MTProto || newIb.Protocol == model.MTProto {
|
|
||||||
return l.updateMtprotoInbound(ctx, oldIb, newIb)
|
|
||||||
}
|
|
||||||
if oldIb.Protocol == model.AmneziaWG || newIb.Protocol == model.AmneziaWG {
|
|
||||||
return l.updateAmneziaWGInbound(ctx, oldIb, newIb)
|
|
||||||
}
|
|
||||||
_ = l.DelInbound(ctx, oldIb)
|
|
||||||
if !newIb.Enable {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return l.AddInbound(ctx, newIb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateMtprotoInbound applies an inbound update without the Del+Add sequence
|
|
||||||
// the xray path uses: Remove would drop the manager's fingerprint state, which
|
|
||||||
// is what lets Ensure keep the running mtg process (and its live connections)
|
|
||||||
// when nothing in the generated config changed. The sidecar is only stopped
|
|
||||||
// when the inbound is disabled, loses its last active secret, or moves to a
|
|
||||||
// different protocol.
|
|
||||||
func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
|
||||||
if oldIb.Protocol == model.MTProto && newIb.Protocol != model.MTProto {
|
|
||||||
mtproto.GetManager().Remove(oldIb.Id)
|
|
||||||
if !newIb.Enable {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return l.AddInbound(ctx, newIb)
|
|
||||||
}
|
|
||||||
if oldIb.Protocol != model.MTProto {
|
|
||||||
_ = l.DelInbound(ctx, oldIb)
|
|
||||||
}
|
|
||||||
if !newIb.Enable {
|
|
||||||
mtproto.GetManager().Remove(newIb.Id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
inst, ok := mtproto.InstanceFromInbound(newIb)
|
|
||||||
if !ok {
|
|
||||||
mtproto.GetManager().Remove(newIb.Id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return mtproto.GetManager().Ensure(inst)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateAmneziaWGInbound mirrors updateMtprotoInbound: it skips the
|
|
||||||
// Remove+Ensure sequence a plain Del+Add would force so that, on an
|
|
||||||
// AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison
|
|
||||||
// can reconfigure the running embedded Device in place via IpcSet instead
|
|
||||||
// of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked --
|
|
||||||
// only an address/MTU change forces a rebuild there, not a peer edit).
|
|
||||||
//
|
|
||||||
// Every exit path below only touches the embedded Device via
|
|
||||||
// amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which
|
|
||||||
// is what actually creates/removes injectAmneziawgnetSocks's relay inbound.
|
|
||||||
// A peer edit that changes whether this inbound has a qualifying peer at
|
|
||||||
// all (its first peer added, or its last one removed) must still get that
|
|
||||||
// relay created or torn down, so flag Xray for a resync unconditionally
|
|
||||||
// here rather than trying to enumerate which of the branches below need it.
|
|
||||||
func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
|
||||||
if l.deps.SetNeedRestart != nil {
|
|
||||||
l.deps.SetNeedRestart()
|
|
||||||
}
|
|
||||||
if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG {
|
|
||||||
amneziawgnet.GetManager().Remove(oldIb.Id)
|
|
||||||
if !newIb.Enable {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return l.AddInbound(ctx, newIb)
|
|
||||||
}
|
|
||||||
if oldIb.Protocol != model.AmneziaWG {
|
|
||||||
_ = l.DelInbound(ctx, oldIb)
|
|
||||||
}
|
|
||||||
if !newIb.Enable {
|
|
||||||
amneziawgnet.GetManager().Remove(newIb.Id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
inst, ok := amneziawg.InstanceFromInbound(newIb)
|
|
||||||
if !ok {
|
|
||||||
amneziawgnet.GetManager().Remove(newIb.Id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
|
|
||||||
Instance: inst,
|
|
||||||
Options: amneziawgnet.DeviceOptions{
|
|
||||||
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
|
|
||||||
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
|
|
||||||
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
|
|
||||||
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
|
|
||||||
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
|
|
||||||
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
|
|
||||||
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
|
|
||||||
RandomTrailers: inst.Obfuscation.RandomTrailers,
|
|
||||||
DisableCookies: inst.Obfuscation.DisableCookies,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
|
|
||||||
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return l.withAPI(func(api *xray.XrayAPI) error {
|
|
||||||
return api.AddUser(string(ib.Protocol), ib.Tag, userMap)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
|
|
||||||
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return l.withAPI(func(api *xray.XrayAPI) error {
|
|
||||||
return api.RemoveUser(ib.Tag, email)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
|
|
||||||
if !client.Enable {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
user := map[string]any{
|
|
||||||
"email": client.Email,
|
|
||||||
"id": client.ID,
|
|
||||||
"security": client.Security,
|
|
||||||
"flow": client.Flow,
|
|
||||||
"auth": client.Auth,
|
|
||||||
"password": client.Password,
|
|
||||||
"publicKey": client.PublicKey,
|
|
||||||
"allowedIPs": client.AllowedIPs,
|
|
||||||
"preSharedKey": client.PreSharedKey,
|
|
||||||
"keepAlive": wgKeepAlive(client.KeepAlive),
|
|
||||||
}
|
|
||||||
return l.AddUser(ctx, ib, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
|
|
||||||
if email == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := l.RemoveUser(ctx, ib, email); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "not found") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Local) DeleteClient(context.Context, string) error {
|
func (l *Local) AddUser(_ context.Context, _ *model.Inbound, _ map[string]any) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
|
func (l *Local) RemoveUser(_ context.Context, _ *model.Inbound, _ string) error {
|
||||||
if oldEmail != "" {
|
|
||||||
if err := l.RemoveUser(ctx, ib, oldEmail); err != nil && !strings.Contains(err.Error(), "not found") {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !payload.Enable {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
user := map[string]any{
|
|
||||||
"email": payload.Email,
|
|
||||||
"id": payload.ID,
|
|
||||||
"security": payload.Security,
|
|
||||||
"flow": payload.Flow,
|
|
||||||
"auth": payload.Auth,
|
|
||||||
"password": payload.Password,
|
|
||||||
"publicKey": payload.PublicKey,
|
|
||||||
"allowedIPs": payload.AllowedIPs,
|
|
||||||
"preSharedKey": payload.PreSharedKey,
|
|
||||||
"keepAlive": wgKeepAlive(payload.KeepAlive),
|
|
||||||
}
|
|
||||||
return l.AddUser(ctx, ib, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func wgKeepAlive(seconds int) string {
|
|
||||||
if seconds <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strconv.Itoa(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) RestartXray(_ context.Context) error {
|
|
||||||
if l.deps.SetNeedRestart != nil {
|
|
||||||
l.deps.SetNeedRestart()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Local) UpdateUser(_ context.Context, _ *model.Inbound, _ string, _ model.Client) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) DeleteUser(_ context.Context, _ *model.Inbound, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) AddClient(_ context.Context, _ *model.Inbound, _ model.Client) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) DeleteClient(_ context.Context, _ string) error { return nil }
|
||||||
|
|
||||||
|
func (l *Local) RestartXray(_ context.Context) error { return nil }
|
||||||
|
|
||||||
func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string) error {
|
func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Local) ResetAllTraffics(_ context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) ResetInboundTraffic(_ context.Context, _ *model.Inbound) error {
|
func (l *Local) ResetInboundTraffic(_ context.Context, _ *model.Inbound) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Local) ResetAllTraffics(_ context.Context) error { return nil }
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ type Manager struct {
|
|||||||
egressResolver NodeEgressResolver
|
egressResolver NodeEgressResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(localDeps LocalDeps) *Manager {
|
func NewManager() *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
local: NewLocal(localDeps),
|
local: NewLocal(),
|
||||||
remotes: make(map[int]*Remote),
|
remotes: make(map[int]*Remote),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestManagerRemoteForRefreshesChangedCredential(t *testing.T) {
|
func TestManagerRemoteForRefreshesChangedCredential(t *testing.T) {
|
||||||
m := NewManager(LocalDeps{})
|
m := NewManager()
|
||||||
first, err := m.RemoteFor(&model.Node{
|
first, err := m.RemoteFor(&model.Node{
|
||||||
Id: 1,
|
Id: 1,
|
||||||
Name: "node",
|
Name: "node",
|
||||||
@@ -81,7 +81,7 @@ func TestManagerRemoteForIdentityFields(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
m := NewManager(LocalDeps{})
|
m := NewManager()
|
||||||
firstNode := base
|
firstNode := base
|
||||||
first, err := m.RemoteFor(&firstNode)
|
first, err := m.RemoteFor(&firstNode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,7 +102,7 @@ func TestManagerRemoteForIdentityFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerRemoteForClonesInputNode(t *testing.T) {
|
func TestManagerRemoteForClonesInputNode(t *testing.T) {
|
||||||
m := NewManager(LocalDeps{})
|
m := NewManager()
|
||||||
n := &model.Node{
|
n := &model.Node{
|
||||||
Id: 9,
|
Id: 9,
|
||||||
Scheme: "https",
|
Scheme: "https",
|
||||||
|
|||||||
@@ -941,7 +941,22 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
|
|||||||
// It validates port uniqueness, client email uniqueness, and required fields,
|
// It validates port uniqueness, client email uniqueness, and required fields,
|
||||||
// then saves the inbound to the database and optionally adds it to the running Xray instance.
|
// then saves the inbound to the database and optionally adds it to the running Xray instance.
|
||||||
// Returns the created inbound, whether Xray needs restart, and any error.
|
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||||
|
|
||||||
|
// errRetiredProtocol rejects MTProto/AmneziaWG inbounds: both protocols
|
||||||
|
// were served by sidecars of the panel's own local core, which the lite
|
||||||
|
// master no longer runs (P9(4b)). Existing rows stay in the DB and can
|
||||||
|
// still be deleted, but they can no longer be created or edited.
|
||||||
|
func errRetiredProtocol(protocol model.Protocol) error {
|
||||||
|
if protocol == model.MTProto || protocol == model.AmneziaWG {
|
||||||
|
return common.NewErrorf("%s inbounds are retired in the lite master (no local core); delete the inbound instead", protocol)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
|
if err := errRetiredProtocol(inbound.Protocol); err != nil {
|
||||||
|
return inbound, false, err
|
||||||
|
}
|
||||||
inbound.Id = 0
|
inbound.Id = 0
|
||||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||||
// Normalize streamSettings based on protocol
|
// Normalize streamSettings based on protocol
|
||||||
@@ -1466,6 +1481,9 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
|
if err := errRetiredProtocol(inbound.Protocol); err != nil {
|
||||||
|
return inbound, false, err
|
||||||
|
}
|
||||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||||
// Normalize streamSettings based on protocol
|
// Normalize streamSettings based on protocol
|
||||||
s.normalizeStreamSettings(inbound)
|
s.normalizeStreamSettings(inbound)
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func TestUpdateInbound_RejectsFinalMaskRealityCombo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetXrayConfig must heal a row that already carries finalmask+reality in the
|
// The agent config build must heal a row that already carries finalmask+reality in the
|
||||||
// DB (saved before this guard existed - an upgrade, a node sync, a restored
|
// DB (saved before this guard existed - an upgrade, a node sync, a restored
|
||||||
// backup, or a direct DB edit) rather than handing xray-core a config that
|
// backup, or a direct DB edit) rather than handing xray-core a config that
|
||||||
// panics it on the first connection. Bypasses AddInbound/UpdateInbound
|
// panics it on the first connection. Bypasses AddInbound/UpdateInbound
|
||||||
@@ -187,11 +187,20 @@ func TestGetXrayConfig_HealsFinalMaskRealityCombo(t *testing.T) {
|
|||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
seedInboundConflict(t, "in-44302-tcp", "0.0.0.0", 44302, model.VLESS,
|
seedInboundConflict(t, "in-44302-tcp", "0.0.0.0", 44302, model.VLESS,
|
||||||
realityFinalMaskStream, `{"clients":[]}`)
|
realityFinalMaskStream, `{"clients":[]}`)
|
||||||
|
// Home the row on an agent node: the only config build left is the
|
||||||
|
// per-agent one.
|
||||||
|
node := &model.Node{Name: "heal-node", Address: "10.0.0.9", Port: 2053, ApiToken: "t"}
|
||||||
|
if err := database.GetDB().Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "in-44302-tcp").Update("node_id", node.Id).Error; err != nil {
|
||||||
|
t.Fatalf("home inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
svc := &XrayService{}
|
svc := &XrayService{}
|
||||||
cfg, err := svc.GetXrayConfig()
|
cfg, err := svc.GetXrayConfigForAgent(node.Id, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetXrayConfig: %v", err)
|
t.Fatalf("GetXrayConfigForAgent: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range cfg.InboundConfigs {
|
for i := range cfg.InboundConfigs {
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mtgConfigPath(t *testing.T, inboundId int) string {
|
|
||||||
t.Helper()
|
|
||||||
return filepath.Join(os.Getenv("XUI_BIN_FOLDER"), "mtproto", fmt.Sprintf("mtg-%d.toml", inboundId))
|
|
||||||
}
|
|
||||||
|
|
||||||
func readMtgConfig(t *testing.T, inboundId int) string {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(mtgConfigPath(t, inboundId))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read mtg config: %v", err)
|
|
||||||
}
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateInboundMtprotoUnchangedDoesNotRestart(t *testing.T) {
|
|
||||||
setupConflictDB(t)
|
|
||||||
pidFile := installFakeMtg(t)
|
|
||||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }}))
|
|
||||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
|
||||||
|
|
||||||
seedInboundConflict(t, "mt-apply", "", 46101, model.MTProto,
|
|
||||||
"",
|
|
||||||
`{"clients":[`+
|
|
||||||
`{"email":"mtga","secret":"`+mtprotoTestSecretA+`","enable":true},`+
|
|
||||||
`{"email":"mtgb","secret":"`+mtprotoTestSecretB+`","enable":true}]}`)
|
|
||||||
seeded := loadInboundByTag(t, "mt-apply")
|
|
||||||
seedClientTraffic(t, seeded.Id, "mtga", true)
|
|
||||||
seedClientTraffic(t, seeded.Id, "mtgb", true)
|
|
||||||
|
|
||||||
svc := &InboundService{}
|
|
||||||
primed, ok := mtproto.InstanceFromInbound(seeded)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("seed inbound must produce an mtg instance")
|
|
||||||
}
|
|
||||||
if err := mtproto.GetManager().Ensure(primed); err != nil {
|
|
||||||
t.Fatalf("prime mtg: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { mtproto.GetManager().Remove(seeded.Id) })
|
|
||||||
waitForSpawns(t, pidFile, 1)
|
|
||||||
primedConfig := readMtgConfig(t, seeded.Id)
|
|
||||||
|
|
||||||
saveAndAssertKept := func(t *testing.T, mutate func(*model.Inbound)) {
|
|
||||||
t.Helper()
|
|
||||||
update := *loadInboundByTag(t, "mt-apply")
|
|
||||||
mutate(&update)
|
|
||||||
_, needRestart, err := svc.UpdateInbound(&update)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpdateInbound: %v", err)
|
|
||||||
}
|
|
||||||
if needRestart {
|
|
||||||
t.Fatal("an mtproto-only edit must not request an xray restart")
|
|
||||||
}
|
|
||||||
assertNoNewSpawns(t, pidFile, 1)
|
|
||||||
if got := readMtgConfig(t, seeded.Id); got != primedConfig {
|
|
||||||
t.Fatalf("config rewritten on a no-op edit:\nbefore:\n%s\nafter:\n%s", primedConfig, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("unchangedSaveKeepsProcess", func(t *testing.T) {
|
|
||||||
saveAndAssertKept(t, func(*model.Inbound) {})
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("remarkOnlyEditKeepsProcess", func(t *testing.T) {
|
|
||||||
saveAndAssertKept(t, func(ib *model.Inbound) { ib.Remark = "renamed while users stay connected" })
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rekeyedSecretRestartsProcess", func(t *testing.T) {
|
|
||||||
update := *loadInboundByTag(t, "mt-apply")
|
|
||||||
update.Settings = strings.Replace(update.Settings, mtprotoTestSecretA, mtprotoTestSecretD, 1)
|
|
||||||
if !strings.Contains(update.Settings, mtprotoTestSecretD) {
|
|
||||||
t.Fatal("fixture must contain the re-keyed secret")
|
|
||||||
}
|
|
||||||
_, needRestart, err := svc.UpdateInbound(&update)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpdateInbound: %v", err)
|
|
||||||
}
|
|
||||||
if needRestart {
|
|
||||||
t.Fatal("an mtproto secret change must not request an xray restart")
|
|
||||||
}
|
|
||||||
waitForSpawns(t, pidFile, 2)
|
|
||||||
if got := readMtgConfig(t, seeded.Id); !strings.Contains(got, mtprotoTestSecretD) {
|
|
||||||
t.Fatalf("restarted config must carry the new secret:\n%s", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClientCrudMtprotoAppliesImmediately(t *testing.T) {
|
|
||||||
setupConflictDB(t)
|
|
||||||
pidFile := installFakeMtg(t)
|
|
||||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }}))
|
|
||||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
|
||||||
|
|
||||||
inboundSvc := &InboundService{}
|
|
||||||
clientSvc := &ClientService{}
|
|
||||||
|
|
||||||
created, _, err := inboundSvc.AddInbound(&model.Inbound{
|
|
||||||
Enable: true,
|
|
||||||
Listen: "",
|
|
||||||
Port: 46201,
|
|
||||||
Protocol: model.MTProto,
|
|
||||||
Settings: `{"clients":[{"email":"first","secret":"` + mtprotoTestSecretA + `","enable":true}]}`,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AddInbound: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { mtproto.GetManager().Remove(created.Id) })
|
|
||||||
waitForSpawns(t, pidFile, 1)
|
|
||||||
|
|
||||||
t.Run("add client rewrites the served config", func(t *testing.T) {
|
|
||||||
payload := &model.Inbound{
|
|
||||||
Id: created.Id,
|
|
||||||
Settings: `{"clients":[{"email":"second","secret":"` + mtprotoTestSecretB + `","enable":true}]}`,
|
|
||||||
}
|
|
||||||
needRestart, err := clientSvc.AddInboundClient(inboundSvc, payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AddInboundClient: %v", err)
|
|
||||||
}
|
|
||||||
if needRestart {
|
|
||||||
t.Fatal("adding an mtproto client must not request an xray restart")
|
|
||||||
}
|
|
||||||
cfg := readMtgConfig(t, created.Id)
|
|
||||||
if !strings.Contains(cfg, `"second" = "`+mtprotoTestSecretB+`"`) {
|
|
||||||
t.Fatalf("new client must be in the served config:\n%s", cfg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(cfg, `"first" = "`+mtprotoTestSecretA+`"`) {
|
|
||||||
t.Fatalf("existing client must remain served:\n%s", cfg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("delete client drops it from the served config", func(t *testing.T) {
|
|
||||||
if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "second", false, true); err != nil {
|
|
||||||
t.Fatalf("DelInboundClientByEmail: %v", err)
|
|
||||||
}
|
|
||||||
cfg := readMtgConfig(t, created.Id)
|
|
||||||
if strings.Contains(cfg, mtprotoTestSecretB) {
|
|
||||||
t.Fatalf("deleted client must leave the served config:\n%s", cfg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(cfg, mtprotoTestSecretA) {
|
|
||||||
t.Fatalf("surviving client must stay served:\n%s", cfg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUpdateInboundLocalMtprotoDefersPushUntilCommit(t *testing.T) {
|
|
||||||
setupConflictDB(t)
|
|
||||||
|
|
||||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
|
||||||
fake := &fakeNodeRuntime{}
|
|
||||||
mgr.SetLocalRuntimeOverride(fake)
|
|
||||||
runtime.SetManager(mgr)
|
|
||||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
|
||||||
|
|
||||||
seedInboundConflict(t, "mt-txfail", "", 46150, model.MTProto, "",
|
|
||||||
`{"clients":[{"email":"mtx","secret":"`+mtprotoTestSecretA+`","enable":true}]}`)
|
|
||||||
seeded := loadInboundByTag(t, "mt-txfail")
|
|
||||||
seedClientTraffic(t, seeded.Id, "mtx", true)
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
const cbName = "b1-05:fail-inbound-update"
|
|
||||||
if err := db.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
|
|
||||||
if tx.Statement != nil && tx.Statement.Table == "inbounds" {
|
|
||||||
tx.AddError(errors.New("injected transaction failure"))
|
|
||||||
}
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("register callback: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = db.Callback().Update().Remove(cbName) })
|
|
||||||
|
|
||||||
update := *loadInboundByTag(t, "mt-txfail")
|
|
||||||
update.Remark = "edited"
|
|
||||||
if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil {
|
|
||||||
t.Fatal("UpdateInbound: expected the injected transaction failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := fake.updateInbound.Load(); n != 0 {
|
|
||||||
t.Fatalf("the MTProto sidecar push ran %d time(s) inside the failed transaction; it must be deferred until the commit succeeds", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetInboundEnableRoutedMtprotoRequestsRestart(t *testing.T) {
|
|
||||||
setupConflictDB(t)
|
|
||||||
|
|
||||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
|
||||||
mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
|
||||||
runtime.SetManager(mgr)
|
|
||||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
|
||||||
|
|
||||||
seedInboundConflict(t, "mt-route", "", 46160, model.MTProto, "",
|
|
||||||
`{"clients":[{"email":"mtr","secret":"`+mtprotoTestSecretA+`","enable":true}],"routeThroughXray":true,"routeXrayPort":12345}`)
|
|
||||||
seeded := loadInboundByTag(t, "mt-route")
|
|
||||||
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", seeded.Id).Update("enable", false).Error; err != nil {
|
|
||||||
t.Fatalf("force disable: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
needRestart, err := (&InboundService{}).SetInboundEnable(seeded.Id, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SetInboundEnable: %v", err)
|
|
||||||
}
|
|
||||||
if !needRestart {
|
|
||||||
t.Fatal("re-enabling a routed MTProto inbound must request an xray restart to re-inject the egress bridge")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -439,7 +439,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
nodeRow.Id = nodeID
|
nodeRow.Id = nodeID
|
||||||
unmanagedTag := unmanagedTagPredicate(&nodeRow)
|
unmanagedTag := unmanagedTagPredicate(&nodeRow)
|
||||||
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
|
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
|
||||||
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
|
|
||||||
originGuidFor := func(snapIb *model.Inbound) string {
|
originGuidFor := func(snapIb *model.Inbound) string {
|
||||||
if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid {
|
if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid {
|
||||||
return snapIb.OriginNodeGuid
|
return snapIb.OriginNodeGuid
|
||||||
@@ -1268,34 +1267,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if process := currentXrayProcess(); process != nil {
|
|
||||||
tree := snap.OnlineTree
|
|
||||||
switch {
|
|
||||||
case len(tree) == 0 && len(snap.OnlineEmails) > 0:
|
|
||||||
// Old-build node (no GUID tree): key its flat online list under its
|
|
||||||
// own effective identity so attribution still works for that branch.
|
|
||||||
tree = map[string][]string{selfKey: snap.OnlineEmails}
|
|
||||||
case guidShared && len(tree) > 0:
|
|
||||||
// Newer cloned node: its own clients arrive keyed under the shared
|
|
||||||
// panelGuid. Remap just that entry to the node-unique key so the
|
|
||||||
// clones don't merge; descendant subtrees keep their distinct GUIDs.
|
|
||||||
if _, ok := tree[nodeRow.Guid]; ok {
|
|
||||||
tree = remapGuidTreeKey(tree, nodeRow.Guid, selfKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.SetNodeOnlineTree(nodeID, tree)
|
|
||||||
|
|
||||||
activeTree := normalizeActiveInboundTreeTags(snap.ActiveInboundTree, tagToCentral)
|
|
||||||
if guidShared && len(activeTree) > 0 {
|
|
||||||
if _, ok := activeTree[nodeRow.Guid]; ok {
|
|
||||||
activeTree = remapGuidTreeKey(activeTree, nodeRow.Guid, selfKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(activeTree) > 0 {
|
|
||||||
activeTree = filterGuidTreeKeys(activeTree, activeInboundGuidKeys(snap.Inbounds, tagToCentral, originGuidFor))
|
|
||||||
}
|
|
||||||
process.SetNodeActiveInboundTree(nodeID, activeTree)
|
|
||||||
}
|
|
||||||
|
|
||||||
return structuralChange, nil
|
return structuralChange, nil
|
||||||
}
|
}
|
||||||
@@ -1323,11 +1294,9 @@ func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetOnlineClients() []string {
|
func (s *InboundService) GetOnlineClients() []string {
|
||||||
process := currentXrayProcess()
|
// Lite master: no local core aggregates online clients; node onlines
|
||||||
if process == nil {
|
// arrive through agent reports and node polling instead.
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
|
||||||
return process.GetOnlineClients()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
|
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
|
||||||
@@ -1336,17 +1305,7 @@ func (s *InboundService) GetOnlineClients() []string {
|
|||||||
// node-id keying so a client three hops down is attributed to its real node,
|
// node-id keying so a client three hops down is attributed to its real node,
|
||||||
// not the intermediate one it was synced through.
|
// not the intermediate one it was synced through.
|
||||||
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
||||||
process := currentXrayProcess()
|
return map[string][]string{}
|
||||||
if process == nil {
|
|
||||||
return map[string][]string{}
|
|
||||||
}
|
|
||||||
out := process.GetMergedNodeTrees()
|
|
||||||
if local := process.GetLocalOnlineClients(); len(local) > 0 {
|
|
||||||
if guid := s.panelGuid(); guid != "" {
|
|
||||||
out[guid] = mergeEmails(out[guid], local)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveInboundsByGuid returns the inbound tags that carried traffic within
|
// GetActiveInboundsByGuid returns the inbound tags that carried traffic within
|
||||||
@@ -1354,34 +1313,12 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
|||||||
// each inbound. A GUID missing from the map means "don't gate" for that node's
|
// each inbound. A GUID missing from the map means "don't gate" for that node's
|
||||||
// inbounds (old-build node or no active-inbound signal).
|
// inbounds (old-build node or no active-inbound signal).
|
||||||
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
||||||
process := currentXrayProcess()
|
return map[string][]string{}
|
||||||
if process == nil {
|
|
||||||
return map[string][]string{}
|
|
||||||
}
|
|
||||||
out := process.GetMergedActiveInboundTrees()
|
|
||||||
active := process.GetLocalActiveInbounds()
|
|
||||||
if len(active) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
guid := s.panelGuid()
|
|
||||||
if guid == "" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
out[guid] = mergeEmails(out[guid], active)
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
|
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {}
|
||||||
if process := currentXrayProcess(); process != nil {
|
|
||||||
process.SetNodeOnlineTree(nodeID, tree)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
|
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {}
|
||||||
if process := currentXrayProcess(); process != nil {
|
|
||||||
process.ClearNodeOnlineClients(nodeID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// panelGuid returns this panel's stable self-identifier, used to key the local
|
// panelGuid returns this panel's stable self-identifier, used to key the local
|
||||||
// panel's own clients in the per-node online maps (#4983).
|
// panel's own clients in the per-node online maps (#4983).
|
||||||
@@ -1518,11 +1455,7 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
|
|||||||
// grace window and pruning stale entries. Pass nil to only prune. See
|
// grace window and pruning stale entries. Pass nil to only prune. See
|
||||||
// xray.Process for why the local sets are kept separate from the shared
|
// xray.Process for why the local sets are kept separate from the shared
|
||||||
// last_online column.
|
// last_online column.
|
||||||
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
|
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {}
|
||||||
if process := currentXrayProcess(); process != nil {
|
|
||||||
process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
|
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
mtprotoTestSecretA = "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d"
|
|
||||||
mtprotoTestSecretB = "ee101112131415161718191a1b1c1d1e1f6578616d706c652e636f6d"
|
|
||||||
mtprotoTestSecretC = "ee202122232425262728292a2b2c2d2e2f6578616d706c652e636f6d"
|
|
||||||
mtprotoTestSecretD = "ee303132333435363738393a3b3c3d3e3f6578616d706c652e636f6d"
|
|
||||||
)
|
|
||||||
|
|
||||||
func seedClientTraffic(t *testing.T, inboundId int, email string, enable bool) {
|
|
||||||
t.Helper()
|
|
||||||
row := xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: enable}
|
|
||||||
if err := database.GetDB().Create(&row).Error; err != nil {
|
|
||||||
t.Fatalf("seed traffic %s: %v", email, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadInboundByTag(t *testing.T, tag string) *model.Inbound {
|
|
||||||
t.Helper()
|
|
||||||
var ib model.Inbound
|
|
||||||
if err := database.GetDB().Where("tag = ?", tag).First(&ib).Error; err != nil {
|
|
||||||
t.Fatalf("load inbound %s: %v", tag, err)
|
|
||||||
}
|
|
||||||
return &ib
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeMtgChildMain is what the re-executed test binary runs when posing as an
|
|
||||||
// mtg child process: it appends its pid to the file named by MTG_FAKE_PIDFILE
|
|
||||||
// so tests can count spawns, then blocks until the manager kills it.
|
|
||||||
func fakeMtgChildMain() {
|
|
||||||
if f, err := os.OpenFile(os.Getenv("MTG_FAKE_PIDFILE"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
|
|
||||||
fmt.Fprintf(f, "%d\n", os.Getpid())
|
|
||||||
f.Close()
|
|
||||||
}
|
|
||||||
select {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// installFakeMtg points the mtproto manager at a copy of the running test
|
|
||||||
// binary posing as mtg (via the MTG_FAKE_CHILD gate in TestMain) and returns
|
|
||||||
// the pid file whose line count equals the number of processes spawned so far.
|
|
||||||
func installFakeMtg(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
binDir := t.TempDir()
|
|
||||||
self, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("locate test binary: %v", err)
|
|
||||||
}
|
|
||||||
payload, err := os.ReadFile(self)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read test binary: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(binDir, mtproto.GetBinaryName()), payload, 0o755); err != nil {
|
|
||||||
t.Fatalf("install fake mtg: %v", err)
|
|
||||||
}
|
|
||||||
pidFile := filepath.Join(binDir, "mtg-pids.txt")
|
|
||||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
|
||||||
t.Setenv("MTG_FAKE_CHILD", "1")
|
|
||||||
t.Setenv("MTG_FAKE_PIDFILE", pidFile)
|
|
||||||
return pidFile
|
|
||||||
}
|
|
||||||
|
|
||||||
func countSpawns(t *testing.T, pidFile string) int {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(pidFile)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read pid file: %v", err)
|
|
||||||
}
|
|
||||||
return len(strings.Fields(string(data)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForSpawns polls until exactly want processes have registered, failing
|
|
||||||
// fast when the count overshoots and on timeout.
|
|
||||||
func waitForSpawns(t *testing.T, pidFile string, want int) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
for {
|
|
||||||
got := countSpawns(t, pidFile)
|
|
||||||
if got == want {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got > want {
|
|
||||||
t.Fatalf("expected %d mtg spawn(s), got %d", want, got)
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("expected %d mtg spawn(s), still %d after timeout", want, got)
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertNoNewSpawns gives a wrongly spawned child time to register, then
|
|
||||||
// asserts the spawn count is still exactly want.
|
|
||||||
func assertNoNewSpawns(t *testing.T, pidFile string, want int) {
|
|
||||||
t.Helper()
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
if got := countSpawns(t, pidFile); got != want {
|
|
||||||
t.Fatalf("expected the mtg process to be kept (%d spawn(s)), got %d", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared helpers for the surviving MTProto/AmneziaWG pure-logic tests.
|
||||||
|
// They used to live in mtproto_fake_test.go alongside the fake mtg child
|
||||||
|
// machinery; the fake went away with the local-core runtime tests in
|
||||||
|
// P9(4b), these seeders are still used by other tests.
|
||||||
|
const (
|
||||||
|
mtprotoTestSecretA = "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d"
|
||||||
|
mtprotoTestSecretB = "ee101112131415161718191a1b1c1d1e1f6578616d706c652e636f6d"
|
||||||
|
mtprotoTestSecretC = "ee202122232425262728292a2b2c2d2e2f6578616d706c652e636f6d"
|
||||||
|
mtprotoTestSecretD = "ee303132333435363738393a3b3c3d3e3f6578616d706c652e636f6d"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedClientTraffic(t *testing.T, inboundId int, email string, enable bool) {
|
||||||
|
t.Helper()
|
||||||
|
row := xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: enable}
|
||||||
|
if err := database.GetDB().Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("seed traffic %s: %v", email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadInboundByTag(t *testing.T, tag string) *model.Inbound {
|
||||||
|
t.Helper()
|
||||||
|
var ib model.Inbound
|
||||||
|
if err := database.GetDB().Where("tag = ?", tag).First(&ib).Error; err != nil {
|
||||||
|
t.Fatalf("load inbound %s: %v", tag, err)
|
||||||
|
}
|
||||||
|
return &ib
|
||||||
|
}
|
||||||
@@ -22,10 +22,8 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -1109,103 +1107,12 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
|
|||||||
return patch, err
|
return patch, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// withOutboundBridge stands up a temporary loopback SOCKS5 inbound in the
|
// withOutboundBridge used to stand up a temporary loopback SOCKS5 inbound
|
||||||
// running Xray, routes it through outboundTag, and runs fn with the bridge's
|
// in the panel's own running xray to reach a node through its connection
|
||||||
// proxy URL before tearing it down. It is used to reach a node through its
|
// outbound. The lite master runs no local core, so probes always take the
|
||||||
// connection outbound before the persistent egress bridge has been injected
|
// direct-connection fallback this function used when xray was down.
|
||||||
// into the config (e.g. while the node is still being added or edited). When
|
|
||||||
// Xray isn't running or the bridge can't be built, fn runs with an empty
|
|
||||||
// proxyURL so callers fall back to a direct connection.
|
|
||||||
func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) {
|
func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) {
|
||||||
proc := XrayProcess()
|
fn("")
|
||||||
if proc == nil || !proc.IsRunning() {
|
|
||||||
fn("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
apiPort := proc.GetAPIPort()
|
|
||||||
if apiPort <= 0 {
|
|
||||||
fn("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
fn("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
port := listener.Addr().(*net.TCPAddr).Port
|
|
||||||
listener.Close()
|
|
||||||
|
|
||||||
tag := fmt.Sprintf("node-test-%d-%d", nodeID, time.Now().UnixNano())
|
|
||||||
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port)
|
|
||||||
|
|
||||||
inboundJSON, err := json.Marshal(xray.InboundConfig{
|
|
||||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
|
||||||
Port: port,
|
|
||||||
Protocol: "socks",
|
|
||||||
Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
|
|
||||||
Tag: tag,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
fn("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := proc.GetConfig()
|
|
||||||
routing := map[string]any{}
|
|
||||||
if len(cfg.RouterConfig) > 0 {
|
|
||||||
_ = json.Unmarshal(cfg.RouterConfig, &routing)
|
|
||||||
}
|
|
||||||
rules, _ := routing["rules"].([]any)
|
|
||||||
rule := map[string]any{
|
|
||||||
"type": "field",
|
|
||||||
"inboundTag": []any{tag},
|
|
||||||
}
|
|
||||||
if routingTagIsBalancer(routing, outboundTag) {
|
|
||||||
rule["balancerTag"] = outboundTag
|
|
||||||
} else {
|
|
||||||
rule["outboundTag"] = outboundTag
|
|
||||||
}
|
|
||||||
routing["rules"] = append([]any{rule}, rules...)
|
|
||||||
routingJSON, err := json.Marshal(routing)
|
|
||||||
if err != nil {
|
|
||||||
fn("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
originalRoutingJSON := cfg.RouterConfig
|
|
||||||
|
|
||||||
api := xray.XrayAPI{}
|
|
||||||
if err := api.Init(apiPort); err != nil {
|
|
||||||
fn("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer api.Close()
|
|
||||||
|
|
||||||
if err := api.AddInbound(inboundJSON); err != nil {
|
|
||||||
fn("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := api.DelInbound(tag); err != nil {
|
|
||||||
logger.Warning("remove temp node bridge inbound failed:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := api.ApplyRoutingConfig(routingJSON); err != nil {
|
|
||||||
fn("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
restore := originalRoutingJSON
|
|
||||||
if len(restore) == 0 {
|
|
||||||
restore = []byte("{}")
|
|
||||||
}
|
|
||||||
if err := api.ApplyRoutingConfig(restore); err != nil {
|
|
||||||
logger.Warning("restore routing after node bridge failed:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
fn(proxyURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
|
func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func (f *fakeNodeRuntime) ResetAllTraffics(context.Context) error
|
|||||||
func setupNodeRuntime(t *testing.T) (int, *fakeNodeRuntime) {
|
func setupNodeRuntime(t *testing.T) (int, *fakeNodeRuntime) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
prev := runtime.GetManager()
|
prev := runtime.GetManager()
|
||||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
|
mgr := runtime.NewManager()
|
||||||
runtime.SetManager(mgr)
|
runtime.SetManager(mgr)
|
||||||
t.Cleanup(func() { runtime.SetManager(prev) })
|
t.Cleanup(func() { runtime.SetManager(prev) })
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func assertStringSet(t *testing.T, label string, got, want []string) {
|
func assertStringSet(t *testing.T, label string, got, want []string) {
|
||||||
@@ -144,173 +143,8 @@ func TestSetRemoteTraffic_RemapsClonedNodeOwnGuidOrigin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetRemoteTraffic_RemapsActiveInboundTreeAndCentralTags(t *testing.T) {
|
|
||||||
setupConflictDB(t)
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
previousProcess, previousResult := xrayState.snapshot()
|
|
||||||
process := xray.NewTestProcess(nil, "")
|
|
||||||
xrayState.replace(process)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
xrayState.mu.Lock()
|
|
||||||
xrayState.process = previousProcess
|
|
||||||
xrayState.result = previousResult
|
|
||||||
xrayState.mu.Unlock()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Force the remote inbound to be adopted with an n1- prefix on the master:
|
|
||||||
// the active-inbound tree still arrives with the node-local tag.
|
|
||||||
if err := db.Create(&model.Inbound{
|
|
||||||
Tag: "shared-tag", Enable: true, Port: 1000, Protocol: model.VLESS, Settings: `{"clients":[]}`,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create local conflicting inbound: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two cloned nodes share the same panelGuid, so the node's own active tags
|
|
||||||
// must be keyed by node:1 instead of the duplicated GUID.
|
|
||||||
for _, n := range []*model.Node{
|
|
||||||
{Id: 1, Name: "a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup"},
|
|
||||||
{Id: 2, Name: "b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup"},
|
|
||||||
} {
|
|
||||||
if err := db.Create(n).Error; err != nil {
|
|
||||||
t.Fatalf("create node %s: %v", n.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
snap := &runtime.TrafficSnapshot{
|
|
||||||
Inbounds: []*model.Inbound{{
|
|
||||||
Tag: "shared-tag",
|
|
||||||
Enable: true,
|
|
||||||
Port: 8443,
|
|
||||||
Protocol: model.VLESS,
|
|
||||||
Settings: `{"clients":[]}`,
|
|
||||||
}},
|
|
||||||
ActiveInboundTree: map[string][]string{
|
|
||||||
"dup": {"shared-tag"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := InboundService{}
|
|
||||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := process.GetMergedActiveInboundTrees()
|
|
||||||
assertStringSet(t, "active node:1", merged["node:1"], []string{"n1-shared-tag"})
|
|
||||||
if _, ok := merged["dup"]; ok {
|
|
||||||
t.Fatalf("cloned active-inbound subtree must not stay under shared GUID: %v", merged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetRemoteTraffic_NormalizesForwardedActiveInboundSubtreeTags(t *testing.T) {
|
|
||||||
setupConflictDB(t)
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
previousProcess, previousResult := xrayState.snapshot()
|
|
||||||
process := xray.NewTestProcess(nil, "")
|
|
||||||
xrayState.replace(process)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
xrayState.mu.Lock()
|
|
||||||
xrayState.process = previousProcess
|
|
||||||
xrayState.result = previousResult
|
|
||||||
xrayState.mu.Unlock()
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, tag := range []string{"own-tag", "child-tag"} {
|
|
||||||
if err := db.Create(&model.Inbound{
|
|
||||||
Tag: tag, Enable: true, Port: 1000, Protocol: model.VLESS, Settings: `{"clients":[]}`,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create local conflicting inbound %q: %v", tag, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := db.Create(&model.Node{
|
|
||||||
Id: 1, Name: "node2", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node2-guid",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create node: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
snap := &runtime.TrafficSnapshot{
|
|
||||||
Inbounds: []*model.Inbound{
|
|
||||||
{
|
|
||||||
Tag: "own-tag",
|
|
||||||
Enable: true,
|
|
||||||
Port: 8443,
|
|
||||||
Protocol: model.VLESS,
|
|
||||||
Settings: `{"clients":[]}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Tag: "child-tag",
|
|
||||||
Enable: true,
|
|
||||||
Port: 9443,
|
|
||||||
Protocol: model.VLESS,
|
|
||||||
Settings: `{"clients":[]}`,
|
|
||||||
OriginNodeGuid: "child-guid",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ActiveInboundTree: map[string][]string{
|
|
||||||
"node2-guid": {"own-tag"},
|
|
||||||
"child-guid": {"child-tag"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := InboundService{}
|
|
||||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := process.GetMergedActiveInboundTrees()
|
|
||||||
assertStringSet(t, "direct node active tags", merged["node2-guid"], []string{"n1-own-tag"})
|
|
||||||
assertStringSet(t, "forwarded child active tags", merged["child-guid"], []string{"n1-child-tag"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetRemoteTraffic_DropsForeignActiveInboundGuid(t *testing.T) {
|
|
||||||
setupConflictDB(t)
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
previousProcess, previousResult := xrayState.snapshot()
|
|
||||||
process := xray.NewTestProcess(nil, "")
|
|
||||||
xrayState.replace(process)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
xrayState.mu.Lock()
|
|
||||||
xrayState.process = previousProcess
|
|
||||||
xrayState.result = previousResult
|
|
||||||
xrayState.mu.Unlock()
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, n := range []*model.Node{
|
|
||||||
{Id: 1, Name: "node-a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "node-a-guid"},
|
|
||||||
{Id: 2, Name: "node-b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node-b-guid"},
|
|
||||||
} {
|
|
||||||
if err := db.Create(n).Error; err != nil {
|
|
||||||
t.Fatalf("create node %s: %v", n.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
snap := &runtime.TrafficSnapshot{
|
|
||||||
Inbounds: []*model.Inbound{{
|
|
||||||
Tag: "own-tag",
|
|
||||||
Enable: true,
|
|
||||||
Port: 8443,
|
|
||||||
Protocol: model.VLESS,
|
|
||||||
Settings: `{"clients":[]}`,
|
|
||||||
}},
|
|
||||||
ActiveInboundTree: map[string][]string{
|
|
||||||
"node-a-guid": {"own-tag"},
|
|
||||||
"node-b-guid": {"foreign-tag"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := InboundService{}
|
|
||||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := process.GetMergedActiveInboundTrees()
|
|
||||||
assertStringSet(t, "own active tags", merged["node-a-guid"], []string{"own-tag"})
|
|
||||||
if _, ok := merged["node-b-guid"]; ok {
|
|
||||||
t.Fatalf("foreign active-inbound subtree should be ignored: %v", merged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A node mid-restart can return an empty inbound list with success=true. The
|
// A node mid-restart can return an empty inbound list with success=true. The
|
||||||
// sync must NOT treat that as "delete all my inbounds" — otherwise a blip wipes
|
// sync must NOT treat that as "delete all my inbounds" — otherwise a blip wipes
|
||||||
|
|||||||
@@ -29,10 +29,9 @@ import (
|
|||||||
// core predates that rejection, unencrypted outbounds are kept, mirroring
|
// core predates that rejection, unencrypted outbounds are kept, mirroring
|
||||||
// CheckXrayConfig's version gate.
|
// CheckXrayConfig's version gate.
|
||||||
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
|
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
|
||||||
|
// Lite master: no local core, so the version gate always takes the
|
||||||
|
// "Unknown" branch — the same outcome as upstream with xray stopped.
|
||||||
coreVersion := "Unknown"
|
coreVersion := "Unknown"
|
||||||
if process := currentXrayProcess(); process != nil {
|
|
||||||
coreVersion = process.GetXrayVersion()
|
|
||||||
}
|
|
||||||
kept := make([]any, 0, len(outbounds))
|
kept := make([]any, 0, len(outbounds))
|
||||||
var dropped []string
|
var dropped []string
|
||||||
for _, ob := range outbounds {
|
for _, ob := range outbounds {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestResetAllTrafficsDoesNotBlockWriterOnNodeCall(t *testing.T) {
|
|||||||
resetTrafficWriterForTest(t)
|
resetTrafficWriterForTest(t)
|
||||||
StartTrafficWriter()
|
StartTrafficWriter()
|
||||||
|
|
||||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
mgr := runtime.NewManager()
|
||||||
runtime.SetManager(mgr)
|
runtime.SetManager(mgr)
|
||||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||||
|
|
||||||
|
|||||||
@@ -540,20 +540,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
|||||||
status.PublicIP.IPv4 = s.cachedIPv4
|
status.PublicIP.IPv4 = s.cachedIPv4
|
||||||
status.PublicIP.IPv6 = s.cachedIPv6
|
status.PublicIP.IPv6 = s.cachedIPv6
|
||||||
|
|
||||||
// Xray status
|
// Xray status: the lite master runs no local core, so it always
|
||||||
if s.xrayService.IsXrayRunning() {
|
// reports stopped with no version.
|
||||||
status.Xray.State = Running
|
status.Xray.State = Stop
|
||||||
status.Xray.ErrorMsg = ""
|
status.Xray.ErrorMsg = ""
|
||||||
} else {
|
status.Xray.Version = ""
|
||||||
err := s.xrayService.GetXrayErr()
|
|
||||||
if err != nil {
|
|
||||||
status.Xray.State = Error
|
|
||||||
} else {
|
|
||||||
status.Xray.State = Stop
|
|
||||||
}
|
|
||||||
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
|
|
||||||
}
|
|
||||||
status.Xray.Version = s.xrayService.GetXrayVersion()
|
|
||||||
|
|
||||||
var amneziawgCount int64
|
var amneziawgCount int64
|
||||||
if err := database.GetDB().Model(model.Inbound{}).
|
if err := database.GetDB().Model(model.Inbound{}).
|
||||||
@@ -578,11 +569,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
|||||||
status.AppStats.Mem = rtm.Sys
|
status.AppStats.Mem = rtm.Sys
|
||||||
}
|
}
|
||||||
status.AppStats.Threads = uint32(runtime.NumGoroutine())
|
status.AppStats.Threads = uint32(runtime.NumGoroutine())
|
||||||
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
status.AppStats.Uptime = 0
|
||||||
status.AppStats.Uptime = process.GetUptime()
|
|
||||||
} else {
|
|
||||||
status.AppStats.Uptime = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
@@ -672,21 +659,15 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
|
|||||||
return s.emaCPU, nil
|
return s.emaCPU, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StopXrayService is a no-op on the lite master: there is no local core to
|
||||||
|
// stop. It stays so the DB import/restore flows keep their shape.
|
||||||
func (s *ServerService) StopXrayService() error {
|
func (s *ServerService) StopXrayService() error {
|
||||||
err := s.xrayService.StopXray()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("stop xray failed:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestartXrayService is a no-op on the lite master: there is no local core
|
||||||
|
// to restart. Agent nodes pick up changes on their next config pull.
|
||||||
func (s *ServerService) RestartXrayService() error {
|
func (s *ServerService) RestartXrayService() error {
|
||||||
err := s.xrayService.RestartXray(true)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("start xray failed:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,19 +699,17 @@ func isVirtualInterface(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfigJson returns the stored xray template as JSON. The lite master
|
||||||
|
// builds no local config (there is no local core to run one), so this is
|
||||||
|
// the template itself — the same document the settings page edits.
|
||||||
func (s *ServerService) GetConfigJson() (any, error) {
|
func (s *ServerService) GetConfigJson() (any, error) {
|
||||||
config, err := s.xrayService.GetXrayConfig()
|
template, err := s.settingService.GetXrayConfigTemplate()
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
contents, err := json.MarshalIndent(config, "", " ")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsonData any
|
var jsonData any
|
||||||
err = json.Unmarshal(contents, &jsonData)
|
if err := json.Unmarshal([]byte(template), &jsonData); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -393,45 +393,14 @@ func (s *SettingService) SetPanelOutbound(tag string) error {
|
|||||||
// It returns "" — meaning a direct connection — when the feature is off or
|
// It returns "" — meaning a direct connection — when the feature is off or
|
||||||
// the bridge is not present in the running core yet.
|
// the bridge is not present in the running core yet.
|
||||||
func (s *SettingService) PanelEgressProxyURL() string {
|
func (s *SettingService) PanelEgressProxyURL() string {
|
||||||
tag, err := s.GetPanelOutbound()
|
// The panel-egress bridge lived in the panel's own xray core. The lite
|
||||||
if err != nil || tag == "" {
|
// master runs no local core, so there is never a bridge: direct.
|
||||||
return ""
|
|
||||||
}
|
|
||||||
proc := XrayProcess()
|
|
||||||
if proc == nil || !proc.IsRunning() {
|
|
||||||
logger.Warning("panel outbound [", tag, "] is set but Xray is not running, using a direct connection")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
cfg := proc.GetConfig()
|
|
||||||
if cfg == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for i := range cfg.InboundConfigs {
|
|
||||||
if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
|
|
||||||
return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.Warning("panel outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) NodeEgressProxyURL(nodeID int) string {
|
func (s *SettingService) NodeEgressProxyURL(nodeID int) string {
|
||||||
tag := NodeEgressInboundTag(nodeID)
|
// The per-node egress bridge also lived in the panel's own xray core;
|
||||||
proc := XrayProcess()
|
// with no local core there is never a bridge: direct connection.
|
||||||
if proc == nil || !proc.IsRunning() {
|
|
||||||
logger.Warning("node outbound [", tag, "] is set but Xray is not running, using a direct connection")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
cfg := proc.GetConfig()
|
|
||||||
if cfg == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for i := range cfg.InboundConfigs {
|
|
||||||
if cfg.InboundConfigs[i].Tag == tag {
|
|
||||||
return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.Warning("node outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -492,14 +491,10 @@ func (t *Tgbot) clientInfoMsg(
|
|||||||
active = t.I18nBot("tgbot.messages.no")
|
active = t.I18nBot("tgbot.messages.no")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lite master: per-client online state comes from agent reports, not
|
||||||
|
// a local core; the bot reports the client as offline here.
|
||||||
status := t.I18nBot("tgbot.offline")
|
status := t.I18nBot("tgbot.offline")
|
||||||
isOnline := false
|
isOnline := false
|
||||||
if process := service.XrayProcess(); process != nil && process.IsRunning() {
|
|
||||||
if slices.Contains(process.GetOnlineClients(), traffic.Email) {
|
|
||||||
status = t.I18nBot("tgbot.online")
|
|
||||||
isOnline = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output := ""
|
output := ""
|
||||||
output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email)
|
output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
@@ -110,10 +109,7 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
|||||||
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
||||||
t.setCachedStatus(t.lastStatus)
|
t.setCachedStatus(t.lastStatus)
|
||||||
}
|
}
|
||||||
var onlines []string
|
onlines := t.inboundService.GetOnlineClients()
|
||||||
if process := service.XrayProcess(); process != nil {
|
|
||||||
onlines = process.GetOnlineClients()
|
|
||||||
}
|
|
||||||
|
|
||||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
|
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
|
||||||
@@ -369,12 +365,7 @@ func (t *Tgbot) notifyExhausted() {
|
|||||||
|
|
||||||
// onlineClients retrieves and sends information about online clients.
|
// onlineClients retrieves and sends information about online clients.
|
||||||
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||||
process := service.XrayProcess()
|
onlines := t.inboundService.GetOnlineClients()
|
||||||
if process == nil || !process.IsRunning() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onlines := process.GetOnlineClients()
|
|
||||||
onlinesCount := len(onlines)
|
onlinesCount := len(onlines)
|
||||||
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
|
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
|
||||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ func (t *Tgbot) OnReceive() {
|
|||||||
userID := sharedUser.UserID
|
userID := sharedUser.UserID
|
||||||
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, message.UsersShared.RequestID, userID)
|
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, message.UsersShared.RequestID, userID)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
t.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
output := ""
|
output := ""
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,16 +219,8 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
|
|||||||
onlyMessage = true
|
onlyMessage = true
|
||||||
if isAdmin {
|
if isAdmin {
|
||||||
if len(commandArgs) == 0 {
|
if len(commandArgs) == 0 {
|
||||||
if t.xrayService.IsXrayRunning() {
|
// Lite master: there is no local xray core to restart.
|
||||||
err := t.xrayService.RestartXray(true)
|
msg += t.I18nBot("tgbot.commands.xrayNotRunning")
|
||||||
if err != nil {
|
|
||||||
msg += t.I18nBot("tgbot.commands.restartFailed", "Error=="+err.Error())
|
|
||||||
} else {
|
|
||||||
msg += t.I18nBot("tgbot.commands.restartSuccess")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msg += t.I18nBot("tgbot.commands.xrayNotRunning")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
handleUnknownCommand()
|
handleUnknownCommand()
|
||||||
msg += t.I18nBot("tgbot.commands.restartUsage")
|
msg += t.I18nBot("tgbot.commands.restartUsage")
|
||||||
@@ -420,7 +411,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
needRestart, err := t.clientService.ResetClientTrafficLimitByEmail(&t.inboundService, email, limitTraffic)
|
needRestart, err := t.clientService.ResetClientTrafficLimitByEmail(&t.inboundService, email, limitTraffic)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
t.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.setTrafficLimitSuccess", "Email=="+email))
|
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.setTrafficLimitSuccess", "Email=="+email))
|
||||||
@@ -602,7 +592,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||||||
}
|
}
|
||||||
needRestart, err := t.clientService.ResetClientExpiryTimeByEmail(&t.inboundService, email, date)
|
needRestart, err := t.clientService.ResetClientExpiryTimeByEmail(&t.inboundService, email, date)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
t.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.expireResetSuccess", "Email=="+email))
|
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.expireResetSuccess", "Email=="+email))
|
||||||
@@ -772,7 +761,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
needRestart, err := t.clientService.ResetClientIpLimitByEmail(&t.inboundService, email, count)
|
needRestart, err := t.clientService.ResetClientIpLimitByEmail(&t.inboundService, email, count)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
t.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetIpSuccess", "Email=="+email, "Count=="+strconv.Itoa(count)))
|
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetIpSuccess", "Email=="+email, "Count=="+strconv.Itoa(count)))
|
||||||
@@ -940,7 +928,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||||||
}
|
}
|
||||||
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, traffic.Id, EmptyTelegramUserID)
|
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, traffic.Id, EmptyTelegramUserID)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
t.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.removedTGUserSuccess", "Email=="+email))
|
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.removedTGUserSuccess", "Email=="+email))
|
||||||
@@ -961,7 +948,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||||||
case "toggle_enable_c":
|
case "toggle_enable_c":
|
||||||
enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email)
|
enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
t.xrayService.SetToNeedRestart()
|
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestTrafficDisableImmediatelyUpdatesNodeRuntime(t *testing.T) {
|
|||||||
|
|
||||||
func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
|
func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
mgr := runtime.NewManager()
|
||||||
fake := &fakeNodeRuntime{}
|
fake := &fakeNodeRuntime{}
|
||||||
mgr.SetLocalRuntimeOverride(fake)
|
mgr.SetLocalRuntimeOverride(fake)
|
||||||
runtime.SetManager(mgr)
|
runtime.SetManager(mgr)
|
||||||
@@ -62,7 +62,7 @@ func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
|
|||||||
|
|
||||||
func TestDelDepletedClientsCleansRuntimeAfterCommit(t *testing.T) {
|
func TestDelDepletedClientsCleansRuntimeAfterCommit(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
mgr := runtime.NewManager()
|
||||||
fake := &fakeNodeRuntime{}
|
fake := &fakeNodeRuntime{}
|
||||||
mgr.SetLocalRuntimeOverride(fake)
|
mgr.SetLocalRuntimeOverride(fake)
|
||||||
runtime.SetManager(mgr)
|
runtime.SetManager(mgr)
|
||||||
|
|||||||
+20
-1188
File diff suppressed because it is too large
Load Diff
@@ -4,20 +4,41 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// clientsTestNodeID returns the shared agent node these client-emission
|
||||||
|
// tests home their inbounds on, creating it on first use. The only config
|
||||||
|
// build left in the lite master is the per-agent one.
|
||||||
|
func clientsTestNodeID(t *testing.T, db *gorm.DB) int {
|
||||||
|
t.Helper()
|
||||||
|
var node model.Node
|
||||||
|
if err := db.Where("name = ?", "clients-test-node").First(&node).Error; err == nil {
|
||||||
|
return node.Id
|
||||||
|
}
|
||||||
|
node = model.Node{Name: "clients-test-node", Address: "10.0.0.8", Port: 2053, ApiToken: "t"}
|
||||||
|
if err := db.Create(&node).Error; err != nil {
|
||||||
|
t.Fatalf("create clients-test node: %v", err)
|
||||||
|
}
|
||||||
|
return node.Id
|
||||||
|
}
|
||||||
|
|
||||||
func seedVlessInbound(t *testing.T, tag string, port int, clients []model.Client) {
|
func seedVlessInbound(t *testing.T, tag string, port int, clients []model.Client) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
setupSettingTestDB(t)
|
setupSettingTestDB(t)
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
nodeID := clientsTestNodeID(t, db)
|
||||||
in := &model.Inbound{
|
in := &model.Inbound{
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Enable: true,
|
Enable: true,
|
||||||
Port: port,
|
Port: port,
|
||||||
Protocol: model.VLESS,
|
Protocol: model.VLESS,
|
||||||
Settings: `{"clients":[],"decryption":"none"}`,
|
Settings: `{"clients":[],"decryption":"none"}`,
|
||||||
|
NodeID: &nodeID,
|
||||||
}
|
}
|
||||||
if err := db.Create(in).Error; err != nil {
|
if err := db.Create(in).Error; err != nil {
|
||||||
t.Fatalf("create vless inbound: %v", err)
|
t.Fatalf("create vless inbound: %v", err)
|
||||||
@@ -40,9 +61,9 @@ func disableClients(t *testing.T, emails ...string) {
|
|||||||
func emittedClients(t *testing.T, tag string) (any, bool) {
|
func emittedClients(t *testing.T, tag string) (any, bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
svc := &XrayService{}
|
svc := &XrayService{}
|
||||||
cfg, err := svc.GetXrayConfig()
|
cfg, err := svc.GetXrayConfigForAgent(clientsTestNodeID(t, database.GetDB()), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetXrayConfig: %v", err)
|
t.Fatalf("GetXrayConfigForAgent: %v", err)
|
||||||
}
|
}
|
||||||
for i := range cfg.InboundConfigs {
|
for i := range cfg.InboundConfigs {
|
||||||
if cfg.InboundConfigs[i].Tag != tag {
|
if cfg.InboundConfigs[i].Tag != tag {
|
||||||
|
|||||||
@@ -3,12 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
xuilogger "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"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
@@ -17,13 +13,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// A test binary re-executed with MTG_FAKE_CHILD=1 poses as an mtg child
|
// The package logger must exist before any test exercises a path that
|
||||||
// process (see mtproto_fake_test.go) and never reaches the test runner.
|
// logs a skip/warning.
|
||||||
if os.Getenv("MTG_FAKE_CHILD") == "1" {
|
|
||||||
fakeMtgChildMain()
|
|
||||||
}
|
|
||||||
// injectPanelEgress logs when it skips injection; the package logger must
|
|
||||||
// exist before any test exercises a skipped path.
|
|
||||||
xuilogger.InitLogger(logging.ERROR)
|
xuilogger.InitLogger(logging.ERROR)
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
@@ -146,870 +137,3 @@ type egressRouting struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
} `json:"rules"`
|
} `json:"rules"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInjectPanelEgress(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
injectPanelEgress(cfg, "warp")
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("expected the egress inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
|
|
||||||
}
|
|
||||||
ib := cfg.InboundConfigs[1]
|
|
||||||
if ib.Tag != PanelEgressInboundTag || ib.Protocol != "socks" || ib.Port != panelEgressBasePort {
|
|
||||||
t.Fatalf("unexpected egress inbound: %+v", ib)
|
|
||||||
}
|
|
||||||
if string(ib.Listen) != `"127.0.0.1"` {
|
|
||||||
t.Fatalf("egress inbound must listen on loopback, got %s", ib.Listen)
|
|
||||||
}
|
|
||||||
|
|
||||||
var routing egressRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if routing.DomainStrategy != "AsIs" {
|
|
||||||
t.Fatalf("routing keys outside rules must be preserved, got %+v", routing)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 2 {
|
|
||||||
t.Fatalf("expected egress rule + existing rule, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
first := routing.Rules[0]
|
|
||||||
if first.Type != "field" || first.OutboundTag != "warp" ||
|
|
||||||
len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
|
|
||||||
t.Fatalf("egress rule must be prepended, got %+v", first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectPanelEgress_BalancerTag(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
|
||||||
|
|
||||||
// A tag that names a balancer must be targeted via balancerTag so the
|
|
||||||
// router resolves it; an outbound tag coexisting with balancers still uses
|
|
||||||
// outboundTag.
|
|
||||||
injectPanelEgress(cfg, "lb")
|
|
||||||
|
|
||||||
var routing struct {
|
|
||||||
Rules []struct {
|
|
||||||
InboundTag []string `json:"inboundTag"`
|
|
||||||
OutboundTag string `json:"outboundTag"`
|
|
||||||
BalancerTag string `json:"balancerTag"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
} `json:"rules"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 1 {
|
|
||||||
t.Fatalf("expected the egress rule, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
first := routing.Rules[0]
|
|
||||||
if first.BalancerTag != "lb" || first.OutboundTag != "" {
|
|
||||||
t.Fatalf("a balancer tag must target balancerTag, not outboundTag, got %+v", first)
|
|
||||||
}
|
|
||||||
if len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
|
|
||||||
t.Fatalf("egress rule must bind the egress inbound, got %+v", first)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A non-balancer tag alongside balancers keeps the plain outbound path.
|
|
||||||
cfg2 := egressTestConfig()
|
|
||||||
cfg2.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
|
||||||
injectPanelEgress(cfg2, "warp")
|
|
||||||
var routing2 struct {
|
|
||||||
Rules []struct {
|
|
||||||
OutboundTag string `json:"outboundTag"`
|
|
||||||
BalancerTag string `json:"balancerTag"`
|
|
||||||
} `json:"rules"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(cfg2.RouterConfig, &routing2); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if routing2.Rules[0].OutboundTag != "warp" || routing2.Rules[0].BalancerTag != "" {
|
|
||||||
t.Fatalf("a concrete outbound must target outboundTag, got %+v", routing2.Rules[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectPanelEgress_PortCollision(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
|
||||||
xray.InboundConfig{Port: panelEgressBasePort, Protocol: "vless", Tag: "in-1"},
|
|
||||||
xray.InboundConfig{Port: panelEgressBasePort + 1, Protocol: "vless", Tag: "in-2"},
|
|
||||||
)
|
|
||||||
injectPanelEgress(cfg, "direct")
|
|
||||||
got := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
|
|
||||||
if got.Tag != PanelEgressInboundTag || got.Port != panelEgressBasePort+2 {
|
|
||||||
t.Fatalf("egress inbound must skip taken ports, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectPanelEgress_TagCollisionSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
|
||||||
xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: PanelEgressInboundTag},
|
|
||||||
)
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectPanelEgress(cfg, "direct")
|
|
||||||
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatal("a user inbound owning the egress tag must make injection a no-op")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectPanelEgress_NoRoutingSection(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = nil
|
|
||||||
injectPanelEgress(cfg, "direct")
|
|
||||||
|
|
||||||
var routing egressRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 1 || routing.Rules[0].OutboundTag != "direct" {
|
|
||||||
t.Fatalf("a routing section must be created with the egress rule, got %+v", routing)
|
|
||||||
}
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatal("egress inbound must still be appended")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
|
||||||
injectPanelEgress(cfg, "direct")
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatal("unparsable routing must skip the whole injection, inbound included")
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != `{not json` {
|
|
||||||
t.Fatal("unparsable routing must be left untouched")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectPanelEgress_MissingTargetSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectPanelEgress(cfg, "removed-subscription-outbound")
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("a missing target must not expose the panel bridge, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("a missing target must leave routing untouched, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectPanelEgress_BadOutboundsSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectPanelEgress(cfg, "direct")
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("unparsable outbounds must not expose the panel bridge, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectNodeEgresses_MissingTargetSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
injectNodeEgresses(cfg, []*model.Node{
|
|
||||||
{Id: 1, Enable: true, OutboundTag: "removed-subscription-outbound"},
|
|
||||||
{Id: 2, Enable: true, OutboundTag: "warp"},
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("only the node with a valid target should get a bridge, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
bridge := cfg.InboundConfigs[1]
|
|
||||||
if bridge.Tag != NodeEgressInboundTag(2) || bridge.Port != nodeEgressBasePort+2 {
|
|
||||||
t.Fatalf("unexpected node egress bridge: %+v", bridge)
|
|
||||||
}
|
|
||||||
|
|
||||||
var routing egressRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "warp" ||
|
|
||||||
len(routing.Rules[0].InboundTag) != 1 || routing.Rules[0].InboundTag[0] != NodeEgressInboundTag(2) {
|
|
||||||
t.Fatalf("only the valid node egress rule should be prepended, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectNodeEgresses_BadOutboundsSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("unparsable outbounds must not expose a node bridge, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectNodeEgresses_BalancerTarget(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
|
||||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "lb"}})
|
|
||||||
|
|
||||||
var routing struct {
|
|
||||||
Rules []struct {
|
|
||||||
OutboundTag string `json:"outboundTag"`
|
|
||||||
BalancerTag string `json:"balancerTag"`
|
|
||||||
} `json:"rules"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 ||
|
|
||||||
routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
|
|
||||||
t.Fatalf("a valid balancer target must create the node bridge and rule, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectNodeEgresses_TagCollisionSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
|
||||||
xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: NodeEgressInboundTag(1)},
|
|
||||||
)
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatal("an existing node egress tag must make that node injection a no-op")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectNodeEgresses_PortCollision(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
|
||||||
xray.InboundConfig{Port: nodeEgressBasePort + 1, Protocol: "vless", Tag: "in-1"},
|
|
||||||
xray.InboundConfig{Port: nodeEgressBasePort + 2, Protocol: "vless", Tag: "in-2"},
|
|
||||||
)
|
|
||||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
|
||||||
|
|
||||||
bridge := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
|
|
||||||
if bridge.Tag != NodeEgressInboundTag(1) || bridge.Port != nodeEgressBasePort+3 {
|
|
||||||
t.Fatalf("node egress must skip taken ports, got %+v", bridge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectNodeEgresses_NoRoutingSection(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = nil
|
|
||||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
|
||||||
|
|
||||||
var routing egressRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 ||
|
|
||||||
routing.Rules[0].OutboundTag != "direct" ||
|
|
||||||
len(routing.Rules[0].InboundTag) != 1 || routing.Rules[0].InboundTag[0] != NodeEgressInboundTag(1) {
|
|
||||||
t.Fatalf("a routing section must be created with the node egress rule, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectNodeEgresses_BadRoutingSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
|
||||||
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("unparsable routing must not expose a node bridge, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != `{not json` {
|
|
||||||
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mtprotoInbound(tag string, settings string) *model.Inbound {
|
|
||||||
return &model.Inbound{Tag: tag, Protocol: model.MTProto, Enable: true, Settings: settings}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectMtprotoEgress_WithOutbound(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
|
||||||
`{"routeThroughXray":true,"routeXrayPort":50000,"outboundTag":"warp"}`))
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("expected the bridge inbound to be appended, got %d", len(cfg.InboundConfigs))
|
|
||||||
}
|
|
||||||
ib := cfg.InboundConfigs[1]
|
|
||||||
if ib.Tag != "inbound-443" || ib.Protocol != "socks" || ib.Port != 50000 {
|
|
||||||
t.Fatalf("unexpected bridge inbound: %+v", ib)
|
|
||||||
}
|
|
||||||
if string(ib.Listen) != `"127.0.0.1"` {
|
|
||||||
t.Fatalf("bridge must listen on loopback, got %s", ib.Listen)
|
|
||||||
}
|
|
||||||
|
|
||||||
var routing egressRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 2 {
|
|
||||||
t.Fatalf("expected the egress rule prepended to the existing rule, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
first := routing.Rules[0]
|
|
||||||
if first.Type != "field" || first.OutboundTag != "warp" ||
|
|
||||||
len(first.InboundTag) != 1 || first.InboundTag[0] != "inbound-443" {
|
|
||||||
t.Fatalf("egress rule must bind the inbound tag to the outbound, got %+v", first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectMtprotoEgress_NoOutboundLeavesRouting(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
|
||||||
`{"routeThroughXray":true,"routeXrayPort":50001}`))
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 || cfg.InboundConfigs[1].Port != 50001 {
|
|
||||||
t.Fatalf("bridge must still be appended without an outbound, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("no outbound means no rule change, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectMtprotoEgress_BalancerTag(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
|
||||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
|
||||||
`{"routeThroughXray":true,"routeXrayPort":50002,"outboundTag":"lb"}`))
|
|
||||||
|
|
||||||
var routing struct {
|
|
||||||
Rules []struct {
|
|
||||||
OutboundTag string `json:"outboundTag"`
|
|
||||||
BalancerTag string `json:"balancerTag"`
|
|
||||||
} `json:"rules"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 1 || routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
|
|
||||||
t.Fatalf("a balancer tag must target balancerTag, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectMtprotoEgress_Disabled(t *testing.T) {
|
|
||||||
// Not routed, and routed-but-portless, are both no-ops.
|
|
||||||
for _, settings := range []string{
|
|
||||||
`{"routeThroughXray":false,"routeXrayPort":50000}`,
|
|
||||||
`{"routeThroughXray":true}`,
|
|
||||||
`{"routeThroughXray":true,"routeXrayPort":0}`,
|
|
||||||
} {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443", settings))
|
|
||||||
if len(cfg.InboundConfigs) != 1 || string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("settings %s must be a no-op, got %d inbounds", settings, len(cfg.InboundConfigs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectMtprotoEgress_TagCollisionSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
|
||||||
xray.InboundConfig{Port: 443, Protocol: "vless", Tag: "inbound-443"})
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
|
||||||
`{"routeThroughXray":true,"routeXrayPort":50003,"outboundTag":"warp"}`))
|
|
||||||
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatal("a real inbound already owning the tag must make the bridge a no-op")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectMtprotoEgress_MissingTargetSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
|
||||||
`{"routeThroughXray":true,"routeXrayPort":50004,"outboundTag":"removed-subscription-outbound"}`))
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("a missing target must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("a missing target must leave routing untouched, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectMtprotoEgress_BadOutboundsSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
|
||||||
`{"routeThroughXray":true,"routeXrayPort":50005,"outboundTag":"direct"}`))
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("unparsable outbounds must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
|
||||||
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
|
|
||||||
`{"routeThroughXray":true,"routeXrayPort":50006,"outboundTag":"direct"}`))
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("unparsable routing must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != `{not json` {
|
|
||||||
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
|
|
||||||
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24}
|
|
||||||
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
|
|
||||||
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgnetSocks_CreatesRelayTaggedWithInboundsOwnTag(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
inbound := amneziawgInbound(7, "awg-7", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("expected the relay inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
|
|
||||||
}
|
|
||||||
ib := cfg.InboundConfigs[1]
|
|
||||||
if ib.Tag != "awg-7" || ib.Protocol != "socks" || ib.Port != amneziawgnet.SOCKSPortForInbound(7) {
|
|
||||||
t.Fatalf("relay inbound must reuse the inbound's own tag (so per-inbound stats totals keep matching, and it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib)
|
|
||||||
}
|
|
||||||
if string(ib.Listen) != `"127.0.0.1"` {
|
|
||||||
t.Fatalf("relay inbound must listen on loopback, got %s", ib.Listen)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(ib.Settings), `"auth":"password"`) || !strings.Contains(string(ib.Settings), `"udp":true`) {
|
|
||||||
t.Fatalf("relay inbound must require password auth and allow UDP ASSOCIATE, got %s", ib.Settings)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(ib.Settings), `"a@x"`) {
|
|
||||||
t.Fatalf("relay inbound must have an account for the peer's email, got %s", ib.Settings)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(ib.Sniffing), `"enabled":true`) {
|
|
||||||
t.Fatalf("relay inbound must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing)
|
|
||||||
}
|
|
||||||
// No auto-generated routing rule: it's entirely up to the admin's own
|
|
||||||
// Routing-page rules, same as any other protocol's inbound tag.
|
|
||||||
if string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("injectAmneziawgnetSocks must never touch the routing section, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgnetSocks_MultipleInboundsEachGetOwnRelay(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
||||||
})
|
|
||||||
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 3 {
|
|
||||||
t.Fatalf("expected one relay inbound per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
byTag := map[string]int{}
|
|
||||||
for _, ib := range cfg.InboundConfigs[1:] {
|
|
||||||
byTag[ib.Tag] = ib.Port
|
|
||||||
}
|
|
||||||
if byTag["awg-1"] != amneziawgnet.SOCKSPortForInbound(1) || byTag["awg-2"] != amneziawgnet.SOCKSPortForInbound(2) {
|
|
||||||
t.Fatalf("each inbound must get its own tag and its own derived port, got %+v", byTag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgnetSocks_NoQualifyingPeerSkipsRelay(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
client model.Client
|
|
||||||
enable bool
|
|
||||||
}{
|
|
||||||
{"client disabled", model.Client{Email: "a@x", Enable: false, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
|
|
||||||
{"no PublicKey", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}}, true},
|
|
||||||
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a"}, true},
|
|
||||||
{"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, false},
|
|
||||||
{"no Email", model.Client{Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{c.client})
|
|
||||||
inbound.Enable = c.enable
|
|
||||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgnetSocks_AlwaysOnRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
|
|
||||||
// Unlike the retired kernel-module bridge, the embedded relay has no
|
|
||||||
// opt-in gate: there is no alternative datapath once traffic is
|
|
||||||
// decapsulated in gVisor. A stale RouteThroughXray=false left over from
|
|
||||||
// a pre-cutover install must not suppress the relay inbound.
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: false}
|
|
||||||
settings, _ := json.Marshal(amneziawg.InboundSettings{
|
|
||||||
Server: &server,
|
|
||||||
Clients: []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
inbound := &model.Inbound{Id: 1, Tag: "awg-1", Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
|
||||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("the relay inbound must always be created regardless of RouteThroughXray, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgnetSocks_WrongProtocolOrNodeSkipped(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
|
|
||||||
nodeID := 5
|
|
||||||
nodeHosted := amneziawgInbound(2, "awg-2", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
||||||
})
|
|
||||||
nodeHosted.NodeID = &nodeID
|
|
||||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{vless, nodeHosted})
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a relay inbound, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgnetSocks_TagCollisionSkipsThatInboundOnly(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
|
||||||
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
|
|
||||||
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
||||||
})
|
|
||||||
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
|
|
||||||
|
|
||||||
// Started with 2 (api + the colliding vless entry); only awg-2's relay
|
|
||||||
// inbound should have been added, awg-1's skipped since its tag is taken.
|
|
||||||
if len(cfg.InboundConfigs) != 3 {
|
|
||||||
t.Fatalf("expected only the non-colliding inbound's relay inbound to be added, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, ib := range cfg.InboundConfigs {
|
|
||||||
if ib.Tag == "awg-2" && ib.Protocol == "socks" {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("awg-2's relay inbound must still be created despite awg-1's tag collision")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// amneziawgV6Inbound builds an AmneziaWG inbound with IPv6 enabled and a
|
|
||||||
// given external interface -- amneziawgInbound's own ServerSettings never
|
|
||||||
// sets these, so injectAmneziawgV6Egress's tests need their own variant.
|
|
||||||
func amneziawgV6Inbound(id int, tag string, ext6 string, clients []model.Client) *model.Inbound {
|
|
||||||
server := amneziawg.ServerSettings{
|
|
||||||
SubnetIP: "10.8.1.0", SubnetCIDR: 24,
|
|
||||||
IPv6Enabled: true, IPv6ExternalInterface: ext6,
|
|
||||||
}
|
|
||||||
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
|
|
||||||
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// amneziawgV6InboundNotActive builds an inbound that fails V6AliasesActive
|
|
||||||
// (either toggle can do it), unlike amneziawgV6Inbound which always passes it.
|
|
||||||
func amneziawgV6InboundNotActive(id int, tag string, ipv6Enabled bool, ext6 string, clients []model.Client) *model.Inbound {
|
|
||||||
server := amneziawg.ServerSettings{
|
|
||||||
SubnetIP: "10.8.1.0", SubnetCIDR: 24,
|
|
||||||
IPv6Enabled: ipv6Enabled, IPv6ExternalInterface: ext6,
|
|
||||||
}
|
|
||||||
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
|
|
||||||
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectAmneziawgV6Egress runs after injectAmneziawgnetSocks in the real
|
|
||||||
// GetXrayConfig() pipeline and depends on its relay inbound already
|
|
||||||
// existing (see the "live" tag check) -- every test below calls both, in
|
|
||||||
// that order, to match production.
|
|
||||||
func injectAmneziawgSocksThenV6(cfg *xray.Config, inbounds []*model.Inbound) {
|
|
||||||
injectAmneziawgnetSocks(cfg, inbounds)
|
|
||||||
injectAmneziawgV6Egress(cfg, inbounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
type v6EgressRouting struct {
|
|
||||||
Rules []struct {
|
|
||||||
InboundTag []string `json:"inboundTag"`
|
|
||||||
User []string `json:"user"`
|
|
||||||
OutboundTag string `json:"outboundTag"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
} `json:"rules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type v6EgressOutbound struct {
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
Protocol string `json:"protocol"`
|
|
||||||
SendThrough string `json:"sendThrough"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_CreatesOutboundAndRuleForV6Peer(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgV6Inbound(7, "awg-7", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
var outbounds []v6EgressOutbound
|
|
||||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
wantTag := amneziawgV6EgressTag(7, "a@x")
|
|
||||||
var got *v6EgressOutbound
|
|
||||||
for i := range outbounds {
|
|
||||||
if outbounds[i].Tag == wantTag {
|
|
||||||
got = &outbounds[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got == nil {
|
|
||||||
t.Fatalf("expected an outbound tagged %q, got %+v", wantTag, outbounds)
|
|
||||||
}
|
|
||||||
if got.Protocol != "freedom" || got.SendThrough != "fd86:ea04:1115::2" {
|
|
||||||
t.Fatalf("outbound must be a freedom outbound bound to the peer's own v6 address, got %+v", got)
|
|
||||||
}
|
|
||||||
// Pre-existing outbounds (direct, warp) must survive untouched.
|
|
||||||
if len(outbounds) != 3 {
|
|
||||||
t.Fatalf("expected the 2 pre-existing outbounds plus 1 new one, got %+v", outbounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
var routing v6EgressRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ruleIdx := -1
|
|
||||||
for i := range routing.Rules {
|
|
||||||
if routing.Rules[i].OutboundTag == wantTag {
|
|
||||||
ruleIdx = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ruleIdx == -1 {
|
|
||||||
t.Fatalf("expected a routing rule targeting %q, got %+v", wantTag, routing.Rules)
|
|
||||||
}
|
|
||||||
rule := routing.Rules[ruleIdx]
|
|
||||||
if rule.Type != "field" || len(rule.User) != 1 || rule.User[0] != "a@x" ||
|
|
||||||
len(rule.InboundTag) != 1 || rule.InboundTag[0] != "awg-7" {
|
|
||||||
t.Fatalf("rule must match this peer's email and inbound tag, got %+v", rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_SkipsPeerWithoutV6Address(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.OutboundConfigs)
|
|
||||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, // v4 only
|
|
||||||
})
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
if string(cfg.OutboundConfigs) != before {
|
|
||||||
t.Fatalf("a peer with no v6 AllowedIPs entry must not get an outbound, got %s", cfg.OutboundConfigs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The documented "leave the interface blank to auto-detect" happy path must
|
|
||||||
// not silently emit a sendThrough for an address the host was never told to
|
|
||||||
// own -- there is no auto-detect, so that would fail every connection.
|
|
||||||
func TestInjectAmneziawgV6Egress_SkipsWhenIPv6EnabledButInterfaceBlank(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.OutboundConfigs)
|
|
||||||
inbound := amneziawgV6InboundNotActive(1, "awg-1", true, "", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86::2/128"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
if string(cfg.OutboundConfigs) != before {
|
|
||||||
t.Fatalf("IPv6Enabled with a blank interface must not get an outbound (no auto-detect exists), got %s", cfg.OutboundConfigs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The inverse of amneziawgV6Inbound's own always-true IPv6Enabled: a filled
|
|
||||||
// IPv6ExternalInterface alone (e.g. left over from a previous enable) must
|
|
||||||
// not activate egress on its own.
|
|
||||||
func TestInjectAmneziawgV6Egress_SkipsWhenIPv6DisabledEvenWithInterfaceSet(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.OutboundConfigs)
|
|
||||||
inbound := amneziawgV6InboundNotActive(1, "awg-1", false, "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86::2/128"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
if string(cfg.OutboundConfigs) != before {
|
|
||||||
t.Fatalf("IPv6Enabled false must not get an outbound even with a leftover interface set, got %s", cfg.OutboundConfigs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_MultiplePeersEachGetOwnOutboundAndRule(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
var outbounds []v6EgressOutbound
|
|
||||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
tagA, tagB := amneziawgV6EgressTag(1, "a@x"), amneziawgV6EgressTag(1, "b@x")
|
|
||||||
seen := map[string]string{}
|
|
||||||
for _, o := range outbounds {
|
|
||||||
seen[o.Tag] = o.SendThrough
|
|
||||||
}
|
|
||||||
if seen[tagA] != "fd86:ea04:1115::2" || seen[tagB] != "fd86:ea04:1115::3" {
|
|
||||||
t.Fatalf("each peer must get its own outbound bound to its own address, got %+v", seen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_StableTagAcrossRegenerations(t *testing.T) {
|
|
||||||
// Same instance data, two independent injections -- hot_diff.go relies on
|
|
||||||
// the tag being a pure function of (inboundID, email) so it recognizes
|
|
||||||
// "unchanged" rather than remove+recreate on every poll.
|
|
||||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
|
||||||
})
|
|
||||||
cfg1 := egressTestConfig()
|
|
||||||
injectAmneziawgSocksThenV6(cfg1, []*model.Inbound{inbound})
|
|
||||||
cfg2 := egressTestConfig()
|
|
||||||
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
var out1, out2 []v6EgressOutbound
|
|
||||||
json.Unmarshal(cfg1.OutboundConfigs, &out1)
|
|
||||||
json.Unmarshal(cfg2.OutboundConfigs, &out2)
|
|
||||||
if len(out1) != len(out2) || out1[len(out1)-1].Tag != out2[len(out2)-1].Tag {
|
|
||||||
t.Fatalf("tag must be stable across independent regenerations, got %+v vs %+v", out1, out2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_SkipsWrongProtocolOrNodeHostedOrDisabled(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.OutboundConfigs)
|
|
||||||
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
|
|
||||||
nodeID := 5
|
|
||||||
nodeHosted := amneziawgV6Inbound(2, "awg-2", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
|
||||||
})
|
|
||||||
nodeHosted.NodeID = &nodeID
|
|
||||||
disabled := amneziawgV6Inbound(3, "awg-3", "eth0", []model.Client{
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
|
|
||||||
})
|
|
||||||
disabled.Enable = false
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{vless, nodeHosted, disabled})
|
|
||||||
if string(cfg.OutboundConfigs) != before {
|
|
||||||
t.Fatalf("wrong-protocol, node-hosted, and disabled inbounds must never get a v6 outbound, got %s", cfg.OutboundConfigs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_SkipsWhenRelayInboundNotCreated(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
// A pre-existing inbound already holds this AmneziaWG inbound's tag, so
|
|
||||||
// injectAmneziawgnetSocks (called first, matching production order)
|
|
||||||
// skips creating its relay SOCKS5 inbound entirely.
|
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
|
||||||
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
|
|
||||||
before := string(cfg.OutboundConfigs)
|
|
||||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
if string(cfg.OutboundConfigs) != before {
|
|
||||||
t.Fatalf("no v6 outbound should be created when the relay inbound itself never got created, got %s", cfg.OutboundConfigs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_OutboundTagCollisionSkipsThatPeerOnly(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
|
|
||||||
})
|
|
||||||
// Pre-seed a colliding outbound tag for a@x specifically.
|
|
||||||
collidingTag := amneziawgV6EgressTag(1, "a@x")
|
|
||||||
existing, _ := json.Marshal([]any{map[string]any{"tag": collidingTag, "protocol": "freedom"}})
|
|
||||||
cfg.OutboundConfigs = json_util.RawMessage(existing)
|
|
||||||
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
var outbounds []v6EgressOutbound
|
|
||||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
tagB := amneziawgV6EgressTag(1, "b@x")
|
|
||||||
foundB := false
|
|
||||||
countA := 0
|
|
||||||
for _, o := range outbounds {
|
|
||||||
if o.Tag == collidingTag {
|
|
||||||
countA++
|
|
||||||
}
|
|
||||||
if o.Tag == tagB {
|
|
||||||
foundB = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if countA != 1 {
|
|
||||||
t.Fatalf("a@x's pre-existing outbound must not be duplicated, got %d copies", countA)
|
|
||||||
}
|
|
||||||
if !foundB {
|
|
||||||
t.Fatal("b@x must still get its own outbound despite a@x's tag collision")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_BadOutboundsOrRoutingSkips(t *testing.T) {
|
|
||||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
|
||||||
})
|
|
||||||
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
if string(cfg.OutboundConfigs) != `{not json` {
|
|
||||||
t.Fatalf("unparsable outbounds must be left untouched, got %s", cfg.OutboundConfigs)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg2 := egressTestConfig()
|
|
||||||
cfg2.RouterConfig = json_util.RawMessage(`{not json`)
|
|
||||||
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
|
|
||||||
if string(cfg2.RouterConfig) != `{not json` {
|
|
||||||
t.Fatalf("unparsable routing must be left untouched, got %s", cfg2.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_NoQualifyingPeerLeavesConfigUntouched(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
beforeOut, beforeRoute := string(cfg.OutboundConfigs), string(cfg.RouterConfig)
|
|
||||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", nil) // no clients at all
|
|
||||||
injectAmneziawgV6Egress(cfg, []*model.Inbound{inbound})
|
|
||||||
if string(cfg.OutboundConfigs) != beforeOut || string(cfg.RouterConfig) != beforeRoute {
|
|
||||||
t.Fatalf("an inbound with no qualifying peer must leave the config byte-identical")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgV6Egress_RulesPrependedBeforeExistingRules(t *testing.T) {
|
|
||||||
cfg := egressTestConfig() // already has one rule, targeting "api"
|
|
||||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
var routing v6EgressRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 2 {
|
|
||||||
t.Fatalf("expected the new rule plus the pre-existing one, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
if routing.Rules[0].OutboundTag != amneziawgV6EgressTag(1, "a@x") {
|
|
||||||
t.Fatalf("the new infra rule must be prepended ahead of the pre-existing rule, got %+v", routing.Rules[0])
|
|
||||||
}
|
|
||||||
if routing.Rules[1].OutboundTag != "api" {
|
|
||||||
t.Fatalf("the pre-existing rule must survive, got %+v", routing.Rules[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,19 +25,28 @@ func TestGetXrayConfigScale(t *testing.T) {
|
|||||||
for _, shape := range shapes {
|
for _, shape := range shapes {
|
||||||
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
||||||
ds := seedScaleDataset(t, n, shape.inbounds)
|
ds := seedScaleDataset(t, n, shape.inbounds)
|
||||||
|
// Home the seeded inbounds on one agent node: the per-agent
|
||||||
|
// build is the only config build left in the lite master.
|
||||||
|
scaleNode := &model.Node{Name: "scale-node", Address: "10.0.0.7", Port: 2053, ApiToken: "t"}
|
||||||
|
if err := database.GetDB().Create(scaleNode).Error; err != nil {
|
||||||
|
t.Fatalf("create scale node: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Model(&model.Inbound{}).Where("1 = 1").Update("node_id", scaleNode.Id).Error; err != nil {
|
||||||
|
t.Fatalf("home scale inbounds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
const reps = 3
|
const reps = 3
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
for range reps {
|
for range reps {
|
||||||
cfg, err := svc.GetXrayConfig()
|
cfg, err := svc.GetXrayConfigForAgent(scaleNode.Id, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetXrayConfig: %v", err)
|
t.Fatalf("GetXrayConfigForAgent: %v", err)
|
||||||
}
|
}
|
||||||
if len(cfg.InboundConfigs) < shape.inbounds {
|
if len(cfg.InboundConfigs) < shape.inbounds {
|
||||||
t.Fatalf("config has %d inbounds, want >= %d", len(cfg.InboundConfigs), shape.inbounds)
|
t.Fatalf("config has %d inbounds, want >= %d", len(cfg.InboundConfigs), shape.inbounds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Logf("N=%-7d shape=%-8s GetXrayConfig=%v/run",
|
t.Logf("N=%-7d shape=%-8s GetXrayConfigForAgent=%v/run",
|
||||||
n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
|
n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
|
||||||
|
|
||||||
var ib model.Inbound
|
var ib model.Inbound
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestXrayLifecycleSnapshotDoesNotOverwriteNewerResult(t *testing.T) {
|
|
||||||
state := xrayLifecycle{}
|
|
||||||
first := xray.NewProcess(&xray.Config{})
|
|
||||||
second := xray.NewProcess(&xray.Config{})
|
|
||||||
|
|
||||||
state.replace(first)
|
|
||||||
state.storeResult(first, "first result")
|
|
||||||
process, result := state.snapshot()
|
|
||||||
if process != first || result != "first result" {
|
|
||||||
t.Fatalf("snapshot = (%p, %q), want (%p, %q)", process, result, first, "first result")
|
|
||||||
}
|
|
||||||
state.replace(second)
|
|
||||||
state.storeResult(first, "old result")
|
|
||||||
|
|
||||||
process, result = state.snapshot()
|
|
||||||
if process != second {
|
|
||||||
t.Fatal("snapshot returned the replaced process")
|
|
||||||
}
|
|
||||||
if result != "" {
|
|
||||||
t.Fatalf("snapshot result = %q, want empty", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestXrayLifecycleConcurrentStatusResultAndTrafficReads(t *testing.T) {
|
|
||||||
previousProcess, previousResult := xrayState.snapshot()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
xrayState.mu.Lock()
|
|
||||||
xrayState.process = previousProcess
|
|
||||||
xrayState.result = previousResult
|
|
||||||
xrayState.mu.Unlock()
|
|
||||||
})
|
|
||||||
|
|
||||||
first := xray.NewProcess(&xray.Config{})
|
|
||||||
second := xray.NewProcess(&xray.Config{})
|
|
||||||
service := XrayService{}
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
wg.Go(func() {
|
|
||||||
for range 200 {
|
|
||||||
xrayState.replace(first)
|
|
||||||
xrayState.replace(second)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for range 4 {
|
|
||||||
wg.Go(func() {
|
|
||||||
for range 200 {
|
|
||||||
_ = service.IsXrayRunning()
|
|
||||||
_ = service.GetXrayResult()
|
|
||||||
_, _, err := service.GetXrayTraffic()
|
|
||||||
if err == nil || err.Error() != "xray is not running" {
|
|
||||||
t.Errorf("GetXrayTraffic error = %v, want xray is not running", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRestartXrayRespectsManualStop(t *testing.T) {
|
|
||||||
setupSettingTestDB(t)
|
|
||||||
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {
|
|
||||||
t.Fatalf("seed template: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { isManuallyStopped.Store(false) })
|
|
||||||
|
|
||||||
isManuallyStopped.Store(true)
|
|
||||||
_ = (&XrayService{}).RestartXray(false)
|
|
||||||
|
|
||||||
if !isManuallyStopped.Load() {
|
|
||||||
t.Fatal("a non-forced restart cleared a deliberate manual stop and would revive xray")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The local core never runs in this build, so ApplyPendingRestart no
|
|
||||||
// longer restarts anything and must not re-arm the flag for a retry loop:
|
|
||||||
// it consumes the pending flag and drops it.
|
|
||||||
func TestApplyPendingRestartDropsFlagWithoutLocalCore(t *testing.T) {
|
|
||||||
setupSettingTestDB(t)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
isManuallyStopped.Store(false)
|
|
||||||
isNeedXrayRestart.Store(false)
|
|
||||||
})
|
|
||||||
isManuallyStopped.Store(false)
|
|
||||||
|
|
||||||
svc := &XrayService{}
|
|
||||||
svc.SetToNeedRestart()
|
|
||||||
svc.ApplyPendingRestart()
|
|
||||||
|
|
||||||
if isNeedXrayRestart.Load() {
|
|
||||||
t.Fatal("pending restart must be consumed and dropped; there is no local core to retry against")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,10 +53,8 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
|||||||
if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
|
if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
|
||||||
return common.NewError("xray template config invalid: outbounds is not an array:", err)
|
return common.NewError("xray template config invalid: outbounds is not an array:", err)
|
||||||
}
|
}
|
||||||
|
// Lite master: no local core; the version gate takes "Unknown".
|
||||||
coreVersion := "Unknown"
|
coreVersion := "Unknown"
|
||||||
if process := currentXrayProcess(); process != nil {
|
|
||||||
coreVersion = process.GetXrayVersion()
|
|
||||||
}
|
|
||||||
for _, outbound := range outbounds {
|
for _, outbound := range outbounds {
|
||||||
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
||||||
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
|
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// wgTestNodeID returns the shared agent node the wireguard emission tests
|
||||||
|
// home their inbounds on, creating it on first use.
|
||||||
|
func wgTestNodeID(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
var node model.Node
|
||||||
|
if err := db.Where("name = ?", "wg-test-node").First(&node).Error; err == nil {
|
||||||
|
return node.Id
|
||||||
|
}
|
||||||
|
node = model.Node{Name: "wg-test-node", Address: "10.0.0.6", Port: 2053, ApiToken: "t"}
|
||||||
|
if err := db.Create(&node).Error; err != nil {
|
||||||
|
t.Fatalf("create wg-test node: %v", err)
|
||||||
|
}
|
||||||
|
return node.Id
|
||||||
|
}
|
||||||
|
|
||||||
func wgTestSecretKey() string {
|
func wgTestSecretKey() string {
|
||||||
return base64.StdEncoding.EncodeToString(make([]byte, 32))
|
return base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||||
}
|
}
|
||||||
@@ -16,9 +33,9 @@ func wgTestSecretKey() string {
|
|||||||
func wgInboundEmittedSettings(t *testing.T, tag string) map[string]any {
|
func wgInboundEmittedSettings(t *testing.T, tag string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
svc := &XrayService{}
|
svc := &XrayService{}
|
||||||
cfg, err := svc.GetXrayConfig()
|
cfg, err := svc.GetXrayConfigForAgent(wgTestNodeID(t), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetXrayConfig: %v", err)
|
t.Fatalf("GetXrayConfigForAgent: %v", err)
|
||||||
}
|
}
|
||||||
for i := range cfg.InboundConfigs {
|
for i := range cfg.InboundConfigs {
|
||||||
ic := cfg.InboundConfigs[i]
|
ic := cfg.InboundConfigs[i]
|
||||||
@@ -39,11 +56,13 @@ func seedWGInbound(t *testing.T, tag string, port int, clients []model.Client) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
setupSettingTestDB(t)
|
setupSettingTestDB(t)
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
nodeID := wgTestNodeID(t)
|
||||||
in := &model.Inbound{
|
in := &model.Inbound{
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Enable: true,
|
Enable: true,
|
||||||
Port: port,
|
Port: port,
|
||||||
Protocol: model.WireGuard,
|
Protocol: model.WireGuard,
|
||||||
|
NodeID: &nodeID,
|
||||||
Settings: `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420}`,
|
Settings: `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420}`,
|
||||||
}
|
}
|
||||||
if err := db.Create(in).Error; err != nil {
|
if err := db.Create(in).Error; err != nil {
|
||||||
|
|||||||
+7
-29
@@ -16,11 +16,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/controller"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/controller"
|
||||||
@@ -33,7 +31,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -317,15 +314,9 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
|||||||
// retained for signature compatibility and intentionally unused.
|
// retained for signature compatibility and intentionally unused.
|
||||||
_ = restartXray
|
_ = restartXray
|
||||||
|
|
||||||
// Reconcile mtproto (mtg) sidecars and scrape their traffic
|
// MTProto/AmneziaWG sidecars were retired with the local core in
|
||||||
mtJob := job.NewMtprotoJob()
|
// P9(4b): neither protocol can be created anymore and no reconcile
|
||||||
_, _ = s.cron.AddJob(cadenceMtproto, mtJob)
|
// jobs are scheduled for them.
|
||||||
go mtJob.Run()
|
|
||||||
|
|
||||||
// Reconcile embedded AmneziaWG interfaces; traffic rides Xray's own stats
|
|
||||||
awgJob := job.NewAmneziaWGJob()
|
|
||||||
_, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob)
|
|
||||||
go awgJob.Run()
|
|
||||||
|
|
||||||
// The client-IP limit job only reads the local xray core's online
|
// The client-IP limit job only reads the local xray core's online
|
||||||
// API, which never exists in this build, so it is not scheduled
|
// API, which never exists in this build, so it is not scheduled
|
||||||
@@ -483,10 +474,7 @@ func (s *Server) start(restartXray bool) (err error) {
|
|||||||
// add/update/delete to either the local xray or a remote node panel.
|
// add/update/delete to either the local xray or a remote node panel.
|
||||||
// The closures bridge into XrayService (which owns the running xray
|
// The closures bridge into XrayService (which owns the running xray
|
||||||
// process state) without forcing the runtime package to import service.
|
// process state) without forcing the runtime package to import service.
|
||||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{
|
runtime.SetManager(runtime.NewManager())
|
||||||
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
|
|
||||||
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
|
|
||||||
}))
|
|
||||||
runtime.GetManager().SetNodeEgressResolver(&s.settingService)
|
runtime.GetManager().SetNodeEgressResolver(&s.settingService)
|
||||||
// Supply the master client certificate for nodes in mtls mode. Issued lazily
|
// Supply the master client certificate for nodes in mtls mode. Issued lazily
|
||||||
// from the node CA on first use; runtime stays free of a service import.
|
// from the node CA on first use; runtime stays free of a service import.
|
||||||
@@ -608,15 +596,6 @@ func (s *Server) start(restartXray bool) (err error) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wire xray crash callback BEFORE startTask so it's ready
|
|
||||||
xray.OnCrash = func(err error) {
|
|
||||||
if s.bus != nil {
|
|
||||||
s.bus.Publish(eventbus.Event{
|
|
||||||
Type: eventbus.EventXrayCrash,
|
|
||||||
Data: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.startTask(restartXray, loc)
|
s.startTask(restartXray, loc)
|
||||||
|
|
||||||
@@ -642,9 +621,6 @@ func (s *Server) StopPanelOnly() error {
|
|||||||
func (s *Server) stop(stopXray bool) error {
|
func (s *Server) stop(stopXray bool) error {
|
||||||
s.cancel()
|
s.cancel()
|
||||||
if stopXray {
|
if stopXray {
|
||||||
_ = s.xrayService.StopXray()
|
|
||||||
mtproto.GetManager().StopAll()
|
|
||||||
amneziawgnet.GetManager().StopAll()
|
|
||||||
}
|
}
|
||||||
if s.cron != nil {
|
if s.cron != nil {
|
||||||
s.cron.Stop()
|
s.cron.Stop()
|
||||||
@@ -690,6 +666,8 @@ func (s *Server) GetWSHub() any {
|
|||||||
return s.wsHub
|
return s.wsHub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestartXray is a no-op kept for the CLI/SIGHUP call sites in main.go:
|
||||||
|
// the lite master runs no local core, so there is nothing to restart.
|
||||||
func (s *Server) RestartXray() error {
|
func (s *Server) RestartXray() error {
|
||||||
return s.xrayService.RestartXray(true)
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user