mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-09 12:50:59 +00:00
feat(nodes): per-node client IP attribution for IP-limit
Record each panel's own Xray IP observations under its panelGuid and merge each node's guid-keyed report on the master, so the panel can tell which node a client IP is connecting through (the flat inbound_client_ips union is pushed back to every node and cannot attribute). Adds the NodeClientIp model + migration, the clientIpsByGuid endpoint and node-sync merge, node-name labels in the client IP log, and cleanup on node deletion.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
// Shape of one entry in a client's IP log, as returned by
|
||||
// POST /panel/api/clients/ips/:email. `node` is the name of the node the IP is
|
||||
// connecting through, or '' when it is on this local panel (or unattributed).
|
||||
export type ClientIpInfo = {
|
||||
ip: string;
|
||||
time: string;
|
||||
node: string;
|
||||
};
|
||||
|
||||
// normalizeClientIps accepts the API payload and returns typed entries. It also
|
||||
// tolerates the legacy shape (a plain array of "ip (time)" strings) so the UI
|
||||
// keeps working against older panels.
|
||||
export function normalizeClientIps(obj: unknown): ClientIpInfo[] {
|
||||
if (!Array.isArray(obj)) return [];
|
||||
const out: ClientIpInfo[] = [];
|
||||
for (const x of obj) {
|
||||
if (typeof x === 'string') {
|
||||
if (x.length > 0) out.push({ ip: x, time: '', node: '' });
|
||||
continue;
|
||||
}
|
||||
if (x && typeof x === 'object') {
|
||||
const o = x as Record<string, unknown>;
|
||||
const ip = typeof o.ip === 'string' ? o.ip : '';
|
||||
if (!ip) continue;
|
||||
out.push({
|
||||
ip,
|
||||
time: typeof o.time === 'string' ? o.time : '',
|
||||
node: typeof o.node === 'string' ? o.node : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
|
||||
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||
@@ -177,7 +178,7 @@ export default function ClientFormModal({
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [clientIps, setClientIps] = useState<string[]>([]);
|
||||
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsClearing, setIpsClearing] = useState(false);
|
||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||
@@ -355,8 +356,7 @@ export default function ClientFormModal({
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
||||
if (!msg?.success) { setClientIps([]); return; }
|
||||
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
|
||||
setClientIps(normalizeClientIps(msg.obj));
|
||||
} finally {
|
||||
setIpsLoading(false);
|
||||
}
|
||||
@@ -806,7 +806,7 @@ export default function ClientFormModal({
|
||||
>
|
||||
{clientIps.length > 0 ? (
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{clientIps.map((ip, idx) => (
|
||||
{clientIps.map((entry, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
color="blue"
|
||||
@@ -819,7 +819,10 @@ export default function ClientFormModal({
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
|
||||
{entry.node ? (
|
||||
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
|
||||
) : null}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-
|
||||
|
||||
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||
@@ -80,7 +81,7 @@ export default function ClientInfoModal({
|
||||
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [links, setLinks] = useState<string[]>([]);
|
||||
const [clientIps, setClientIps] = useState<string[]>([]);
|
||||
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsClearing, setIpsClearing] = useState(false);
|
||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||
@@ -144,8 +145,7 @@ export default function ClientInfoModal({
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
||||
if (!msg?.success) { setClientIps([]); return; }
|
||||
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
|
||||
setClientIps(normalizeClientIps(msg.obj));
|
||||
} finally {
|
||||
setIpsLoading(false);
|
||||
}
|
||||
@@ -503,7 +503,7 @@ export default function ClientInfoModal({
|
||||
>
|
||||
{clientIps.length > 0 ? (
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{clientIps.map((ip, idx) => (
|
||||
{clientIps.map((entry, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
color="blue"
|
||||
@@ -516,7 +516,10 @@ export default function ClientInfoModal({
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
|
||||
{entry.node ? (
|
||||
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
|
||||
) : null}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user