Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9843a7ce4e | ||
|
|
750a270955 | ||
|
|
8e89960474 | ||
|
|
afb5456b8f | ||
|
|
36acc4c2b1 | ||
|
|
7f098c6c4e | ||
|
|
942b220f80 | ||
|
|
7d9a38490d | ||
|
|
3df652a67d | ||
|
|
c91fb454a8 | ||
|
|
c490bdcd76 | ||
|
|
5c432205ad | ||
|
|
8901267867 |
@@ -127,6 +127,17 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Category chip hover list (same values as the inbounds page). */
|
||||
.client-email-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.card-history {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Button, Card, Dropdown, Modal, Space, Switch, Table, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Popover,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import type { BadgeProps } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -48,6 +60,45 @@ function isUpdateEligible(n: NodeRecord): boolean {
|
||||
return !!n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
// One category chip of the nodes table's clients cell. When the backend
|
||||
// sent the category's email list, hovering pops the list over (same
|
||||
// pattern as the inbounds page); without a list it keeps the plain
|
||||
// category tooltip. Chip styling is identical either way.
|
||||
function CategoryChip({
|
||||
title,
|
||||
emails,
|
||||
color,
|
||||
count,
|
||||
}: {
|
||||
title: string;
|
||||
emails?: string[];
|
||||
color?: string;
|
||||
count: number;
|
||||
}) {
|
||||
const tag = (
|
||||
<Tag color={color} className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{count}
|
||||
</Tag>
|
||||
);
|
||||
if (emails && emails.length > 0) {
|
||||
return (
|
||||
<Popover
|
||||
title={title}
|
||||
content={
|
||||
<div className="client-email-list">
|
||||
{emails.map((e) => (
|
||||
<div key={e}>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
return <Tooltip title={title}>{tag}</Tooltip>;
|
||||
}
|
||||
|
||||
interface NodeRow extends NodeRecord {
|
||||
url: string;
|
||||
key: string | number;
|
||||
@@ -495,44 +546,35 @@ export default function NodeList({
|
||||
<TeamOutlined /> {record.clientCount || 0}
|
||||
</Tag>
|
||||
{record.activeCount ? (
|
||||
<Tooltip title={t('subscription.active')}>
|
||||
<Tag
|
||||
color="green"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.activeCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('subscription.active')}
|
||||
emails={record.activeEmails}
|
||||
color="green"
|
||||
count={record.activeCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.disabledCount ? (
|
||||
<Tooltip title={t('disabled')}>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{record.disabledCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('disabled')}
|
||||
emails={record.disabledEmails}
|
||||
count={record.disabledCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.depletedCount ? (
|
||||
<Tooltip title={t('depleted')}>
|
||||
<Tag
|
||||
color="red"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.depletedCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('depleted')}
|
||||
emails={record.depletedEmails}
|
||||
color="red"
|
||||
count={record.depletedCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.onlineCount ? (
|
||||
<Tooltip title={t('online')}>
|
||||
<Tag
|
||||
color="blue"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.onlineCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('online')}
|
||||
emails={record.onlineEmails}
|
||||
color="blue"
|
||||
count={record.onlineCount}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -25,6 +25,12 @@ export const NodeRecordSchema = z
|
||||
activeCount: z.number().optional(),
|
||||
disabledCount: z.number().optional(),
|
||||
depletedCount: z.number().optional(),
|
||||
// Per-category client email lists behind the counts above; the nodes
|
||||
// page category chips pop these over on hover.
|
||||
onlineEmails: z.array(z.string()).optional(),
|
||||
activeEmails: z.array(z.string()).optional(),
|
||||
disabledEmails: z.array(z.string()).optional(),
|
||||
depletedEmails: z.array(z.string()).optional(),
|
||||
lastHeartbeat: z.number().optional(),
|
||||
lastError: z.string().optional(),
|
||||
// Xray state captured from the remote node's own /panel/api/server/status.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { fireEvent, waitFor, within } from '@testing-library/react';
|
||||
|
||||
import NodeList from '@/pages/nodes/NodeList';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function renderNodes(nodes: NodeRecord[]) {
|
||||
return renderWithProviders(
|
||||
<NodeList
|
||||
nodes={nodes}
|
||||
isMobile={false}
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onProbe={noop}
|
||||
onToggleEnable={noop}
|
||||
onUpdateNode={noop}
|
||||
onUpdateSelected={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function rowFor(container: HTMLElement, name: string): HTMLElement {
|
||||
const cell = within(container).getByText(name);
|
||||
const row = cell.closest('tr');
|
||||
if (!row) throw new Error(`row not found for ${name}`);
|
||||
return row as HTMLElement;
|
||||
}
|
||||
|
||||
// The nodes page category chips used to hover to a bare category title.
|
||||
// When the backend sends the category's email list, hovering a chip must
|
||||
// pop the list over (same pattern as the inbounds page); without a list
|
||||
// the chip keeps the plain title tooltip.
|
||||
describe('NodeList clients-cell category chips', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('pops the online and depleted email lists on hover', async () => {
|
||||
const { container } = renderNodes([
|
||||
{
|
||||
id: 5,
|
||||
name: 'agent-node',
|
||||
enable: true,
|
||||
status: 'online',
|
||||
nodeType: 'agent',
|
||||
clientCount: 9,
|
||||
activeCount: 2,
|
||||
activeEmails: ['active-a@x', 'active-b@x'],
|
||||
depletedCount: 3,
|
||||
depletedEmails: ['dep-a@x', 'dep-b@x', 'dep-c@x'],
|
||||
onlineCount: 1,
|
||||
onlineEmails: ['online@x'],
|
||||
},
|
||||
]);
|
||||
|
||||
const row = rowFor(container, 'agent-node');
|
||||
|
||||
fireEvent.mouseEnter(within(row).getByText('1'));
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.body.textContent).toContain('online@x');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
fireEvent.mouseEnter(within(row).getByText('3'));
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.body.textContent).toContain('dep-b@x');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the title tooltip when a category has no email list', async () => {
|
||||
const { container } = renderNodes([
|
||||
{
|
||||
id: 6,
|
||||
name: 'bare-node',
|
||||
enable: true,
|
||||
status: 'online',
|
||||
clientCount: 4,
|
||||
onlineCount: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const row = rowFor(container, 'bare-node');
|
||||
fireEvent.mouseEnter(within(row).getByText('1'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.textContent).toContain('Online');
|
||||
});
|
||||
expect(document.querySelector('.client-email-list')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,20 @@ type AgentNode struct {
|
||||
// a telemetry object.
|
||||
TelemetryAt int64 `json:"telemetryAt"`
|
||||
|
||||
// Seen source addresses (public-address resolution): the source IP of
|
||||
// the agent's own authenticated requests, as observed by the master —
|
||||
// the CF-Connecting-IP header when the agent comes through Cloudflare,
|
||||
// else the direct peer address. An agent behind 1:1 NAT reports only
|
||||
// its private interface address in telemetry (its public address is
|
||||
// not assigned to any local interface, so enumeration can never see
|
||||
// it); the observed source address is the master's view of the
|
||||
// agent's public side. Writes are change-only (see
|
||||
// AgentController.captureSeenIP); SeenIPAt is the unix-milli time of
|
||||
// the last change, not of the last request.
|
||||
SeenIPv4 string `json:"seenIpv4"`
|
||||
SeenIPv6 string `json:"seenIpv6"`
|
||||
SeenIPAt int64 `json:"seenIpAt"`
|
||||
|
||||
// One-click update (item 20): the master records an update intent here;
|
||||
// the agent picks it up from its config poll, downloads + verifies +
|
||||
// swaps its binary, restarts itself and reports the outcome back on
|
||||
|
||||
@@ -847,6 +847,15 @@ type Node struct {
|
||||
DisabledCount int `json:"disabledCount" gorm:"-" example:"3"`
|
||||
DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
|
||||
|
||||
// Per-category client email lists backing the counts above, filled by
|
||||
// the node read paths (GetAll / recountByGuid) from the same bucketing
|
||||
// so the nodes page can show WHO is in each category on hover, not just
|
||||
// how many. Read-time only, never persisted.
|
||||
OnlineEmails []string `json:"-" gorm:"-"`
|
||||
ActiveEmails []string `json:"-" gorm:"-"`
|
||||
DisabledEmails []string `json:"-" gorm:"-"`
|
||||
DepletedEmails []string `json:"-" gorm:"-"`
|
||||
|
||||
// ParentGuid + Transitive are set only when a node is surfaced as part of a
|
||||
// node tree (#4983): direct nodes carry the master panel's own GUID, a
|
||||
// transitive sub-node carries its parent node's GUID. Transitive nodes are
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# ITEM6 golden fixtures (B0)
|
||||
|
||||
Per-protocol × representative-transport fixtures for the xray→sing-box
|
||||
migration. Consumed by B1 (data model + migrator) and B2 (config builder)
|
||||
as the acceptance baseline; see `ITEM6-CONVERSION-SPEC.md` in
|
||||
`~/workspace/xui-lite-work/` for the normative mapping behind every file.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
testdata/golden/<case>/
|
||||
input.json current-shape sample (see below)
|
||||
expected-singbox.json expected converter output fragment
|
||||
expected-link.txt expected share link for the primary client
|
||||
```
|
||||
|
||||
`input.json` fields: `case`, `title`, `source` (where the shape was taken
|
||||
from — frontend defaults / backend tests / production backup traits),
|
||||
`disposition`, `risk` (ITEM6-SCOPE N-number or null), `notes`, `inbound`
|
||||
(a `model.Inbound` row shape: `settings` / `streamSettings` / `sniffing`
|
||||
as embedded objects), `clientEmail`, `client` (the primary client's
|
||||
settings-mirror entry — the same object embedded in `settings.clients`
|
||||
where the protocol carries one).
|
||||
|
||||
`expected-singbox.json` is a fragment: `inbounds[]` (and `endpoints[]`
|
||||
for WireGuard). Keys starting with `_` are fixture metadata (`_b0`:
|
||||
disposition, unmapped concepts awaiting Q1, notes) and are stripped
|
||||
before comparison. Blocked cases intentionally contain **no** fabricated
|
||||
output — `inbounds` is empty and `_b0.unmapped` records exactly what has
|
||||
no sing-box carrier, pending the user's Q1 decision.
|
||||
|
||||
## Provenance of the link baselines
|
||||
|
||||
`expected-link.txt` files were generated on 2026-10-23 by running the
|
||||
**current** code (main `8e89960`) `SubService.GetLink` over each
|
||||
`input.json` inbound — i.e. they are the pre-migration outputs the
|
||||
migration must reproduce byte for byte (ITEM6-SCOPE §2.4). A throwaway
|
||||
harness test produced them and was deleted afterwards; regenerate only
|
||||
by re-running `GetLink` against the same inputs on pre-migration code.
|
||||
Empty files are real current behaviour for `http-auth`, `tun` and
|
||||
`tunnel-dokodemo` (no share link exists today).
|
||||
|
||||
All credentials in this corpus are fake (sequential-byte X25519/WireGuard
|
||||
keys, `fx-` passwords, TEST-NET `203.0.113.10`). In fixtures, `listen`
|
||||
doubles as the resolved share address, mirroring the characterization
|
||||
tests in `internal/sub`; bind-address defaults are B2 base-config
|
||||
business, not fixture content.
|
||||
|
||||
## Baseline scope: links only
|
||||
|
||||
Per the 2026-10-23 product decision, the JSON and Clash subscription
|
||||
families are being deleted wholesale (separate work line); subscriptions
|
||||
converge on the link format only. No Clash/JSON baselines are built or
|
||||
kept here, and ITEM6-SCOPE Q2 is closed by that decision.
|
||||
|
||||
## Dispositions
|
||||
|
||||
- `direct` — full mapping defined; B2 must reproduce the fragment.
|
||||
- `partial-pending-q1` — mappable body is in the fragment; `_b0.unmapped`
|
||||
lists concepts with no sing-box carrier, awaiting Q1.
|
||||
- `blocked-pending-q1` — no valid output exists until Q1 picks a carrier;
|
||||
fragment is an explicit marker, not a silent drop.
|
||||
- `external-runtime` — protocol never enters the core (mtproto,
|
||||
amneziawg); the migration duty is verbatim data passthrough only.
|
||||
@@ -0,0 +1,20 @@
|
||||
// Package singbox hosts the L-UI xray→sing-box migration (ITEM6) assets.
|
||||
//
|
||||
// B0 deliverable: the golden fixture corpus under testdata/golden — for each
|
||||
// protocol × representative transport, a current-shape sample (input.json),
|
||||
// the expected sing-box fragment (expected-singbox.json), and the expected
|
||||
// share-link output (expected-link.txt) produced by the CURRENT code at
|
||||
// main 8e89960. B1 (data model + migrator) and B2 (config builder) consume
|
||||
// these fixtures as their acceptance baseline:
|
||||
//
|
||||
// - the converter's output for input.json must equal expected-singbox.json
|
||||
// (keys starting with "_" are fixture metadata, stripped before compare);
|
||||
// - subscription links regenerated from the converted model must equal
|
||||
// expected-link.txt byte for byte (ITEM6-SCOPE §2.4 invariants).
|
||||
//
|
||||
// Subscription baselines cover the LINK route only. Per the 2026-10-23
|
||||
// product decision, the JSON and Clash subscription families are being
|
||||
// removed entirely (separate work line); no baselines are kept for them.
|
||||
//
|
||||
// The conversion spec lives in ~/workspace/xui-lite-work/ITEM6-CONVERSION-SPEC.md.
|
||||
package singbox
|
||||
@@ -0,0 +1,120 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// goldenCase mirrors the fixture envelope shared by input.json and
|
||||
// expected-singbox.json. The tests in this file are READ-ONLY fixture
|
||||
// validation: they pin the corpus so B1/B2 can build the converter
|
||||
// against it. They never write to testdata.
|
||||
type goldenEnvelope struct {
|
||||
Case string `json:"case"`
|
||||
B0 *struct {
|
||||
Disposition string `json:"disposition"`
|
||||
Unmapped []json.RawMessage `json:"unmapped"`
|
||||
} `json:"_b0"`
|
||||
Inbounds []json.RawMessage `json:"inbounds"`
|
||||
}
|
||||
|
||||
var knownDispositions = map[string]bool{
|
||||
"direct": true,
|
||||
"partial-pending-q1": true,
|
||||
"blocked-pending-q1": true,
|
||||
"external-runtime": true,
|
||||
"external": true, // used on the input side
|
||||
}
|
||||
|
||||
func goldenDirs(t *testing.T) []string {
|
||||
t.Helper()
|
||||
root := filepath.Join("testdata", "golden")
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden root: %v", err)
|
||||
}
|
||||
var dirs []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
dirs = append(dirs, filepath.Join(root, e.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
if len(dirs) == 0 {
|
||||
t.Fatal("no golden fixture cases found")
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func readJSON(t *testing.T, path string, v any) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, v); err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenFixturesWellFormed pins corpus completeness: every case dir
|
||||
// carries input.json, expected-singbox.json and expected-link.txt; every
|
||||
// JSON parses; dispositions are from the known taxonomy; blocked cases
|
||||
// must not smuggle in a fabricated sing-box output — they carry an empty
|
||||
// inbounds list plus at least one _b0.unmapped entry awaiting Q1.
|
||||
func TestGoldenFixturesWellFormed(t *testing.T) {
|
||||
for _, dir := range goldenDirs(t) {
|
||||
name := filepath.Base(dir)
|
||||
|
||||
var input struct {
|
||||
Case string `json:"case"`
|
||||
Disposition string `json:"disposition"`
|
||||
}
|
||||
readJSON(t, filepath.Join(dir, "input.json"), &input)
|
||||
if input.Case != name {
|
||||
t.Errorf("%s: input case %q != dir name", name, input.Case)
|
||||
}
|
||||
if !knownDispositions[input.Disposition] {
|
||||
t.Errorf("%s: unknown input disposition %q", name, input.Disposition)
|
||||
}
|
||||
|
||||
var expected goldenEnvelope
|
||||
readJSON(t, filepath.Join(dir, "expected-singbox.json"), &expected)
|
||||
if expected.B0 == nil {
|
||||
t.Errorf("%s: expected-singbox.json missing _b0 metadata", name)
|
||||
continue
|
||||
}
|
||||
disp := expected.B0.Disposition
|
||||
if !knownDispositions[disp] {
|
||||
t.Errorf("%s: unknown expected disposition %q", name, disp)
|
||||
}
|
||||
if strings.HasPrefix(disp, "blocked") {
|
||||
if len(expected.Inbounds) != 0 {
|
||||
t.Errorf("%s: blocked case must not fabricate sing-box inbounds", name)
|
||||
}
|
||||
if len(expected.B0.Unmapped) == 0 {
|
||||
t.Errorf("%s: blocked case must record >=1 unmapped concept for Q1", name)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(disp, "partial") && len(expected.B0.Unmapped) == 0 {
|
||||
t.Errorf("%s: partial case must record its unmapped concepts", name)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "expected-link.txt")); err != nil {
|
||||
t.Errorf("%s: missing expected-link.txt: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenConversion is the B1/B2 hook: once the converter exists, feed
|
||||
// each input.json through it and compare against expected-singbox.json
|
||||
// (metadata keys stripped), then regenerate share links from the converted
|
||||
// model and compare against expected-link.txt byte for byte. Skipped until
|
||||
// the converter lands.
|
||||
func TestGoldenConversion(t *testing.T) {
|
||||
t.Skip("converter lands in ITEM6 B1/B2; fixtures are the baseline it must meet")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vpn://W0ludGVyZmFjZV0KUHJpdmF0ZUtleSA9IE16UTFOamM0T1RvN1BEMCtQMEJCUWtORVJVWkhTRWxLUzB4TlRrOVFVVkkKQWRkcmVzcyA9IDEwLjguMS4yLzMyCkROUyA9IDguOC44LjgsIDguOC40LjQKSmMgPSA0CkptaW4gPSA0MApKbWF4ID0gNzAKUzEgPSAyMApTMiA9IDMwCkgxID0gMTAwMDAwMQpIMiA9IDEwMDAwMDIKSDMgPSAxMDAwMDAzCkg0ID0gMTAwMDAwNAoKIyBmeC1hbW5lemlhd2ctdXNlckBleGFtcGxlLmNvbQpbUGVlcl0KUHVibGljS2V5ID0gd1YwaVpVV1VWY24vRlc1c0hhYS90NUVMdUs5UThyTDUrRlBxa3lVbG5VMD0KQWxsb3dlZElQcyA9IDAuMC4wLjAvMCwgOjovMApFbmRwb2ludCA9IDIwMy4wLjExMy4xMDozODQ0Mw
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"inbounds": [],
|
||||
"_b0": {
|
||||
"disposition": "external-runtime",
|
||||
"notes": "不进核心(internal/amneziawg 外部运行时)。sing-box wireguard endpoint 表达不了 Amnezia 混淆参数(jc/jmin/jmax/s1-s4/h1-h4/i1-i5)与 per-client forwardedPorts;新模型义务仅为 server/clients 数据原样透传,vpn:// 链接与 .conf 不变。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"case": "amneziawg",
|
||||
"title": "AmneziaWG(外部运行时承载,不进核心)",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts",
|
||||
"internal/amneziawg/types.go ServerSettings"
|
||||
],
|
||||
"disposition": "external",
|
||||
"risk": null,
|
||||
"notes": "sing-box wireguard endpoint 表达不了 Amnezia 混淆参数(jc/jmin/jmax/s/h/i)与 per-client 转发端口;运行时与参数原样保留,仅数据模型透传。",
|
||||
"inbound": {
|
||||
"id": 126,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 38443,
|
||||
"protocol": "amneziawg",
|
||||
"remark": "fx-amneziawg",
|
||||
"tag": "inbound-38443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"server": {
|
||||
"privateKey": "ycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+g",
|
||||
"publicKey": "wV0iZUWUVcn/FW5sHaa/t5ELuK9Q8rL5+FPqkyUlnU0=",
|
||||
"subnetIp": "10.8.1.0",
|
||||
"subnetCidr": 24,
|
||||
"primaryDns": "8.8.8.8",
|
||||
"secondaryDns": "8.8.4.4",
|
||||
"externalInterface": "",
|
||||
"ipv6Enabled": false,
|
||||
"ipv6Subnet": "",
|
||||
"ipv6ExternalInterface": "",
|
||||
"jc": 4,
|
||||
"jmin": 40,
|
||||
"jmax": 70,
|
||||
"s1": 20,
|
||||
"s2": 30,
|
||||
"h1": "1000001",
|
||||
"h2": "1000002",
|
||||
"h3": "1000003",
|
||||
"h4": "1000004"
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"privateKey": "MzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVI",
|
||||
"publicKey": "wV0iZUWUVcn/FW5sHaa/t5ELuK9Q8rL5+FPqkyUlnU0=",
|
||||
"allowedIPs": [
|
||||
"10.8.1.2/32"
|
||||
],
|
||||
"forwardedPorts": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"privateKey": "MzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVI",
|
||||
"publicKey": "wV0iZUWUVcn/FW5sHaa/t5ELuK9Q8rL5+FPqkyUlnU0=",
|
||||
"allowedIPs": [
|
||||
"10.8.1.2/32"
|
||||
],
|
||||
"forwardedPorts": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "http",
|
||||
"tag": "inbound-33443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 33443,
|
||||
"users": [
|
||||
{
|
||||
"username": "fxhttp",
|
||||
"password": "fxhttppass"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "allowTransparent=false 与 sing-box(无透明代理对应)行为一致,无损;allowTransparent=true 的存量处置见规范(无对应,待 Q1)。http 无分享链接(expected-link.txt 为空,属现行行为)。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"case": "http-auth",
|
||||
"title": "HTTP 代理 / 账号",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "GetLink 对 http 返回空(无分享链);Clash/JSON 两路有输出。sing-box http 入站 users 改名映射,allowTransparent 无对应(本样本 false,无损)。",
|
||||
"inbound": {
|
||||
"id": 119,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 33443,
|
||||
"protocol": "http",
|
||||
"remark": "fx-http",
|
||||
"tag": "inbound-33443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"accounts": [
|
||||
{
|
||||
"user": "fxhttp",
|
||||
"pass": "fxhttppass"
|
||||
}
|
||||
],
|
||||
"allowTransparent": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
hysteria://fx-hy1-auth@203.0.113.10:35443?alpn=h3&fp=chrome&security=tls&sni=hy1.example.com#fx-hy1-user%40example.com
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "hysteria",
|
||||
"tag": "inbound-35443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 35443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"auth": "fx-hy1-auth"
|
||||
}
|
||||
],
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "hy1.example.com",
|
||||
"alpn": [
|
||||
"h3"
|
||||
],
|
||||
"certificate_path": "/etc/x-ui/cert/fx.pem",
|
||||
"key_path": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "v1 按 type:hysteria 分发;users 字段为 auth(v1 语义)。带宽字段(up/down_mbps)现形状无来源(hysteriaSettings 只有 version/udpIdleTimeout),省略走核心默认;B1 落形时以 v1.13.21 文档复核字段名。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"case": "hysteria1",
|
||||
"title": "Hysteria v1",
|
||||
"source": [
|
||||
"internal/database/model/hysteria_version_test.go"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "按 version 分发:v1 → sing-box type:hysteria(auth 语义字段)。现网前端强制 v2、本样本为存量兜底;生成侧有 version 自愈逻辑,B1 迁移器须以 settings.version 原值为准。",
|
||||
"inbound": {
|
||||
"id": 121,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 35443,
|
||||
"protocol": "hysteria",
|
||||
"remark": "fx-hy1",
|
||||
"tag": "inbound-35443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"version": 1,
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"auth": "fx-hy1-auth"
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "hysteria",
|
||||
"security": "tls",
|
||||
"hysteriaSettings": {
|
||||
"version": 1,
|
||||
"udpIdleTimeout": 60
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "hy1.example.com",
|
||||
"alpn": [
|
||||
"h3"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"auth": "fx-hy1-auth"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
hysteria2://fx-hy2-auth@203.0.113.10:34443?alpn=h3&fp=chrome&obfs=salamander&obfs-password=fx-obfs-pass&security=tls&sni=hy.example.com#fx-hy2-user%40example.com
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "hysteria2",
|
||||
"tag": "inbound-34443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 34443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"password": "fx-hy2-auth"
|
||||
}
|
||||
],
|
||||
"obfs": {
|
||||
"type": "salamander",
|
||||
"password": "fx-obfs-pass"
|
||||
},
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "hy.example.com",
|
||||
"alpn": [
|
||||
"h3"
|
||||
],
|
||||
"certificate_path": "/etc/x-ui/cert/fx.pem",
|
||||
"key_path": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "finalmask.udp[salamander] → hysteria2 obfs 直映(现行订阅链接已带 obfs/obfs-password 参数,服务端必须同值否则握手失败——见 expected-link.txt)。masquerade 未启用(type 空)省略。hysteriaSettings.udpIdleTimeout 在 sing-box hysteria2 无对应字段(QUIC 层自理),见规范 §4.2 小损项,随 Q1 一并确认。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"case": "hysteria2-salamander",
|
||||
"title": "Hysteria2 + finalmask salamander obfs",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts",
|
||||
"internal/sub/service.go genHysteriaLink 的 salamander 桥"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "关键桥:现订阅代码已把 finalmask.udp[salamander] 翻成 hysteria2 URI 的 obfs 参数;sing-box hysteria2 原生 obfs{type:salamander,password} 直映——这是 finalmask 里唯一有直映路径的子集。masquerade 未启用(空)。",
|
||||
"inbound": {
|
||||
"id": 120,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 34443,
|
||||
"protocol": "hysteria",
|
||||
"remark": "fx-hy2",
|
||||
"tag": "inbound-34443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"version": 2,
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"auth": "fx-hy2-auth"
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "hysteria",
|
||||
"security": "tls",
|
||||
"hysteriaSettings": {
|
||||
"version": 2,
|
||||
"udpIdleTimeout": 60,
|
||||
"masquerade": {
|
||||
"type": "",
|
||||
"dir": "",
|
||||
"url": "",
|
||||
"rewriteHost": false,
|
||||
"statusCode": 0
|
||||
}
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "hy.example.com",
|
||||
"alpn": [
|
||||
"h3"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
},
|
||||
"finalmask": {
|
||||
"udp": [
|
||||
{
|
||||
"type": "salamander",
|
||||
"settings": {
|
||||
"password": "fx-obfs-pass"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"auth": "fx-hy2-auth"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
socks://user%40example.com:fx-mixed-pass@203.0.113.10:31443#fx-mixed-user%40example.com
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "mixed",
|
||||
"tag": "inbound-31443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 31443,
|
||||
"users": [
|
||||
{
|
||||
"username": "fxacct",
|
||||
"password": "fxacctpass"
|
||||
},
|
||||
{
|
||||
"username": "user@example.com",
|
||||
"password": "fx-mixed-pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "顺序沿现生成语义:settings 固定 accounts 居首、身份层客户随后。mixed 的 udp 由协议原生 UDP Associate 承载(settings.udp=true 已含);settings.ip(UDP 回源地址)无直对应,本样本 127.0.0.1 为默认值、实际无损,规范 §3.5 标注待 Q1 确认。流量归因键:sing-box 以 username 记 user 计数(B3 复核)。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"case": "mixed-auth",
|
||||
"title": "Mixed / 密码账号(settings accounts + 身份层客户)",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "现链路:身份层客户在生成时折成 accounts(落地固定账号居首)。sing-box mixed 用 users[{username,password}],username=email。streamSettings 存量为空串(备份库实证),本样本不带 streamSettings。",
|
||||
"inbound": {
|
||||
"id": 117,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 31443,
|
||||
"protocol": "mixed",
|
||||
"remark": "fx-mixed",
|
||||
"tag": "inbound-31443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"auth": "password",
|
||||
"accounts": [
|
||||
{
|
||||
"user": "fxacct",
|
||||
"pass": "fxacctpass"
|
||||
}
|
||||
],
|
||||
"udp": true,
|
||||
"ip": "127.0.0.1",
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"password": "fx-mixed-pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"password": "fx-mixed-pass"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
tg://proxy?port=37443&secret=ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d&server=203.0.113.10
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"inbounds": [],
|
||||
"_b0": {
|
||||
"disposition": "external-runtime",
|
||||
"notes": "不进核心(现状即被生成器跳过,internal/mtproto 独立 manager 承载)。新模型义务仅为:settings{fakeTlsDomain, clients[{secret,...}]} 原样透传,订阅 tg:// 链接不变(见 expected-link.txt)。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"case": "mtproto",
|
||||
"title": "MTProto(外部进程承载,不进核心)",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts",
|
||||
"internal/sub/service_mtproto_test.go"
|
||||
],
|
||||
"disposition": "external",
|
||||
"risk": null,
|
||||
"notes": "现状即被生成器跳过;新模型只要求 settings/clients 数据原样透传(不变量 §2.4-5 secret)。expected-singbox 为空入站 + 透传标记。",
|
||||
"inbound": {
|
||||
"id": 125,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 37443,
|
||||
"protocol": "mtproto",
|
||||
"remark": "fx-mtproto",
|
||||
"tag": "inbound-37443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"fakeTlsDomain": "www.cloudflare.com",
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ss://2022-blake3-aes-128-gcm:MDEyMzQ1Njc4OWFiY2RlZg%3D%3D:Y2xpZW50LXBhc3MtMDAwMQ%3D%3D@203.0.113.10:28443?type=tcp#fx-ss2022-user%40example.com
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "shadowsocks",
|
||||
"tag": "inbound-28443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 28443,
|
||||
"method": "2022-blake3-aes-128-gcm",
|
||||
"password": "MDEyMzQ1Njc4OWFiY2RlZg==",
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"password": "Y2xpZW50LXBhc3MtMDAwMQ=="
|
||||
},
|
||||
{
|
||||
"name": "user2@example.com",
|
||||
"password": "Y2xpZW50LXBhc3MtMDAwMg=="
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "network=tcp,udp 为 sing-box ss 默认监听面,省略不写。ivCheck 无对应(sing-box 恒校验),本样本 false→恒校验属行为收紧,规范 §3.4 标注、处置随 Q1 一并确认。NexusGate 已实证 2022 组合密码语义两侧一致。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"case": "shadowsocks-2022",
|
||||
"title": "Shadowsocks 2022-blake3-aes-128-gcm(多用户)",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts",
|
||||
"~/workspace/NexusGate 生产实证(组合密码语义)"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "服务端密码:客户密码组合式两侧语义一致(不变量 §2.4-4)。主客户为 user@example.com,user2 仅出现在 settings.clients 与 expected sing-box users。",
|
||||
"inbound": {
|
||||
"id": 114,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 28443,
|
||||
"protocol": "shadowsocks",
|
||||
"remark": "fx-ss2022",
|
||||
"tag": "inbound-28443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"method": "2022-blake3-aes-128-gcm",
|
||||
"password": "MDEyMzQ1Njc4OWFiY2RlZg==",
|
||||
"network": "tcp,udp",
|
||||
"ivCheck": false,
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"method": "",
|
||||
"password": "Y2xpZW50LXBhc3MtMDAwMQ=="
|
||||
},
|
||||
{
|
||||
"email": "user2@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"method": "",
|
||||
"password": "Y2xpZW50LXBhc3MtMDAwMg=="
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "none",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"method": "",
|
||||
"password": "Y2xpZW50LXBhc3MtMDAwMQ=="
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpmeC1zcy1sZWdhY3ktY2xpZW50@203.0.113.10:29443?type=tcp#fx-ss-legacy-user%40example.com
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "shadowsocks",
|
||||
"tag": "inbound-29443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 29443,
|
||||
"method": "chacha20-ietf-poly1305",
|
||||
"password": "fx-ss-inbound-pass",
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"password": "fx-ss-legacy-client"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "legacy 方法 sing-box 仍支持;user 密码语义为逐用户独立密码(不做 2022 式组合),与 xray 客户行为一致,B3 以真客户端复核。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"case": "shadowsocks-legacy",
|
||||
"title": "Shadowsocks legacy chacha20-ietf-poly1305",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "legacy 单密码语义:客户密码即连接密码。sing-box 仍支持该 cipher。",
|
||||
"inbound": {
|
||||
"id": 115,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 29443,
|
||||
"protocol": "shadowsocks",
|
||||
"remark": "fx-ss-legacy",
|
||||
"tag": "inbound-29443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"method": "chacha20-ietf-poly1305",
|
||||
"password": "fx-ss-inbound-pass",
|
||||
"network": "tcp,udp",
|
||||
"ivCheck": false,
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"method": "",
|
||||
"password": "fx-ss-legacy-client"
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "none",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"method": "",
|
||||
"password": "fx-ss-legacy-client"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ss://YWVzLTI1Ni1jZmI6Zngtc3MtY2ZiLWNsaWVudA@203.0.113.10:30443?type=tcp#fx-ss-cfb-user%40example.com
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"inbounds": [],
|
||||
"_b0": {
|
||||
"disposition": "blocked-pending-q1",
|
||||
"risk": "N6",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "shadowsocks method=aes-256-cfb",
|
||||
"input": {
|
||||
"method": "aes-256-cfb"
|
||||
},
|
||||
"reason": "sing-box 已移除 cfb/ctr 类 cipher,无承载;存量按 method 分组计数(census Q06)后随 Q1 报用户。",
|
||||
"pending": "Q1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"case": "shadowsocks-removed-cipher",
|
||||
"title": "Shadowsocks aes-256-cfb(sing-box 已移除)",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "blocked-pending-q1",
|
||||
"risk": "N6",
|
||||
"notes": "cfb/ctr 类 cipher 在 sing-box 已移除,无承载。只登记,等 Q1(与 N6 按 method 分组计数一起报)。",
|
||||
"inbound": {
|
||||
"id": 116,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 30443,
|
||||
"protocol": "shadowsocks",
|
||||
"remark": "fx-ss-cfb",
|
||||
"tag": "inbound-30443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"method": "aes-256-cfb",
|
||||
"password": "fx-ss-cfb-pass",
|
||||
"network": "tcp,udp",
|
||||
"ivCheck": false,
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"method": "",
|
||||
"password": "fx-ss-cfb-client"
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "none",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"method": "",
|
||||
"password": "fx-ss-cfb-client"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
socks://user%40example.com:fx-socks-pass@203.0.113.10:32443#fx-socks-user%40example.com
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "socks",
|
||||
"tag": "inbound-32443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 32443,
|
||||
"users": [
|
||||
{
|
||||
"username": "fxsacct",
|
||||
"password": "fxsacctpass"
|
||||
},
|
||||
{
|
||||
"username": "user@example.com",
|
||||
"password": "fx-socks-pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "socks 与 mixed 在新模型分开成两个 type(§4.1);users 字段为 username/password(NexusGate 服务端实证)。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"case": "socks-auth",
|
||||
"title": "SOCKS(传统节点同步流入形状,无原生表单)",
|
||||
"source": [
|
||||
"internal/sub/socks_link_test.go"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "sing-box socks 入站 users 字段为 username/password(NexusGate 服务端实证),与 mixed 在新模型分开成两个 type。",
|
||||
"inbound": {
|
||||
"id": 118,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 32443,
|
||||
"protocol": "socks",
|
||||
"remark": "fx-socks",
|
||||
"tag": "inbound-32443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"auth": "password",
|
||||
"accounts": [
|
||||
{
|
||||
"user": "fxsacct",
|
||||
"pass": "fxsacctpass"
|
||||
}
|
||||
],
|
||||
"udp": true,
|
||||
"ip": "127.0.0.1",
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"password": "fx-socks-pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"password": "fx-socks-pass"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
trojan://fx-trojan-pass@203.0.113.10:27443?alpn=h2%2Chttp%2F1.1&fp=chrome&security=tls&sni=tjf.example.com&type=tcp#fx-trojan-flow-user%40example.com
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "trojan",
|
||||
"tag": "inbound-27443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 27443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"password": "fx-trojan-pass"
|
||||
}
|
||||
],
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "tjf.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificate_path": "/etc/x-ui/cert/fx.pem",
|
||||
"key_path": "/etc/x-ui/cert/fx.key"
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "partial-pending-q1",
|
||||
"risk": "N5",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "trojan client flow",
|
||||
"input": "xtls-rprx-vision",
|
||||
"reason": "sing-box v1.13.21 trojan 入站对 flow 的支持度未实证;B3 真机验前不写入 users(写了可能被核心拒绝或静默忽略),验明后回改本基线。",
|
||||
"pending": "Q1/B3"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"case": "trojan-tcp-tls-flow",
|
||||
"title": "Trojan / TCP / TLS + xtls flow(支持度待验)",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "partial-pending-q1",
|
||||
"risk": "N5",
|
||||
"notes": "密码直映;flow 在 sing-box trojan 入站的支持度未验(v1.13.21),登记 _b0.unmapped 待 B3 真机验,不预删。",
|
||||
"inbound": {
|
||||
"id": 113,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 27443,
|
||||
"protocol": "trojan",
|
||||
"remark": "fx-trojan-flow",
|
||||
"tag": "inbound-27443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"password": "fx-trojan-pass",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
],
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "tls",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "tjf.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"password": "fx-trojan-pass",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
trojan://fx-trojan-pass@203.0.113.10:26443?alpn=h2%2Chttp%2F1.1&fp=chrome&security=tls&sni=tj.example.com&type=tcp#fx-trojan-user%40example.com
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "trojan",
|
||||
"tag": "inbound-26443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 26443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"password": "fx-trojan-pass"
|
||||
}
|
||||
],
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "tj.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificate_path": "/etc/x-ui/cert/fx.pem",
|
||||
"key_path": "/etc/x-ui/cert/fx.key"
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"case": "trojan-tcp-tls",
|
||||
"title": "Trojan / TCP / TLS",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "",
|
||||
"inbound": {
|
||||
"id": 112,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 26443,
|
||||
"protocol": "trojan",
|
||||
"remark": "fx-trojan",
|
||||
"tag": "inbound-26443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"password": "fx-trojan-pass"
|
||||
}
|
||||
],
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "tls",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "tj.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"password": "fx-trojan-pass"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "tun",
|
||||
"tag": "inbound-0",
|
||||
"interface_name": "xray0",
|
||||
"address": [
|
||||
"172.19.0.1/28"
|
||||
],
|
||||
"mtu": 1500,
|
||||
"auto_route": true,
|
||||
"route_address": [
|
||||
"0.0.0.0/0"
|
||||
],
|
||||
"stack": "system"
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"risk": "Q5",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "settings.dns",
|
||||
"input": [
|
||||
"1.1.1.1"
|
||||
],
|
||||
"reason": "tun 入站本体无 dns 字段,由 sing-box 独立 dns 配置承载——属基础配置面而非入站面,B2 定落点,此处登记防丢。",
|
||||
"pending": "B2"
|
||||
},
|
||||
{
|
||||
"concept": "settings.userLevel",
|
||||
"input": 0,
|
||||
"reason": "xray policy level 概念,sing-box 无对应;本样本 0 为默认,实际无损。",
|
||||
"pending": "Q1"
|
||||
}
|
||||
],
|
||||
"notes": "gateway[] → address[];autoSystemRoutingTable → route_address + auto_route;autoOutboundsInterface=auto 省略(sing-box auto_redirect/出口自动选择为近似,字段落位 B1 定)。是否挂 agent 承载属 Q5。"
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"case": "tun",
|
||||
"title": "TUN(面板本地)",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": "Q5",
|
||||
"notes": "sing-box 原生 tun 入站且字段更完整;本样本按现字段名映射(gateway→address 等改名在规范中逐项列)。是否挂 agent 承载属 Q5,不影响形状映射。",
|
||||
"inbound": {
|
||||
"id": 124,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 0,
|
||||
"protocol": "tun",
|
||||
"remark": "fx-tun",
|
||||
"tag": "inbound-0",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"name": "xray0",
|
||||
"mtu": 1500,
|
||||
"gateway": [
|
||||
"172.19.0.1/28"
|
||||
],
|
||||
"dns": [
|
||||
"1.1.1.1"
|
||||
],
|
||||
"userLevel": 0,
|
||||
"autoSystemRoutingTable": [
|
||||
"0.0.0.0/0"
|
||||
],
|
||||
"autoOutboundsInterface": "auto"
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"inbounds": [],
|
||||
"_b0": {
|
||||
"disposition": "blocked-pending-q1",
|
||||
"risk": "N2",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "tunnel(dokodemo-door) settings",
|
||||
"input": {
|
||||
"rewriteAddress": "192.0.2.50",
|
||||
"rewritePort": 8080,
|
||||
"portMap": {},
|
||||
"allowedNetwork": "tcp,udp",
|
||||
"followRedirect": false
|
||||
},
|
||||
"reason": "sing-box 无 dokodemo 等价物(direct 入站已弃用并在新版移除)。现为面板本地协议、不下发 agent;承载(自维护极小转发组件 / route 近似)待 Q1+Q5。",
|
||||
"pending": "Q1"
|
||||
}
|
||||
],
|
||||
"mappableRemainder": "无。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"case": "tunnel-dokodemo",
|
||||
"title": "Tunnel(dokodemo-door 面板本地)",
|
||||
"source": [
|
||||
"frontend/src/schemas/protocols/inbound/tunnel.ts"
|
||||
],
|
||||
"disposition": "blocked-pending-q1",
|
||||
"risk": "N2",
|
||||
"notes": "sing-box 无稳定等价物(direct 入站已弃用并在新版移除)。现为面板本地协议、不下发 agent。承载方式(极小转发组件/route 近似)等 Q1/Q5,先登记形状。",
|
||||
"inbound": {
|
||||
"id": 123,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 36443,
|
||||
"protocol": "tunnel",
|
||||
"remark": "fx-tunnel",
|
||||
"tag": "inbound-36443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"rewriteAddress": "192.0.2.50",
|
||||
"rewritePort": 8080,
|
||||
"portMap": {},
|
||||
"allowedNetwork": "tcp,udp",
|
||||
"followRedirect": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:10443?alpn=h2%2Chttp%2F1.1&authority=&encryption=none&fp=chrome&security=tls&serviceName=fx-grpc&sni=grpc.example.com&type=grpc#fx-vless-grpc-user%40example.com
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "inbound-10443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 10443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
],
|
||||
"transport": {
|
||||
"type": "grpc",
|
||||
"service_name": "fx-grpc"
|
||||
},
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "grpc.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificate_path": "/etc/x-ui/cert/fx.pem",
|
||||
"key_path": "/etc/x-ui/cert/fx.key"
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "grpcSettings.multiMode 在 sing-box grpc transport 无对应字段;本样本 false 与 sing-box 默认行为一致,无损。multiMode=true 的存量处置见规范(近似/无对应,待 Q1)。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"case": "vless-grpc-tls",
|
||||
"title": "VLESS / gRPC / TLS",
|
||||
"source": [
|
||||
"frontend/src/schemas/protocols/stream/grpc.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "grpc serviceName 两侧改名 service_name,multiMode 在 sing-box 无对应字段(近似:默认关)——本样本 multiMode=false,无损。",
|
||||
"inbound": {
|
||||
"id": 103,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 10443,
|
||||
"protocol": "vless",
|
||||
"remark": "fx-vless-grpc",
|
||||
"tag": "inbound-10443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"encryption": "none",
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "grpc",
|
||||
"security": "tls",
|
||||
"grpcSettings": {
|
||||
"serviceName": "fx-grpc",
|
||||
"multiMode": false
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "grpc.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:11443?alpn=h2%2Chttp%2F1.1&encryption=none&fp=chrome&host=hu.example.com&path=%2Ffxhu&security=tls&sni=hu.example.com&type=httpupgrade#fx-vless-httpupgrade-user%40example.com
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "inbound-11443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 11443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
],
|
||||
"transport": {
|
||||
"type": "httpupgrade",
|
||||
"host": "hu.example.com",
|
||||
"path": "/fxhu"
|
||||
},
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "hu.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificate_path": "/etc/x-ui/cert/fx.pem",
|
||||
"key_path": "/etc/x-ui/cert/fx.key"
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"case": "vless-httpupgrade-tls",
|
||||
"title": "VLESS / HTTPUpgrade / TLS",
|
||||
"source": [
|
||||
"frontend/src/schemas/protocols/stream/httpupgrade.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "",
|
||||
"inbound": {
|
||||
"id": 104,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 11443,
|
||||
"protocol": "vless",
|
||||
"remark": "fx-vless-httpupgrade",
|
||||
"tag": "inbound-11443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"encryption": "none",
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "httpupgrade",
|
||||
"security": "tls",
|
||||
"httpupgradeSettings": {
|
||||
"path": "/fxhu",
|
||||
"host": "hu.example.com"
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "hu.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:21443?encryption=none&flow=xtls-rprx-vision&fm=%7B%22tcp%22%3A%5B%7B%22settings%22%3A%7B%22interval%22%3A%2210-20%22%2C%22length%22%3A%22100-200%22%2C%22packets%22%3A%22tlshello%22%7D%2C%22type%22%3A%22fragment%22%7D%5D%7D&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-finalmask-user%40example.com
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "inbound-21443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 21443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
],
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "www.amd.com",
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"handshake": {
|
||||
"server": "www.amd.com",
|
||||
"server_port": 443
|
||||
},
|
||||
"private_key": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
|
||||
"short_id": [
|
||||
"ab12cd34"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "partial-pending-q1",
|
||||
"risk": "N9",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "streamSettings.finalmask.tcp[fragment]",
|
||||
"input": {
|
||||
"type": "fragment",
|
||||
"settings": {
|
||||
"packets": "tlshello",
|
||||
"length": "100-200",
|
||||
"interval": "10-20"
|
||||
}
|
||||
},
|
||||
"reason": "sing-box 无 finalmask 机制。且现行分享链接携带 fm= 参数(见 expected-link.txt),删服务端 mask 会使存量客户端链接失效——属用户可见变化,待 Q1。",
|
||||
"pending": "Q1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"case": "vless-tcp-finalmask",
|
||||
"title": "VLESS / TCP / Reality + finalmask(fragment)",
|
||||
"source": [
|
||||
"frontend/src/schemas/protocols/stream/finalmask.ts"
|
||||
],
|
||||
"disposition": "partial-pending-q1",
|
||||
"risk": "N9",
|
||||
"notes": "finalmask 无 sing-box 对应。主入站本体可直映;mask 登记 _b0.unmapped。注意例外:hysteria2 的 udp salamander mask 有直映路径,见 hysteria2-salamander。",
|
||||
"inbound": {
|
||||
"id": 107,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 21443,
|
||||
"protocol": "vless",
|
||||
"remark": "fx-vless-finalmask",
|
||||
"tag": "inbound-21443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"encryption": "none",
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "reality",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
},
|
||||
"realitySettings": {
|
||||
"show": false,
|
||||
"xver": 0,
|
||||
"target": "www.amd.com:443",
|
||||
"serverNames": [
|
||||
"www.amd.com"
|
||||
],
|
||||
"privateKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
|
||||
"minClientVer": "",
|
||||
"maxClientVer": "",
|
||||
"maxTimediff": 0,
|
||||
"shortIds": [
|
||||
"ab12cd34"
|
||||
],
|
||||
"mldsa65Seed": "",
|
||||
"settings": {
|
||||
"publicKey": "B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9/AsrhtHHw=",
|
||||
"fingerprint": "chrome",
|
||||
"serverName": "",
|
||||
"spiderX": "/",
|
||||
"mldsa65Verify": ""
|
||||
}
|
||||
},
|
||||
"finalmask": {
|
||||
"tcp": [
|
||||
{
|
||||
"type": "fragment",
|
||||
"settings": {
|
||||
"packets": "tlshello",
|
||||
"length": "100-200",
|
||||
"interval": "10-20"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:20443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=WGmv9FBUlzLLqu1eXfmzCm2jHLDldCutWtShp2jxpns%3D&security=reality&sid=77aa10f0&sni=www.microsoft.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-fallback-user%40example.com
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "inbound-20443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 20443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
],
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "www.microsoft.com",
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"handshake": {
|
||||
"server": "www.microsoft.com",
|
||||
"server_port": 443
|
||||
},
|
||||
"private_key": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=",
|
||||
"short_id": [
|
||||
"77aa10f0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "partial-pending-q1",
|
||||
"risk": "N1",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "fallbacks(inbound_fallbacks 表行 → settings.fallbacks 注入形)",
|
||||
"input": {
|
||||
"name": "",
|
||||
"alpn": "",
|
||||
"path": "",
|
||||
"dest": 8080,
|
||||
"xver": 0
|
||||
},
|
||||
"reason": "sing-box 无 fallback 概念;主入站本体已直映,回落关系(按 alpn/path 分流到 dest)无承载。候选承载(route 条件分流近似 / 专用小组件)待 Q1 拍板。",
|
||||
"pending": "Q1"
|
||||
}
|
||||
],
|
||||
"notes": "迁移器须同时读 inbound_fallbacks 表与 settings 内嵌 fallbacks 两种存量形态(见规范 §4.1)。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"case": "vless-tcp-reality-fallbacks",
|
||||
"title": "VLESS / TCP / Reality + fallbacks(子入站回落)",
|
||||
"source": [
|
||||
"internal/database/model/model.go InboundFallback",
|
||||
"internal/web/service/xray.go BuildFallbacksJSON 注入"
|
||||
],
|
||||
"disposition": "partial-pending-q1",
|
||||
"risk": "N1",
|
||||
"notes": "fallbacks 在本 fork 的真源是 inbound_fallbacks 表(master_id/child_id/dest/xver),生成时注入 settings.fallbacks。本样本以 settings 注入形表示一条 dest=8080 的网页回落。主入站本体可直映;回落关系无 sing-box 对应,登记在 expected 的 _b0.unmapped。",
|
||||
"inbound": {
|
||||
"id": 106,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 20443,
|
||||
"protocol": "vless",
|
||||
"remark": "fx-vless-fallback",
|
||||
"tag": "inbound-20443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"encryption": "none",
|
||||
"fallbacks": [
|
||||
{
|
||||
"name": "",
|
||||
"alpn": "",
|
||||
"path": "",
|
||||
"dest": 8080,
|
||||
"xver": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "reality",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
},
|
||||
"realitySettings": {
|
||||
"show": false,
|
||||
"xver": 0,
|
||||
"target": "www.microsoft.com:443",
|
||||
"serverNames": [
|
||||
"www.microsoft.com"
|
||||
],
|
||||
"privateKey": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=",
|
||||
"minClientVer": "",
|
||||
"maxClientVer": "",
|
||||
"maxTimediff": 0,
|
||||
"shortIds": [
|
||||
"77aa10f0"
|
||||
],
|
||||
"mldsa65Seed": "",
|
||||
"settings": {
|
||||
"publicKey": "WGmv9FBUlzLLqu1eXfmzCm2jHLDldCutWtShp2jxpns=",
|
||||
"fingerprint": "chrome",
|
||||
"serverName": "",
|
||||
"spiderX": "/",
|
||||
"mldsa65Verify": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-reality-user%40example.com
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "inbound-443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
],
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "www.amd.com",
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"handshake": {
|
||||
"server": "www.amd.com",
|
||||
"server_port": 443
|
||||
},
|
||||
"private_key": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
|
||||
"short_id": [
|
||||
"ab12cd34"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "sniff destOverride[http,tls,quic] 由 route 层 sniff 动作近似承载(见规范 §5),入站侧只落 sniff:true。reality.maxTimediff=0 省略(sing-box max_time_difference 默认)。nested settings{publicKey,fingerprint,spiderX} 为客户端侧参数、不进服务端 tls,归订阅元数据。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"case": "vless-tcp-reality",
|
||||
"title": "VLESS / TCP / Reality / Vision flow",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts",
|
||||
"internal/sub/characterization_test.go (C4 shape)"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "现网主力形状(备份库 12 条 vless 全部 tcp+reality)。flow 归一规则:xtls-rprx-vision-udp443 在生成侧归一为 xtls-rprx-vision,sing-box 同名保留。",
|
||||
"inbound": {
|
||||
"id": 101,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"remark": "fx-vless-reality",
|
||||
"tag": "inbound-443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"encryption": "none",
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "reality",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
},
|
||||
"realitySettings": {
|
||||
"show": false,
|
||||
"xver": 0,
|
||||
"target": "www.amd.com:443",
|
||||
"serverNames": [
|
||||
"www.amd.com"
|
||||
],
|
||||
"privateKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
|
||||
"minClientVer": "",
|
||||
"maxClientVer": "",
|
||||
"maxTimediff": 0,
|
||||
"shortIds": [
|
||||
"ab12cd34"
|
||||
],
|
||||
"mldsa65Seed": "",
|
||||
"settings": {
|
||||
"publicKey": "B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9/AsrhtHHw=",
|
||||
"fingerprint": "chrome",
|
||||
"serverName": "",
|
||||
"spiderX": "/",
|
||||
"mldsa65Verify": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:22443?encryption=none&security=none&type=tcp#fx-vless-vlessenc-user%40example.com
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "inbound-22443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 22443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
],
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "partial-pending-q1",
|
||||
"risk": "N3",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "settings.decryption(vlessenc / ML-KEM 加密串)",
|
||||
"input": "mlkem768x25519plus.native.0rtt.*(假串)",
|
||||
"reason": "sing-box vless 无对应加密层(decryption 恒 none 语义);带 vlessenc 的入站迁过去即明文 vless,行为与安全性变化,待 Q1。",
|
||||
"pending": "Q1"
|
||||
}
|
||||
],
|
||||
"notes": "users 本体已映;阻塞点仅 decryption。client 侧 reverse 字段同属 N4,见规范 §3.1。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"case": "vless-vlessenc",
|
||||
"title": "VLESS / TCP / vlessenc(decryption=ML-KEM 串)",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/vless-encryption.ts"
|
||||
],
|
||||
"disposition": "partial-pending-q1",
|
||||
"risk": "N3",
|
||||
"notes": "decryption 为 vlessenc 串(此处假串)时 sing-box 无对应加密层。users 本体可映;decryption 登记 _b0.unmapped,等 Q1。",
|
||||
"inbound": {
|
||||
"id": 108,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 22443,
|
||||
"protocol": "vless",
|
||||
"remark": "fx-vless-vlessenc",
|
||||
"tag": "inbound-22443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
],
|
||||
"decryption": "mlkem768x25519plus.native.0rtt.FAKEFAKEFAKEFAKE",
|
||||
"encryption": "none",
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "none",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:8443?alpn=h2%2Chttp%2F1.1&encryption=none&fp=chrome&host=cdn.example.com&path=%2Ffxws&security=tls&sni=cdn.example.com&type=ws#fx-vless-ws-user%40example.com
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "inbound-8443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 8443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
],
|
||||
"transport": {
|
||||
"type": "ws",
|
||||
"path": "/fxws",
|
||||
"headers": {
|
||||
"Host": "cdn.example.com"
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "cdn.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificate_path": "/etc/x-ui/cert/fx.pem",
|
||||
"key_path": "/etc/x-ui/cert/fx.key"
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "tlsSettings.settings.fingerprint 为客户端侧参数,不进服务端 tls。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"case": "vless-ws-tls",
|
||||
"title": "VLESS / WS / TLS",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts",
|
||||
"frontend/src/schemas/protocols/stream/ws.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "ws headers.Host 是订阅与服务端共同参数,逐项对齐不变量 §2.4-3。",
|
||||
"inbound": {
|
||||
"id": 102,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 8443,
|
||||
"protocol": "vless",
|
||||
"remark": "fx-vless-ws",
|
||||
"tag": "inbound-8443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"encryption": "none",
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "ws",
|
||||
"security": "tls",
|
||||
"wsSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"path": "/fxws",
|
||||
"headers": {
|
||||
"Host": "cdn.example.com"
|
||||
}
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "cdn.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:12443?alpn=h2%2Chttp%2F1.1&encryption=none&extra=%7B%22mode%22%3A%22auto%22%2C%22xmux%22%3A%7B%22cMaxReuseTimes%22%3A0%2C%22hKeepAlivePeriod%22%3A0%2C%22hMaxRequestTimes%22%3A%22600-900%22%2C%22hMaxReusableSecs%22%3A%221800-3000%22%2C%22maxConcurrency%22%3A%2216-32%22%2C%22maxConnections%22%3A0%7D%7D&fp=chrome&host=xh.example.com&mode=auto&path=%2Ffxxh&security=tls&sni=xh.example.com&type=xhttp#fx-vless-xhttp-user%40example.com
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"inbounds": [],
|
||||
"_b0": {
|
||||
"disposition": "blocked-pending-q1",
|
||||
"risk": "N8",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "streamSettings.network=xhttp + xhttpSettings",
|
||||
"input": {
|
||||
"path": "/fxxh",
|
||||
"host": "xh.example.com",
|
||||
"mode": "auto",
|
||||
"xmux": {
|
||||
"maxConcurrency": "16-32"
|
||||
}
|
||||
},
|
||||
"reason": "sing-box v1.13.21 无 XHTTP 传输,无任何等价承载;强行落 ws/httpupgrade 会改变订阅链接(违背无感知不变量)。处置(近似映射/专用承载)待 Q1 拍板后补基线。",
|
||||
"pending": "Q1"
|
||||
}
|
||||
],
|
||||
"mappableRemainder": "users、tls、sniff 与 vless-ws-tls 同构;阻塞点仅在 transport。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"case": "vless-xhttp-tls",
|
||||
"title": "VLESS / XHTTP / TLS(无对应)",
|
||||
"source": [
|
||||
"frontend/src/schemas/protocols/stream/xhttp.ts"
|
||||
],
|
||||
"disposition": "blocked-pending-q1",
|
||||
"risk": "N8",
|
||||
"notes": "sing-box 无 XHTTP。expected-singbox.json 不给伪造输出,只登记未映射事实,等 Q1 拍板近似承载(ws/httpupgrade)后再定基线。",
|
||||
"inbound": {
|
||||
"id": 105,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 12443,
|
||||
"protocol": "vless",
|
||||
"remark": "fx-vless-xhttp",
|
||||
"tag": "inbound-12443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"encryption": "none",
|
||||
"fallbacks": []
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "xhttp",
|
||||
"security": "tls",
|
||||
"xhttpSettings": {
|
||||
"path": "/fxxh",
|
||||
"host": "xh.example.com",
|
||||
"mode": "auto",
|
||||
"xmux": {
|
||||
"maxConcurrency": "16-32",
|
||||
"maxConnections": 0,
|
||||
"cMaxReuseTimes": 0,
|
||||
"hMaxRequestTimes": "600-900",
|
||||
"hMaxReusableSecs": "1800-3000",
|
||||
"hKeepAlivePeriod": 0
|
||||
}
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "xh.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"flow": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vmess://ewogICJhZGQiOiAiMjAzLjAuMTEzLjEwIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibXR1IjogMTM1MCwKICAibmV0IjogImtjcCIsCiAgInBhdGgiOiAiZngta2NwLXNlZWQiLAogICJwb3J0IjogMjU0NDMsCiAgInBzIjogImZ4LXZtZXNzLWtjcC11c2VyQGV4YW1wbGUuY29tIiwKICAic2N5IjogImF1dG8iLAogICJ0bHMiOiAibm9uZSIsCiAgInR0aSI6IDIwLAogICJ0eXBlIjogInNydHAiLAogICJ2IjogIjIiCn0=
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"inbounds": [],
|
||||
"_b0": {
|
||||
"disposition": "blocked-pending-q1",
|
||||
"risk": "N8",
|
||||
"unmapped": [
|
||||
{
|
||||
"concept": "streamSettings.network=kcp + kcpSettings",
|
||||
"input": {
|
||||
"mtu": 1350,
|
||||
"tti": 20,
|
||||
"uplinkCapacity": 5,
|
||||
"downlinkCapacity": 20,
|
||||
"header": {
|
||||
"type": "srtp"
|
||||
},
|
||||
"seed": "*"
|
||||
},
|
||||
"reason": "sing-box 无 mKCP 传输,无等价承载;处置同 xhttp,待 Q1。",
|
||||
"pending": "Q1"
|
||||
}
|
||||
],
|
||||
"mappableRemainder": "users/sniff 与 vmess-ws-tls 同构;阻塞点仅在 transport。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"case": "vmess-kcp",
|
||||
"title": "VMess / mKCP(无对应)",
|
||||
"source": [
|
||||
"frontend/src/schemas/protocols/stream/kcp.ts"
|
||||
],
|
||||
"disposition": "blocked-pending-q1",
|
||||
"risk": "N8",
|
||||
"notes": "sing-box 无 mKCP,同 xhttp 口径:只登记事实,等 Q1。",
|
||||
"inbound": {
|
||||
"id": 111,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 25443,
|
||||
"protocol": "vmess",
|
||||
"remark": "fx-vmess-kcp",
|
||||
"tag": "inbound-25443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"security": "auto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "kcp",
|
||||
"security": "none",
|
||||
"kcpSettings": {
|
||||
"mtu": 1350,
|
||||
"tti": 20,
|
||||
"uplinkCapacity": 5,
|
||||
"downlinkCapacity": 20,
|
||||
"cwndMultiplier": 1,
|
||||
"maxSendingWindow": 2097152,
|
||||
"header": {
|
||||
"type": "srtp"
|
||||
},
|
||||
"seed": "fx-kcp-seed"
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"security": "auto"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vmess://ewogICJhZGQiOiAiMjAzLjAuMTEzLjEwIiwKICAiaG9zdCI6ICJ3d3cuZXhhbXBsZS5jb20iLAogICJpZCI6ICIxMTExMTExMS0yMjIyLTQzMzMtODQ0NC01NTU1NTU1NTU1NTUiLAogICJuZXQiOiAidGNwIiwKICAicGF0aCI6ICIvIiwKICAicG9ydCI6IDI0NDQzLAogICJwcyI6ICJmeC12bWVzcy1odHRwY2Ftby11c2VyQGV4YW1wbGUuY29tIiwKICAic2N5IjogImFlcy0xMjgtZ2NtIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogImh0dHAiLAogICJ2IjogIjIiCn0=
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vmess",
|
||||
"tag": "inbound-24443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 24443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555",
|
||||
"security": "aes-128-gcm",
|
||||
"alter_id": 0
|
||||
}
|
||||
],
|
||||
"transport": {
|
||||
"type": "http",
|
||||
"host": [
|
||||
"www.example.com"
|
||||
],
|
||||
"path": [
|
||||
"/"
|
||||
],
|
||||
"method": "GET"
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "tcp header type=http → transport{type:http};request.headers.Host 提升为 host 数组,其余 headers 进 transport.headers(本样本无其余项)。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"case": "vmess-tcp-httpheader",
|
||||
"title": "VMess / TCP / HTTP 头伪装",
|
||||
"source": [
|
||||
"frontend/src/schemas/protocols/stream/tcp.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "tcp header type=http → sing-box transport{type:http}(仅请求侧伪装字段改名)。",
|
||||
"inbound": {
|
||||
"id": 110,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 24443,
|
||||
"protocol": "vmess",
|
||||
"remark": "fx-vmess-httpcamo",
|
||||
"tag": "inbound-24443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"security": "aes-128-gcm"
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "none",
|
||||
"tcpSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"header": {
|
||||
"type": "http",
|
||||
"request": {
|
||||
"version": "1.1",
|
||||
"method": "GET",
|
||||
"path": [
|
||||
"/"
|
||||
],
|
||||
"headers": {
|
||||
"Host": [
|
||||
"www.example.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"security": "aes-128-gcm"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
vmess://ewogICJhZGQiOiAiMjAzLjAuMTEzLjEwIiwKICAiYWxwbiI6ICJoMixodHRwLzEuMSIsCiAgImZwIjogImNocm9tZSIsCiAgImhvc3QiOiAidm0uZXhhbXBsZS5jb20iLAogICJpZCI6ICIxMTExMTExMS0yMjIyLTQzMzMtODQ0NC01NTU1NTU1NTU1NTUiLAogICJuZXQiOiAid3MiLAogICJwYXRoIjogIi9meHZtIiwKICAicG9ydCI6IDIzNDQzLAogICJwcyI6ICJmeC12bWVzcy13cy11c2VyQGV4YW1wbGUuY29tIiwKICAic2N5IjogImF1dG8iLAogICJzbmkiOiAidm0uZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "vmess",
|
||||
"tag": "inbound-23443",
|
||||
"listen": "203.0.113.10",
|
||||
"listen_port": 23443,
|
||||
"users": [
|
||||
{
|
||||
"name": "user@example.com",
|
||||
"uuid": "11111111-2222-4333-8444-555555555555",
|
||||
"security": "auto",
|
||||
"alter_id": 0
|
||||
}
|
||||
],
|
||||
"transport": {
|
||||
"type": "ws",
|
||||
"path": "/fxvm",
|
||||
"headers": {
|
||||
"Host": "vm.example.com"
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "vm.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificate_path": "/etc/x-ui/cert/fx.pem",
|
||||
"key_path": "/etc/x-ui/cert/fx.key"
|
||||
},
|
||||
"sniff": true
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"notes": "alterId 在 wire 上为 alterId、sing-box 为 alter_id;security 逐字保留(不变量 §2.4-5)。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"case": "vmess-ws-tls",
|
||||
"title": "VMess / WS / TLS",
|
||||
"source": [
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": null,
|
||||
"notes": "",
|
||||
"inbound": {
|
||||
"id": 109,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 23443,
|
||||
"protocol": "vmess",
|
||||
"remark": "fx-vmess-ws",
|
||||
"tag": "inbound-23443",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"security": "auto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "ws",
|
||||
"security": "tls",
|
||||
"wsSettings": {
|
||||
"acceptProxyProtocol": false,
|
||||
"path": "/fxvm",
|
||||
"headers": {
|
||||
"Host": "vm.example.com"
|
||||
}
|
||||
},
|
||||
"tlsSettings": {
|
||||
"serverName": "vm.example.com",
|
||||
"alpn": [
|
||||
"h2",
|
||||
"http/1.1"
|
||||
],
|
||||
"certificates": [
|
||||
{
|
||||
"certificateFile": "/etc/x-ui/cert/fx.pem",
|
||||
"keyFile": "/etc/x-ui/cert/fx.key"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"fingerprint": "chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"security": "auto"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
wireguard://l5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tbY%3D@203.0.113.10:51820?address=10.0.0.2%2F32&keepalive=25&mtu=1420&publickey=VxR2nRFr92Q2rnS8eT0sMK0ZA8WaxSc4BcfiaYtBDDY%3D#fx-wireguard-user%40example.com
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"inbounds": [],
|
||||
"endpoints": [
|
||||
{
|
||||
"type": "wireguard",
|
||||
"tag": "inbound-51820",
|
||||
"listen_port": 51820,
|
||||
"address": [
|
||||
"10.0.0.1/24"
|
||||
],
|
||||
"private_key": "ZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4Q=",
|
||||
"mtu": 1420,
|
||||
"peers": [
|
||||
{
|
||||
"public_key": "FThkYS9cXUf2hNZomaneT3wwtfdKjr1plkIhK84A2Tg=",
|
||||
"allowed_ips": [
|
||||
"10.0.0.2/32"
|
||||
],
|
||||
"persistent_keepalive_interval": 25
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"_b0": {
|
||||
"disposition": "direct",
|
||||
"risk": "N7",
|
||||
"notes": "挂载位置变化:inbounds[] → endpoints[],且须 route 规则把流量引入(B2 基础配置处理)。服务端 address 按现后端推导口径取 subnet 首地址(10.0.0.0/24 → 10.0.0.1/24),B1 与现生成链(WireguardPeerFromClient/服务端地址推导)逐项对。noKernelTun=false → 系统 tun 模式;内核/系统模式真机差异待 B3(N7)。peers 的 preshared_key 本样本为空省略。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"case": "wireguard",
|
||||
"title": "WireGuard(endpoint 化)",
|
||||
"source": [
|
||||
"internal/sub/service_wireguard_test.go",
|
||||
"frontend/src/lib/xray/inbound-defaults.ts"
|
||||
],
|
||||
"disposition": "direct",
|
||||
"risk": "N7",
|
||||
"notes": "新模型不是 inbound 而是 endpoints[] + route 引用。客户端 .conf 与 wireguard:// 链均属不变量 §2.4-5。noKernelTun/系统模式差异待 B3 真机验(N7)。",
|
||||
"inbound": {
|
||||
"id": 122,
|
||||
"listen": "203.0.113.10",
|
||||
"port": 51820,
|
||||
"protocol": "wireguard",
|
||||
"remark": "fx-wireguard",
|
||||
"tag": "inbound-51820",
|
||||
"enable": true,
|
||||
"settings": {
|
||||
"mtu": 1420,
|
||||
"secretKey": "ZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4Q=",
|
||||
"peers": [],
|
||||
"clients": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"privateKey": "l5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tbY=",
|
||||
"publicKey": "FThkYS9cXUf2hNZomaneT3wwtfdKjr1plkIhK84A2Tg=",
|
||||
"allowedIPs": [
|
||||
"10.0.0.2/32"
|
||||
],
|
||||
"keepAlive": 25
|
||||
}
|
||||
],
|
||||
"noKernelTun": false,
|
||||
"subnetIp": "10.0.0.0",
|
||||
"subnetCidr": 24
|
||||
}
|
||||
},
|
||||
"clientEmail": "user@example.com",
|
||||
"client": {
|
||||
"email": "user@example.com",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": true,
|
||||
"tgId": 0,
|
||||
"subId": "fxsubid000000001",
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"privateKey": "l5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tbY=",
|
||||
"publicKey": "FThkYS9cXUf2hNZomaneT3wwtfdKjr1plkIhK84A2Tg=",
|
||||
"allowedIPs": [
|
||||
"10.0.0.2/32"
|
||||
],
|
||||
"keepAlive": 25
|
||||
}
|
||||
}
|
||||
+65
-2
@@ -53,6 +53,13 @@ type SubService struct {
|
||||
// inbound whose NodeID is set. Keeps the per-link host derivation
|
||||
// O(1) instead of O(N) DB hits.
|
||||
nodesByID map[int]*model.Node
|
||||
// agentByNodeID is populated alongside nodesByID with the latest
|
||||
// agent binding per node (agent_nodes rows). A node present here is
|
||||
// agent-managed: its Node.Address is the literal placeholder "agent"
|
||||
// written by the create form (the panel never dials agent nodes), so
|
||||
// resolveInboundAddress advertises the agent's telemetry address
|
||||
// instead of the stored placeholder.
|
||||
agentByNodeID map[int]*model.AgentNode
|
||||
// statsByEmail maps a client email to its traffic row across ALL inbounds
|
||||
// loaded for the request. client_traffics.email is globally unique, so this
|
||||
// lets statsForClient resolve usage for a client even on an inbound that
|
||||
@@ -1296,6 +1303,7 @@ func (s *SubService) loadNodes() {
|
||||
if err := db.Model(&model.Node{}).Where("address != ''").Find(&nodes).Error; err != nil {
|
||||
logger.Warning("subscription: load nodes failed:", err)
|
||||
s.nodesByID = nil
|
||||
s.agentByNodeID = nil
|
||||
return
|
||||
}
|
||||
m := make(map[int]*model.Node, len(nodes))
|
||||
@@ -1303,11 +1311,60 @@ func (s *SubService) loadNodes() {
|
||||
m[n.Id] = n
|
||||
}
|
||||
s.nodesByID = m
|
||||
s.loadAgentBindings()
|
||||
}
|
||||
|
||||
// loadAgentBindings refreshes agentByNodeID with the most recently seen
|
||||
// agent binding for each node, mirroring the selection the node list
|
||||
// makes in annotateAgentTelemetry (latest reporter wins).
|
||||
func (s *SubService) loadAgentBindings() {
|
||||
db := database.GetDB()
|
||||
var agents []model.AgentNode
|
||||
if err := db.Model(&model.AgentNode{}).
|
||||
Where("node_id IS NOT NULL").
|
||||
Order("last_seen DESC").
|
||||
Find(&agents).Error; err != nil {
|
||||
logger.Warning("subscription: load agent bindings failed:", err)
|
||||
s.agentByNodeID = nil
|
||||
return
|
||||
}
|
||||
byNode := make(map[int]*model.AgentNode, len(agents))
|
||||
for i := range agents {
|
||||
if agents[i].NodeID == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := byNode[*agents[i].NodeID]; !ok {
|
||||
byNode[*agents[i].NodeID] = &agents[i]
|
||||
}
|
||||
}
|
||||
s.agentByNodeID = byNode
|
||||
}
|
||||
|
||||
// agentNodeAddress is the single point where the export chain resolves
|
||||
// the connect address of an agent-managed node: the IPv4 from the
|
||||
// agent's latest heartbeat telemetry, falling back to its IPv6. It
|
||||
// returns "" when the node has no bound agent or the agent never
|
||||
// reported an address, letting the caller's fallback chain continue to
|
||||
// listen / configured public host / request host. The address stored in
|
||||
// the node row is never used for agent nodes — it is the create form's
|
||||
// placeholder "agent", which is not a connectable host.
|
||||
func (s *SubService) agentNodeAddress(nodeID int) string {
|
||||
ag, ok := s.agentByNodeID[nodeID]
|
||||
if !ok || ag == nil {
|
||||
return ""
|
||||
}
|
||||
v4, v6 := service.ResolveAgentAddresses(ag)
|
||||
if v4 != "" {
|
||||
return v4
|
||||
}
|
||||
return v6
|
||||
}
|
||||
|
||||
// resolveInboundAddress picks the host an external client should connect to,
|
||||
// honoring the inbound's share address strategy the same way the panel's
|
||||
// share/QR link builder does (#5208):
|
||||
// share/QR link builder does (#5208). For agent-managed nodes the "node"
|
||||
// step of every chain is the agent's telemetry-reported address (see
|
||||
// agentNodeAddress), never the placeholder stored in the node row:
|
||||
// - "listen": an explicit, client-reachable bind Listen wins, backed by the
|
||||
// node's address for node-managed inbounds;
|
||||
// - "custom": the inbound's ShareAddr wins, then node, then listen;
|
||||
@@ -1324,7 +1381,13 @@ func (s *SubService) loadNodes() {
|
||||
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
|
||||
var nodeAddr string
|
||||
if inbound.NodeID != nil && s.nodesByID != nil {
|
||||
if n, ok := s.nodesByID[*inbound.NodeID]; ok {
|
||||
if _, isAgent := s.agentByNodeID[*inbound.NodeID]; isAgent {
|
||||
// Agent-managed node: the stored address is the create
|
||||
// form's placeholder ("agent"), never a connectable host,
|
||||
// so the node step of every strategy resolves through the
|
||||
// agent's own reported address instead.
|
||||
nodeAddr = s.agentNodeAddress(*inbound.NodeID)
|
||||
} else if n, ok := s.nodesByID[*inbound.NodeID]; ok {
|
||||
nodeAddr = n.Address
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
@@ -204,6 +205,142 @@ func TestResolveInboundAddress(t *testing.T) {
|
||||
t.Fatalf("node-strategy address = %q, want node7.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
// Agent-managed nodes store the literal placeholder "agent" in the
|
||||
// node row (the create form writes it; the panel never dials agent
|
||||
// nodes). The node step of every strategy must resolve through the
|
||||
// agent's telemetry-reported address instead — a link addressed to
|
||||
// "agent:<port>" is unusable.
|
||||
agentSvc := func(ipv4, ipv6 string) *SubService {
|
||||
id := 8
|
||||
return &SubService{
|
||||
address: reqHost,
|
||||
nodesByID: map[int]*model.Node{8: {Id: 8, Address: "agent", Port: 2053}},
|
||||
agentByNodeID: map[int]*model.AgentNode{
|
||||
8: {NodeID: &id, IPv4: ipv4, IPv6: ipv6},
|
||||
},
|
||||
}
|
||||
}
|
||||
agentInbound := func(mut func(*model.Inbound)) *model.Inbound {
|
||||
id := 8
|
||||
ib := &model.Inbound{NodeID: &id, Port: 31324}
|
||||
if mut != nil {
|
||||
mut(ib)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
t.Run("agent-managed inbound advertises the agent telemetry ipv4", func(t *testing.T) {
|
||||
s := agentSvc("198.51.100.23", "2001:db8::23")
|
||||
if got := s.resolveInboundAddress(agentInbound(nil)); got != "198.51.100.23" {
|
||||
t.Fatalf("agent address = %q, want telemetry IPv4 198.51.100.23 (never the placeholder)", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-managed inbound falls back to telemetry ipv6", func(t *testing.T) {
|
||||
s := agentSvc("", "2001:db8::23")
|
||||
if got := s.resolveInboundAddress(agentInbound(nil)); got != "2001:db8::23" {
|
||||
t.Fatalf("agent address = %q, want telemetry IPv6 2001:db8::23", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-managed inbound without telemetry continues the chain", func(t *testing.T) {
|
||||
s := agentSvc("", "")
|
||||
ib := agentInbound(func(ib *model.Inbound) { ib.Listen = "203.0.113.9" })
|
||||
if got := s.resolveInboundAddress(ib); got != "203.0.113.9" {
|
||||
t.Fatalf("agent-without-telemetry address = %q, want routable listen 203.0.113.9", got)
|
||||
}
|
||||
ib.Listen = "0.0.0.0"
|
||||
if got := s.resolveInboundAddress(ib); got != reqHost {
|
||||
t.Fatalf("agent-without-telemetry address = %q, want subscriber host %q (never the placeholder)", got, reqHost)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-managed listen strategy still prefers the bind", func(t *testing.T) {
|
||||
s := agentSvc("198.51.100.23", "")
|
||||
ib := agentInbound(func(ib *model.Inbound) {
|
||||
ib.Listen = "203.0.113.9"
|
||||
ib.ShareAddrStrategy = "listen"
|
||||
})
|
||||
if got := s.resolveInboundAddress(ib); got != "203.0.113.9" {
|
||||
t.Fatalf("listen-strategy agent address = %q, want the bind 203.0.113.9", got)
|
||||
}
|
||||
ib.Listen = "0.0.0.0"
|
||||
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
|
||||
t.Fatalf("listen-strategy agent address = %q, want telemetry IPv4 fallback 198.51.100.23", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-managed custom strategy keeps the share address first", func(t *testing.T) {
|
||||
s := agentSvc("198.51.100.23", "")
|
||||
ib := agentInbound(func(ib *model.Inbound) {
|
||||
ib.ShareAddrStrategy = "custom"
|
||||
ib.ShareAddr = "edge.example.com"
|
||||
})
|
||||
if got := s.resolveInboundAddress(ib); got != "edge.example.com" {
|
||||
t.Fatalf("custom-strategy agent address = %q, want edge.example.com", got)
|
||||
}
|
||||
ib.ShareAddr = ""
|
||||
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
|
||||
t.Fatalf("custom-strategy agent fallback = %q, want telemetry IPv4 198.51.100.23", got)
|
||||
}
|
||||
})
|
||||
|
||||
// loadNodes must surface the latest agent binding per node so a real
|
||||
// request (PrepareForRequest) resolves agent addresses from the DB,
|
||||
// not just from hand-built fixtures.
|
||||
t.Run("loadNodes picks the latest agent binding per node", func(t *testing.T) {
|
||||
initSubDB(t)
|
||||
node := &model.Node{Name: "agent-1", Scheme: "https", Address: "agent", Port: 2053, Enable: true}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
stale := &model.AgentNode{TokenHash: "stalehash", Name: "agent-1", NodeID: &node.Id, IPv4: "192.0.2.1", LastSeen: 1000}
|
||||
fresh := &model.AgentNode{TokenHash: "freshhash", Name: "agent-1", NodeID: &node.Id, IPv4: "198.51.100.23", IPv6: "2001:db8::23", LastSeen: 2000}
|
||||
if err := database.GetDB().Create(stale).Error; err != nil {
|
||||
t.Fatalf("create stale binding: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Create(fresh).Error; err != nil {
|
||||
t.Fatalf("create fresh binding: %v", err)
|
||||
}
|
||||
s := &SubService{address: reqHost}
|
||||
s.loadNodes()
|
||||
ib := &model.Inbound{NodeID: &node.Id, Port: 31324}
|
||||
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
|
||||
t.Fatalf("loaded agent address = %q, want latest binding IPv4 198.51.100.23", got)
|
||||
}
|
||||
if got := s.agentNodeAddress(node.Id); got != "198.51.100.23" {
|
||||
t.Fatalf("agentNodeAddress = %q, want 198.51.100.23", got)
|
||||
}
|
||||
if got := s.agentNodeAddress(99999); got != "" {
|
||||
t.Fatalf("agentNodeAddress(unknown) = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A full share link for an agent-hosted inbound must carry the agent's
|
||||
// telemetry address and inbound port — the regression was a link whose
|
||||
// host was the node row's placeholder, e.g. vless://…@agent:31324.
|
||||
func TestAgentInboundShareLinkUsesTelemetryAddress(t *testing.T) {
|
||||
nodeID := 8
|
||||
s := &SubService{
|
||||
address: "sub.example.com",
|
||||
nodesByID: map[int]*model.Node{8: {Id: 8, Address: "agent", Port: 2053}},
|
||||
agentByNodeID: map[int]*model.AgentNode{
|
||||
8: {NodeID: &nodeID, IPv4: "198.51.100.23", IPv6: "2001:db8::23"},
|
||||
},
|
||||
}
|
||||
ib := shareLinkInbound(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
ib.NodeID = &nodeID
|
||||
ib.Listen = "0.0.0.0"
|
||||
ib.Port = 31324
|
||||
link := s.GetLink(ib, "user")
|
||||
if !strings.Contains(link, "@198.51.100.23:31324") {
|
||||
t.Fatalf("agent share link = %q, want host 198.51.100.23:31324", link)
|
||||
}
|
||||
if strings.Contains(link, "@agent:") {
|
||||
t.Fatalf("agent share link leaks the placeholder address: %q", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalStreamSettings(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
@@ -83,11 +85,16 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
|
||||
// Item 19 backfill: the token has just passed the hash check, so for
|
||||
// rows created before plaintext storage existed, adopt the presented
|
||||
// token as the recoverable copy. The guarded update only fires while
|
||||
// token_plain is empty — an authenticated request can never overwrite
|
||||
// an already-stored token (only issuance writes a fresh one).
|
||||
// token_plain is unset — an authenticated request can never overwrite
|
||||
// an already-stored token (only issuance writes a fresh one). "Unset"
|
||||
// must cover SQL NULL as well as '': AutoMigrate added the column to
|
||||
// pre-existing rows as NULL, and NULL = '' is never true, so an
|
||||
// equality-only guard silently matched zero legacy rows in
|
||||
// production (the GORM-scanned value reads as "" either way, which is
|
||||
// why the in-memory check above cannot distinguish them).
|
||||
if agent.TokenPlain == "" {
|
||||
if res := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("id = ? AND token_plain = ''", agent.Id).
|
||||
Where("id = ? AND (token_plain IS NULL OR token_plain = '')", agent.Id).
|
||||
Update("token_plain", token); res.Error == nil && res.RowsAffected > 0 {
|
||||
agent.TokenPlain = token
|
||||
}
|
||||
@@ -95,6 +102,69 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
|
||||
return &agent, true
|
||||
}
|
||||
|
||||
// requestSourceIP extracts the real source IP of an agent request.
|
||||
// Agents reach the master through Cloudflare, so the TCP peer is a CF
|
||||
// edge and the agent's own address travels in CF-Connecting-IP; the
|
||||
// first X-Forwarded-For hop is the next best signal, and the transport
|
||||
// RemoteAddr is the direct-connection fallback. Only literal IPs are
|
||||
// accepted — anything unparsable falls through to the next source.
|
||||
func requestSourceIP(c *gin.Context) net.IP {
|
||||
var candidates []string
|
||||
if v := c.GetHeader("CF-Connecting-IP"); v != "" {
|
||||
candidates = append(candidates, v)
|
||||
}
|
||||
if v := c.GetHeader("X-Forwarded-For"); v != "" {
|
||||
candidates = append(candidates, strings.Split(v, ",")[0])
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(c.Request.RemoteAddr); err == nil {
|
||||
candidates = append(candidates, host)
|
||||
} else {
|
||||
candidates = append(candidates, c.Request.RemoteAddr)
|
||||
}
|
||||
for _, cand := range candidates {
|
||||
if ip := net.ParseIP(strings.TrimSpace(cand)); ip != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureSeenIP records the source address of an authenticated agent
|
||||
// request on the agent row (SeenIPv4/SeenIPv6). This is the master's
|
||||
// view of the agent's public side: for a NAT'd agent it is the only
|
||||
// place the public address ever appears. Writes are change-only —
|
||||
// traffic reports land every ~10s and the address almost never moves,
|
||||
// so an unchanged address must not turn into a DB write per request.
|
||||
func (a *AgentController) captureSeenIP(c *gin.Context, agent *model.AgentNode) {
|
||||
ip := requestSourceIP(c)
|
||||
if ip == nil || agent == nil {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{"seen_ip_at": time.Now().UnixMilli()}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if agent.SeenIPv4 == v4.String() {
|
||||
return
|
||||
}
|
||||
updates["seen_ipv4"] = v4.String()
|
||||
} else {
|
||||
if agent.SeenIPv6 == ip.String() {
|
||||
return
|
||||
}
|
||||
updates["seen_ipv6"] = ip.String()
|
||||
}
|
||||
if err := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("id = ?", agent.Id).Updates(updates).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if v, ok := updates["seen_ipv4"]; ok {
|
||||
agent.SeenIPv4 = v.(string)
|
||||
}
|
||||
if v, ok := updates["seen_ipv6"]; ok {
|
||||
agent.SeenIPv6 = v.(string)
|
||||
}
|
||||
agent.SeenIPAt = updates["seen_ip_at"].(int64)
|
||||
}
|
||||
|
||||
func (a *AgentController) touchAgent(id uint, name string) {
|
||||
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
|
||||
if name != "" {
|
||||
@@ -128,6 +198,7 @@ func (a *AgentController) register(c *gin.Context) {
|
||||
c.JSON(401, gin.H{"message": "unknown token"})
|
||||
return
|
||||
}
|
||||
a.captureSeenIP(c, agent)
|
||||
a.touchAgent(uint(agent.Id), req.Name)
|
||||
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
|
||||
jsonObj(c, gin.H{"message": "registered"}, nil)
|
||||
@@ -252,6 +323,7 @@ func (a *AgentController) heartbeat(c *gin.Context) {
|
||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
a.captureSeenIP(c, agent)
|
||||
// Telemetry is optional and best-effort: a body that fails to parse
|
||||
// (or no body at all) must never fail the heartbeat itself.
|
||||
var req struct {
|
||||
@@ -281,6 +353,7 @@ func (a *AgentController) getConfig(c *gin.Context) {
|
||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
a.captureSeenIP(c, agent)
|
||||
if agent.NodeID == nil {
|
||||
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
|
||||
return
|
||||
@@ -339,6 +412,7 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
|
||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
a.captureSeenIP(c, agent)
|
||||
var req agentTrafficReport
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, "Invalid request", err)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Public-address capture: every authenticated agent request records its
|
||||
// source address on the agent row — CF-Connecting-IP first (agents come
|
||||
// through Cloudflare), then the first X-Forwarded-For hop, then the
|
||||
// transport RemoteAddr. Writes are change-only so the ~10s traffic
|
||||
// cadence does not become a DB write per request.
|
||||
func TestAgentSeenIPCapture(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
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() })
|
||||
db := database.GetDB()
|
||||
|
||||
node := &model.Node{Name: "seen-ip-node", Address: ""}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
const token = "seen-ip-test-token"
|
||||
agent := &model.AgentNode{
|
||||
TokenHash: hashAgentToken(token),
|
||||
TokenPrefix: "seen-ip-",
|
||||
Name: "seen-ip-node",
|
||||
NodeID: &node.Id,
|
||||
}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
NewAgentController(engine.Group("/panel/api/agent"))
|
||||
|
||||
heartbeat := func(headers map[string]string, remoteAddr string) int {
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if remoteAddr != "" {
|
||||
req.RemoteAddr = remoteAddr
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
load := func() model.AgentNode {
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, agent.Id).Error; err != nil {
|
||||
t.Fatalf("reload agent: %v", err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// CF-Connecting-IP wins and is stored.
|
||||
if code := heartbeat(map[string]string{"CF-Connecting-IP": "111.91.29.150"}, ""); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat with CF header: status %d", code)
|
||||
}
|
||||
row := load()
|
||||
if row.SeenIPv4 != "111.91.29.150" || row.SeenIPAt == 0 {
|
||||
t.Fatalf("CF source not stored: %+v", row)
|
||||
}
|
||||
|
||||
// Change-only: same address again must not rewrite (SeenIPAt frozen).
|
||||
frozen := row.SeenIPAt
|
||||
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).
|
||||
Update("seen_ip_at", frozen).Error; err != nil {
|
||||
t.Fatalf("pin seen_ip_at: %v", err)
|
||||
}
|
||||
if code := heartbeat(map[string]string{"CF-Connecting-IP": "111.91.29.150"}, ""); code != http.StatusOK {
|
||||
t.Fatalf("repeat heartbeat: status %d", code)
|
||||
}
|
||||
if row = load(); row.SeenIPAt != frozen {
|
||||
t.Fatalf("unchanged address rewrote the row: seen_ip_at %d -> %d", frozen, row.SeenIPAt)
|
||||
}
|
||||
|
||||
// X-Forwarded-For first hop is next in precedence.
|
||||
if code := heartbeat(map[string]string{"X-Forwarded-For": "203.0.113.9, 70.41.3.18"}, ""); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat with XFF: status %d", code)
|
||||
}
|
||||
if row = load(); row.SeenIPv4 != "203.0.113.9" {
|
||||
t.Fatalf("XFF first hop not stored: %+v", row)
|
||||
}
|
||||
|
||||
// CF beats XFF when both are present.
|
||||
if code := heartbeat(map[string]string{
|
||||
"CF-Connecting-IP": "111.91.29.150",
|
||||
"X-Forwarded-For": "203.0.113.9",
|
||||
}, ""); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat with both headers: status %d", code)
|
||||
}
|
||||
if row = load(); row.SeenIPv4 != "111.91.29.150" {
|
||||
t.Fatalf("CF header did not win over XFF: %+v", row)
|
||||
}
|
||||
|
||||
// A garbage CF value falls through to XFF.
|
||||
if code := heartbeat(map[string]string{
|
||||
"CF-Connecting-IP": "not-an-ip",
|
||||
"X-Forwarded-For": "198.51.100.7",
|
||||
}, ""); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat with garbage CF header: status %d", code)
|
||||
}
|
||||
if row = load(); row.SeenIPv4 != "198.51.100.7" {
|
||||
t.Fatalf("garbage CF value did not fall through to XFF: %+v", row)
|
||||
}
|
||||
|
||||
// No headers: the transport RemoteAddr is the fallback.
|
||||
if code := heartbeat(nil, "198.51.100.23:5555"); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat without headers: status %d", code)
|
||||
}
|
||||
if row = load(); row.SeenIPv4 != "198.51.100.23" {
|
||||
t.Fatalf("RemoteAddr fallback not stored: %+v", row)
|
||||
}
|
||||
|
||||
// IPv6 sources land in the v6 column.
|
||||
if code := heartbeat(map[string]string{"CF-Connecting-IP": "2602:f32b:1:310f::1"}, ""); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat with v6 CF header: status %d", code)
|
||||
}
|
||||
if row = load(); row.SeenIPv6 != "2602:f32b:1:310f::1" {
|
||||
t.Fatalf("v6 source not stored: %+v", row)
|
||||
}
|
||||
|
||||
// Unauthenticated requests capture nothing.
|
||||
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).
|
||||
Updates(map[string]any{"seen_ipv4": "", "seen_ipv6": ""}).Error; err != nil {
|
||||
t.Fatalf("reset seen: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer wrong-token")
|
||||
req.Header.Set("CF-Connecting-IP", "111.91.29.150")
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bad token heartbeat: status %d, want 401", rec.Code)
|
||||
}
|
||||
if row = load(); row.SeenIPv4 != "" || row.SeenIPv6 != "" {
|
||||
t.Fatalf("unauthenticated request captured a source: %+v", row)
|
||||
}
|
||||
|
||||
// Register is an authenticated agent request too: it captures.
|
||||
regReq := httptest.NewRequest(http.MethodPost, "/panel/api/agent/register",
|
||||
bytes.NewBufferString(`{"token":"`+token+`","name":"seen-ip-node"}`))
|
||||
regReq.Header.Set("Content-Type", "application/json")
|
||||
regReq.Header.Set("CF-Connecting-IP", "111.91.29.150")
|
||||
regRec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(regRec, regReq)
|
||||
if regRec.Code != http.StatusOK {
|
||||
t.Fatalf("register: status %d", regRec.Code)
|
||||
}
|
||||
if row = load(); row.SeenIPv4 != "111.91.29.150" {
|
||||
t.Fatalf("register did not capture source: %+v", row)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,62 @@ func TestAgentTokenBackfillOnAuthenticatedRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the production failure of the first V1.0.0 deployment:
|
||||
// rows that existed before item 19 got token_plain from AutoMigrate as
|
||||
// SQL NULL, not ”, and the original guard `token_plain = ”` never
|
||||
// matches NULL — the backfill silently never fired on any legacy node.
|
||||
// Seed the row exactly as production has it (raw SQL, explicit NULL)
|
||||
// instead of through GORM, which would write ”.
|
||||
func TestAgentTokenBackfillFromNullLegacyRow(t *testing.T) {
|
||||
engine, node := setupBackfillTest(t)
|
||||
const token = "legacy-token-backfill-00000000000000000004"
|
||||
db := database.GetDB()
|
||||
if err := db.Exec(
|
||||
"INSERT INTO agent_nodes (token_hash, token_prefix, token_plain, name, node_id, last_seen, created_at, updated_at) VALUES (?, ?, NULL, ?, ?, 0, 0, 0)",
|
||||
hashAgentToken(token), token[:12], "backfill-node", node.Id,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed NULL legacy row: %v", err)
|
||||
}
|
||||
var seeded model.AgentNode
|
||||
if err := db.Where("token_hash = ?", hashAgentToken(token)).First(&seeded).Error; err != nil {
|
||||
t.Fatalf("load seeded row: %v", err)
|
||||
}
|
||||
var nullRows int
|
||||
if err := db.Raw("SELECT COUNT(*) FROM agent_nodes WHERE id = ? AND token_plain IS NULL", seeded.Id).Scan(&nullRows).Error; err != nil {
|
||||
t.Fatalf("NULL check: %v", err)
|
||||
}
|
||||
if nullRows != 1 {
|
||||
t.Fatalf("seeded row is not SQL NULL as intended (null rows = %d)", nullRows)
|
||||
}
|
||||
|
||||
if code := heartbeatWith(engine, token); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat with valid token: status %d", code)
|
||||
}
|
||||
if row := reloadAgent(t, seeded.Id); row.TokenPlain != token {
|
||||
t.Fatalf("token not backfilled from NULL legacy row: %q", row.TokenPlain)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/install/"+strconv.Itoa(node.Id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("install endpoint: status %d", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Obj struct {
|
||||
Token string `json:"token"`
|
||||
HasToken bool `json:"hasToken"`
|
||||
} `json:"obj"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("install response not JSON: %v", err)
|
||||
}
|
||||
if !resp.Success || !resp.Obj.HasToken || resp.Obj.Token != token {
|
||||
t.Fatalf("install endpoint did not return the backfilled token: %+v", resp.Obj)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTokenNoBackfillOnFailedAuth(t *testing.T) {
|
||||
engine, node := setupBackfillTest(t)
|
||||
const token = "legacy-token-backfill-00000000000000000002"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Public-address resolution for agent nodes.
|
||||
//
|
||||
// An agent reports the addresses of its own network interfaces in its
|
||||
// heartbeat telemetry (P13). On a host behind 1:1 NAT (typical cloud
|
||||
// EIP setups) the public address is mapped in the provider's network
|
||||
// and never assigned to a local interface, so the agent can only ever
|
||||
// report its private address — the node list then shows an unusable
|
||||
// RFC1918 address for such nodes. The master, however, observes the
|
||||
// source address of every authenticated agent request (recorded on the
|
||||
// agent row as SeenIPv4/SeenIPv6 by AgentController.captureSeenIP).
|
||||
// ResolveAgentAddresses combines both views into the address pair a
|
||||
// client can actually connect to.
|
||||
|
||||
// IsPublicIPLiteral reports whether s is an IP literal that is globally
|
||||
// routable: not loopback, link-local, multicast, unspecified, RFC1918,
|
||||
// ULA, or the CGNAT shared range 100.64.0.0/10 (RFC 6598 — not private,
|
||||
// but not publicly routable either). Go's IsGlobalUnicast alone is not
|
||||
// sufficient for this: it counts RFC1918 addresses as global unicast.
|
||||
//
|
||||
// Exported for reuse by the subscription/export chain.
|
||||
func IsPublicIPLiteral(s string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(s))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() || ip.IsUnspecified() || ip.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xC0 == 0x40 {
|
||||
return false // 100.64.0.0/10, CGNAT shared address space
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// familyLiteral returns s normalized as an IP literal of the wanted
|
||||
// family, or "" when s is not a valid literal of that family.
|
||||
func familyLiteral(s string, want4 bool) string {
|
||||
ip := net.ParseIP(strings.TrimSpace(s))
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if want4 {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
return ""
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
// resolveAddressFamily picks the externally usable address of one
|
||||
// family: the agent-reported address when it is public; otherwise the
|
||||
// master-observed source address when IT is public; otherwise the
|
||||
// reported address as-is (a private telemetry address still beats
|
||||
// nothing, e.g. for LAN-only deployments). A non-public observed
|
||||
// address is never promoted over the reported one.
|
||||
func resolveAddressFamily(reported, seen string, want4 bool) string {
|
||||
rep := familyLiteral(reported, want4)
|
||||
if IsPublicIPLiteral(rep) {
|
||||
return rep
|
||||
}
|
||||
if sn := familyLiteral(seen, want4); IsPublicIPLiteral(sn) {
|
||||
return sn
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// ResolveAgentAddresses returns the externally usable IPv4/IPv6 pair
|
||||
// for an agent binding, resolved from its telemetry addresses and the
|
||||
// source addresses the master observed on its requests. This is the
|
||||
// single resolution point shared by the node list (agent row address)
|
||||
// and the subscription/export chain (advertised connect address).
|
||||
func ResolveAgentAddresses(ag *model.AgentNode) (ipv4, ipv6 string) {
|
||||
if ag == nil {
|
||||
return "", ""
|
||||
}
|
||||
return resolveAddressFamily(ag.IPv4, ag.SeenIPv4, true),
|
||||
resolveAddressFamily(ag.IPv6, ag.SeenIPv6, false)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Public-address resolution: the node list must show the address a
|
||||
// client can actually connect to. An agent behind 1:1 NAT only ever
|
||||
// reports its private interface address; the master-observed source
|
||||
// address (SeenIPv4/SeenIPv6) is the public side. Resolution order per
|
||||
// family: public telemetry > public observed > telemetry as-is.
|
||||
|
||||
func TestIsPublicIPLiteral(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"8.8.8.8": true,
|
||||
"111.91.29.150": true,
|
||||
"203.0.113.7": true, // documentation range: not excluded (codebase treats it as public)
|
||||
"2602:f32b:1:310f::1": true,
|
||||
"2001:db8::7": true,
|
||||
"10.0.0.1": false, // RFC1918
|
||||
"172.31.2.240": false, // RFC1918 (the HWSG case)
|
||||
"192.168.1.1": false, // RFC1918
|
||||
"100.64.1.2": false, // CGNAT shared space
|
||||
"100.127.255.254": false, // CGNAT shared space, top edge
|
||||
"127.0.0.1": false, // loopback
|
||||
"169.254.1.1": false, // link-local
|
||||
"fd00::1": false, // ULA
|
||||
"fe80::1": false, // link-local v6
|
||||
"::1": false,
|
||||
"::": false,
|
||||
"224.0.0.1": false, // multicast
|
||||
"": false,
|
||||
"not-an-ip": false,
|
||||
"999.1.1.1": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsPublicIPLiteral(in); got != want {
|
||||
t.Errorf("IsPublicIPLiteral(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentAddresses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ag *model.AgentNode
|
||||
want4, want6 string
|
||||
}{
|
||||
{
|
||||
name: "telemetry public wins over seen public",
|
||||
ag: &model.AgentNode{IPv4: "140.235.229.19", SeenIPv4: "8.8.8.8"},
|
||||
want4: "140.235.229.19",
|
||||
},
|
||||
{
|
||||
name: "NAT: private telemetry, public seen",
|
||||
ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "111.91.29.150"},
|
||||
want4: "111.91.29.150",
|
||||
},
|
||||
{
|
||||
name: "both private: telemetry fallback",
|
||||
ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "192.168.0.8"},
|
||||
want4: "172.31.2.240",
|
||||
},
|
||||
{
|
||||
name: "no telemetry yet, public seen",
|
||||
ag: &model.AgentNode{SeenIPv4: "111.91.29.150"},
|
||||
want4: "111.91.29.150",
|
||||
},
|
||||
{
|
||||
name: "CGNAT seen is not trusted",
|
||||
ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "100.64.1.2"},
|
||||
want4: "172.31.2.240",
|
||||
},
|
||||
{
|
||||
name: "v6 only, telemetry public",
|
||||
ag: &model.AgentNode{IPv6: "2602:f32b:1:310f::1"},
|
||||
want6: "2602:f32b:1:310f::1",
|
||||
},
|
||||
{
|
||||
name: "v6 ULA telemetry, public seen",
|
||||
ag: &model.AgentNode{IPv6: "fd00::1", SeenIPv6: "2401:1da0:22:148::a"},
|
||||
want6: "2401:1da0:22:148::a",
|
||||
},
|
||||
{
|
||||
name: "garbage telemetry ignored",
|
||||
ag: &model.AgentNode{IPv4: "not-an-ip", SeenIPv4: "111.91.29.150"},
|
||||
want4: "111.91.29.150",
|
||||
},
|
||||
{
|
||||
name: "families resolve independently",
|
||||
ag: &model.AgentNode{IPv4: "140.235.229.19", IPv6: "fd00::9", SeenIPv6: "2401:1da0:22:148::a"},
|
||||
want4: "140.235.229.19",
|
||||
want6: "2401:1da0:22:148::a",
|
||||
},
|
||||
{
|
||||
name: "nothing known",
|
||||
ag: &model.AgentNode{},
|
||||
},
|
||||
{
|
||||
name: "nil agent",
|
||||
ag: nil,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
v4, v6 := ResolveAgentAddresses(tc.ag)
|
||||
if v4 != tc.want4 || v6 != tc.want6 {
|
||||
t.Fatalf("ResolveAgentAddresses(%+v) = (%q, %q), want (%q, %q)",
|
||||
tc.ag, v4, v6, tc.want4, tc.want6)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Display: the node list row address comes from ResolveAgentAddresses,
|
||||
// including for an agent that has not reported telemetry yet.
|
||||
func TestAnnotateAgentTelemetryResolvedAddress(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
natNode := &model.Node{Id: 7501, Name: "nat-agent-node", Address: ""}
|
||||
preNode := &model.Node{Id: 7502, Name: "pre-telemetry-agent-node", Address: ""}
|
||||
for _, n := range []*model.Node{natNode, preNode} {
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node %d: %v", n.Id, err)
|
||||
}
|
||||
}
|
||||
nat := &model.AgentNode{
|
||||
TokenHash: "hash-nat", Name: "nat-agent-node", NodeID: &natNode.Id,
|
||||
AgentVersion: "A1.0.0", XrayVersion: "26.6.27", TelemetryAt: 1700000000000,
|
||||
IPv4: "172.31.2.240", SeenIPv4: "111.91.29.150",
|
||||
}
|
||||
pre := &model.AgentNode{
|
||||
TokenHash: "hash-pre", Name: "pre-telemetry-agent-node", NodeID: &preNode.Id,
|
||||
SeenIPv4: "203.0.113.55", SeenIPAt: 1700000000000,
|
||||
}
|
||||
for _, ag := range []*model.AgentNode{nat, pre} {
|
||||
if err := db.Create(ag).Error; err != nil {
|
||||
t.Fatalf("create agent row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
views := toNodeViews([]*model.Node{natNode, preNode})
|
||||
annotateNodeTypes(views)
|
||||
annotateAgentTelemetry(views)
|
||||
byID := map[int]*NodeView{}
|
||||
for _, v := range views {
|
||||
byID[v.Id] = v
|
||||
}
|
||||
|
||||
if got := byID[7501].Ipv4; got != "111.91.29.150" {
|
||||
t.Fatalf("NAT node address = %q, want observed public 111.91.29.150 (view %+v)", got, byID[7501])
|
||||
}
|
||||
if got := byID[7502].Ipv4; got != "203.0.113.55" {
|
||||
t.Fatalf("pre-telemetry node address = %q, want observed 203.0.113.55 (view %+v)", got, byID[7502])
|
||||
}
|
||||
// The pre-telemetry overlay must not invent other fields.
|
||||
if pv := byID[7502]; pv.XrayVersion != "" || pv.UptimeSecs != 0 || pv.PanelVersion != "" {
|
||||
t.Fatalf("pre-telemetry view picked up telemetry fields: %+v", pv)
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,59 @@ func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
|
||||
}
|
||||
}
|
||||
|
||||
// annotateAgentOriginGuid fills OriginNodeGuid (read-time only, like
|
||||
// annotateLocalOriginGuid) for inbounds hosted on agent-managed nodes with
|
||||
// the node's effective attribution key — the exact key
|
||||
// SetAgentOnlineClients files the node's reported online clients under.
|
||||
//
|
||||
// Agent inbounds are defined centrally on the master, so their stored
|
||||
// OriginNodeGuid is either empty (created straight onto the agent node) or
|
||||
// STALE: an inbound re-homed from a panel node keeps the GUID the snapshot
|
||||
// merge stamped for its previous host, and nothing on the move path rewrites
|
||||
// it. The inbounds page looks the online set up by that stored GUID first,
|
||||
// so a re-homed agent inbound's online list never matched and its clients
|
||||
// never lit up. An agent has no sub-node chain to preserve attribution for —
|
||||
// the hosting node physically terminates every one of its inbounds — so the
|
||||
// node's own key is always the right value, and computing it at read time
|
||||
// also heals rows already carrying a stale GUID without a storage rewrite.
|
||||
func (s *InboundService) annotateAgentOriginGuid(db *gorm.DB, inbounds []*model.Inbound) {
|
||||
nodeIDs := make(map[int]struct{})
|
||||
for _, ib := range inbounds {
|
||||
if ib.NodeID != nil {
|
||||
nodeIDs[*ib.NodeID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(nodeIDs) == 0 {
|
||||
return
|
||||
}
|
||||
agents := agentManagedNodeIDs()
|
||||
keyByNode := make(map[int]string, len(nodeIDs))
|
||||
for id := range nodeIDs {
|
||||
if !agents[id] {
|
||||
continue
|
||||
}
|
||||
var nodeRow model.Node
|
||||
if err := db.Select("guid").Where("id = ?", id).First(&nodeRow).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
nodeRow.Id = id
|
||||
if key := effectiveNodeKey(&nodeRow); key != "" {
|
||||
keyByNode[id] = key
|
||||
}
|
||||
}
|
||||
if len(keyByNode) == 0 {
|
||||
return
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
if ib.NodeID == nil {
|
||||
continue
|
||||
}
|
||||
if key, ok := keyByNode[*ib.NodeID]; ok {
|
||||
ib.OriginNodeGuid = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetInboundsSlim returns the same list of inbounds as GetInbounds but
|
||||
// strips every per-client field other than email / enable / comment from
|
||||
// settings.clients and skips UUID/SubId enrichment on ClientStats. The
|
||||
@@ -215,6 +268,7 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
|
||||
}
|
||||
s.annotateFallbackParents(db, inbounds)
|
||||
s.annotateLocalOriginGuid(inbounds)
|
||||
s.annotateAgentOriginGuid(db, inbounds)
|
||||
// Top up stats rows owned by sibling inbounds (multi-attached clients)
|
||||
// so the list's depleted/expiring badges see every client; the UUID/SubId
|
||||
// enrichment stays skipped. Must run before slimming strips the settings.
|
||||
@@ -362,6 +416,21 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
// Agent-managed nodes carry the create form's placeholder ("agent")
|
||||
// in nodes.address, which is not a connectable host. For those rows
|
||||
// NodeAddress must be the agent's telemetry-reported address (IPv4,
|
||||
// then IPv6; "" when never reported, so the frontend fallback chain
|
||||
// continues as it does for a local inbound) — otherwise every
|
||||
// share/QR link and WireGuard endpoint rendered from these options
|
||||
// points at the literal placeholder. Mirrors the backend export
|
||||
// chain's agentNodeAddress in internal/sub.
|
||||
rowNodeIDs := make(map[int]bool)
|
||||
for _, r := range rows {
|
||||
if r.NodeId != nil {
|
||||
rowNodeIDs[*r.NodeId] = true
|
||||
}
|
||||
}
|
||||
agentAddrByNode := agentAdvertisedAddresses(db, rowNodeIDs)
|
||||
out := make([]InboundOption, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
|
||||
@@ -369,6 +438,12 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
if shareAddrStrategy == "node" {
|
||||
shareAddrStrategy = ""
|
||||
}
|
||||
nodeAddress := r.NodeAddress
|
||||
if r.NodeId != nil {
|
||||
if addr, isAgent := agentAddrByNode[*r.NodeId]; isAgent {
|
||||
nodeAddress = addr
|
||||
}
|
||||
}
|
||||
out = append(out, InboundOption{
|
||||
Id: r.Id,
|
||||
Remark: r.Remark,
|
||||
@@ -384,7 +459,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
MtprotoDomain: inboundMtprotoDomain(r.Protocol, r.Settings),
|
||||
AwgServer: inboundAmneziaWGServer(r.Protocol, r.Settings),
|
||||
NodeId: r.NodeId,
|
||||
NodeAddress: r.NodeAddress,
|
||||
NodeAddress: nodeAddress,
|
||||
Listen: r.Listen,
|
||||
ShareAddr: r.ShareAddr,
|
||||
ShareAddrStrategy: shareAddrStrategy,
|
||||
@@ -393,6 +468,47 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// agentAdvertisedAddresses resolves, for each requested node id that is
|
||||
// agent-managed, the address share/export surfaces should advertise for
|
||||
// it: the IPv4 from the bound agent's latest heartbeat telemetry,
|
||||
// falling back to its IPv6 ("" when the agent never reported one). The
|
||||
// map contains an entry for every agent-managed id in the set —
|
||||
// presence in the map is the agent-managed signal, mirroring
|
||||
// IsAgentManaged's binding-existence check — so callers can tell
|
||||
// "agent node without telemetry" apart from "traditional node" and
|
||||
// never fall back to the placeholder stored in nodes.address. Latest
|
||||
// reporter wins when several tokens are bound to one node, mirroring
|
||||
// annotateAgentTelemetry.
|
||||
func agentAdvertisedAddresses(db *gorm.DB, nodeIDs map[int]bool) map[int]string {
|
||||
out := make(map[int]string)
|
||||
if len(nodeIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
var agents []model.AgentNode
|
||||
if err := db.Model(&model.AgentNode{}).
|
||||
Where("node_id IS NOT NULL").
|
||||
Order("last_seen DESC").
|
||||
Find(&agents).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
for i := range agents {
|
||||
ag := &agents[i]
|
||||
if ag.NodeID == nil || !nodeIDs[*ag.NodeID] {
|
||||
continue
|
||||
}
|
||||
if _, done := out[*ag.NodeID]; done {
|
||||
continue
|
||||
}
|
||||
v4, v6 := ResolveAgentAddresses(ag)
|
||||
addr := v4
|
||||
if addr == "" {
|
||||
addr = v6
|
||||
}
|
||||
out[*ag.NodeID] = addr
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func inboundWireguardHints(protocol string, settings string) (string, int, string) {
|
||||
if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
|
||||
return "", 0, ""
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// The inbounds page looks a node's online set up under the inbound's
|
||||
// originNodeGuid (falling back to node:{id}). Agent inbounds are defined
|
||||
// centrally: a fresh one stores no origin GUID, and one re-homed from a
|
||||
// panel node keeps the GUID the snapshot merge stamped for its PREVIOUS
|
||||
// host — while the agent's onlines are filed under the agent node's own
|
||||
// effective key. The slim list must therefore stamp agent-hosted inbounds
|
||||
// with the hosting node's effective key at read time, or their clients
|
||||
// never show as online on the inbounds page (production: every converted
|
||||
// agent node was affected). Traditional inbounds' stored origins are
|
||||
// chain attribution (#4983) and must pass through untouched.
|
||||
func TestGetInboundsSlim_AnnotatesAgentOriginGuid(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
svc := InboundService{}
|
||||
|
||||
const agentNodeID = 8201
|
||||
const tradNodeID = 8202
|
||||
const userID = 1
|
||||
|
||||
if err := db.Create(&model.Node{Id: agentNodeID, Name: "agent-node", Address: "10.0.8.1"}).Error; err != nil {
|
||||
t.Fatalf("create agent node: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Node{Id: tradNodeID, Name: "trad-node", Address: "10.0.8.2", Port: 2053, ApiToken: "t", Guid: "trad-guid-8202"}).Error; err != nil {
|
||||
t.Fatalf("create trad node: %v", err)
|
||||
}
|
||||
// The agent binding is what makes the node "agent-managed".
|
||||
agentID := agentNodeID
|
||||
if err := db.Create(&model.AgentNode{
|
||||
TokenHash: fmt.Sprintf("%064d", agentNodeID),
|
||||
Name: "agent-node",
|
||||
NodeID: &agentID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create agent binding: %v", err)
|
||||
}
|
||||
|
||||
mkInbound := func(tag string, nodeID *int, origin string) {
|
||||
ib := &model.Inbound{
|
||||
UserId: userID,
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"a@example.com","enable":true}]}`,
|
||||
NodeID: nodeID,
|
||||
OriginNodeGuid: origin,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", tag, err)
|
||||
}
|
||||
}
|
||||
agentIDCopy := agentNodeID
|
||||
tradIDCopy := tradNodeID
|
||||
mkInbound("agent-stale", &agentIDCopy, "guid-of-previous-panel-host")
|
||||
mkInbound("agent-fresh", &agentIDCopy, "")
|
||||
mkInbound("trad-synced", &tradIDCopy, "trad-guid-8202")
|
||||
|
||||
slim, err := svc.GetInboundsSlim(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundsSlim: %v", err)
|
||||
}
|
||||
byTag := make(map[string]*model.Inbound, len(slim))
|
||||
for _, ib := range slim {
|
||||
byTag[ib.Tag] = ib
|
||||
}
|
||||
|
||||
wantKey := fmt.Sprintf("node:%d", agentNodeID)
|
||||
if got := byTag["agent-stale"].OriginNodeGuid; got != wantKey {
|
||||
t.Errorf("agent-stale originNodeGuid = %q, want %q (stale host GUID healed to the agent's key)", got, wantKey)
|
||||
}
|
||||
if got := byTag["agent-fresh"].OriginNodeGuid; got != wantKey {
|
||||
t.Errorf("agent-fresh originNodeGuid = %q, want %q", got, wantKey)
|
||||
}
|
||||
if got := byTag["trad-synced"].OriginNodeGuid; got != "trad-guid-8202" {
|
||||
t.Errorf("trad-synced originNodeGuid = %q, want stored GUID untouched", got)
|
||||
}
|
||||
|
||||
// Read-time only: the stored value must not be rewritten.
|
||||
var stored model.Inbound
|
||||
if err := db.Where("tag = ?", "agent-stale").First(&stored).Error; err != nil {
|
||||
t.Fatalf("reload agent-stale: %v", err)
|
||||
}
|
||||
if stored.OriginNodeGuid != "guid-of-previous-panel-host" {
|
||||
t.Errorf("stored origin_node_guid = %q, want the stale value preserved (annotation is read-time only)", stored.OriginNodeGuid)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,89 @@ func TestGetInboundOptions_NodeAddress(t *testing.T) {
|
||||
t.Fatalf("local inbound ShareAddr = %q, want vpn.example.com", local.ShareAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetInboundOptions_AgentNodeAddress verifies that an agent-managed
|
||||
// node's inbounds never inherit the node row's stored address: the
|
||||
// create form writes the literal placeholder "agent" there, which is
|
||||
// not a connectable host. The option must carry the agent's
|
||||
// telemetry-reported address instead (IPv4 first, then IPv6; empty when
|
||||
// the agent never reported one), so share/QR links and WireGuard
|
||||
// endpoints rendered from these options reach the agent host.
|
||||
func TestGetInboundOptions_AgentNodeAddress(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
mkNode := func(name string) *model.Node {
|
||||
n := &model.Node{Name: name, Scheme: "https", Address: "agent", Port: 2053, Enable: true}
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node %s: %v", name, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
mkInbound := func(tag string, node *model.Node) *model.Inbound {
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 31324,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
NodeID: &node.Id,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", tag, err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
mkBinding := func(node *model.Node, ipv4, ipv6 string, lastSeen int64) {
|
||||
ag := &model.AgentNode{
|
||||
TokenHash: "hash-" + node.Name,
|
||||
Name: node.Name,
|
||||
NodeID: &node.Id,
|
||||
IPv4: ipv4,
|
||||
IPv6: ipv6,
|
||||
LastSeen: lastSeen,
|
||||
}
|
||||
if err := db.Create(ag).Error; err != nil {
|
||||
t.Fatalf("create binding for %s: %v", node.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
dualNode := mkNode("agent-dual")
|
||||
mkBinding(dualNode, "198.51.100.23", "2001:db8::23", 2000)
|
||||
dualInbound := mkInbound("in-agent-dual", dualNode)
|
||||
|
||||
v6Node := mkNode("agent-v6")
|
||||
mkBinding(v6Node, "", "2001:db8::99", 2000)
|
||||
v6Inbound := mkInbound("in-agent-v6", v6Node)
|
||||
|
||||
silentNode := mkNode("agent-silent")
|
||||
mkBinding(silentNode, "", "", 2000)
|
||||
silentInbound := mkInbound("in-agent-silent", silentNode)
|
||||
|
||||
svc := &InboundService{}
|
||||
options, err := svc.GetInboundOptions(1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundOptions: %v", err)
|
||||
}
|
||||
byID := make(map[int]InboundOption, len(options))
|
||||
for _, o := range options {
|
||||
byID[o.Id] = o
|
||||
}
|
||||
|
||||
if got := byID[dualInbound.Id].NodeAddress; got != "198.51.100.23" {
|
||||
t.Fatalf("dual-stack agent NodeAddress = %q, want telemetry IPv4 198.51.100.23 (never the placeholder)", got)
|
||||
}
|
||||
if got := byID[v6Inbound.Id].NodeAddress; got != "2001:db8::99" {
|
||||
t.Fatalf("ipv6-only agent NodeAddress = %q, want telemetry IPv6 2001:db8::99", got)
|
||||
}
|
||||
if got := byID[silentInbound.Id].NodeAddress; got != "" {
|
||||
t.Fatalf("silent agent NodeAddress = %q, want empty (never the placeholder)", got)
|
||||
}
|
||||
for id, o := range byID {
|
||||
if o.NodeAddress == "agent" {
|
||||
t.Fatalf("inbound %d NodeAddress is the placeholder %q", id, o.NodeAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,12 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
||||
depletedByNode := make(map[int]int)
|
||||
disabledByNode := make(map[int]int)
|
||||
activeByNode := make(map[int]int)
|
||||
// Email lists ride along with the counts, appended in the same pass and
|
||||
// under the same dedupe, so a category's hover list always names exactly
|
||||
// the clients its count tallied.
|
||||
depletedEmailsByNode := make(map[int][]string)
|
||||
disabledEmailsByNode := make(map[int][]string)
|
||||
activeEmailsByNode := make(map[int][]string)
|
||||
statuses, _ := s.nodeClientStatuses()
|
||||
seen := make(map[int]map[int]struct{}, len(nodes))
|
||||
for _, st := range statuses {
|
||||
@@ -190,10 +196,19 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
||||
switch {
|
||||
case st.Depleted:
|
||||
depletedByNode[st.NodeID]++
|
||||
if st.Email != "" {
|
||||
depletedEmailsByNode[st.NodeID] = append(depletedEmailsByNode[st.NodeID], st.Email)
|
||||
}
|
||||
case st.Disabled:
|
||||
disabledByNode[st.NodeID]++
|
||||
if st.Email != "" {
|
||||
disabledEmailsByNode[st.NodeID] = append(disabledEmailsByNode[st.NodeID], st.Email)
|
||||
}
|
||||
default:
|
||||
activeByNode[st.NodeID]++
|
||||
if st.Email != "" {
|
||||
activeEmailsByNode[st.NodeID] = append(activeEmailsByNode[st.NodeID], st.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
onlineByGuid := s.onlineEmailsByGuid()
|
||||
@@ -204,10 +219,15 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
||||
n.DepletedCount = depletedByNode[n.Id]
|
||||
n.DisabledCount = disabledByNode[n.Id]
|
||||
n.ActiveCount = activeByNode[n.Id]
|
||||
n.DepletedEmails = depletedEmailsByNode[n.Id]
|
||||
n.DisabledEmails = disabledEmailsByNode[n.Id]
|
||||
n.ActiveEmails = activeEmailsByNode[n.Id]
|
||||
// Online is attributed to the node that physically hosts the client
|
||||
// (by GUID): a client on a sub-node counts under the sub-node, not
|
||||
// the intermediate node it syncs through (#4983).
|
||||
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n, ambiguous)])
|
||||
onlineSet := onlineByGuid[effectiveNodeGuid(n, ambiguous)]
|
||||
n.OnlineCount = len(onlineSet)
|
||||
n.OnlineEmails = sortedEmailSet(onlineSet)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
@@ -219,6 +239,7 @@ type nodeClientStatus struct {
|
||||
InboundID int
|
||||
NodeID int
|
||||
ClientID int
|
||||
Email string
|
||||
Depleted bool
|
||||
Disabled bool
|
||||
}
|
||||
@@ -232,19 +253,20 @@ type nodeClientStatus struct {
|
||||
// depleted (expired/exhausted) wins over disabled.
|
||||
func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
|
||||
type row struct {
|
||||
InboundID int `gorm:"column:inbound_id"`
|
||||
NodeID int `gorm:"column:node_id"`
|
||||
ClientID int `gorm:"column:client_id"`
|
||||
Enable bool `gorm:"column:enable"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
Up int64 `gorm:"column:up"`
|
||||
Down int64 `gorm:"column:down"`
|
||||
ExpiryTime int64 `gorm:"column:expiry_time"`
|
||||
InboundID int `gorm:"column:inbound_id"`
|
||||
NodeID int `gorm:"column:node_id"`
|
||||
ClientID int `gorm:"column:client_id"`
|
||||
Email string `gorm:"column:email"`
|
||||
Enable bool `gorm:"column:enable"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
Up int64 `gorm:"column:up"`
|
||||
Down int64 `gorm:"column:down"`
|
||||
ExpiryTime int64 `gorm:"column:expiry_time"`
|
||||
}
|
||||
var rows []row
|
||||
if err := database.GetDB().Table("inbounds").
|
||||
Select("inbounds.id AS inbound_id, inbounds.node_id AS node_id, clients.id AS client_id, " +
|
||||
"clients.enable AS enable, ct.total AS total, ct.up AS up, ct.down AS down, ct.expiry_time AS expiry_time").
|
||||
"clients.email AS email, clients.enable AS enable, ct.total AS total, ct.up AS up, ct.down AS down, ct.expiry_time AS expiry_time").
|
||||
Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
|
||||
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||
Joins("LEFT JOIN client_traffics ct ON ct.email = clients.email").
|
||||
@@ -255,7 +277,7 @@ func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
out := make([]nodeClientStatus, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
st := nodeClientStatus{InboundID: r.InboundID, NodeID: r.NodeID, ClientID: r.ClientID}
|
||||
st := nodeClientStatus{InboundID: r.InboundID, NodeID: r.NodeID, ClientID: r.ClientID, Email: r.Email}
|
||||
expired := r.ExpiryTime > 0 && r.ExpiryTime <= now
|
||||
exhausted := r.Total > 0 && r.Up+r.Down >= r.Total
|
||||
switch {
|
||||
@@ -283,6 +305,20 @@ func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedEmailSet flattens an email set into a deterministic list for the
|
||||
// node views' hover popovers. Nil for an empty set.
|
||||
func sortedEmailSet(set map[string]struct{}) []string {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for email := range set {
|
||||
out = append(out, email)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// effectiveNodeGuid is a node's stable online/inbound attribution key: its
|
||||
// reported panelGuid, or a master-local synthetic node-id fallback when the node
|
||||
// has no GUID yet (old build) or its GUID is ambiguous. ambiguous comes from
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// The nodes page category chips used to hover to a bare category title:
|
||||
// the API carried only the counts, never WHO is in each category. GetAll
|
||||
// must now emit the per-category email lists from the exact same bucketing
|
||||
// pass as the counts (statuses by node id, online set by attribution GUID),
|
||||
// and the tree view must carry them through to the wire contract.
|
||||
func TestGetAll_ClientEmailListsMatchCounts(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
nodeSvc := NodeService{}
|
||||
inboundSvc := InboundService{}
|
||||
|
||||
const nodeID = 8301
|
||||
if err := db.Create(&model.Node{Id: nodeID, Name: "agent-node", Address: "10.0.8.3"}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
nid := nodeID
|
||||
ib := &model.Inbound{Tag: "lists-in", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
mk := func(id int, email string, enable bool) {
|
||||
if err := db.Create(&model.ClientRecord{Id: id, Email: email, Enable: enable}).Error; err != nil {
|
||||
t.Fatalf("create client %s: %v", email, err)
|
||||
}
|
||||
if !enable {
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", id).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable client %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("attach client %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
mk(8301, "online@x", true)
|
||||
mk(8302, "active@x", true)
|
||||
mk(8303, "disabled@x", false)
|
||||
mk(8304, "depleted@x", true)
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: 999, Email: "depleted@x", Enable: true, Total: 100, Up: 60, Down: 60}).Error; err != nil {
|
||||
t.Fatalf("create traffic: %v", err)
|
||||
}
|
||||
|
||||
// The agent's online report — same entry the agent controller calls.
|
||||
inboundSvc.SetAgentOnlineClients(nodeID, []string{"online@x"})
|
||||
t.Cleanup(func() { inboundSvc.ClearNodeOnlineClients(nodeID) })
|
||||
|
||||
nodes, err := nodeSvc.GetAll()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAll: %v", err)
|
||||
}
|
||||
var n *model.Node
|
||||
for _, x := range nodes {
|
||||
if x.Id == nodeID {
|
||||
n = x
|
||||
}
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("node not found")
|
||||
}
|
||||
if n.ClientCount != 4 || n.ActiveCount != 2 || n.DisabledCount != 1 || n.DepletedCount != 1 || n.OnlineCount != 1 {
|
||||
t.Fatalf("counts = total %d active %d disabled %d depleted %d online %d, want 4/2/1/1/1",
|
||||
n.ClientCount, n.ActiveCount, n.DisabledCount, n.DepletedCount, n.OnlineCount)
|
||||
}
|
||||
assertStringSet(t, "active emails", n.ActiveEmails, []string{"online@x", "active@x"})
|
||||
assertStringSet(t, "disabled emails", n.DisabledEmails, []string{"disabled@x"})
|
||||
assertStringSet(t, "depleted emails", n.DepletedEmails, []string{"depleted@x"})
|
||||
assertStringSet(t, "online emails", n.OnlineEmails, []string{"online@x"})
|
||||
|
||||
// The wire contract (nodes page) must carry the same lists.
|
||||
views, err := nodeSvc.GetNodeTreeView()
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodeTreeView: %v", err)
|
||||
}
|
||||
var v *NodeView
|
||||
for _, x := range views {
|
||||
if x.Id == nodeID {
|
||||
v = x
|
||||
}
|
||||
}
|
||||
if v == nil {
|
||||
t.Fatal("node view not found")
|
||||
}
|
||||
assertStringSet(t, "view online emails", v.OnlineEmails, []string{"online@x"})
|
||||
assertStringSet(t, "view active emails", v.ActiveEmails, []string{"online@x", "active@x"})
|
||||
assertStringSet(t, "view disabled emails", v.DisabledEmails, []string{"disabled@x"})
|
||||
assertStringSet(t, "view depleted emails", v.DepletedEmails, []string{"depleted@x"})
|
||||
}
|
||||
@@ -33,10 +33,11 @@ type NodeView struct {
|
||||
// panel UI annotate it via annotateNodeTypes. Empty on views built
|
||||
// outside those paths.
|
||||
NodeType string `json:"nodeType,omitempty" example:"traditional"`
|
||||
// Ipv4/Ipv6 carry the agent-reported host addresses for agent nodes
|
||||
// (P13). The nodes page renders them as plain text on agent rows
|
||||
// instead of the panel-URL link used for traditional nodes. Empty for
|
||||
// traditional nodes and for agents that never reported telemetry.
|
||||
// Ipv4/Ipv6 carry the resolved externally usable host addresses for
|
||||
// agent nodes (P13 telemetry, public-address resolution on top).
|
||||
// The nodes page renders them as plain text on agent rows instead
|
||||
// of the panel-URL link used for traditional nodes. Empty for
|
||||
// traditional nodes and for agents with no known address yet.
|
||||
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
|
||||
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
|
||||
// Agent one-click update bookkeeping (item 20), agent nodes only:
|
||||
@@ -66,6 +67,13 @@ type NodeView struct {
|
||||
ActiveCount int `json:"activeCount" example:"20"`
|
||||
DisabledCount int `json:"disabledCount" example:"2"`
|
||||
DepletedCount int `json:"depletedCount" example:"1"`
|
||||
// Per-category client email lists behind the counts above, from the
|
||||
// same attribution bucketing, so the nodes page category chips can
|
||||
// pop over the actual usernames. Empty when the category is empty.
|
||||
OnlineEmails []string `json:"onlineEmails,omitempty" example:"[\"a@example.com\"]"`
|
||||
ActiveEmails []string `json:"activeEmails,omitempty" example:"[\"a@example.com\"]"`
|
||||
DisabledEmails []string `json:"disabledEmails,omitempty" example:"[\"b@example.com\"]"`
|
||||
DepletedEmails []string `json:"depletedEmails,omitempty" example:"[\"c@example.com\"]"`
|
||||
ParentGuid string `json:"parentGuid,omitempty" example:""`
|
||||
Transitive bool `json:"transitive,omitempty" example:"false"`
|
||||
CreatedAt int64 `json:"createdAt" example:"1700000000"`
|
||||
@@ -114,6 +122,10 @@ func toNodeView(n *model.Node) *NodeView {
|
||||
ActiveCount: n.ActiveCount,
|
||||
DisabledCount: n.DisabledCount,
|
||||
DepletedCount: n.DepletedCount,
|
||||
OnlineEmails: n.OnlineEmails,
|
||||
ActiveEmails: n.ActiveEmails,
|
||||
DisabledEmails: n.DisabledEmails,
|
||||
DepletedEmails: n.DepletedEmails,
|
||||
ParentGuid: n.ParentGuid,
|
||||
Transitive: n.Transitive,
|
||||
CreatedAt: n.CreatedAt,
|
||||
@@ -171,9 +183,12 @@ func annotateNodeTypes(views []*NodeView) {
|
||||
// annotateAgentTelemetry overlays the latest heartbeat telemetry reported
|
||||
// by each node's bound agent onto its view (P13): xray version, the
|
||||
// "agent <version>" panel-version label, host uptime, node->master latency,
|
||||
// CPU/memory percentages and the host IPv4/IPv6 pair. Views for traditional
|
||||
// nodes (and agent nodes whose agent never reported telemetry) are left
|
||||
// exactly as built. Must run after annotateNodeTypes.
|
||||
// CPU/memory percentages and the host IPv4/IPv6 pair. The address pair is
|
||||
// the resolved externally usable one (ResolveAgentAddresses), not the raw
|
||||
// telemetry — a NAT'd agent only ever reports its private interface
|
||||
// address. Views for traditional nodes (and agent nodes whose agent never
|
||||
// reported telemetry) are left exactly as built. Must run after
|
||||
// annotateNodeTypes.
|
||||
func annotateAgentTelemetry(views []*NodeView) {
|
||||
if len(views) == 0 {
|
||||
return
|
||||
@@ -198,7 +213,14 @@ func annotateAgentTelemetry(views []*NodeView) {
|
||||
continue
|
||||
}
|
||||
ag, ok := byNode[v.Id]
|
||||
if !ok || ag.TelemetryAt == 0 {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Address resolution applies to every bound agent, even before
|
||||
// its first telemetry report: an agent that has only registered
|
||||
// already has a master-observed source address.
|
||||
v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag)
|
||||
if ag.TelemetryAt == 0 {
|
||||
continue
|
||||
}
|
||||
if ag.XrayVersion != "" {
|
||||
@@ -221,8 +243,6 @@ func annotateAgentTelemetry(views []*NodeView) {
|
||||
v.LatencyMs = ag.LatencyMs
|
||||
v.CpuPct = ag.CpuPct
|
||||
v.MemPct = ag.MemPct
|
||||
v.Ipv4 = ag.IPv4
|
||||
v.Ipv6 = ag.IPv6
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user