Skip to content

Feature/teminuosi inbound tools - #6335

Open
Fourgetu wants to merge 13 commits into
MHSanaei:mainfrom
Fourgetu:feature/teminuosi-inbound-tools
Open

Feature/teminuosi inbound tools#6335
Fourgetu wants to merge 13 commits into
MHSanaei:mainfrom
Fourgetu:feature/teminuosi-inbound-tools

Conversation

@Fourgetu

Copy link
Copy Markdown

Summary

Why

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Documentation
  • Tests only
  • Build / CI / tooling
  • Other

Areas affected

  • Frontend (UI / panel pages)
  • Backend (API endpoints, login, settings)
  • Xray config generation
  • Subscription (share links / Clash / JSON)
  • Statistics / traffic counters
  • Database / migrations
  • Install / upgrade script
  • Docker image
  • Multi-node (sub-nodes)
  • Telegram bot

How was this tested?

Screenshots / recordings

Breaking changes

Checklist

  • I tested the change locally and confirmed the described behavior.
  • I added or updated tests for the new behavior (when applicable).
  • go build ./... and the test suite pass locally.
  • For frontend changes: npm run lint, npm run typecheck, and npm run build pass.
  • I updated the Wiki / README / API docs if user-facing behavior changed.
  • My commits follow the project's existing message style.
  • I have no unrelated changes mixed into this PR.

Comment on lines +394 to +395
const secrets: Parameters<typeof applyPresetSecrets>[1] = {
subId: await fetchCommonSubId(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Every preset-created client is stamped with one panel-wide subId, so unrelated customers end up sharing a subscription URL and can read each other's credentials.

applyPresetSecrets overwrites subId on every client in the preset row (inbound-presets.ts#L247-L256), and applyPreset is auto-invoked on every Add-Inbound modal open, not only when a preset card is clicked:

if (mode === 'add') {
const recommended = INBOUND_PRESETS.find((preset) => preset.recommended) ?? INBOUND_PRESETS[0];
if (recommended) void applyPreset(recommended);
}

In 3x-ui subId is the subscription identity: getInboundsBySubId selects every enabled inbound joined to any client with that sub_id, and matchingClients returns all of them per inbound:

func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Where(`id in (
SELECT DISTINCT inbounds.id
FROM inbounds
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
JOIN clients ON clients.id = client_inbounds.client_id
WHERE
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto')
AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil {

Scenario: admin opens Add Inbound (preset auto-applies), saves, hands customer A https://panel:2096/sub/<COMMON>. Weeks later the same flow creates an inbound for customer B — B's link is the identical string. Fetching it returns A's full VLESS UUID / Trojan password, and the sub page lists A's email and traffic. Before this PR the default was a per-client random id (subId: seed.subId ?? RandomUtil.randomLowerAndNum(16), inbound-defaults.ts#L60-L62). This is on the public internal/sub/ surface.

The PR's own test pins the behaviour (applyPresetSecrets stamps a shared subId on every client, inbound-presets.test.ts#L74-L80), so it needs a design change rather than a one-line patch: the "one URL for all my nodes" goal only holds within a single subscriber. Generate a fresh random id per inbound and reuse a batch-local id only inside addAllRecommended; drop the persisted panel-wide commonSubId setting and its endpoint entirely.

(Secondary, same design: SettingService.GetCommonSubId is a lock-free read-modify-write on a table whose key index is not unique — model.go#L768-L770 — so two concurrent calls insert two commonSubId rows and the second caller's id is orphaned. Removing the setting removes this too.)

Comment on lines +74 to +75
g.GET("/:id/orphanCount", a.getOrphanCount)
g.POST("/orphanCountBatch", a.getOrphanCountBatch)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Three new /panel/api/* routes with no entry in the endpoint registry — TestRouteRegistryContract fails, so make test-go and the go-test CI job are red.

New routes in this PR:

  • GET /panel/api/inbounds/:id/orphanCount (this line)
  • POST /panel/api/inbounds/orphanCountBatch (next line)
  • GET /panel/api/server/getCommonSubId (server.go#L58-L60)

frontend/src/pages/api-docs/endpoints.ts is not in the changed-file list, and grepping the head for orphanCount|getCommonSubId never hits it. The contract test builds the real router and diffs it against the registry both ways for everything under /panel/api/:

}
func inContractScope(method, path string) bool {
return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]

Per CLAUDE.md: "New g.POST/g.GET in internal/web/controller/ REQUIRES a matching entry in frontend/src/pages/api-docs/endpoints.ts, then make gen", plus the fourth step nothing checks: "copy frontend/public/openapi.jsondocs/public/openapi.json, then cd docs && pnpm gen:api". None of frontend/src/generated/, frontend/public/openapi.json or docs/public/openapi.json were regenerated, so make verify's gen-check fails too.

(If the commonSubId endpoint is dropped per the other review comment, only the two orphanCount routes need registering.)

Comment on lines +406 to +411
if (cert?.certFile && cert.keyFile) {
secrets.certFile = cert.certFile;
secrets.keyFile = cert.keyFile;
secrets.domain = domain;
}
applyPresetSecrets(row, secrets);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A TLS preset on a panel with no web certificate silently saves an inbound with security: "tls" and a certificate entry holding no certificate at all.

The guard has no else and does not abort, while the sibling batch path does skip cert-needing presets:

const domain = presetDomain.trim() || cert.domain;
const hasCertificate = !!(cert.certFile && cert.keyFile && domain);
const commonSubId = await fetchCommonSubId();
const targets = INBOUND_PRESETS.filter((preset) => !preset.needsDomain || hasCertificate);
const usedPorts = new Set(

With no web cert, fetchTargetCertificate() returns {certFile:'', keyFile:'', domain:''}, so emptyFileCert() (inbound-presets.ts#L86-L98) stays blank. It still passes the submit gate: TlsCertSchema is a union and the inline arm only requires certificate/key to be arrays, which [] satisfies (tls.ts#L39-L55). So certificates:[{certificate:[],key:[],…}] is POSTed, and nothing on the Go side rejects it — AddInbound validates finalmask/reality/mtproto/amneziawg only.

In simpleMode the stream and security tabs are not rendered, so there is no cert field anywhere in the one-click flow to notice or fix it. Affects trojan-tls, vmess-ws-tls and hysteria2.

This repo already states the consequence:

// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
// from the StreamSettings before sending to a remote node, but ONLY when
// inline certificate content (certificate / key) is also present in the same
// entry. In that case the file paths are redundant and stripping them avoids
// confusion when the central panel's local paths don't exist on the remote.
//
// When a certificate entry contains ONLY file paths (no inline content) the
// paths are left untouched: the user explicitly entered paths that exist on
// the remote node's filesystem, and removing them would leave Xray with TLS
// configured but no certificate, causing Xray to crash on the remote node.

I could not read xray-core here to name the deciding upstream symbol, so the exact failure mode is unverified — but per the comment above it takes the whole Xray process down, not just this inbound. Reuse the hasCertificate check addAllRecommended already computes: refuse the apply (or surface an error) when preset.needsDomain and the panel has no cert.

Comment thread internal/database/db.go
Comment on lines +392 to +401
// migrateRemarkTemplateDefault moves panels that started on the v3.4.0 default
// subscription template to the v3.4.1 default that includes the client email.
// Exact-match only: operator-customized templates are preserved.
func migrateRemarkTemplateDefault() error {
const officialV340Default = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
const officialV341Default = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
return db.Model(&model.Setting{}).
Where("key = ? AND value = ?", "remarkTemplate", officialV340Default).
Update("value", officialV341Default).Error
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This migration re-runs on every InitDB with no one-shot guard, which makes one legitimate template string permanently unsettable — the opposite of what the comment claims.

Every other value-rewriting settings migration in this file registers a HistoryOfSeeders row so it can never run twice — normalizeFreedomFinalRules"FreedomFinalRulesReverseFix" (db.go:1658-1682), clearLegacyProxySettings"LegacyProxySettingsCleanup", seedHostsFromExternalProxy. This one is called unconditionally from initModels:

if err := migrateClientEmailLowerIndex(); err != nil {
return err
}
if err := migrateRemarkTemplateDefault(); err != nil {
return err
}
if IsPostgres() {

remarkTemplate is operator-editable (Settings → Subscription, entity.AllSetting.RemarkTemplate), and UpdateAllSetting writes every field with no skip-if-equals-default, so the row always exists after one save. An operator who deletes -{{EMAIL}} from the shipped default — the single most likely edit, since the email is usually a username that shows up in every client app — lands on a string byte-identical to officialV340Default and gets it silently reverted on the next restart, x-ui setting … invocation, or DB import. Then again, forever. So "operator-customized templates are preserved" is false for exactly the value this function targets.

Note the head branch already ships the v3.4.1 string as DefaultRemarkTemplate (setting.go:39), so this only realigns rows persisted under the older constant — a strictly one-shot job. Gate it the same way the neighbours are gated:

// migrateRemarkTemplateDefault moves panels that persisted the v3.4.0 default
// subscription template to the current default. Exact-match, one-shot.
func migrateRemarkTemplateDefault() error {
	const seederName = "RemarkTemplateV341Default"
	var seeded int64
	if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", seederName).Count(&seeded).Error; err != nil {
		return err
	}
	if seeded > 0 {
		return nil
	}
	const officialV340Default = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
	const officialV341Default = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
	if err := db.Model(&model.Setting{}).
		Where("key = ? AND value = ?", "remarkTemplate", officialV340Default).
		Update("value", officialV341Default).Error; err != nil {
		return err
	}
	return db.Create(&model.HistoryOfSeeders{SeederName: seederName}).Error
}

Also worth a test: TestMigrateRemarkTemplateDefaultPreservesCustomValue seeds {{INBOUND}}/{{EMAIL}}, which is not the value that actually gets clobbered, so it cannot catch this.

Comment on lines +673 to 685
func (s *SubService) uniqueBodyRemark(remark string) string {
if !s.subscriptionBody || strings.TrimSpace(remark) == "" {
return remark
}
if s.remarkCounts == nil {
s.remarkCounts = map[string]int{}
}
s.remarkCounts[remark]++
if s.remarkCounts[remark] <= 1 {
return remark
}
return ctx.configName()
return fmt.Sprintf("%s-%d", remark, s.remarkCounts[remark])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The counter is charged at remark-generation time, but callers decide whether to emit the entry afterwards — so dropped links still consume a number and surviving entries ship a -2 with no -1. The composed suffix is also never registered, so it can still collide.

Phantom counts. In the Clash path the remark is built before the bail-outs:

proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep, network),
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
}
if !s.applyTransport(proxy, network, stream) {
return nil
}

buildProxy then returns nil for default: (mtproto / amneziawg / …) and for a failed applyTransport/applySecurity, and the append is skipped. getInboundsBySubId explicitly selects wireguard/amneziawg/mtproto alongside the rest, so these reach the code. Concretely: one subscriber, three enabled inbounds all remarked DE — VLESS/tcp, AmneziaWG, VLESS/ws. The AmneziaWG proxy is dropped but takes count 1, so the surviving ws proxy is emitted as DE-2 with no DE anywhere. On the base branch it was DE. Same shape in the JSON sub (json_service.go:488 vs the continue at 536-539) and in the raw body, where genWireguardLink/genAmneziaWGLink call genRemark after renderHostRemark already charged for the host — one emitted link, two increments, so the very first such link is named …-2. genMtprotoLink emits no remark at all yet still charges.

Collision. fmt.Sprintf("%s-%d", …) is never written back into remarkCounts, so DE, DE, DE-2 emits DE, DE-2, DE-2 — a duplicate, the exact thing this exists to prevent. The correct pattern already lives in the same package and loops until the composed name is unused:

func ensureUniqueProxyNames(proxies []map[string]any) {
seen := make(map[string]struct{}, len(proxies))
for i, proxy := range proxies {
base, _ := proxy["name"].(string)
if base == "" {
base = fallbackProxyName(proxy, i)
}
name := base
for n := 2; ; n++ {
if _, dup := seen[name]; !dup {
break
}
name = fmt.Sprintf("%s-%d", base, n)
}
seen[name] = struct{}{}
proxy["name"] = name
}
}

That is also why the Clash path needs no help here at all: ensureUniqueProxyNames runs at clash_service.go:75, after all drops, on surviving proxies only, using the identical -N format. So for Clash this change adds nothing and only introduces the phantom.

Suggested shape: drop the uniqueBodyRemark call from the Clash path, and for raw/JSON either move the charge to the point the entry is known to survive, or at minimum register the composed name and loop until unused. The new tests (remark_vars_test.go:778-830) call genHostRemark/genTemplatedRemark directly and cannot see the drop interaction — a test that drives a real subscription containing a droppable inbound would.

{messageContextHolder}
<Modal
open={open}
title={t('pages.inbounds.relay.title', { defaultValue: '新建中转' })}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The whole relay wizard and preset gallery render in Simplified Chinese for all 13 locales — ~45 strings whose keys exist in none of the locale files.

pages.inbounds.relay.title is not in any of the 13 files under internal/web/translation/; the only relay child added by this PR is badge. Same for every pages.inbounds.presets.* and recommendToggle* key (grep presets|recommendToggle over the locale dir → 0 matches). i18next is initialised with no parseMissingKeyHandler, so a key missing from both the active bundle and the en-US fallback returns options.defaultValue — the Chinese literal is what an English, Russian or Persian operator actually sees.

Affected: RelayWizardModal.tsx (34 sites), InboundFormModal.tsx (11), PRESET_FALLBACK in inbound-presets.ts#L285-L308, and the toolbar button at InboundList.tsx#L206-L210 — plus three with no t() at all (placeholder="客户端 UUID" at :321, placeholder="1.2.3.4 或 …" at :313, and the list separator in the InboundFormModal.tsx:481 toast). Line 168 uses '中转' as the stored remark when the field is blank, so the Chinese also reaches the DB and the share link.

This is not the "missing i18n key" case CI covers: i18n-dead-keys.test.ts only asserts (a) every en-US key is referenced and (b) locale sets match en-US. Both pass here, because the keys were never added to en-US. Nothing else in ci.yml checks that a referenced key resolves — so this ships silently.

The base branch has zero CJK defaultValue literals (its 6 defaultValue: uses are all t(err.message, { defaultValue: err.message })), so this introduces the pattern rather than following it. Add the ~51 keys to en-US.json and the other 12 locales and drop the Chinese defaults.

Comment on lines +85 to +95
const onLandingAddressChange = (value: string) => {
const parsed = parseLandingEndpoint(value);
if (!parsed) {
setAddress(value);
return;
}
setAddress(parsed.address);
if (parsed.port) setLandingPort(parsed.port);
if (parsed.user !== undefined) setUser(parsed.user);
if (parsed.pass !== undefined) setPass(parsed.pass);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This rewrites a controlled input on every keystroke, so typing host:port (rather than pasting it) mangles the address and pins the port to the first digit typed.

The comment says "when the user pastes", but the handler is wired to onChange on a controlled <Input value={address}> (:310-314), which fires per character. Typing 1.2.3.4:1080:

typed value in parse field becomes
1.2.3.4 1.2.3.4 null 1.2.3.4
: 1.2.3.4: null 1.2.3.4:
1 1.2.3.4:1 {address:'1.2.3.4', port:1} 1.2.3.4 — the :1 is deleted, port := 1
0 1.2.3.40 null 1.2.3.40
8 0 null 1.2.3.4080

End state: address 1.2.3.4080, port 1. example.com:8080 ends as example.com080 / port 8. Those values go straight into buildLanding → the outbound that create() splices into the live Xray config and restarts on. Pasting the whole string in one event is fine — only typing breaks.

Simplest fix: only auto-split when the value arrives as a paste (onPaste), or gate the rewrite behind onBlur so the user's in-progress text is never replaced mid-edit.

Comment on lines +1318 to +1327
func (s *InboundService) DelInbounds(ids []int, purgeClients bool) (BulkDelInboundResult, bool, error) {
result := BulkDelInboundResult{}
needRestart := false
if purgeClients {
restart, err := s.clientService.PurgeOrphansForInbounds(s, ids)
if err != nil {
return result, needRestart, err
}
needRestart = restart
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The purge runs before any inbound is deleted and nothing wraps the two, so a failure permanently deletes clients while leaving the inbounds in place.

PurgeOrphansForInbounds loops s.Delete(…) and returns on the first error (client_link.go#L324-L341); each client delete commits its own transaction, so everything before the failure is already gone. Two ways it bites:

  • Purge fails partway → the early return at line 1324 fires before the delete loop, so N clients are permanently gone (record, traffic, IP log, HWID, external links) and zero inbounds are deleted. Reachable via nodePushPlan returning gorm.ErrRecordNotFound for a node-backed inbound whose nodes row is missing, or the persistent "invalid clients format in inbound settings" at client_inbound_apply.go:1045-1052 — persistent meaning the operator loses more clients on every retry.
  • A later DelInbound(id) fails → this function treats that as expected and records it in Skipped (that is what the doc comment above says), but the purge already destroyed the orphans of every id including the skipped one. That inbound survives, stripped of its clients, and the API still returns 200 with a partial-success body.

Computing the orphan ids first, deleting the inbounds, then purging only the ids that actually got deleted fails safe in both directions. Same ordering in DelInboundPurgeOrphans at :1291-1302.

Minor, same lines: PurgeOrphansForInbounds can return (true, err)client_inbound_apply.go:1167/1175 sets needRestart when a live client's hot removal could not be applied — but line 1324 returns the still-false local, and 1296 returns a literal false. A client removed from the DB then keeps working in the running Xray until an unrelated restart. (Both controller call sites also drop needRestart on error, so fixing only these two returns changes nothing observable — worth doing together.)

There are also no tests for PurgeOrphansForInbounds, CountOrphansForInbounds or DelInboundPurgeOrphans; nothing in the tree references them.

}

func TestIdentityTokenBodyVsDisplay(t *testing.T) {
func TestIdentityTokenKeptInBodyAndDisplay(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Both rewritten tests pass identically on the base branch and on this branch, so neither can fail without the change they accompany.

The production edit they follow is a pure no-op rename: base had mergeTokenSets(usageInfoTokens, map[string]bool{"EMAIL": true, "USERNAME": true}), head has mergeTokenSets(usageInfoTokens, identityTokens) with identityTokens being that identical map. showIdentityOnAllLinks and effectiveTemplate are unchanged from base. So the commit "fix: honor subscription identity toggle" changes no behaviour.

Setting showIdentityOnAllLinks: true here (and in TestEmailOnEveryBodyLink at :684-713) then flipping the assertions makes both tests duplicates of the already-existing TestIdentityOnAllLinks/enabled subtest, which the PR leaves untouched and which asserts exact equality rather than strings.Contains — strictly stronger:

wantSecond string
}{
{name: "disabled", wantSecond: "DE"},
{name: "enabled", enabled: true, wantSecond: "DE-alice@x|alice@x"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SubService{
remarkTemplate: template,
subscriptionBody: true,
showIdentityOnAllLinks: tt.enabled,
}
client := model.Client{Email: "alice@x"}
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != "DE-alice@x|alice@x|📊80.00GB|✅" {
t.Fatalf("first link = %q", got)
}
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != tt.wantSecond {
t.Fatalf("second link = %q, want %q", got, tt.wantSecond)
}
})

Per CLAUDE.md: "A test must fail without its fix. … A test that passes either way is worse than no test." I checked and the original versions of both tests also still pass against this branch's code, so nothing forced the rewrite. Default-path coverage survives via TestIdentityOnAllLinks/disabled and TestSharedSubIDRemark_FullInfoOncePerSubscription, so the cleanest fix is to revert both tests and drop the no-op identityTokens extraction with them.

Comment on lines +22 to +34
// One-click inbound presets. Each preset turns a single click into a full,
// ready-to-save inbound config: protocol + transport + security + one default
// client + a random port. The modal feeds the returned row through
// rawInboundToFormValues() — the exact same path buildAddModeValues() uses —
// so every preset rides the existing form validation and submit pipeline.
//
// Two flags drive post-apply work the modal owns:
// - needsRealityKeys: the modal fetches GET /panel/api/server/getNewX25519Cert
// and injects privateKey/publicKey after applying (Reality keys are
// generated by the xray binary, not in the browser).
// - needsDomain: the preset is TLS-based and the share link won't work until
// the operator points a domain at this server and installs a cert. The
// modal surfaces a domain input; the typed value lands in serverName/SNI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 19 new comment blocks in this PR exceed the 2-line capCLAUDE.md: "Comments in committed Go/TS: 2 lines MAX per comment block. … spend the 2 lines on the why a name cannot hold."

This one is 13 lines. Counts by file: inbound-presets.ts 6 (this block plus :63, :82, :99, :158, :242), relay.ts 9 (longest is 8 lines at :49), RelayWizardModal.tsx 2, internal/database/db.go 1 (:392), and frontend/src/lib/inbounds/label.ts where the PR lengthened a previously-compliant 2-line block to 4. None are directives or HTML comments.

Flagging once with the count rather than 19 times. Worth noting the base branch is not clean here either (outbound-link-parser.ts opens with a 15-line header, internal/xray/process.go has ~20 such blocks), so this is a low-priority tidy-up — but the new files are where the rule is cheapest to honour. firstLinkOnlyBodyTokens in remark_vars.go went 4 → 3 lines in this PR, which is the right direction.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

5 🔴 / 5 🟡 / 1 🟣 (plus 3 similar nits not posted)

Detail is in the inline comments; this is the map.

🔴 Important

  1. Every preset-created client is stamped with one panel-wide subIdInboundFormModal.tsx:394-395 + applyPresetSecrets. subId is the subscription identity (getInboundsBySubId / matchingClients), so two customers created through the Add-Inbound modal are handed the byte-identical /sub/<id> URL and read each other's UUIDs, passwords, emails and traffic. Auto-applied on every modal open, not only on a preset click. Public internal/sub/ surface; needs a design change, not a patch.
  2. Three new /panel/api/* routes with no endpoints.ts entryinbound.go:74-75, server.go:59. TestRouteRegistryContract fails make test-go; frontend/src/generated/, frontend/public/openapi.json and the docs/public/openapi.json copy are all unregenerated.
  3. TLS presets emit certificates[0] with no certificateInboundFormModal.tsx:406-410 lacks the hasCertificate guard its sibling addAllRecommended has. Passes the Zod inline arm, no Go-side rejection, no cert field in simpleMode. Per internal/web/runtime/remote.go:832-841 this class of config crashes Xray.
  4. migrateRemarkTemplateDefault re-runs on every InitDBdb.go:392-401, no HistoryOfSeeders guard unlike every other value-rewriting migration here. Makes {{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D permanently unsettable — the one edit operators most plausibly make.
  5. uniqueBodyRemark charges dropped links and never registers its own suffixremark_vars.go:673-685. Emits DE-2 with no DE when a proxy is dropped downstream, double-charges WireGuard/AmneziaWG host links, and still ships duplicates for DE/DE/DE-2. For Clash it is redundant with ensureUniqueProxyNames, which already does it correctly after the drops.

🟡 Nits

  1. ~45 Chinese-only strings across 4 files (RelayWizardModal.tsx, InboundFormModal.tsx, PRESET_FALLBACK, InboundList.tsx) — ~51 keys that exist in none of the 13 locale files, so every locale falls back to the Chinese defaultValue. i18n-dead-keys.test.ts provably does not catch this, so nothing in CI is red. Base has zero CJK defaults.
  2. Relay landing-address input corrupts typed host:portRelayWizardModal.tsx:85-95 rewrites a controlled input per keystroke; typing 1.2.3.4:1080 ends as address 1.2.3.4080, port 1.
  3. Orphan purge runs before the inbounds are deletedinbound.go:1318-1327. A failure deletes clients permanently while leaving the inbounds; a Skipped inbound survives stripped of its clients. needRestart from a partial purge is discarded on both error paths.
  4. Two rewritten subscription tests can't fail on either side of the diffremark_vars_test.go:413 and :684. The production change they follow (identityTokens extraction) is a byte-for-byte no-op, and both rewrites duplicate the untouched TestIdentityOnAllLinks/enabled subtest with weaker Contains assertions.
  5. 19 new comment blocks over the 2-line CLAUDE.md capinbound-presets.ts 6, relay.ts 9, RelayWizardModal.tsx 2, db.go 1, label.ts lengthened 2 to 4. Base is not clean here either, so low priority.

Not posted, same shape: hysteria2 preset leaves fingerprint: 'chrome' where createHysteriaTlsSettingsWithDefaultCert() deliberately sets '', so preset-made and hand-made hysteria2 inbounds ship different subscription links; applyPreset's two awaited round-trips let a methods.reset() wipe anything typed during modal open; inbound-label.test.ts asserts the same input twice and never covers the tag-fallback or port <= 0 branches.

🟣 Pre-existing

  • Hand-configured hysteria2 inbounds already disagree with themselves on the wire: stream-wire-normalize.ts:214 deletes an empty fingerprint before POST, so the panel/QR link re-defaults it to fp=chrome while internal/sub/service.go:1162-1166 reads the raw stored JSON and emits no fp at all. Predates this PR; the new preset just lands on the other side of it.

Coverage

  • Head reviewed: 497a2570d65d7173905a845ce0fa97a728faf86a — 53 files, +2519/-153. No prior Claude review on this PR.
  • Go backend (internal/sub/, internal/database/, internal/web/{controller,service}/, ~370 lines): read in full against the base. Traced uniqueBodyRemark through raw/JSON/Clash, orphanClientIDsForInbounds SQL, the new Source filter, GetCommonSubId.
  • Checked and clean: no runtime.Runtime bypass (ClientService.Delete to nodePushPlan to rt.RemoveUser); no DelInbounds call-site breakage (one caller, updated); no SQL injection in the Source filter (three hard-coded literals, precedence correct, relay-in-% matches the tag the wizard writes); no SubService data race (ForRequest copies per request); getCommonSubId is admin-auth'd via checkAPIAuth, not public; the 4 genuinely-new i18n keys are in all 13 locales.
  • Frontend (lib/xray/inbound-presets.ts, lib/xray/relay.ts, RelayWizardModal.tsx, InboundFormModal.tsx, clients pages, ~1600 lines): imports all resolve, relay routing/tag wiring is correct end-to-end, new API paths/methods/params match the Go handlers, test/setup.ts is a location polyfill and suppresses nothing.
  • Unverified: the exact xray-core failure for a TLS block with an empty certificate — the module cache is not readable here, so I cite this repo's own remote.go:832-841 comment rather than an upstream symbol. Likewise whether a hysteria2 client rejects a stray fp= — no upstream source available.
  • CI: nothing ran on this head. CI, CodeQL Advanced and Release 3X-UI are all action_required (awaiting maintainer approval for an outside contributor) — run 32988095537. So findings 2 and 5 above have not been confirmed red by a real run; nothing here has been built or executed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant