mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-21 02:21:01 +00:00
feat(online): use xray online-stats API for onlines and access-log-free IP limit
Adopt xray-core's statsUserOnline policy and GetUsersStats RPC so online detection is connection-based and IP limiting no longer requires an access log. Falls back to the legacy traffic-delta onlines and access-log parsing when the running core lacks the RPCs (Unimplemented), probed lazily per process so a panel-driven version switch re-evaluates automatically. Backend: - xray/api.go: GetOnlineUsers (one GetUsersStats call returns all online users and their source IPs) and IsUnimplementedErr. - xray/process.go: per-process OnlineAPISupport tri-state capability cache. - service/xray.go: ensureStatsPolicy injects statsUserOnline into every policy level of the generated config; XrayService.GetOnlineUsers probes and falls back. - job/xray_traffic_job.go: union API onlines into the delta-derived active set; bump last_online for idle-but-connected clients. - job/check_client_ip_job.go: API-first IP source with shared enforcement; live observations bypass the 30-min stale cutoff; access-log path unchanged for older cores. - service/setting.go: GetIpLimitEnable always true; new accessLogEnable default for features that genuinely read the access log. Frontend: - Client form split into Basic and Config tabs; IP Limit and IP Log no longer gated on access log; compact Auto Renew next to Start After First Use; tabBasic/tabConfig added to all 13 locales. - Xray logs button on the dashboard now gated on accessLogEnable.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||||
|
export type OnlineAPISupport = number;
|
||||||
export type ProcessState = string;
|
export type ProcessState = string;
|
||||||
export type Protocol = string;
|
export type Protocol = string;
|
||||||
export type SubLinkProvider = unknown;
|
export type SubLinkProvider = unknown;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
export const OnlineAPISupportSchema = z.number().int();
|
||||||
|
export type OnlineAPISupport = z.infer<typeof OnlineAPISupportSchema>;
|
||||||
|
|
||||||
export const ProcessStateSchema = z.string();
|
export const ProcessStateSchema = z.string();
|
||||||
export type ProcessState = z.infer<typeof ProcessStateSchema>;
|
export type ProcessState = z.infer<typeof ProcessStateSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
|||||||
interface ClientBulkAddModalProps {
|
interface ClientBulkAddModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
inbounds: InboundOption[];
|
inbounds: InboundOption[];
|
||||||
ipLimitEnable?: boolean;
|
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSaved?: () => void;
|
onSaved?: () => void;
|
||||||
@@ -52,7 +51,6 @@ function emptyForm(): FormState {
|
|||||||
export default function ClientBulkAddModal({
|
export default function ClientBulkAddModal({
|
||||||
open,
|
open,
|
||||||
inbounds,
|
inbounds,
|
||||||
ipLimitEnable = false,
|
|
||||||
groups = [],
|
groups = [],
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -316,11 +314,9 @@ export default function ClientBulkAddModal({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ipLimitEnable && (
|
<Form.Item label={t('pages.clients.limitIp')}>
|
||||||
<Form.Item label={t('pages.clients.limitIp')}>
|
<InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
|
||||||
<InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
|
</Form.Item>
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form.Item label={t('pages.clients.totalGB')}>
|
<Form.Item label={t('pages.clients.totalGB')}>
|
||||||
<InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />
|
<InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -64,7 +65,6 @@ interface ClientFormModalProps {
|
|||||||
client: ClientRecord | null;
|
client: ClientRecord | null;
|
||||||
inbounds: InboundOption[];
|
inbounds: InboundOption[];
|
||||||
attachedIds?: number[];
|
attachedIds?: number[];
|
||||||
ipLimitEnable?: boolean;
|
|
||||||
tgBotEnable?: boolean;
|
tgBotEnable?: boolean;
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
save: (
|
save: (
|
||||||
@@ -136,7 +136,6 @@ export default function ClientFormModal({
|
|||||||
client,
|
client,
|
||||||
inbounds,
|
inbounds,
|
||||||
attachedIds = [],
|
attachedIds = [],
|
||||||
ipLimitEnable = false,
|
|
||||||
tgBotEnable = false,
|
tgBotEnable = false,
|
||||||
groups = [],
|
groups = [],
|
||||||
save,
|
save,
|
||||||
@@ -424,214 +423,232 @@ export default function ClientFormModal({
|
|||||||
onCancel={close}
|
onCancel={close}
|
||||||
>
|
>
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Row gutter={16}>
|
<Tabs
|
||||||
<Col xs={24} md={12}>
|
defaultActiveKey="basic"
|
||||||
<Form.Item label={t('pages.clients.email')} required>
|
items={[
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
{
|
||||||
<Input
|
key: 'basic',
|
||||||
value={form.email}
|
label: t('pages.clients.tabBasic'),
|
||||||
placeholder={t('pages.clients.email')}
|
children: (
|
||||||
style={{ flex: 1 }}
|
<>
|
||||||
onChange={(e) => update('email', e.target.value)}
|
<Row gutter={16}>
|
||||||
/>
|
<Col xs={24} md={12}>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
|
<Form.Item label={t('pages.clients.email')} required>
|
||||||
</Space.Compact>
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
</Form.Item>
|
<Input
|
||||||
</Col>
|
value={form.email}
|
||||||
<Col xs={24} md={12}>
|
placeholder={t('pages.clients.email')}
|
||||||
<Form.Item label={t('pages.clients.subId')}>
|
style={{ flex: 1 }}
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
onChange={(e) => update('email', e.target.value)}
|
||||||
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
|
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item label={t('pages.clients.totalGB')}>
|
||||||
|
<InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
|
||||||
|
onChange={(v) => update('totalGB', Number(v) || 0)} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={4}>
|
||||||
|
<Form.Item label={t('pages.clients.limitIp')}>
|
||||||
|
<InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
|
||||||
|
onChange={(v) => update('limitIp', Number(v) || 0)} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<Form.Item label={t('pages.clients.hysteriaAuth')}>
|
{form.delayedStart ? (
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
<Form.Item label={t('pages.clients.expireDays')}>
|
||||||
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
|
<InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
|
onChange={(v) => update('delayedDays', Number(v) || 0)} />
|
||||||
</Space.Compact>
|
</Form.Item>
|
||||||
</Form.Item>
|
) : (
|
||||||
</Col>
|
<Form.Item label={t('pages.clients.expiryTime')}>
|
||||||
<Col xs={24} md={12}>
|
<DateTimePicker
|
||||||
<Form.Item label={t('pages.clients.password')}>
|
value={form.expiryDate}
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
onChange={(d) => update('expiryDate', d || null)}
|
||||||
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
</Form.Item>
|
||||||
</Space.Compact>
|
)}
|
||||||
</Form.Item>
|
</Col>
|
||||||
</Col>
|
<Col xs={12} md={6}>
|
||||||
</Row>
|
<Form.Item label={t('pages.clients.delayedStart')}>
|
||||||
|
<Switch
|
||||||
|
checked={form.delayedStart}
|
||||||
|
onChange={(v) => {
|
||||||
|
update('delayedStart', v);
|
||||||
|
if (v) update('expiryDate', null);
|
||||||
|
else update('delayedDays', 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={6}>
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.clients.renew')}
|
||||||
|
tooltip={t('pages.clients.renewDesc')}
|
||||||
|
>
|
||||||
|
<InputNumber value={form.reset} min={0} style={{ width: '100%' }}
|
||||||
|
onChange={(v) => update('reset', Number(v) || 0)} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={12}>
|
{tgBotEnable && (
|
||||||
<Form.Item label={t('pages.clients.uuid')}>
|
<Col xs={24} md={12}>
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
<Form.Item label={t('pages.clients.telegramId')}>
|
||||||
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
|
<InputNumber value={form.tgId} min={0} controls={false}
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
|
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
|
||||||
</Space.Compact>
|
onChange={(v) => update('tgId', Number(v) || 0)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={ipLimitEnable ? 8 : 12}>
|
)}
|
||||||
<Form.Item label={t('pages.clients.totalGB')}>
|
<Col xs={24} md={tgBotEnable ? 12 : 24}>
|
||||||
<InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
|
<Form.Item label={t('pages.clients.comment')}>
|
||||||
onChange={(v) => update('totalGB', Number(v) || 0)} />
|
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
{ipLimitEnable && (
|
<Col xs={24} md={12}>
|
||||||
<Col xs={24} md={4}>
|
<Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
|
||||||
<Form.Item label={t('pages.clients.limitIp')}>
|
<AutoComplete
|
||||||
<InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
|
value={form.group}
|
||||||
onChange={(v) => update('limitIp', Number(v) || 0)} />
|
placeholder={t('pages.clients.groupPlaceholder')}
|
||||||
</Form.Item>
|
options={groups.map((g) => ({ value: g }))}
|
||||||
</Col>
|
onChange={(v) => update('group', v ?? '')}
|
||||||
)}
|
filterOption={(input, option) =>
|
||||||
</Row>
|
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
|
||||||
|
}
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
|
||||||
<Col xs={24} md={12}>
|
<SelectAllClearButtons
|
||||||
{form.delayedStart ? (
|
options={inboundOptions}
|
||||||
<Form.Item label={t('pages.clients.expireDays')}>
|
value={form.inboundIds}
|
||||||
<InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
|
onChange={(v) => update('inboundIds', v)}
|
||||||
onChange={(v) => update('delayedDays', Number(v) || 0)} />
|
/>
|
||||||
</Form.Item>
|
<Select
|
||||||
) : (
|
mode="multiple"
|
||||||
<Form.Item label={t('pages.clients.expiryTime')}>
|
value={form.inboundIds}
|
||||||
<DateTimePicker
|
onChange={(v) => update('inboundIds', v)}
|
||||||
value={form.expiryDate}
|
options={inboundOptions}
|
||||||
onChange={(d) => update('expiryDate', d || null)}
|
placeholder={t('pages.clients.selectInbound')}
|
||||||
/>
|
maxTagCount="responsive"
|
||||||
</Form.Item>
|
placement="topLeft"
|
||||||
)}
|
listHeight={220}
|
||||||
</Col>
|
showSearch={{
|
||||||
<Col xs={24} md={12}>
|
filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
|
||||||
<Form.Item label={t('pages.clients.delayedStart')}>
|
}}
|
||||||
<Switch
|
/>
|
||||||
checked={form.delayedStart}
|
</Form.Item>
|
||||||
onChange={(v) => {
|
|
||||||
update('delayedStart', v);
|
|
||||||
if (v) update('expiryDate', null);
|
|
||||||
else update('delayedDays', 0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Form.Item>
|
||||||
<Col xs={24} md={12}>
|
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
|
||||||
<Form.Item
|
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
|
||||||
label={t('pages.clients.renew')}
|
</Form.Item>
|
||||||
tooltip={t('pages.clients.renewDesc')}
|
|
||||||
>
|
|
||||||
<InputNumber value={form.reset} min={0} style={{ width: '100%' }}
|
|
||||||
onChange={(v) => update('reset', Number(v) || 0)} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
{showReverseTag && (
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Form.Item label={t('pages.clients.reverseTag')}>
|
|
||||||
<Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
|
|
||||||
onChange={(e) => update('reverseTag', e.target.value)} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
{showFlow && (
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Form.Item label={t('pages.clients.flow')}>
|
|
||||||
<Select
|
|
||||||
value={form.flow}
|
|
||||||
onChange={(v) => update('flow', v)}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('none') },
|
|
||||||
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
{showSecurity && (
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Form.Item label={t('pages.clients.vmessSecurity')}>
|
|
||||||
<Select
|
|
||||||
value={form.security}
|
|
||||||
onChange={(v) => update('security', v)}
|
|
||||||
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
{isEdit && (
|
||||||
{tgBotEnable && (
|
<Form.Item label={t('pages.clients.ipLog')}>
|
||||||
<Col xs={24} md={12}>
|
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
|
||||||
<Form.Item label={t('pages.clients.telegramId')}>
|
{clientIps.length > 0 ? clientIps.length : ''}
|
||||||
<InputNumber value={form.tgId} min={0} controls={false}
|
</Button>
|
||||||
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
|
</Form.Item>
|
||||||
onChange={(v) => update('tgId', Number(v) || 0)} />
|
)}
|
||||||
</Form.Item>
|
</>
|
||||||
</Col>
|
),
|
||||||
)}
|
},
|
||||||
<Col xs={24} md={tgBotEnable ? 12 : 24}>
|
{
|
||||||
<Form.Item label={t('pages.clients.comment')}>
|
key: 'config',
|
||||||
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
|
label: t('pages.clients.tabConfig'),
|
||||||
</Form.Item>
|
children: (
|
||||||
</Col>
|
<>
|
||||||
<Col xs={24} md={12}>
|
<Row gutter={16}>
|
||||||
<Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
|
<Col xs={24} md={12}>
|
||||||
<AutoComplete
|
<Form.Item label={t('pages.clients.uuid')}>
|
||||||
value={form.group}
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
placeholder={t('pages.clients.groupPlaceholder')}
|
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
|
||||||
options={groups.map((g) => ({ value: g }))}
|
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
|
||||||
onChange={(v) => update('group', v ?? '')}
|
</Space.Compact>
|
||||||
filterOption={(input, option) =>
|
</Form.Item>
|
||||||
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
|
</Col>
|
||||||
}
|
<Col xs={24} md={12}>
|
||||||
allowClear
|
<Form.Item label={t('pages.clients.password')}>
|
||||||
style={{ width: '100%' }}
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
/>
|
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
||||||
</Form.Item>
|
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
||||||
</Col>
|
</Space.Compact>
|
||||||
</Row>
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
|
<Row gutter={16}>
|
||||||
<SelectAllClearButtons
|
<Col xs={24} md={12}>
|
||||||
options={inboundOptions}
|
<Form.Item label={t('pages.clients.subId')}>
|
||||||
value={form.inboundIds}
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
onChange={(v) => update('inboundIds', v)}
|
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
|
||||||
/>
|
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
|
||||||
<Select
|
</Space.Compact>
|
||||||
mode="multiple"
|
</Form.Item>
|
||||||
value={form.inboundIds}
|
</Col>
|
||||||
onChange={(v) => update('inboundIds', v)}
|
<Col xs={24} md={12}>
|
||||||
options={inboundOptions}
|
<Form.Item label={t('pages.clients.hysteriaAuth')}>
|
||||||
placeholder={t('pages.clients.selectInbound')}
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
maxTagCount="responsive"
|
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
|
||||||
placement="topLeft"
|
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
|
||||||
listHeight={220}
|
</Space.Compact>
|
||||||
showSearch={{
|
</Form.Item>
|
||||||
filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
|
</Col>
|
||||||
}}
|
</Row>
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item>
|
<Row gutter={16}>
|
||||||
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
|
{showFlow && (
|
||||||
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
|
<Col xs={24} md={12}>
|
||||||
</Form.Item>
|
<Form.Item label={t('pages.clients.flow')}>
|
||||||
|
<Select
|
||||||
{isEdit && ipLimitEnable && (
|
value={form.flow}
|
||||||
<Form.Item label={t('pages.clients.ipLog')}>
|
onChange={(v) => update('flow', v)}
|
||||||
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
|
options={[
|
||||||
{clientIps.length > 0 ? clientIps.length : ''}
|
{ value: '', label: t('none') },
|
||||||
</Button>
|
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
|
||||||
</Form.Item>
|
]}
|
||||||
)}
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{showSecurity && (
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item label={t('pages.clients.vmessSecurity')}>
|
||||||
|
<Select
|
||||||
|
value={form.security}
|
||||||
|
onChange={(v) => update('security', v)}
|
||||||
|
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{showReverseTag && (
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item label={t('pages.clients.reverseTag')}>
|
||||||
|
<Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
|
||||||
|
onChange={(e) => update('reverseTag', e.target.value)} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export default function ClientsPage() {
|
|||||||
allGroups,
|
allGroups,
|
||||||
setQuery,
|
setQuery,
|
||||||
inbounds, onlines, loading, fetched, fetchError, subSettings,
|
inbounds, onlines, loading, fetched, fetchError, subSettings,
|
||||||
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||||||
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
|
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
|
||||||
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
||||||
applyTrafficEvent, applyClientStatsEvent,
|
applyTrafficEvent, applyClientStatsEvent,
|
||||||
@@ -1219,7 +1219,6 @@ export default function ClientsPage() {
|
|||||||
client={editingClient}
|
client={editingClient}
|
||||||
attachedIds={editingAttachedIds}
|
attachedIds={editingAttachedIds}
|
||||||
inbounds={inbounds}
|
inbounds={inbounds}
|
||||||
ipLimitEnable={ipLimitEnable}
|
|
||||||
tgBotEnable={tgBotEnable}
|
tgBotEnable={tgBotEnable}
|
||||||
groups={allGroups}
|
groups={allGroups}
|
||||||
save={onSave}
|
save={onSave}
|
||||||
@@ -1248,7 +1247,6 @@ export default function ClientsPage() {
|
|||||||
<ClientBulkAddModal
|
<ClientBulkAddModal
|
||||||
open={bulkAddOpen}
|
open={bulkAddOpen}
|
||||||
inbounds={inbounds}
|
inbounds={inbounds}
|
||||||
ipLimitEnable={ipLimitEnable}
|
|
||||||
groups={allGroups}
|
groups={allGroups}
|
||||||
onOpenChange={setBulkAddOpen}
|
onOpenChange={setBulkAddOpen}
|
||||||
onSaved={() => setBulkAddOpen(false)}
|
onSaved={() => setBulkAddOpen(false)}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function IndexPage() {
|
|||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
const [accessLogEnable, setAccessLogEnable] = useState(false);
|
||||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||||
currentVersion: '',
|
currentVersion: '',
|
||||||
latestVersion: '',
|
latestVersion: '',
|
||||||
@@ -87,8 +87,8 @@ export default function IndexPage() {
|
|||||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
|
HttpUtil.post<{ accessLogEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
|
||||||
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
|
if (msg?.success && msg.obj) setAccessLogEnable(!!msg.obj.accessLogEnable);
|
||||||
});
|
});
|
||||||
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||||
@@ -186,7 +186,7 @@ export default function IndexPage() {
|
|||||||
<XrayStatusCard
|
<XrayStatusCard
|
||||||
status={status}
|
status={status}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
ipLimitEnable={ipLimitEnable}
|
accessLogEnable={accessLogEnable}
|
||||||
onStopXray={stopXray}
|
onStopXray={stopXray}
|
||||||
onRestartXray={restartXray}
|
onRestartXray={restartXray}
|
||||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import './XrayStatusCard.css';
|
|||||||
interface XrayStatusCardProps {
|
interface XrayStatusCardProps {
|
||||||
status: Status;
|
status: Status;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
ipLimitEnable: boolean;
|
accessLogEnable: boolean;
|
||||||
onStopXray: () => void;
|
onStopXray: () => void;
|
||||||
onRestartXray: () => void;
|
onRestartXray: () => void;
|
||||||
onOpenLogs: () => void;
|
onOpenLogs: () => void;
|
||||||
@@ -31,7 +31,7 @@ const XRAY_STATE_KEYS: Record<string, string> = {
|
|||||||
export default function XrayStatusCard({
|
export default function XrayStatusCard({
|
||||||
status,
|
status,
|
||||||
isMobile,
|
isMobile,
|
||||||
ipLimitEnable,
|
accessLogEnable,
|
||||||
onStopXray,
|
onStopXray,
|
||||||
onRestartXray,
|
onRestartXray,
|
||||||
onOpenLogs,
|
onOpenLogs,
|
||||||
@@ -86,7 +86,9 @@ export default function XrayStatusCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
...(ipLimitEnable
|
// the xray log viewer reads the access log file, so the button only makes
|
||||||
|
// sense when one is configured (unlike IP limit, which no longer needs it)
|
||||||
|
...(accessLogEnable
|
||||||
? [
|
? [
|
||||||
<Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
|
<Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
|
||||||
<BarsOutlined />
|
<BarsOutlined />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const DefaultsPayloadSchema = z.object({
|
|||||||
remarkModel: z.string().optional(),
|
remarkModel: z.string().optional(),
|
||||||
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||||
ipLimitEnable: z.boolean().optional(),
|
ipLimitEnable: z.boolean().optional(),
|
||||||
|
accessLogEnable: z.boolean().optional(),
|
||||||
webDomain: z.string().optional(),
|
webDomain: z.string().optional(),
|
||||||
subDomain: z.string().optional(),
|
subDomain: z.string().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"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"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -27,10 +28,14 @@ type IPWithTimestamp struct {
|
|||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
|
// 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 when the running core supports it (no access log needed), falling back
|
||||||
|
// to access-log parsing on older cores.
|
||||||
type CheckClientIpJob struct {
|
type CheckClientIpJob struct {
|
||||||
lastClear int64
|
lastClear int64
|
||||||
disAllowedIps []string
|
disAllowedIps []string
|
||||||
|
xrayService service.XrayService
|
||||||
}
|
}
|
||||||
|
|
||||||
var job *CheckClientIpJob
|
var job *CheckClientIpJob
|
||||||
@@ -50,22 +55,32 @@ func (j *CheckClientIpJob) Run() {
|
|||||||
j.lastClear = time.Now().Unix()
|
j.lastClear = time.Now().Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldClearAccessLog := false
|
|
||||||
fail2BanEnabled := isFail2BanEnabled()
|
fail2BanEnabled := isFail2BanEnabled()
|
||||||
hasLimit := fail2BanEnabled && j.hasLimitIp()
|
hasLimit := fail2BanEnabled && j.hasLimitIp()
|
||||||
f2bInstalled := false
|
f2bInstalled := false
|
||||||
if hasLimit {
|
if hasLimit {
|
||||||
f2bInstalled = j.checkFail2BanInstalled()
|
f2bInstalled = j.checkFail2BanInstalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if observed, apiMode := j.collectFromOnlineAPI(); apiMode {
|
||||||
|
if fail2BanEnabled {
|
||||||
|
j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
|
||||||
|
}
|
||||||
|
// The core tracks online IPs itself, so no access log is needed in this
|
||||||
|
// mode; still rotate a user-configured access log hourly so it doesn't
|
||||||
|
// grow unboundedly. The enforcement-triggered rotation is skipped —
|
||||||
|
// nothing here reads the log.
|
||||||
|
if j.checkAccessLogAvailable(false) && time.Now().Unix()-j.lastClear > 3600 {
|
||||||
|
j.clearAccessLog()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldClearAccessLog := false
|
||||||
isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
|
isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
|
||||||
|
|
||||||
if fail2BanEnabled && isAccessLogAvailable {
|
if fail2BanEnabled && isAccessLogAvailable {
|
||||||
enforce := hasLimit
|
shouldClearAccessLog = j.processLogFile(j.resolveEnforce(hasLimit, f2bInstalled))
|
||||||
if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
|
|
||||||
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
|
||||||
enforce = false
|
|
||||||
}
|
|
||||||
shouldClearAccessLog = j.processLogFile(enforce)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
|
if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
|
||||||
@@ -73,6 +88,50 @@ func (j *CheckClientIpJob) Run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveEnforce decides whether limits can actually be enforced this run,
|
||||||
|
// warning when fail2ban is missing on a platform that needs it.
|
||||||
|
func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
|
||||||
|
if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
|
||||||
|
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
||||||
|
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 must fall back to access-log parsing.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) clearAccessLog() {
|
func (j *CheckClientIpJob) clearAccessLog() {
|
||||||
logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
j.checkError(err)
|
j.checkError(err)
|
||||||
@@ -183,18 +242,26 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
|||||||
j.checkError(err)
|
j.checkError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog := false
|
return j.processObserved(inboundClientIps, enforce, false)
|
||||||
for email, ipTimestamps := range inboundClientIps {
|
}
|
||||||
|
|
||||||
// The access log can still reference a client that was just renamed
|
// processObserved runs collection + enforcement for one scan's observations
|
||||||
|
// (email -> ip -> last-seen unix seconds). observedAreLive marks the
|
||||||
|
// observations as live connections (online-stats API) rather than recent log
|
||||||
|
// lines: live entries bypass the stale cutoff, since a connection that opened
|
||||||
|
// hours ago is still live even though its timestamp is old.
|
||||||
|
func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
|
||||||
|
shouldCleanLog := false
|
||||||
|
for email, ipTimestamps := range observed {
|
||||||
|
|
||||||
|
// The observations can still reference a client that was just renamed
|
||||||
// or deleted; its email no longer matches any inbound. Skip it (and
|
// or deleted; its email no longer matches any inbound. Skip it (and
|
||||||
// drop any orphaned tracking row) instead of recreating a row and
|
// drop any orphaned tracking row) instead of recreating a row and
|
||||||
// logging an ERROR every run until the log rotates out the old email
|
// logging an ERROR every run (#4963).
|
||||||
// (#4963).
|
|
||||||
inbound, err := j.getInboundByEmail(email)
|
inbound, err := j.getInboundByEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
|
logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
|
||||||
j.delInboundClientIps(email)
|
j.delInboundClientIps(email)
|
||||||
} else {
|
} else {
|
||||||
j.checkError(err)
|
j.checkError(err)
|
||||||
@@ -214,13 +281,17 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
|
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
|
// 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))
|
ipMap := make(map[string]int64, len(old)+len(new))
|
||||||
for _, ipTime := range old {
|
for _, ipTime := range old {
|
||||||
if ipTime.Timestamp < staleCutoff {
|
if ipTime.Timestamp < staleCutoff {
|
||||||
@@ -229,7 +300,7 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
|
|||||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||||
}
|
}
|
||||||
for _, ipTime := range new {
|
for _, ipTime := range new {
|
||||||
if ipTime.Timestamp < staleCutoff {
|
if !newAlwaysLive && ipTime.Timestamp < staleCutoff {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
|
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
|
||||||
@@ -239,6 +310,16 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
|
|||||||
return ipMap
|
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) {
|
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
||||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
||||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
||||||
@@ -343,7 +424,7 @@ func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
|
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) bool {
|
||||||
if inbound.Settings == "" {
|
if inbound.Settings == "" {
|
||||||
logger.Debug("wrong data:", inbound)
|
logger.Debug("wrong data:", inbound)
|
||||||
return false
|
return false
|
||||||
@@ -380,7 +461,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||||||
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
|
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
|
||||||
|
|
||||||
// only ips seen in this scan count toward the limit. see
|
// only ips seen in this scan count toward the limit. see
|
||||||
// partitionLiveIps.
|
// partitionLiveIps.
|
||||||
@@ -394,15 +475,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||||||
j.disAllowedIps = []string{}
|
j.disAllowedIps = []string{}
|
||||||
|
|
||||||
// historical db-only ips are excluded from this count on purpose.
|
// historical db-only ips are excluded from this count on purpose.
|
||||||
var keptLive []IPWithTimestamp
|
keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
|
||||||
if len(liveIps) > limitIp {
|
if len(bannedLive) > 0 {
|
||||||
shouldCleanLog = true
|
shouldCleanLog = true
|
||||||
|
|
||||||
// keep the newest live ips, ban older ones.
|
|
||||||
cutoff := len(liveIps) - limitIp
|
|
||||||
keptLive = liveIps[cutoff:]
|
|
||||||
bannedLive := liveIps[:cutoff]
|
|
||||||
|
|
||||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||||
@@ -422,8 +498,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||||||
|
|
||||||
// force xray to drop existing connections from banned ips
|
// force xray to drop existing connections from banned ips
|
||||||
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
||||||
} else {
|
|
||||||
keptLive = liveIps
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep kept-live + historical in the blob so the panel keeps showing
|
// keep kept-live + historical in the blob so the panel keeps showing
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getInboundByEmail: %v", err)
|
t.Fatalf("getInboundByEmail: %v", err)
|
||||||
}
|
}
|
||||||
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
|
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
|
||||||
|
|
||||||
if shouldCleanLog {
|
if shouldCleanLog {
|
||||||
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
||||||
@@ -252,7 +252,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getInboundByEmail: %v", err)
|
t.Fatalf("getInboundByEmail: %v", err)
|
||||||
}
|
}
|
||||||
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
|
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
|
||||||
|
|
||||||
if !shouldCleanLog {
|
if !shouldCleanLog {
|
||||||
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
|||||||
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
||||||
}
|
}
|
||||||
|
|
||||||
got := mergeClientIps(old, new, 1000)
|
got := mergeClientIps(old, new, 1000, false)
|
||||||
|
|
||||||
want := map[string]int64{"2.2.2.2": 2000}
|
want := map[string]int64{"2.2.2.2": 2000}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
@@ -36,7 +36,7 @@ func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
|||||||
old := []IPWithTimestamp{
|
old := []IPWithTimestamp{
|
||||||
{IP: "1.1.1.1", Timestamp: 1500},
|
{IP: "1.1.1.1", Timestamp: 1500},
|
||||||
}
|
}
|
||||||
got := mergeClientIps(old, nil, 1000)
|
got := mergeClientIps(old, nil, 1000, false)
|
||||||
|
|
||||||
want := map[string]int64{"1.1.1.1": 1500}
|
want := map[string]int64{"1.1.1.1": 1500}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
@@ -48,7 +48,7 @@ func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
|||||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
|
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
|
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
|
||||||
|
|
||||||
got := mergeClientIps(old, new, 1000)
|
got := mergeClientIps(old, new, 1000, false)
|
||||||
|
|
||||||
if got["1.1.1.1"] != 1700 {
|
if got["1.1.1.1"] != 1700 {
|
||||||
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
|
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
|
||||||
@@ -59,7 +59,7 @@ func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
|||||||
// A log line with a clock-skewed old timestamp must not resurrect a
|
// A log line with a clock-skewed old timestamp must not resurrect a
|
||||||
// stale IP past the cutoff.
|
// stale IP past the cutoff.
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
|
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
|
||||||
got := mergeClientIps(nil, new, 1000)
|
got := mergeClientIps(nil, new, 1000, false)
|
||||||
|
|
||||||
if len(got) != 0 {
|
if len(got) != 0 {
|
||||||
t.Fatalf("stale new IP should have been dropped, got %v", got)
|
t.Fatalf("stale new IP should have been dropped, got %v", got)
|
||||||
@@ -72,7 +72,7 @@ func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
|||||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
|
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
|
||||||
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
|
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
|
||||||
|
|
||||||
got := mergeClientIps(old, new, 0)
|
got := mergeClientIps(old, new, 0, false)
|
||||||
|
|
||||||
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
|
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
@@ -80,6 +80,66 @@ func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func collectIps(entries []IPWithTimestamp) []string {
|
||||||
out := make([]string, 0, len(entries))
|
out := make([]string, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
|||||||
@@ -66,21 +66,37 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
j.xrayService.SetToNeedRestart()
|
j.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
||||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("get clients last online failed:", err)
|
|
||||||
}
|
|
||||||
if lastOnlineMap == nil {
|
|
||||||
lastOnlineMap = make(map[string]int64)
|
|
||||||
}
|
|
||||||
// Derive the local online set from this poll's per-email deltas rather
|
// 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
|
// 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
|
// and would otherwise make a client active only on a remote node appear
|
||||||
// online on local inbounds.
|
// online on local inbounds.
|
||||||
activeEmails := make([]string, 0, len(clientTraffics))
|
activeEmails := make([]string, 0, len(clientTraffics))
|
||||||
|
deltaActive := make(map[string]bool, len(clientTraffics))
|
||||||
for _, ct := range clientTraffics {
|
for _, ct := range clientTraffics {
|
||||||
if ct != nil && ct.Up+ct.Down > 0 {
|
if ct != nil && ct.Up+ct.Down > 0 {
|
||||||
activeEmails = append(activeEmails, ct.Email)
|
activeEmails = append(activeEmails, ct.Email)
|
||||||
|
deltaActive[ct.Email] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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.
|
// Pair the email signal with the inbound tags that moved bytes this poll.
|
||||||
@@ -100,6 +116,13 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("get clients last online failed:", err)
|
||||||
|
}
|
||||||
|
if lastOnlineMap == nil {
|
||||||
|
lastOnlineMap = make(map[string]int64)
|
||||||
|
}
|
||||||
onlineClients := j.inboundService.GetOnlineClients()
|
onlineClients := j.inboundService.GetOnlineClients()
|
||||||
if onlineClients == nil {
|
if onlineClients == nil {
|
||||||
onlineClients = []string{}
|
onlineClients = []string{}
|
||||||
|
|||||||
@@ -837,6 +837,28 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
|
|||||||
return traffics, nil
|
return traffics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BumpClientsLastOnline sets client_traffics.last_online to now for the given
|
||||||
|
// emails. Used in online-API mode for clients that hold a live connection but
|
||||||
|
// moved no bytes this poll — the traffic path (addClientTraffic) only bumps
|
||||||
|
// last_online on a non-zero delta, so idle-but-connected clients would
|
||||||
|
// otherwise show a stale "last online" while being reported online.
|
||||||
|
func (s *InboundService) BumpClientsLastOnline(emails []string) error {
|
||||||
|
uniq := uniqueNonEmptyStrings(emails)
|
||||||
|
if len(uniq) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return submitTrafficWrite(func() error {
|
||||||
|
db := database.GetDB()
|
||||||
|
for _, batch := range chunkStrings(uniq, sqliteMaxVars) {
|
||||||
|
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("last_online", now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
|
func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
|
||||||
uniq := uniqueNonEmptyStrings(emails)
|
uniq := uniqueNonEmptyStrings(emails)
|
||||||
if len(uniq) == 0 {
|
if len(uniq) == 0 {
|
||||||
|
|||||||
@@ -798,7 +798,18 @@ func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error {
|
|||||||
return s.setBool("restartXrayOnClientDisable", value)
|
return s.setBool("restartXrayOnClientDisable", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIpLimitEnable reports whether the IP-limit feature is available. Always
|
||||||
|
// true since the panel enforces limits via the core's online-stats API; on an
|
||||||
|
// older core the job falls back to access-log parsing and warns there when the
|
||||||
|
// log is missing, so the UI no longer hides the field behind that condition.
|
||||||
func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccessLogEnable reports whether an Xray access log is configured. Used by
|
||||||
|
// the UI for features that genuinely read the log file (the xray log viewer) —
|
||||||
|
// distinct from IP limiting, which works without it.
|
||||||
|
func (s *SettingService) GetAccessLogEnable() (bool, error) {
|
||||||
accessLogPath, err := xray.GetAccessLogPath()
|
accessLogPath, err := xray.GetAccessLogPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -1022,25 +1033,26 @@ func (s *SettingService) BuildSubURIBase(host string) string {
|
|||||||
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||||
type settingFunc func() (any, error)
|
type settingFunc func() (any, error)
|
||||||
settings := map[string]settingFunc{
|
settings := map[string]settingFunc{
|
||||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||||
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
||||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||||
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
|
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
|
||||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||||
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
||||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
||||||
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
|
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
|
||||||
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
||||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||||
"webDomain": func() (any, error) { return s.GetWebDomain() },
|
"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
|
||||||
"subDomain": func() (any, error) { return s.GetSubDomain() },
|
"webDomain": func() (any, error) { return s.GetWebDomain() },
|
||||||
|
"subDomain": func() (any, error) { return s.GetSubDomain() },
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[string]any)
|
result := make(map[string]any)
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
}
|
}
|
||||||
xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
|
xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
|
||||||
xrayConfig.API = ensureAPIServices(xrayConfig.API)
|
xrayConfig.API = ensureAPIServices(xrayConfig.API)
|
||||||
|
xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
|
||||||
|
|
||||||
_, _, _ = s.inboundService.AddTraffic(nil, nil)
|
_, _, _ = s.inboundService.AddTraffic(nil, nil)
|
||||||
|
|
||||||
@@ -421,6 +422,51 @@ func ensureAPIServices(api json_util.RawMessage) json_util.RawMessage {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureStatsPolicy guarantees every policy level in the generated config has
|
||||||
|
// statsUserOnline enabled, so the core tracks per-email online IPs for the
|
||||||
|
// panel's online view and access-log-free IP limiting. Generated clients carry
|
||||||
|
// no explicit level, so level "0" is created when absent. The flag is panel
|
||||||
|
// infrastructure and is forced on even over an explicit false in the template,
|
||||||
|
// same as the api services above. An entirely missing or unparsable policy
|
||||||
|
// block is left alone; the stored template itself is never modified — only the
|
||||||
|
// generated runtime config.
|
||||||
|
func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage {
|
||||||
|
if len(policy) == 0 {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(policy, &parsed); err != nil {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
levels, _ := parsed["levels"].(map[string]any)
|
||||||
|
if levels == nil {
|
||||||
|
levels = make(map[string]any)
|
||||||
|
}
|
||||||
|
if _, ok := levels["0"]; !ok {
|
||||||
|
levels["0"] = map[string]any{}
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for _, raw := range levels {
|
||||||
|
level, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if enabled, ok := level["statsUserOnline"].(bool); !ok || !enabled {
|
||||||
|
level["statsUserOnline"] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
parsed["levels"] = levels
|
||||||
|
out, err := json.Marshal(parsed)
|
||||||
|
if err != nil {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
|
// resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
|
||||||
// absolute paths under config.GetLogFolder(), so Xray writes those files
|
// absolute paths under config.GetLogFolder(), so Xray writes those files
|
||||||
// alongside the panel's other logs regardless of the working directory the
|
// alongside the panel's other logs regardless of the working directory the
|
||||||
@@ -493,6 +539,43 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
|
|||||||
return traffic, clientTraffic, nil
|
return traffic, clientTraffic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOnlineUsers returns connection-based online users (email + source IPs)
|
||||||
|
// from the running core's online-stats API. ok=false means the API is not
|
||||||
|
// available — xray isn't running or the core predates the online-stats RPCs —
|
||||||
|
// and callers must use the legacy traffic-delta / access-log paths. The
|
||||||
|
// capability is probed lazily per process: an Unimplemented answer pins this
|
||||||
|
// core as unsupported until the next restart, while transient errors leave the
|
||||||
|
// capability undecided so a flaky poll can't lock in legacy mode.
|
||||||
|
func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
|
||||||
|
if !s.IsXrayRunning() {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||||
|
logger.Debug("Failed to initialize Xray API:", err)
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer s.xrayAPI.Close()
|
||||||
|
|
||||||
|
users, err := s.xrayAPI.GetOnlineUsers()
|
||||||
|
if err != nil {
|
||||||
|
if xray.IsUnimplementedErr(err) {
|
||||||
|
p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
|
||||||
|
logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
logger.Debug("Failed to fetch Xray online users:", err)
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
|
||||||
|
p.SetOnlineAPISupport(xray.OnlineAPISupported)
|
||||||
|
logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
|
||||||
|
}
|
||||||
|
return users, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// BalancerStatus is the live view of one balancer for the panel UI. Running
|
// BalancerStatus is the live view of one balancer for the panel UI. Running
|
||||||
// is false when the balancer isn't present in the running core (e.g. xray is
|
// is false when the balancer isn't present in the running core (e.g. xray is
|
||||||
// stopped or the balancer hasn't been saved/applied yet).
|
// stopped or the balancer hasn't been saved/applied yet).
|
||||||
|
|||||||
@@ -54,6 +54,71 @@ func TestEnsureAPIServices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureStatsPolicy(t *testing.T) {
|
||||||
|
// default-template shape: level "0" exists with traffic flags — the online
|
||||||
|
// flag is added and the siblings survive untouched
|
||||||
|
out := ensureStatsPolicy(json_util.RawMessage(`{"levels":{"0":{"handshake":4,"statsUserUplink":true,"statsUserDownlink":true}},"system":{"statsInboundDownlink":true}}`))
|
||||||
|
var parsed struct {
|
||||||
|
Levels map[string]map[string]any `json:"levels"`
|
||||||
|
System map[string]any `json:"system"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
level0 := parsed.Levels["0"]
|
||||||
|
if level0["statsUserOnline"] != true {
|
||||||
|
t.Fatalf("statsUserOnline must be injected into level 0, got %v", level0)
|
||||||
|
}
|
||||||
|
if level0["statsUserUplink"] != true || level0["statsUserDownlink"] != true || level0["handshake"] != float64(4) {
|
||||||
|
t.Fatalf("sibling keys must be preserved, got %v", level0)
|
||||||
|
}
|
||||||
|
if parsed.System["statsInboundDownlink"] != true {
|
||||||
|
t.Fatalf("system block must be preserved, got %v", parsed.System)
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing levels block: level "0" is created with the flag
|
||||||
|
out = ensureStatsPolicy(json_util.RawMessage(`{"system":{}}`))
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if parsed.Levels["0"]["statsUserOnline"] != true {
|
||||||
|
t.Fatalf("level 0 must be created with statsUserOnline, got %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// every level gets the flag, an explicit false included — the flag is
|
||||||
|
// panel infrastructure, like the api services
|
||||||
|
out = ensureStatsPolicy(json_util.RawMessage(`{"levels":{"0":{"statsUserOnline":false},"1":{"connIdle":300}}}`))
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"0", "1"} {
|
||||||
|
if parsed.Levels[key]["statsUserOnline"] != true {
|
||||||
|
t.Fatalf("level %s must have statsUserOnline forced on, got %s", key, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parsed.Levels["1"]["connIdle"] != float64(300) {
|
||||||
|
t.Fatalf("level 1 siblings must be preserved, got %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// already-enabled input passes through byte-identical (no marshal churn,
|
||||||
|
// no spurious restart)
|
||||||
|
full := json_util.RawMessage(`{"levels":{"0":{"statsUserOnline":true}}}`)
|
||||||
|
if got := ensureStatsPolicy(full); string(got) != string(full) {
|
||||||
|
t.Fatalf("already-enabled policy must pass through untouched, got %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// absent policy block stays absent
|
||||||
|
if got := ensureStatsPolicy(nil); got != nil {
|
||||||
|
t.Fatalf("nil policy must stay nil, got %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unparsable policy is left untouched
|
||||||
|
bad := json_util.RawMessage(`{not json`)
|
||||||
|
if got := ensureStatsPolicy(bad); string(got) != string(bad) {
|
||||||
|
t.Fatalf("unparsable policy must be left untouched, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func egressTestConfig() *xray.Config {
|
func egressTestConfig() *xray.Config {
|
||||||
return &xray.Config{
|
return &xray.Config{
|
||||||
RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
|
RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "أساسي",
|
||||||
|
"tabConfig": "التكوين",
|
||||||
"add": "إضافة عميل",
|
"add": "إضافة عميل",
|
||||||
"edit": "تعديل العميل",
|
"edit": "تعديل العميل",
|
||||||
"submitAdd": "إضافة عميل",
|
"submitAdd": "إضافة عميل",
|
||||||
|
|||||||
@@ -628,6 +628,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "Basic",
|
||||||
|
"tabConfig": "Config",
|
||||||
"add": "Add Client",
|
"add": "Add Client",
|
||||||
"edit": "Edit Client",
|
"edit": "Edit Client",
|
||||||
"submitAdd": "Add Client",
|
"submitAdd": "Add Client",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "Básico",
|
||||||
|
"tabConfig": "Configuración",
|
||||||
"add": "Añadir cliente",
|
"add": "Añadir cliente",
|
||||||
"edit": "Editar cliente",
|
"edit": "Editar cliente",
|
||||||
"submitAdd": "Añadir cliente",
|
"submitAdd": "Añadir cliente",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "پایه",
|
||||||
|
"tabConfig": "پیکربندی",
|
||||||
"add": "افزودن کلاینت",
|
"add": "افزودن کلاینت",
|
||||||
"edit": "ویرایش کلاینت",
|
"edit": "ویرایش کلاینت",
|
||||||
"submitAdd": "افزودن کلاینت",
|
"submitAdd": "افزودن کلاینت",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "Dasar",
|
||||||
|
"tabConfig": "Konfigurasi",
|
||||||
"add": "Tambah klien",
|
"add": "Tambah klien",
|
||||||
"edit": "Ubah klien",
|
"edit": "Ubah klien",
|
||||||
"submitAdd": "Tambah klien",
|
"submitAdd": "Tambah klien",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "基本",
|
||||||
|
"tabConfig": "設定",
|
||||||
"add": "クライアントを追加",
|
"add": "クライアントを追加",
|
||||||
"edit": "クライアントを編集",
|
"edit": "クライアントを編集",
|
||||||
"submitAdd": "クライアントを追加",
|
"submitAdd": "クライアントを追加",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "Básico",
|
||||||
|
"tabConfig": "Configuração",
|
||||||
"add": "Adicionar cliente",
|
"add": "Adicionar cliente",
|
||||||
"edit": "Editar cliente",
|
"edit": "Editar cliente",
|
||||||
"submitAdd": "Adicionar cliente",
|
"submitAdd": "Adicionar cliente",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "Основные",
|
||||||
|
"tabConfig": "Конфигурация",
|
||||||
"add": "Добавить клиента",
|
"add": "Добавить клиента",
|
||||||
"edit": "Изменить клиента",
|
"edit": "Изменить клиента",
|
||||||
"submitAdd": "Добавить клиента",
|
"submitAdd": "Добавить клиента",
|
||||||
|
|||||||
@@ -628,6 +628,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "Temel",
|
||||||
|
"tabConfig": "Yapılandırma",
|
||||||
"add": "Kullanıcı Ekle",
|
"add": "Kullanıcı Ekle",
|
||||||
"edit": "Kullanıcıyı Düzenle",
|
"edit": "Kullanıcıyı Düzenle",
|
||||||
"submitAdd": "Kullanıcı Ekle",
|
"submitAdd": "Kullanıcı Ekle",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "Основні",
|
||||||
|
"tabConfig": "Конфігурація",
|
||||||
"add": "Додати клієнта",
|
"add": "Додати клієнта",
|
||||||
"edit": "Редагувати клієнта",
|
"edit": "Редагувати клієнта",
|
||||||
"submitAdd": "Додати клієнта",
|
"submitAdd": "Додати клієнта",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "Cơ bản",
|
||||||
|
"tabConfig": "Cấu hình",
|
||||||
"add": "Thêm khách hàng",
|
"add": "Thêm khách hàng",
|
||||||
"edit": "Chỉnh sửa khách hàng",
|
"edit": "Chỉnh sửa khách hàng",
|
||||||
"submitAdd": "Thêm khách hàng",
|
"submitAdd": "Thêm khách hàng",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "基本",
|
||||||
|
"tabConfig": "配置",
|
||||||
"add": "添加客户端",
|
"add": "添加客户端",
|
||||||
"edit": "编辑客户端",
|
"edit": "编辑客户端",
|
||||||
"submitAdd": "添加客户端",
|
"submitAdd": "添加客户端",
|
||||||
|
|||||||
@@ -627,6 +627,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
"tabBasic": "基本",
|
||||||
|
"tabConfig": "配置",
|
||||||
"add": "新增客戶端",
|
"add": "新增客戶端",
|
||||||
"edit": "編輯客戶端",
|
"edit": "編輯客戶端",
|
||||||
"submitAdd": "新增客戶端",
|
"submitAdd": "新增客戶端",
|
||||||
|
|||||||
+60
-2
@@ -33,7 +33,9 @@ import (
|
|||||||
"github.com/xtls/xray-core/proxy/vless"
|
"github.com/xtls/xray-core/proxy/vless"
|
||||||
"github.com/xtls/xray-core/proxy/vmess"
|
"github.com/xtls/xray-core/proxy/vmess"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
// XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics.
|
// XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics.
|
||||||
@@ -289,8 +291,8 @@ type RouteTestRequest struct {
|
|||||||
type RouteTestResult struct {
|
type RouteTestResult struct {
|
||||||
// Matched is false when no routing rule matched — traffic would use the
|
// Matched is false when no routing rule matched — traffic would use the
|
||||||
// default (first) outbound and OutboundTag is empty.
|
// default (first) outbound and OutboundTag is empty.
|
||||||
Matched bool `json:"matched"`
|
Matched bool `json:"matched"`
|
||||||
OutboundTag string `json:"outboundTag"`
|
OutboundTag string `json:"outboundTag"`
|
||||||
// GroupTags lists the balancer chain the decision went through, when any.
|
// GroupTags lists the balancer chain the decision went through, when any.
|
||||||
GroupTags []string `json:"groupTags,omitempty"`
|
GroupTags []string `json:"groupTags,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -571,6 +573,62 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
|
|||||||
return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
|
return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnlineIP is one source address of a live connection, with the unix time (seconds)
|
||||||
|
// the core last dispatched a link from it.
|
||||||
|
type OnlineIP struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
LastSeen int64 `json:"lastSeen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnlineUser is a client email with at least one live connection and the source
|
||||||
|
// IPs of those connections, as tracked by Xray's statsUserOnline policy.
|
||||||
|
type OnlineUser struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
IPs []OnlineIP `json:"ips"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnlineUsers returns every user with at least one live connection plus their
|
||||||
|
// source IPs, via StatsService.GetUsersStats (one RPC covers all users). Requires
|
||||||
|
// statsUserOnline enabled in the policy levels; older cores return Unimplemented.
|
||||||
|
func (x *XrayAPI) GetOnlineUsers() ([]OnlineUser, error) {
|
||||||
|
if x.grpcClient == nil {
|
||||||
|
return nil, common.NewError("xray api is not initialized")
|
||||||
|
}
|
||||||
|
if x.StatsServiceClient == nil {
|
||||||
|
return nil, common.NewError("xray StatsServiceClient is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := (*x.StatsServiceClient).GetUsersStats(ctx, &statsService.GetUsersStatsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]OnlineUser, 0, len(resp.GetUsers()))
|
||||||
|
for _, u := range resp.GetUsers() {
|
||||||
|
if u == nil || u.GetEmail() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips := make([]OnlineIP, 0, len(u.GetIps()))
|
||||||
|
for _, entry := range u.GetIps() {
|
||||||
|
if entry == nil || entry.GetIp() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips = append(ips, OnlineIP{IP: entry.GetIp(), LastSeen: entry.GetLastSeen()})
|
||||||
|
}
|
||||||
|
users = append(users, OnlineUser{Email: u.GetEmail(), IPs: ips})
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUnimplementedErr reports whether err is the running core saying it lacks an
|
||||||
|
// RPC (an older Xray binary without the online-stats API).
|
||||||
|
func IsUnimplementedErr(err error) bool {
|
||||||
|
return status.Code(err) == codes.Unimplemented
|
||||||
|
}
|
||||||
|
|
||||||
// processTraffic aggregates a traffic stat into trafficMap using regex matches and value.
|
// processTraffic aggregates a traffic stat into trafficMap using regex matches and value.
|
||||||
func processTraffic(matches []string, value int64, trafficMap map[string]*Traffic) {
|
func processTraffic(matches []string, value int64, trafficMap map[string]*Traffic) {
|
||||||
isInbound := matches[1] == "inbound"
|
isInbound := matches[1] == "inbound"
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ func TestXrayAPI_E2E(t *testing.T) {
|
|||||||
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
|
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"policy": map[string]any{},
|
"policy": map[string]any{
|
||||||
"stats": map[string]any{},
|
"levels": map[string]any{
|
||||||
|
"0": map[string]any{"statsUserOnline": true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"stats": map[string]any{},
|
||||||
}
|
}
|
||||||
cfgBytes, err := json.MarshalIndent(cfg, "", " ")
|
cfgBytes, err := json.MarshalIndent(cfg, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,6 +134,19 @@ func TestXrayAPI_E2E(t *testing.T) {
|
|||||||
t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
|
t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- online-stats API ---
|
||||||
|
// statsUserOnline is enabled in the policy above; with no client
|
||||||
|
// connections the call must succeed and return an empty set. This proves
|
||||||
|
// the GetUsersStats plumbing against a real core (an older binary would
|
||||||
|
// return Unimplemented here — see IsUnimplementedErr).
|
||||||
|
online, err := api.GetOnlineUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOnlineUsers: %v", err)
|
||||||
|
}
|
||||||
|
if len(online) != 0 {
|
||||||
|
t.Fatalf("expected no online users on an idle core, got %+v", online)
|
||||||
|
}
|
||||||
|
|
||||||
// --- routing (rules + balancers replace) ---
|
// --- routing (rules + balancers replace) ---
|
||||||
newRouting := []byte(`{
|
newRouting := []byte(`{
|
||||||
"domainStrategy": "AsIs",
|
"domainStrategy": "AsIs",
|
||||||
|
|||||||
@@ -129,3 +129,21 @@ func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
|
|||||||
t.Errorf("node 3's subtree should be absent after ClearNodeOnlineClients")
|
t.Errorf("node 3's subtree should be absent after ClearNodeOnlineClients")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOnlineAPISupportTriState pins the lazy capability probe contract: a new
|
||||||
|
// process starts Unknown (so the first caller probes), and the flag holds
|
||||||
|
// whatever the probe recorded until the process is replaced on restart.
|
||||||
|
func TestOnlineAPISupportTriState(t *testing.T) {
|
||||||
|
p := newOnlineTestProcess()
|
||||||
|
if got := p.OnlineAPISupport(); got != OnlineAPIUnknown {
|
||||||
|
t.Fatalf("new process must start with OnlineAPIUnknown, got %v", got)
|
||||||
|
}
|
||||||
|
p.SetOnlineAPISupport(OnlineAPISupported)
|
||||||
|
if got := p.OnlineAPISupport(); got != OnlineAPISupported {
|
||||||
|
t.Fatalf("expected OnlineAPISupported, got %v", got)
|
||||||
|
}
|
||||||
|
p.SetOnlineAPISupport(OnlineAPIUnsupported)
|
||||||
|
if got := p.OnlineAPISupport(); got != OnlineAPIUnsupported {
|
||||||
|
t.Fatalf("expected OnlineAPIUnsupported, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,6 +172,12 @@ type process struct {
|
|||||||
nodeOnlineTrees map[int]map[string][]string
|
nodeOnlineTrees map[int]map[string][]string
|
||||||
onlineMu sync.RWMutex
|
onlineMu sync.RWMutex
|
||||||
|
|
||||||
|
// onlineAPISupport caches whether the running core implements the
|
||||||
|
// online-stats RPCs (GetUsersStats). A new process is created on every
|
||||||
|
// restart/version switch, so the flag resets to Unknown and is re-probed
|
||||||
|
// lazily by the first caller.
|
||||||
|
onlineAPISupport atomic.Int32
|
||||||
|
|
||||||
config *Config
|
config *Config
|
||||||
configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
|
configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
|
||||||
logWriter *LogWriter
|
logWriter *LogWriter
|
||||||
@@ -181,6 +187,29 @@ type process struct {
|
|||||||
intentionalStop atomic.Bool
|
intentionalStop atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnlineAPISupport describes whether the running Xray core implements the
|
||||||
|
// online-stats API (statsUserOnline + GetUsersStats).
|
||||||
|
type OnlineAPISupport int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OnlineAPIUnknown means support has not been probed yet for this process.
|
||||||
|
OnlineAPIUnknown OnlineAPISupport = iota
|
||||||
|
// OnlineAPISupported means the core answered the online-stats RPC.
|
||||||
|
OnlineAPISupported
|
||||||
|
// OnlineAPIUnsupported means the core returned Unimplemented (older binary).
|
||||||
|
OnlineAPIUnsupported
|
||||||
|
)
|
||||||
|
|
||||||
|
// OnlineAPISupport returns the cached online-stats capability of this process.
|
||||||
|
func (p *process) OnlineAPISupport() OnlineAPISupport {
|
||||||
|
return OnlineAPISupport(p.onlineAPISupport.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnlineAPISupport records the probed online-stats capability of this process.
|
||||||
|
func (p *process) SetOnlineAPISupport(v OnlineAPISupport) {
|
||||||
|
p.onlineAPISupport.Store(int32(v))
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
xrayGracefulStopTimeout = 5 * time.Second
|
xrayGracefulStopTimeout = 5 * time.Second
|
||||||
xrayForceStopTimeout = 2 * time.Second
|
xrayForceStopTimeout = 2 * time.Second
|
||||||
|
|||||||
Reference in New Issue
Block a user