精简版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:
Muse
2026-10-21 18:43:55 +00:00
parent f6d652019d
commit 1035d00fe4
54 changed files with 310 additions and 5963 deletions
@@ -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 { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
import {
AimOutlined,
ControlOutlined,
ExportOutlined,
ImportOutlined,
@@ -16,7 +15,6 @@ import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import RoutingBasic from './RoutingBasic';
import RouteTester from './RouteTester';
import RuleFormModal from './RuleFormModal';
import type { RoutingRule } from './RuleFormModal';
import RuleCardList from './RuleCardList';
@@ -430,11 +428,6 @@ export default function RoutingTab({
</Space>
),
},
{
key: 'tester',
label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
},
]}
/>
<RuleFormModal
-16
View File
@@ -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)
if needRestart {
a.xrayService.SetToNeedRestart()
}
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)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -230,7 +228,6 @@ func (a *ClientController) delete(c *gin.Context) {
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
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)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -285,7 +281,6 @@ func (a *ClientController) resetAllTraffics(c *gin.Context) {
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -310,7 +305,6 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -338,7 +332,6 @@ func (a *ClientController) bulkAttach(c *gin.Context) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -361,7 +354,6 @@ func (a *ClientController) bulkDetach(c *gin.Context) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -379,7 +371,6 @@ func (a *ClientController) bulkDelete(c *gin.Context) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -409,7 +400,6 @@ func (a *ClientController) bulkSetEnable(c *gin.Context, enable bool) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -427,7 +417,6 @@ func (a *ClientController) bulkCreate(c *gin.Context) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -440,7 +429,6 @@ func (a *ClientController) delDepleted(c *gin.Context) {
}
jsonObj(c, gin.H{"deleted": deleted}, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -482,7 +470,6 @@ func (a *ClientController) importClients(c *gin.Context) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -506,7 +493,6 @@ func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
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)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
@@ -657,6 +642,5 @@ func (a *ClientController) bulkResetTraffic(c *gin.Context) {
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}
-10
View File
@@ -228,7 +228,6 @@ func (a *InboundController) addInbound(c *gin.Context) {
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
a.broadcastInboundsUpdate(user.Id)
notifyClientsChanged()
@@ -248,7 +247,6 @@ func (a *InboundController) delInbound(c *gin.Context) {
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), id, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
user := session.GetLoginUser(c)
a.broadcastInboundsUpdate(user.Id)
@@ -274,7 +272,6 @@ func (a *InboundController) bulkDelInbounds(c *gin.Context) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
user := session.GetLoginUser(c)
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)
if needRestart {
a.xrayService.SetToNeedRestart()
}
user := session.GetLoginUser(c)
a.broadcastInboundsUpdate(user.Id)
@@ -409,7 +405,6 @@ func (a *InboundController) setInboundEnable(c *gin.Context) {
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
// 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)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
}
@@ -463,7 +457,6 @@ func (a *InboundController) delAllInboundClients(c *gin.Context) {
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
user := session.GetLoginUser(c)
a.broadcastInboundsUpdate(user.Id)
@@ -477,7 +470,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
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)
if needRestart {
a.xrayService.SetToNeedRestart()
}
a.broadcastInboundsUpdate(user.Id)
notifyClientsChanged()
@@ -602,6 +593,5 @@ func (a *InboundController) setFallbacks(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
}
-10
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"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/service"
@@ -158,9 +157,6 @@ func (a *NodeController) add(c *gin.Context) {
return
}
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 {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
@@ -215,9 +211,6 @@ func (a *NodeController) update(c *gin.Context) {
return
}
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 {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
@@ -262,9 +255,6 @@ func (a *NodeController) setEnable(c *gin.Context) {
return
}
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)
}
-10
View File
@@ -6,7 +6,6 @@ import (
"strconv"
"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/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
@@ -118,20 +117,11 @@ func (a *SettingController) updateSetting(c *gin.Context) {
return
}
allSetting := &form.AllSetting
oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
oldTgEnable, _ := a.settingService.GetTgbotEnabled()
oldTgToken, _ := a.settingService.GetTgBotToken()
oldTgChatId, _ := a.settingService.GetTgBotChatId()
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
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.
if err == nil && reloadTgbotFunc != nil {
tgChanged := oldTgEnable != allSetting.TgBotEnable ||
+3 -91
View File
@@ -13,7 +13,6 @@ import (
"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/outbound"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/gin-gonic/gin"
)
@@ -44,7 +43,6 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g = g.Group("/xray")
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
g.GET("/getXrayResult", a.getXrayResult)
g.POST("/", a.getXraySetting)
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("/update", a.updateSetting)
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/categories", a.geodataCategories)
@@ -129,22 +124,15 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
jsonObj(c, string(result), nil)
}
// updateSetting updates the Xray configuration settings and applies them to
// the running core right away — through the gRPC API when only inbounds,
// outbounds or routing rules changed, with a process restart otherwise.
// updateSetting updates the Xray configuration settings. Agent nodes pick
// the new template up on their next config pull; there is no local core to
// apply it to.
func (a *XraySettingController) updateSetting(c *gin.Context) {
xraySetting := c.PostForm("xraySetting")
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
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)
}
@@ -158,10 +146,6 @@ func (a *XraySettingController) getDefaultXrayConfig(c *gin.Context) {
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.
func (a *XraySettingController) warp(c *gin.Context) {
@@ -182,7 +166,6 @@ func (a *XraySettingController) warp(c *gin.Context) {
case "changeIp":
resp, err = a.WarpService.ChangeWarpIP()
if err == nil {
a.XrayService.SetToNeedRestart()
// Restart the auto-update clock so a scheduled rotation
// doesn't fire right after this manual one.
err = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
@@ -284,76 +267,8 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
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
// categories than this is not something the panel needs to answer for.
@@ -496,7 +411,6 @@ func (a *XraySettingController) deleteOutboundSub(c *gin.Context) {
return
}
// Signal that xray should drop this subscription's outbounds on next reload.
a.XrayService.SetToNeedRestart()
jsonObj(c, "", nil)
}
@@ -513,7 +427,6 @@ func (a *XraySettingController) refreshOutboundSub(c *gin.Context) {
return
}
// Signal that xray should pick up the new outbounds on next restart/reload
a.XrayService.SetToNeedRestart()
jsonObj(c, obs, nil)
}
@@ -530,7 +443,6 @@ func (a *XraySettingController) moveOutboundSub(c *gin.Context) {
return
}
// Order affects the merged outbounds, so xray needs a reload.
a.XrayService.SetToNeedRestart()
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)
}
}
-789
View File
@@ -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)
}
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()
}
@@ -40,7 +40,6 @@ func (j *OutboundSubscriptionJob) Run() {
if count > 0 {
logger.Infof("Refreshed %d outbound subscription(s)", count)
// 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
// view (new outbounds will be visible after the reload cycle).
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
// something unrelated restarts it.
if needRestart {
j.xrayService.SetToNeedRestart()
}
if resetCount > 0 {
+20
View File
@@ -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"`
}
-1
View File
@@ -55,7 +55,6 @@ func (j *WarpIpJob) Run() {
if err := j.settingService.SetWarpLastUpdate(now); err != nil {
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")
}
}
-206
View File
@@ -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
View File
@@ -2,313 +2,59 @@ package runtime
import (
"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/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
type LocalDeps struct {
APIPort func() int
SetNeedRestart func()
}
// Local is the runtime for inbounds with no node assignment. The lite
// master is a pure control plane — it runs no local xray core and no
// 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 {
deps LocalDeps
mu sync.Mutex
}
func NewLocal(deps LocalDeps) *Local {
return &Local{deps: deps}
}
func NewLocal() *Local { return &Local{} }
func (l *Local) Name() string { return "local" }
func (l *Local) withAPI(fn func(api *xray.XrayAPI) error) error {
l.mu.Lock()
defer l.mu.Unlock()
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
}
func (l *Local) AddInbound(_ context.Context, _ *model.Inbound) error { return nil }
func (l *Local) DelInbound(_ context.Context, _ *model.Inbound) error { return nil }
func (l *Local) UpdateInbound(_ context.Context, _, _ *model.Inbound) error {
return nil
}
func (l *Local) DeleteClient(context.Context, string) error {
func (l *Local) AddUser(_ context.Context, _ *model.Inbound, _ map[string]any) error {
return nil
}
func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) 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()
}
func (l *Local) RemoveUser(_ context.Context, _ *model.Inbound, _ string) error {
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 {
return nil
}
func (l *Local) ResetAllTraffics(_ context.Context) error {
return nil
}
func (l *Local) ResetInboundTraffic(_ context.Context, _ *model.Inbound) error {
return nil
}
func (l *Local) ResetAllTraffics(_ context.Context) error { return nil }
+2 -2
View File
@@ -22,9 +22,9 @@ type Manager struct {
egressResolver NodeEgressResolver
}
func NewManager(localDeps LocalDeps) *Manager {
func NewManager() *Manager {
return &Manager{
local: NewLocal(localDeps),
local: NewLocal(),
remotes: make(map[int]*Remote),
}
}
@@ -7,7 +7,7 @@ import (
)
func TestManagerRemoteForRefreshesChangedCredential(t *testing.T) {
m := NewManager(LocalDeps{})
m := NewManager()
first, err := m.RemoteFor(&model.Node{
Id: 1,
Name: "node",
@@ -81,7 +81,7 @@ func TestManagerRemoteForIdentityFields(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := NewManager(LocalDeps{})
m := NewManager()
firstNode := base
first, err := m.RemoteFor(&firstNode)
if err != nil {
@@ -102,7 +102,7 @@ func TestManagerRemoteForIdentityFields(t *testing.T) {
}
func TestManagerRemoteForClonesInputNode(t *testing.T) {
m := NewManager(LocalDeps{})
m := NewManager()
n := &model.Node{
Id: 9,
Scheme: "https",
+18
View File
@@ -941,7 +941,22 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
// 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.
// 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) {
if err := errRetiredProtocol(inbound.Protocol); err != nil {
return inbound, false, err
}
inbound.Id = 0
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
// 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) {
if err := errRetiredProtocol(inbound.Protocol); err != nil {
return inbound, false, err
}
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
// Normalize streamSettings based on protocol
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
// backup, or a direct DB edit) rather than handing xray-core a config that
// panics it on the first connection. Bypasses AddInbound/UpdateInbound
@@ -187,11 +187,20 @@ func TestGetXrayConfig_HealsFinalMaskRealityCombo(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "in-44302-tcp", "0.0.0.0", 44302, model.VLESS,
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{}
cfg, err := svc.GetXrayConfig()
cfg, err := svc.GetXrayConfigForAgent(node.Id, "", "")
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
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")
}
}
+8 -75
View File
@@ -439,7 +439,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
nodeRow.Id = nodeID
unmanagedTag := unmanagedTagPredicate(&nodeRow)
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
originGuidFor := func(snapIb *model.Inbound) string {
if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid {
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
}
@@ -1323,11 +1294,9 @@ func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
}
func (s *InboundService) GetOnlineClients() []string {
process := currentXrayProcess()
if process == nil {
return []string{}
}
return process.GetOnlineClients()
// Lite master: no local core aggregates online clients; node onlines
// arrive through agent reports and node polling instead.
return []string{}
}
// 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,
// not the intermediate one it was synced through.
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
process := currentXrayProcess()
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
return map[string][]string{}
}
// 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
// inbounds (old-build node or no active-inbound signal).
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
process := currentXrayProcess()
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
return 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) SetNodeOnlineTree(nodeID int, tree map[string][]string) {}
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
if process := currentXrayProcess(); process != nil {
process.ClearNodeOnlineClients(nodeID)
}
}
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {}
// panelGuid returns this panel's stable self-identifier, used to key the local
// 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
// xray.Process for why the local sets are kept separate from the shared
// last_online column.
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
if process := currentXrayProcess(); process != nil {
process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
}
}
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {}
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB()
-116
View File
@@ -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
}
+5 -98
View File
@@ -22,10 +22,8 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"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/json_util"
"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/xray"
"gorm.io/gorm"
)
@@ -1109,103 +1107,12 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
return patch, err
}
// withOutboundBridge stands up a temporary loopback SOCKS5 inbound in the
// running Xray, routes it through outboundTag, and runs fn with the bridge's
// proxy URL before tearing it down. It is used to reach a node through its
// connection outbound before the persistent egress bridge has been injected
// 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.
// withOutboundBridge used to stand up a temporary loopback SOCKS5 inbound
// in the panel's own running xray to reach a node through its connection
// outbound. The lite master runs no local core, so probes always take the
// direct-connection fallback this function used when xray was down.
func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) {
proc := XrayProcess()
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)
fn("")
}
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) {
t.Helper()
prev := runtime.GetManager()
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
mgr := runtime.NewManager()
runtime.SetManager(mgr)
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/model"
"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) {
@@ -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
// 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
// CheckXrayConfig's version gate.
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"
if process := currentXrayProcess(); process != nil {
coreVersion = process.GetXrayVersion()
}
kept := make([]any, 0, len(outbounds))
var dropped []string
for _, ob := range outbounds {
+1 -1
View File
@@ -26,7 +26,7 @@ func TestResetAllTrafficsDoesNotBlockWriterOnNodeCall(t *testing.T) {
resetTrafficWriterForTest(t)
StartTrafficWriter()
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
mgr := runtime.NewManager()
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
+15 -36
View File
@@ -540,20 +540,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.PublicIP.IPv4 = s.cachedIPv4
status.PublicIP.IPv6 = s.cachedIPv6
// Xray status
if s.xrayService.IsXrayRunning() {
status.Xray.State = Running
status.Xray.ErrorMsg = ""
} else {
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()
// Xray status: the lite master runs no local core, so it always
// reports stopped with no version.
status.Xray.State = Stop
status.Xray.ErrorMsg = ""
status.Xray.Version = ""
var amneziawgCount int64
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.Threads = uint32(runtime.NumGoroutine())
if process := currentXrayProcess(); process != nil && process.IsRunning() {
status.AppStats.Uptime = process.GetUptime()
} else {
status.AppStats.Uptime = 0
}
status.AppStats.Uptime = 0
return status
}
@@ -672,21 +659,15 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
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 {
err := s.xrayService.StopXray()
if err != nil {
logger.Error("stop xray failed:", err)
return err
}
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 {
err := s.xrayService.RestartXray(true)
if err != nil {
logger.Error("start xray failed:", err)
return err
}
return nil
}
@@ -718,19 +699,17 @@ func isVirtualInterface(name string) bool {
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) {
config, err := s.xrayService.GetXrayConfig()
if err != nil {
return nil, err
}
contents, err := json.MarshalIndent(config, "", " ")
template, err := s.settingService.GetXrayConfigTemplate()
if err != nil {
return nil, err
}
var jsonData any
err = json.Unmarshal(contents, &jsonData)
if err != nil {
if err := json.Unmarshal([]byte(template), &jsonData); err != nil {
return nil, err
}
+4 -35
View File
@@ -393,45 +393,14 @@ func (s *SettingService) SetPanelOutbound(tag string) error {
// It returns "" — meaning a direct connection — when the feature is off or
// the bridge is not present in the running core yet.
func (s *SettingService) PanelEgressProxyURL() string {
tag, err := s.GetPanelOutbound()
if err != nil || tag == "" {
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")
// The panel-egress bridge lived in the panel's own xray core. The lite
// master runs no local core, so there is never a bridge: direct.
return ""
}
func (s *SettingService) NodeEgressProxyURL(nodeID int) string {
tag := NodeEgressInboundTag(nodeID)
proc := XrayProcess()
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")
// The per-node egress bridge also lived in the panel's own xray core;
// with no local core there is never a bridge: direct connection.
return ""
}
+2 -7
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"slices"
"strconv"
"strings"
"time"
@@ -492,14 +491,10 @@ func (t *Tgbot) clientInfoMsg(
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")
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 += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email)
+2 -11
View File
@@ -14,7 +14,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"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/mymmrac/telego"
@@ -110,10 +109,7 @@ func (t *Tgbot) prepareServerUsageInfo() string {
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
t.setCachedStatus(t.lastStatus)
}
var onlines []string
if process := service.XrayProcess(); process != nil {
onlines = process.GetOnlineClients()
}
onlines := t.inboundService.GetOnlineClients()
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
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.
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
process := service.XrayProcess()
if process == nil || !process.IsRunning() {
return
}
onlines := process.GetOnlineClients()
onlines := t.inboundService.GetOnlineClients()
onlinesCount := len(onlines)
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
+2 -16
View File
@@ -148,7 +148,6 @@ func (t *Tgbot) OnReceive() {
userID := sharedUser.UserID
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, message.UsersShared.RequestID, userID)
if needRestart {
t.xrayService.SetToNeedRestart()
}
output := ""
if err != nil {
@@ -220,16 +219,8 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
onlyMessage = true
if isAdmin {
if len(commandArgs) == 0 {
if t.xrayService.IsXrayRunning() {
err := t.xrayService.RestartXray(true)
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")
}
// Lite master: there is no local xray core to restart.
msg += t.I18nBot("tgbot.commands.xrayNotRunning")
} else {
handleUnknownCommand()
msg += t.I18nBot("tgbot.commands.restartUsage")
@@ -420,7 +411,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if err == nil {
needRestart, err := t.clientService.ResetClientTrafficLimitByEmail(&t.inboundService, email, limitTraffic)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
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)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
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 {
needRestart, err := t.clientService.ResetClientIpLimitByEmail(&t.inboundService, email, count)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
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)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
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":
enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
if enabled {
@@ -30,7 +30,7 @@ func TestTrafficDisableImmediatelyUpdatesNodeRuntime(t *testing.T) {
func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
mgr := runtime.NewManager()
fake := &fakeNodeRuntime{}
mgr.SetLocalRuntimeOverride(fake)
runtime.SetManager(mgr)
@@ -62,7 +62,7 @@ func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
func TestDelDepletedClientsCleansRuntimeAfterCommit(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
mgr := runtime.NewManager()
fake := &fakeNodeRuntime{}
mgr.SetLocalRuntimeOverride(fake)
runtime.SetManager(mgr)
File diff suppressed because it is too large Load Diff
@@ -4,20 +4,41 @@ import (
"encoding/json"
"testing"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"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) {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
nodeID := clientsTestNodeID(t, db)
in := &model.Inbound{
Tag: tag,
Enable: true,
Port: port,
Protocol: model.VLESS,
Settings: `{"clients":[],"decryption":"none"}`,
NodeID: &nodeID,
}
if err := db.Create(in).Error; err != nil {
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) {
t.Helper()
svc := &XrayService{}
cfg, err := svc.GetXrayConfig()
cfg, err := svc.GetXrayConfigForAgent(clientsTestNodeID(t, database.GetDB()), "", "")
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag != tag {
+2 -878
View File
@@ -3,12 +3,8 @@ package service
import (
"encoding/json"
"os"
"strings"
"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"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -17,13 +13,8 @@ import (
)
func TestMain(m *testing.M) {
// A test binary re-executed with MTG_FAKE_CHILD=1 poses as an mtg child
// process (see mtproto_fake_test.go) and never reaches the test runner.
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.
// The package logger must exist before any test exercises a path that
// logs a skip/warning.
xuilogger.InitLogger(logging.ERROR)
os.Exit(m.Run())
}
@@ -146,870 +137,3 @@ type egressRouting struct {
Type string `json:"type"`
} `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])
}
}
+12 -3
View File
@@ -25,19 +25,28 @@ func TestGetXrayConfigScale(t *testing.T) {
for _, shape := range shapes {
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
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
start := time.Now()
for range reps {
cfg, err := svc.GetXrayConfig()
cfg, err := svc.GetXrayConfigForAgent(scaleNode.Id, "", "")
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
if 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))
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()
}
-40
View File
@@ -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")
}
}
+1 -3
View File
@@ -53,10 +53,8 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
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"
if process := currentXrayProcess(); process != nil {
coreVersion = process.GetXrayVersion()
}
for _, outbound := range outbounds {
if err := xray.ValidateOutboundConfig(outbound); err != nil {
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
@@ -9,6 +9,23 @@ import (
"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 {
return base64.StdEncoding.EncodeToString(make([]byte, 32))
}
@@ -16,9 +33,9 @@ func wgTestSecretKey() string {
func wgInboundEmittedSettings(t *testing.T, tag string) map[string]any {
t.Helper()
svc := &XrayService{}
cfg, err := svc.GetXrayConfig()
cfg, err := svc.GetXrayConfigForAgent(wgTestNodeID(t), "", "")
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
for i := range cfg.InboundConfigs {
ic := cfg.InboundConfigs[i]
@@ -39,11 +56,13 @@ func seedWGInbound(t *testing.T, tag string, port int, clients []model.Client) {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
nodeID := wgTestNodeID(t)
in := &model.Inbound{
Tag: tag,
Enable: true,
Port: port,
Protocol: model.WireGuard,
NodeID: &nodeID,
Settings: `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420}`,
}
if err := db.Create(in).Error; err != nil {
+7 -29
View File
@@ -16,11 +16,9 @@ import (
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"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/sys"
"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/tgbot"
"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/sessions"
@@ -317,15 +314,9 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
// retained for signature compatibility and intentionally unused.
_ = restartXray
// Reconcile mtproto (mtg) sidecars and scrape their traffic
mtJob := job.NewMtprotoJob()
_, _ = s.cron.AddJob(cadenceMtproto, mtJob)
go mtJob.Run()
// Reconcile embedded AmneziaWG interfaces; traffic rides Xray's own stats
awgJob := job.NewAmneziaWGJob()
_, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob)
go awgJob.Run()
// MTProto/AmneziaWG sidecars were retired with the local core in
// P9(4b): neither protocol can be created anymore and no reconcile
// jobs are scheduled for them.
// 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
@@ -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.
// The closures bridge into XrayService (which owns the running xray
// process state) without forcing the runtime package to import service.
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
}))
runtime.SetManager(runtime.NewManager())
runtime.GetManager().SetNodeEgressResolver(&s.settingService)
// 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.
@@ -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)
@@ -642,9 +621,6 @@ func (s *Server) StopPanelOnly() error {
func (s *Server) stop(stopXray bool) error {
s.cancel()
if stopXray {
_ = s.xrayService.StopXray()
mtproto.GetManager().StopAll()
amneziawgnet.GetManager().StopAll()
}
if s.cron != nil {
s.cron.Stop()
@@ -690,6 +666,8 @@ func (s *Server) GetWSHub() any {
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 {
return s.xrayService.RestartXray(true)
return nil
}