Compare commits

..

8 Commits

Author SHA1 Message Date
Farhan Zare 95f19b192f fix(nodes): stop a restarting panel from reporting itself as down
Adding a node fails right after that node's panel restarts. nodes/add
probes the node's /panel/api/server/status first, and that endpoint
returns whatever the @2s ticker last sampled - nil until the first tick
lands, so the master reads a healthy panel as unreachable and rejects it
with "Add node (remote returned success=false: )", an error whose
message is empty because the node answered success with a null obj.

The window is far wider than one tick: GetStatus resolved the public
IPv4/IPv6 addresses inline and held s.mu across every lookup, so a box
with no IPv6 route spent 3s per service - about 15s of nil status after
each restart, and the same stall on a fresh panel's first sample.

- status now answers from CurrentStatus, which samples on demand when
  the ticker has not run yet instead of returning a null obj
- the public-IP lookups run in the background and outside s.mu, so a
  status sample never waits on them
- probe tells "no status yet" apart from a genuine success=false, so the
  master's error says something when it meets an older node
2026-09-18 13:25:24 +03:00
BlindMaster24 1c0ce80e8e fix(ci): keep a refused Claude credential from reddening a pull request (#6585)
* fix(ci): keep a refused Claude credential from reddening a PR

An expired subscription ends the claude-code-action step with exit 0, so the
classifier that exists for "the API refused this run" never sees it -- its
condition is a failed step -- and the final "posted nothing" step reddens the
pull request although nothing is wrong with the repository.

Verified against five real runs (35159059540, 35184688775, 35185722358,
35186543654, 35187380192): step 8 success, step 10 found no cause, step 11
failure, transcript {"error":"oauth_org_not_allowed"} plus a result entry with
api_error_status 403. A usage-limited run carries 429 and a rejected
rate_limit_event, and a real review carries is_error false with no status, so
the 401/403 test fires on the refused credential alone.

* fix(ci): stop a refused credential reddening the issue analysis

The same exit-0 refusal reaches this workflow's "posted no reply" check, which
fails for the same reason and shows up as seven failed runs in a day. It never
attaches to a pull request -- the trigger excludes them -- so this is the same
step and the same 401/403 transcript test applied where the refusal lands.

Reported only as a warning annotation: nothing was analysed, and there is no
comment worth posting about a credential the maintainer has to renew.
2026-09-17 10:54:12 +03:00
n0ctal f8db7f6c29 fix(nodes): say which half of node mTLS failed, and say it as an error (#6565)
* fix(nodes): say which half of node mTLS failed, and say it as an error

A configured client CA bundle that will not parse produced the same
warning as a settings read that failed, and both read as though mTLS
were merely unavailable. It is not: the node API silently stops
accepting client certificates, callers fall back to a bearer token or
lose their only credential, and the one line saying so is a warning at
boot.

Report it at error level, and distinguish the two causes rather than
attributing a storage fault to the operator's certificate bundle.
NodeMtlsClientCAPool now tags the parse failure with
ErrNodeMtlsTrustBundleInvalid; its message text is unchanged, so
anything matching on the existing string still matches.

Startup is deliberately left alone. Refusing to boot was considered and
rejected: the bundle is one of two equal credentials here, a panel that
will not start takes the proxies and the subscription server with it,
and bundles written before the stricter validation landed in #6188 are
already stored, editable only through the panel that would no longer
come up.

The tests pin the tag on an unusable bundle and its absence on an unset
one; without the tag the first goes red.

* test(nodes): drop a duplicate node mTLS trust-bundle test

TestNodeMtlsClientCAPoolLeavesUnsetBundleUntagged asserted only that an
unset nodeMtlsClientCAPem yields (nil, nil). That path returns before the
line the sentinel change touched, so the test was green with and without
ErrNodeMtlsTrustBundleInvalid, and TestNodeMtlsClientCAPool already pins
the same two assertions on the same fixture. A test that passes either way
certifies nothing and then gets cited as coverage for the sentinel.

TestNodeMtlsClientCAPoolTagsAnInvalidBundle, which does go red without the
sentinel, stays as the regression guard.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-16 12:30:44 +02:00
sdhfsl 536f9a6338 fix(tgbot): localize QR caption via I18nBot (#6564)
* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

* fix(tgbot): localize QR caption via I18nBot

sendClientQRLinks hardcoded English 'QRCode for client <email>:',
bypassing I18nBot, so non-English bot languages (e.g. ru-RU) still
got English. Add tgbot.answers.qrCodeForClient key with Email param
in all 13 locales and route the caption through I18nBot.

Fixes MHSanaei/3x-ui#6562

* fix(tgbot): repair locale JSON syntax, harden QR i18n test

- Add missing separators so all 13 locale files parse again.
- Rewrite the regression test to read the real shipped files
  (fails on malformed JSON or missing key).
- Add TestTgbotLocalesQrKeyValid covering every locale file.

* chore(tgbot): drop QR caption tests that cannot catch the bug

TestQRCodeForClientLocalizes never calls sendClientQRLinks: it registers
two messages in a synthetic bundle and asserts on I18nBot, a passthrough
to go-i18n. With the tgbot_client.go line reverted to the hardcoded
English caption, both it and TestTgbotLocalesQrKeyValid still pass, so
neither certifies the fix.

The malformed-locale class they were added for is already pinned twice:
the discord package's TestMain loads every translation file through
locale.InitLocalizer and panics on invalid JSON, and
frontend/src/test/i18n-dead-keys.test.ts parses all 13 locales and
checks each carries the en-US key set. Both go red on the #6564 syntax
error this PR first shipped.

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-16 12:23:42 +02:00
sdhfsl d59b77bcdb fix(sub): send stable X-HWID on external subscription fetch (#6567)
* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

* fix(sub): send stable X-HWID on external subscription fetch

A Master panel fetching a donor subscription sent no X-HWID, so an
HWID-limited donor rejected it with 404. Identify this panel with a
stable per-installation id (persisted in settings), occupying exactly
one donor device slot.

Fixes MHSanaei/3x-ui#6559

* fix(sub): address review on external X-HWID

- Serialize first-time id creation with a mutex so concurrent
  first fetches cannot mint two UUIDs.
- Fix goimports grouping for the new third-party import.
- Add externalSubSendHwid opt-out (default send); document it.
- Cover header send/omit with httptest in TestFetchSendsStableHwid.

* fix(sub): drop the SQL-only X-HWID opt-out

The externalSubSendHwid opt-out added in 227ed818 had no settings
field, CLI flag or docs, so an operator could only reach it by editing
the settings table by hand, while every cache-miss fetch paid a query
for it. CLAUDE.md rules out config knobs on a one-header fix.

Also drop the test assertions that only restated the 3x-ui-server-
prefix constant; TestFetchSendsStableHwid still goes red without the
header.

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-16 12:16:26 +02:00
Sanaei 17e89db979 feat(hosts): add a cipher suites override and accept custom suites
The inbound TLS form offered cipherSuites as a closed single-choice list,
but xray reads the value as a colon-separated list and accepts any name Go
knows, so several suites or one missing from the list could not be set.
Both the inbound and the new host field now use a tag picker that keeps
the stored value as the colon-joined string xray expects; old single
values open unchanged.

A host's cipher suites replace the inbound's in the JSON subscription
stream, and a blank field inherits them. Share links and Clash carry no
cipher suite parameter, so their output is unchanged.
2026-09-16 11:56:20 +02:00
Sanaei 040d01c5dc fix(clients): list HWID devices when the HWID limit is 0
EnforceHwidForSubID returned before recording anything when a sub had no
limit, so the panel's HWID Devices list stayed empty for every unlimited
client. Devices are now upserted on the (sub_id, hwid_hash) index without
enforcement or X-Hwid-* headers; the write is best-effort and only logs on
failure, so tracking can never deny a subscription nothing restricts.
2026-09-16 11:50:49 +02:00
Sanaei b78dd82869 fix(nodes): chart node net throughput in KB/s, not percent
The node history panel passed its Net Up / Net Down series to Sparkline
without valueMax or yFormatter, so they inherited the percentage defaults:
a fixed 0-100 scale and a "%" label. Any node above 100 KB/s drew off the
top of the chart and every axis tick and tooltip read as a percentage.

A Sparkline fed non-percentage data has to declare its own scale and unit;
every other call site already did, only the two node net series did not.
2026-09-16 11:48:55 +02:00
50 changed files with 737 additions and 72 deletions
+17 -1
View File
@@ -437,8 +437,24 @@ jobs:
path: ${{ runner.temp }}/claude-execution-output.json
if-no-files-found: ignore
retention-days: 7
- name: Fail if the analysis posted no reply
# A refused credential ends the action with exit 0, so the step below cannot
# tell it from a reply that landed: the transcript is the only place it appears.
- name: Report an analysis the credential refused
id: refused
if: ${{ !cancelled() }}
env:
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
ISSUE: ${{ github.event.issue.number }}
run: |
set -euo pipefail
[ -f "$TRANSCRIPT" ] || exit 0
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| exit 0
echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "::warning::No analysis of #${ISSUE}: the Claude credential was refused, so this issue was not examined."
- name: Fail if the analysis posted no reply
if: ${{ !cancelled() && steps.refused.outputs.skipped != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
+16 -1
View File
@@ -228,10 +228,25 @@ jobs:
echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "::notice::No review of #${PR}: ${reason}."
gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`."
# A refused credential ends the action with exit 0, so the step above never
# sees it: the transcript is the only place that refusal appears.
- name: Report a review the credential refused
id: refused
if: ${{ !cancelled() }}
env:
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
run: |
set -euo pipefail
[ -f "$TRANSCRIPT" ] || exit 0
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| exit 0
echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "::warning::No review of #${PR}: the Claude credential was refused, so nothing in this pull request was examined."
# updated_at, not created_at: a re-review may edit its earlier comment.
# --paginate prints one jq count per page, so the pages are summed.
- name: Fail if the review posted nothing
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }}
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' && steps.refused.outputs.skipped != 'true' }}
env:
HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
STARTED_AT: ${{ steps.started.outputs.at }}
+14
View File
@@ -2301,6 +2301,9 @@
},
"type": "array"
},
"cipherSuites": {
"type": "string"
},
"createdAt": {
"format": "int64",
"type": "integer"
@@ -2434,6 +2437,7 @@
"address",
"allowInsecure",
"alpn",
"cipherSuites",
"createdAt",
"echConfigList",
"excludeFromSubTypes",
@@ -2478,6 +2482,9 @@
},
"type": "array"
},
"cipherSuites": {
"type": "string"
},
"echConfigList": {
"type": "string"
},
@@ -2604,6 +2611,7 @@
"required": [
"allowInsecure",
"alpn",
"cipherSuites",
"echConfigList",
"excludeFromSubTypes",
"finalMask",
@@ -11268,6 +11276,7 @@
"alpn": [
""
],
"cipherSuites": "",
"echConfigList": "",
"excludeFromSubTypes": [
""
@@ -11362,6 +11371,7 @@
"alpn": [
""
],
"cipherSuites": "",
"echConfigList": "",
"excludeFromSubTypes": [
""
@@ -11459,6 +11469,7 @@
"alpn": [
""
],
"cipherSuites": "",
"echConfigList": "",
"excludeFromSubTypes": [
""
@@ -11609,6 +11620,7 @@
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
@@ -11730,6 +11742,7 @@
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
@@ -11981,6 +11994,7 @@
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
+14
View File
@@ -2301,6 +2301,9 @@
},
"type": "array"
},
"cipherSuites": {
"type": "string"
},
"createdAt": {
"format": "int64",
"type": "integer"
@@ -2434,6 +2437,7 @@
"address",
"allowInsecure",
"alpn",
"cipherSuites",
"createdAt",
"echConfigList",
"excludeFromSubTypes",
@@ -2478,6 +2482,9 @@
},
"type": "array"
},
"cipherSuites": {
"type": "string"
},
"echConfigList": {
"type": "string"
},
@@ -2604,6 +2611,7 @@
"required": [
"allowInsecure",
"alpn",
"cipherSuites",
"echConfigList",
"excludeFromSubTypes",
"finalMask",
@@ -11268,6 +11276,7 @@
"alpn": [
""
],
"cipherSuites": "",
"echConfigList": "",
"excludeFromSubTypes": [
""
@@ -11362,6 +11371,7 @@
"alpn": [
""
],
"cipherSuites": "",
"echConfigList": "",
"excludeFromSubTypes": [
""
@@ -11459,6 +11469,7 @@
"alpn": [
""
],
"cipherSuites": "",
"echConfigList": "",
"excludeFromSubTypes": [
""
@@ -11609,6 +11620,7 @@
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
@@ -11730,6 +11742,7 @@
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
@@ -11981,6 +11994,7 @@
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
@@ -0,0 +1,39 @@
import { Select } from 'antd';
import type { SelectProps } from 'antd';
import { TLS_CIPHER_OPTION } from '@/schemas/primitives';
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
type CipherSuitesSelectProps = Omit<
SelectProps<string[]>,
'value' | 'onChange' | 'mode' | 'options'
> & {
// Injected by FormField:
value?: string;
onChange?: (value: string) => void;
};
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
// the stored value stays the single colon-joined string xray reads.
export default function CipherSuitesSelect({
value = '',
onChange,
...rest
}: CipherSuitesSelectProps) {
const suites = value
.split(':')
.map((s) => s.trim())
.filter(Boolean);
return (
<Select
allowClear
tokenSeparators={[':', ',']}
{...rest}
mode="tags"
options={CIPHER_SUITE_OPTIONS}
value={suites}
onChange={(next) => onChange?.(next.join(':'))}
/>
);
}
+1
View File
@@ -3,6 +3,7 @@ export { default as JsonEditor } from './JsonEditor';
export { default as HeaderMapEditor } from './HeaderMapEditor';
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
export { default as CipherSuitesSelect } from './CipherSuitesSelect';
export { default as RemarkTemplateField } from './RemarkTemplateField';
export { default as RemarkVarPicker } from './RemarkVarPicker';
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
+2
View File
@@ -591,6 +591,7 @@ export const EXAMPLES: Record<string, unknown> = {
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
@@ -636,6 +637,7 @@ export const EXAMPLES: Record<string, unknown> = {
"alpn": [
""
],
"cipherSuites": "",
"echConfigList": "",
"excludeFromSubTypes": [
""
+8
View File
@@ -2275,6 +2275,9 @@ export const SCHEMAS: Record<string, unknown> = {
},
"type": "array"
},
"cipherSuites": {
"type": "string"
},
"createdAt": {
"format": "int64",
"type": "integer"
@@ -2408,6 +2411,7 @@ export const SCHEMAS: Record<string, unknown> = {
"address",
"allowInsecure",
"alpn",
"cipherSuites",
"createdAt",
"echConfigList",
"excludeFromSubTypes",
@@ -2452,6 +2456,9 @@ export const SCHEMAS: Record<string, unknown> = {
},
"type": "array"
},
"cipherSuites": {
"type": "string"
},
"echConfigList": {
"type": "string"
},
@@ -2578,6 +2585,7 @@ export const SCHEMAS: Record<string, unknown> = {
"required": [
"allowInsecure",
"alpn",
"cipherSuites",
"echConfigList",
"excludeFromSubTypes",
"finalMask",
+2
View File
@@ -537,6 +537,7 @@ export interface Host {
address: string;
allowInsecure: boolean;
alpn: string[];
cipherSuites: string;
createdAt: number;
echConfigList: string;
excludeFromSubTypes: string[];
@@ -573,6 +574,7 @@ export interface Host {
export interface HostGroup {
allowInsecure: boolean;
alpn: string[];
cipherSuites: string;
echConfigList: string;
excludeFromSubTypes: string[];
finalMask: string;
+2
View File
@@ -573,6 +573,7 @@ export const HostSchema = z.object({
address: z.string(),
allowInsecure: z.boolean(),
alpn: z.array(z.string()),
cipherSuites: z.string(),
createdAt: z.number().int(),
echConfigList: z.string(),
excludeFromSubTypes: z.array(z.string()),
@@ -610,6 +611,7 @@ export type Host = z.infer<typeof HostSchema>;
export const HostGroupSchema = z.object({
allowInsecure: z.boolean(),
alpn: z.array(z.string()),
cipherSuites: z.string(),
echConfigList: z.string(),
excludeFromSubTypes: z.array(z.string()),
finalMask: z.string(),
@@ -17,6 +17,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { CipherSuitesSelect } from '@/components/form';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -56,6 +57,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
path: host?.path ?? '',
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
cipherSuites: host?.cipherSuites ?? '',
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
keepSniBlank: host?.keepSniBlank ?? false,
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
@@ -332,6 +334,12 @@ export default function HostFormModal({
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
<Select mode="multiple" allowClear options={alpnOptions} />
</FormField>
<FormField
name="cipherSuites"
label={t('pages.inbounds.form.cipherSuites')}
>
<CipherSuitesSelect />
</FormField>
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
@@ -8,11 +8,11 @@ import {
} from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { CipherSuitesSelect } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import {
ALPN_OPTION,
DOMAIN_STRATEGY_OPTION,
TLS_CIPHER_OPTION,
TLS_VERSION_OPTION,
USAGE_OPTION,
UTLS_FINGERPRINT,
@@ -240,12 +240,7 @@ export default function TlsForm({
name={['streamSettings', 'tlsSettings', 'cipherSuites']}
label={t('pages.inbounds.form.cipherSuites')}
>
<Select
options={[
{ value: '', label: t('pages.inbounds.form.autoOption') },
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
]}
/>
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
</FormField>
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block>
@@ -25,6 +25,8 @@ interface ApiMsg<T = unknown> {
const REFRESH_MS = 15000;
const formatKbps = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 1 });
export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
const { t } = useTranslation();
const [cpuPoints, setCpuPoints] = useState<number[]>([]);
@@ -51,7 +53,7 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
};
// cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
// as KB/s (no upper clamp, the sparkline auto-scales).
// as KB/s, which must opt out of Sparkline's 0-100 "%" defaults.
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
try {
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
@@ -148,6 +150,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
fillOpacity={0.18}
markerRadius={2.6}
showTooltip
valueMax={null}
yFormatter={formatKbps}
/>
</div>
<div className="series">
@@ -164,6 +168,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
fillOpacity={0.18}
markerRadius={2.6}
showTooltip
valueMax={null}
yFormatter={formatKbps}
/>
</div>
</div>
+2
View File
@@ -35,6 +35,7 @@ export const HostFormSchema = z.object({
(val) => (val === '' ? undefined : val),
UtlsFingerprintSchema.optional(),
),
cipherSuites: z.string().default(''),
overrideSniFromAddress: z.boolean().default(false),
keepSniBlank: z.boolean().default(false),
pinnedPeerCertSha256: z.array(z.string()).default([]),
@@ -87,6 +88,7 @@ export const HostRecordSchema = z
path: z.string().optional(),
alpn: z.array(z.string()).nullish(),
fingerprint: z.string().optional(),
cipherSuites: z.string().optional(),
overrideSniFromAddress: z.boolean().optional(),
keepSniBlank: z.boolean().optional(),
pinnedPeerCertSha256: z.array(z.string()).nullish(),
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { CipherSuitesSelect } from '@/components/form';
function renderSelect(value: string) {
const onChange = vi.fn();
render(<CipherSuitesSelect aria-label="cipher suites" value={value} onChange={onChange} />);
return onChange;
}
describe('CipherSuitesSelect', () => {
it('shows each colon-separated suite as its own tag', () => {
renderSelect('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE');
expect(screen.getByText('TLS_AES_256_GCM_SHA384')).toBeTruthy();
expect(screen.getByText('MY_CUSTOM_SUITE')).toBeTruthy();
});
it('stores a typed custom suite joined with colons after the existing one', () => {
const onChange = renderSelect('TLS_AES_256_GCM_SHA384');
const input = screen.getByRole('combobox', { name: 'cipher suites' });
fireEvent.change(input, { target: { value: 'MY_CUSTOM_SUITE' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 });
expect(onChange).toHaveBeenLastCalledWith('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE');
});
it('stores an empty string once every suite is removed', () => {
const onChange = renderSelect('TLS_AES_256_GCM_SHA384');
const remove = document.querySelector('.ant-select-selection-item-remove');
expect(remove).not.toBeNull();
fireEvent.click(remove as Element);
expect(onChange).toHaveBeenLastCalledWith('');
});
});
@@ -0,0 +1,54 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import NodeHistoryPanel from '@/pages/nodes/NodeHistoryPanel';
import { HttpUtil, Msg } from '@/utils';
const plots = vi.hoisted(() => [] as { scales: { y: { range: () => [number, number] } } }[]);
vi.mock('uplot', () => ({
default: class {
static paths = { spline: () => undefined };
static pxRatio = 1;
constructor(opts: (typeof plots)[number]) {
plots.push(opts);
}
setData() {}
setSize() {}
redraw() {}
destroy() {}
},
}));
// The net series fell through to Sparkline's percentage defaults: a 0-100 scale
// and a "%" label, so 512 KB/s rendered as "512%" far above the chart.
describe('NodeHistoryPanel', () => {
it('charts net throughput in KB/s on its own scale', async () => {
const samples: Record<string, number> = {
cpu: 40,
mem: 60,
netUp: 512 * 1024,
netDown: 200 * 1024,
};
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
const metric = url.split('/').at(-2) ?? '';
return new Msg(true, '', [{ t: 1_700_000_000, v: samples[metric] }]);
});
render(<NodeHistoryPanel node={{ id: 7 }} />);
await waitFor(() => expect(screen.getAllByRole('img')).toHaveLength(4));
expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual([
'40%',
'60%',
'512',
'200',
]);
expect(plots.map((p) => p.scales.y.range())).toEqual([
[0, 100],
[0, 100],
[0, 512 * 1.1],
[0, 200 * 1.1],
]);
});
});
+1
View File
@@ -1083,6 +1083,7 @@ type Host struct {
Path string `json:"path" form:"path"`
Alpn []string `json:"alpn" form:"alpn" gorm:"serializer:json"`
Fingerprint string `json:"fingerprint" form:"fingerprint"`
CipherSuites string `json:"cipherSuites" form:"cipherSuites" gorm:"column:cipher_suites"`
OverrideSniFromAddress bool `json:"overrideSniFromAddress" form:"overrideSniFromAddress" gorm:"column:override_sni_from_address"`
KeepSniBlank bool `json:"keepSniBlank" form:"keepSniBlank" gorm:"column:keep_sni_blank"`
PinnedPeerCertSha256 []string `json:"pinnedPeerCertSha256" form:"pinnedPeerCertSha256" gorm:"serializer:json;column:pinned_peer_cert_sha256"`
+67
View File
@@ -0,0 +1,67 @@
package sub
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// #6559: the Master panel must send a stable X-HWID when fetching external
// subscriptions, otherwise an HWID-limited donor answers 404.
func TestServerHwidStableAcrossCalls(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
first := serverHwid()
if first == "" {
t.Fatal("serverHwid returned empty")
}
second := serverHwid()
if second != first {
t.Fatalf("hwid not stable: %q vs %q", first, second)
}
var row model.Setting
if err := database.GetDB().Where("key = ?", serverHwidKey).First(&row).Error; err != nil {
t.Fatalf("hwid not persisted: %v", err)
}
if row.Value != first {
t.Fatalf("persisted hwid %q != returned %q", row.Value, first)
}
}
// The fetch must carry the stable id so an HWID-limited donor lets it through.
func TestFetchSendsStableHwid(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
var gotHwid string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHwid = r.Header.Get("X-HWID")
_, _ = w.Write([]byte("vless://uuid@host:443?security=none#x"))
}))
defer srv.Close()
res := fetchSubscriptionLinks(srv.URL)
if res.err != nil {
t.Fatalf("fetch: %v", res.err)
}
if len(res.links) != 1 {
t.Fatalf("links = %v", res.links)
}
if gotHwid == "" {
t.Fatal("X-HWID header missing on fetch")
}
if gotHwid != serverHwid() {
t.Fatalf("sent %q != stable %q", gotHwid, serverHwid())
}
}
+43 -4
View File
@@ -9,15 +9,15 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// External subscription fetching: a "subscription" external link is a remote
// URL whose body is a (often base64-encoded) newline list of share links. We
// fetch it on demand, cache the decoded links briefly, and bound the request
// with a short timeout so a slow/dead provider can't stall a client's sub.
// External subscription fetching: a remote URL whose body is a share-link
// list. Fetches are cached briefly and bounded so a dead provider can't stall.
const (
subscriptionCacheTTL = 5 * time.Minute
@@ -150,6 +150,10 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
}
// Some providers gate the link body on a known client User-Agent.
req.Header.Set("User-Agent", "v2rayNG/1.8.5")
// A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559).
if hwid := serverHwid(); hwid != "" {
req.Header.Set("X-HWID", hwid)
}
resp, err := subscriptionHTTPClient.Do(req)
if err != nil {
return nil, err
@@ -173,6 +177,41 @@ var (
errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
)
// serverHwidKey is the settings row holding this panel's stable identity
// for outbound external-subscription fetches.
const serverHwidKey = "externalSubHwid"
// serverHwidMu serializes first-time creation: without it, concurrent first
// fetches of different URLs each mint and persist their own UUID.
var serverHwidMu sync.Mutex
// serverHwid returns a stable per-installation id, creating and persisting
// it on first use. Empty means the DB is unreachable: send no header then.
func serverHwid() string {
serverHwidMu.Lock()
defer serverHwidMu.Unlock()
db := database.GetDB()
if db == nil {
return ""
}
var row model.Setting
if err := db.Where("key = ?", serverHwidKey).First(&row).Error; err == nil {
if strings.TrimSpace(row.Value) != "" {
return strings.TrimSpace(row.Value)
}
}
hwid := "3x-ui-server-" + uuid.NewString()
row = model.Setting{Key: serverHwidKey, Value: hwid}
if err := db.Where(model.Setting{Key: serverHwidKey}).FirstOrCreate(&row).Error; err != nil {
logger.Warningf("sub: persisting server hwid failed: %v", err)
return ""
}
if strings.TrimSpace(row.Value) == "" {
return hwid
}
return strings.TrimSpace(row.Value)
}
type subError struct{ msg string }
func (e *subError) Error() string { return e.msg }
+3
View File
@@ -71,6 +71,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int)
if h.Fingerprint != "" {
ep["fingerprint"] = h.Fingerprint
}
if h.CipherSuites != "" {
ep["cipherSuites"] = h.CipherSuites
}
if len(h.Alpn) > 0 {
ep["alpn"] = stringsToAnySlice(h.Alpn)
}
+28
View File
@@ -442,3 +442,31 @@ func TestSub_HostTlsOverRealityDropsRealityParams(t *testing.T) {
}
}
}
// A host's cipher suites override the inbound's own in the JSON subscription,
// while a host that leaves the field blank inherits them.
func TestSub_HostCipherSuitesJSON(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "cs", 4462, 1,
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni","cipherSuites":"TLS_CHACHA20_POLY1305_SHA256"}}`)
seedHost(t, &model.Host{
InboundId: ib.Id, SortOrder: 0, Remark: "CS", Address: "cs.cdn.com", Port: 8443, Security: "tls",
CipherSuites: "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256",
})
seedHost(t, &model.Host{
InboundId: ib.Id, SortOrder: 1, Remark: "INHERIT", Address: "inh.cdn.com", Port: 8443, Security: "tls",
})
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, `"cipherSuites": "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256"`) &&
!strings.Contains(out, `"cipherSuites":"TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256"`) {
t.Fatalf("json tlsSettings should carry the host's cipher suites:\n%s", out)
}
if !strings.Contains(out, `"cipherSuites": "TLS_CHACHA20_POLY1305_SHA256"`) &&
!strings.Contains(out, `"cipherSuites":"TLS_CHACHA20_POLY1305_SHA256"`) {
t.Fatalf("a host with no cipher suites should inherit the inbound's:\n%s", out)
}
}
+3
View File
@@ -2088,6 +2088,9 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
tlsSettings["alpn"] = alpn
}
if cs, ok := ep["cipherSuites"].(string); ok && cs != "" {
tlsSettings["cipherSuites"] = cs
}
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
settings, _ := tlsSettings["settings"].(map[string]any)
if settings == nil {
+1 -1
View File
@@ -106,7 +106,7 @@ func (a *ServerController) startTask() {
}
// status returns the current server status information.
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.CurrentStatus(), nil) }
func (a *ServerController) getFail2banStatus(c *gin.Context) {
jsonObj(c, a.serverService.GetFail2banStatus(), nil)
+1
View File
@@ -382,6 +382,7 @@ type HostGroup struct {
Path string `json:"path"`
Alpn []string `json:"alpn"`
Fingerprint string `json:"fingerprint"`
CipherSuites string `json:"cipherSuites"`
OverrideSniFromAddress bool `json:"overrideSniFromAddress"`
KeepSniBlank bool `json:"keepSniBlank"`
PinnedPeerCertSha256 []string `json:"pinnedPeerCertSha256"`
+19 -1
View File
@@ -9,8 +9,10 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type HwidRequest struct {
@@ -110,12 +112,15 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid
if err != nil {
return res, err
}
req = normalizeHwidRequest(req)
if limit <= 0 {
res.Allowed = true
if len(req.Hwid) >= minHwidLength {
trackUnlimitedHwid(db, subID, req)
}
return res, nil
}
req = normalizeHwidRequest(req)
res.Active = true
res.Limit = limit
if len(req.Hwid) < minHwidLength {
@@ -177,6 +182,19 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid
return res, err
}
// trackUnlimitedHwid lists devices of a sub with no HWID limit in the panel. It is
// best-effort: a failed write must not deny a subscription nothing restricts.
func trackUnlimitedHwid(db *gorm.DB, subID string, req HwidRequest) {
now := time.Now().UnixMilli()
err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sub_id"}, {Name: "hwid_hash"}},
DoUpdates: clause.AssignmentColumns([]string{"last_seen", "user_agent", "device_os", "os_version", "device_model"}),
}).Create(&model.ClientHwid{SubID: subID, HwidHash: hashHwid(req.Hwid), FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error
if err != nil {
logger.Warning("track HWID for unlimited subscription failed:", err)
}
}
// HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or
// last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced.
func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotStatus, found bool, err error) {
+17
View File
@@ -45,6 +45,23 @@ func TestClientHwidGate(t *testing.T) {
if !res.Allowed || res.Active {
t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
}
for _, ua := range []string{"Happ/1.0", "Happ/2.0"} {
res, err = svc.EnforceHwidForSubID("sub-hwid", HwidRequest{Hwid: "device-one", UserAgent: ua})
if err != nil {
t.Fatalf("no-limit gate with HWID: %v", err)
}
if res != (HwidGateResult{Allowed: true}) {
t.Fatalf("no limit should allow HWID without active headers: %+v", res)
}
}
list, err := svc.ListClientHwids("hwid@example.com")
if err != nil {
t.Fatalf("list HWIDs: %v", err)
}
if len(list) != 1 || list[0].UserAgent != "Happ/2.0" {
t.Fatalf("no limit should still track one device with fresh metadata, got %+v", list)
}
}
func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
+2
View File
@@ -45,6 +45,7 @@ func newHostGroup(h *model.Host, groupId string) *entity.HostGroup {
Path: h.Path,
Alpn: h.Alpn,
Fingerprint: h.Fingerprint,
CipherSuites: h.CipherSuites,
OverrideSniFromAddress: h.OverrideSniFromAddress,
KeepSniBlank: h.KeepSniBlank,
PinnedPeerCertSha256: h.PinnedPeerCertSha256,
@@ -133,6 +134,7 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
Path: req.Path,
Alpn: req.Alpn,
Fingerprint: req.Fingerprint,
CipherSuites: req.CipherSuites,
OverrideSniFromAddress: req.OverrideSniFromAddress,
KeepSniBlank: req.KeepSniBlank,
PinnedPeerCertSha256: req.PinnedPeerCertSha256,
+24
View File
@@ -365,3 +365,27 @@ func TestUpdateHostGroup_ValidateBeforeDelete(t *testing.T) {
t.Fatalf("remark not updated: %s", got2.Remark)
}
}
// Host fields are copied by hand in buildHostRows and newHostGroup; a missed
// copy on either side silently blanks the value on the next edit-and-save.
func TestHostGroup_CipherSuitesRoundTrip(t *testing.T) {
setupBulkDB(t)
svc := &HostService{}
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
const suites = "TLS_AES_256_GCM_SHA384:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
created, err := svc.AddHostGroup(&entity.HostGroup{
InboundIds: []int{ib.Id}, Remark: "cs", Hosts: []string{"cs.example.com"},
Security: "tls", CipherSuites: suites,
})
if err != nil {
t.Fatalf("AddHostGroup: %v", err)
}
g, err := svc.GetHostGroup(created[0].GroupId)
if err != nil {
t.Fatalf("GetHostGroup: %v", err)
}
if g.CipherSuites != suites {
t.Fatalf("CipherSuites = %q, want %q", g.CipherSuites, suites)
}
}
+7 -1
View File
@@ -1323,10 +1323,16 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string)
patch.LastError = "decode response: " + err.Error()
return patch, err
}
if !envelope.Success || envelope.Obj == nil {
if !envelope.Success {
patch.LastError = "remote returned success=false: " + envelope.Msg
return patch, errors.New(patch.LastError)
}
// A panel that has not sampled its status yet answers success with a null
// obj; saying so beats "success=false: " with nothing after the colon.
if envelope.Obj == nil {
patch.LastError = "remote panel reported no status yet; it may still be starting up"
return patch, errors.New(patch.LastError)
}
o := envelope.Obj
patch.CpuPct = o.CpuPct
if o.Mem.Total > 0 {
@@ -0,0 +1,48 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// An older node answers success with a null obj while its status is unsampled,
// which used to surface as "success=false: " with nothing after the colon.
func TestProbeNamesANodeThatHasNoStatusYet(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"msg":"","obj":null}`))
}))
defer srv.Close()
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatalf("parse url: %v", err)
}
port, err := strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("parse port: %v", err)
}
n := &model.Node{
Id: 1, Name: "cold", Scheme: "http", Address: u.Hostname(), Port: port,
BasePath: "/", Enable: true, AllowPrivateAddress: true, TlsVerifyMode: "skip",
}
svc := &NodeService{}
patch, err := svc.Probe(context.Background(), n)
if err == nil {
t.Fatal("Probe accepted a status response with no obj, want an error")
}
if strings.Contains(patch.LastError, "success=false") {
t.Fatalf("LastError = %q, want the missing status named instead of a bare success=false", patch.LastError)
}
if !strings.Contains(patch.LastError, "no status yet") {
t.Fatalf("LastError = %q, want it to say the remote reported no status yet", patch.LastError)
}
}
+87 -27
View File
@@ -148,6 +148,7 @@ type ServerService struct {
cachedIPv4 string
cachedIPv6 string
noIPv6 bool
resolvingIPs bool
mu sync.Mutex
lastCPUTimes cpu.TimesStat
hasLastCPUSample bool
@@ -158,6 +159,7 @@ type ServerService struct {
lastStatusMu sync.RWMutex
lastStatus *Status
coldStatusMu sync.Mutex
versionsCacheMu sync.Mutex
versionsCache *cachedXrayVersions
@@ -208,6 +210,21 @@ func (s *ServerService) LastStatus() *Status {
return s.lastStatus
}
// CurrentStatus never reports "no status yet": the @2s ticker leaves LastStatus
// nil for the first seconds after a restart, and a master probing a node then
// reads the empty snapshot as an offline panel.
func (s *ServerService) CurrentStatus() *Status {
if status := s.LastStatus(); status != nil {
return status
}
s.coldStatusMu.Lock()
defer s.coldStatusMu.Unlock()
if status := s.LastStatus(); status != nil {
return status
}
return s.RefreshStatus()
}
// Fail2banStatus tells the frontend whether the per-client IP limit can
// actually be enforced. Enforcement depends on fail2ban, so a limit set
// without it would silently do nothing.
@@ -429,36 +446,79 @@ var publicIPv6Services = []string{
"https://6.ident.me",
}
// resolvePublicIPs caches the public IPv4/IPv6 addresses on first use. Guarded
// by s.mu because the bot's ServerService may call it from sendBackup while a
// status report runs concurrently.
// resolvePublicIPs caches the public IPv4/IPv6 addresses on first use. The
// lookups run outside s.mu so a stalling service cannot block a status sample.
func (s *ServerService) resolvePublicIPs() {
s.mu.Lock()
wantIPv4 := s.cachedIPv4 == ""
wantIPv6 := s.cachedIPv6 == "" && !s.noIPv6
s.mu.Unlock()
if !wantIPv4 && !wantIPv6 {
return
}
var ipv4, ipv6 string
if wantIPv4 {
ipv4 = firstPublicIP(publicIPv4Services)
}
if wantIPv6 {
ipv6 = firstPublicIP(publicIPv6Services)
}
s.mu.Lock()
defer s.mu.Unlock()
if s.cachedIPv4 == "" {
for _, ip4Service := range publicIPv4Services {
s.cachedIPv4 = getPublicIP(ip4Service)
if s.cachedIPv4 != "N/A" {
break
}
}
if wantIPv4 && s.cachedIPv4 == "" {
s.cachedIPv4 = ipv4
}
if s.cachedIPv6 == "" && !s.noIPv6 {
for _, ip6Service := range publicIPv6Services {
s.cachedIPv6 = getPublicIP(ip6Service)
if s.cachedIPv6 != "N/A" {
break
}
}
if wantIPv6 && s.cachedIPv6 == "" {
s.cachedIPv6 = ipv6
}
if s.cachedIPv6 == "N/A" {
s.noIPv6 = true
}
}
// firstPublicIP returns the first service that answers, or "N/A" when every
// one of them fails.
func firstPublicIP(services []string) string {
var ip string
for _, service := range services {
ip = getPublicIP(service)
if ip != "N/A" {
break
}
}
return ip
}
// resolvePublicIPsInBackground keeps a status sample off the lookup path: a box
// with no IPv6 route spends 3s per service, and the sample is what nodes report.
func (s *ServerService) resolvePublicIPsInBackground() {
s.mu.Lock()
settled := s.cachedIPv4 != "" && (s.cachedIPv6 != "" || s.noIPv6)
if s.resolvingIPs || settled {
s.mu.Unlock()
return
}
s.resolvingIPs = true
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
s.resolvingIPs = false
s.mu.Unlock()
}()
s.resolvePublicIPs()
}()
}
func (s *ServerService) publicIPs() (ipv4 string, ipv6 string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.cachedIPv4, s.cachedIPv6
}
func (s *ServerService) GetStatus(lastStatus *Status) *Status {
now := time.Now()
status := &Status{
@@ -620,9 +680,8 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
logger.Warning("get udp connections failed:", err)
}
s.resolvePublicIPs()
status.PublicIP.IPv4 = s.cachedIPv4
status.PublicIP.IPv6 = s.cachedIPv6
s.resolvePublicIPsInBackground()
status.PublicIP.IPv4, status.PublicIP.IPv6 = s.publicIPs()
// Xray status
if s.xrayService.IsXrayRunning() {
@@ -1549,10 +1608,11 @@ func (s *ServerService) backupHost(requestHost string) string {
}
if host == "" {
s.resolvePublicIPs()
if ip := s.cachedIPv4; ip != "" && ip != "N/A" {
host = ip
} else if ip := s.cachedIPv6; ip != "" && ip != "N/A" {
host = ip
ipv4, ipv6 := s.publicIPs()
if ipv4 != "" && ipv4 != "N/A" {
host = ipv4
} else if ipv6 != "" && ipv6 != "N/A" {
host = ipv6
}
}
return sanitizeBackupHost(host)
@@ -0,0 +1,35 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
// A panel restarts with an empty snapshot until the @2s ticker fires, and a
// master probing that window reads the empty answer as an offline node.
func TestCurrentStatusSamplesBeforeFirstTick(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ServerService{}
if svc.LastStatus() != nil {
t.Fatal("a fresh ServerService should hold no snapshot yet")
}
status := svc.CurrentStatus()
if status == nil {
t.Fatal("CurrentStatus returned nil before the first ticker run, want an on-demand sample")
}
if svc.LastStatus() != status {
t.Fatal("the on-demand sample should be stored as LastStatus")
}
if again := svc.CurrentStatus(); again != status {
t.Fatal("a warm CurrentStatus should reuse the stored snapshot, not resample")
}
}
@@ -0,0 +1,59 @@
package service
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
// A box with no IPv6 route spends 3s per lookup service, and a status sample
// that waits for that is a panel reporting nothing for the first ~15s.
func TestStatusSampleDoesNotWaitOnPublicIPLookup(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
return
}
_, _ = w.Write([]byte("203.0.113.7"))
}))
t.Cleanup(srv.Close)
savedV4, savedV6 := publicIPv4Services, publicIPv6Services
publicIPv4Services = []string{srv.URL}
publicIPv6Services = []string{srv.URL}
t.Cleanup(func() { publicIPv4Services, publicIPv6Services = savedV4, savedV6 })
svc := &ServerService{}
status := svc.CurrentStatus()
if status == nil {
t.Fatal("CurrentStatus returned nil while the IP lookup was in flight")
}
if status.PublicIP.IPv4 != "" {
t.Fatalf("the sample waited for the lookup: PublicIP.IPv4 = %q, want it still unresolved", status.PublicIP.IPv4)
}
close(release)
deadline := time.Now().Add(10 * time.Second)
for {
if ipv4, _ := svc.publicIPs(); ipv4 == "203.0.113.7" {
return
}
if time.Now().After(deadline) {
t.Fatal("the background lookup never cached the public IP")
}
time.Sleep(20 * time.Millisecond)
}
}
+5 -1
View File
@@ -194,7 +194,7 @@ func (s *SettingService) NodeMtlsClientCAPool() (*x509.CertPool, error) {
}
certs, err := parseCertificateBundlePEM([]byte(caPem))
if err != nil {
return nil, fmt.Errorf("nodeMtlsClientCAPem is not a valid certificate bundle: %w", err)
return nil, fmt.Errorf("%w: %w", ErrNodeMtlsTrustBundleInvalid, err)
}
pool := x509.NewCertPool()
for _, cert := range certs {
@@ -203,6 +203,10 @@ func (s *SettingService) NodeMtlsClientCAPool() (*x509.CertPool, error) {
return pool, nil
}
// ErrNodeMtlsTrustBundleInvalid separates a stored bundle that will not parse
// from a settings read that failed, which callers report differently.
var ErrNodeMtlsTrustBundleInvalid = errors.New("nodeMtlsClientCAPem is not a valid certificate bundle")
// parseCertificateBundlePEM avoids AppendCertsFromPEM because that helper can
// silently accept a bundle after parsing only its first certificate.
func parseCertificateBundlePEM(bundle []byte) ([]*x509.Certificate, error) {
@@ -1,6 +1,7 @@
package service
import (
"errors"
"strings"
"testing"
@@ -72,3 +73,21 @@ func TestNodeMtlsClientCAPoolRejectsPartiallyValidBundle(t *testing.T) {
t.Fatalf("NodeMtlsClientCAPool() = %v, error = %v, want %q", pool, err, want)
}
}
// The boot path tells the operator whether the bundle itself is unusable or the
// settings read failed, so the parse failure has to carry a matchable cause.
func TestNodeMtlsClientCAPoolTagsAnInvalidBundle(t *testing.T) {
s := setupSettingMtlsDB(t)
if err := s.setString("nodeMtlsClientCAPem", "-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n"); err != nil {
t.Fatalf("setString: %v", err)
}
pool, err := s.NodeMtlsClientCAPool()
if pool != nil {
t.Fatalf("NodeMtlsClientCAPool() returned a pool built from an unusable bundle")
}
if !errors.Is(err, ErrNodeMtlsTrustBundleInvalid) {
t.Fatalf("NodeMtlsClientCAPool() error = %v, want it to wrap ErrNodeMtlsTrustBundleInvalid", err)
}
}
+1 -1
View File
@@ -348,7 +348,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
}
// Inform user
t.SendMsgToTgbot(chatId, "QRCode for client "+email+":")
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email))
// Send sub URL QR (filename: sub.png)
if png, err := createQR(subURL, 320); err == nil {
+3 -2
View File
@@ -596,7 +596,7 @@
"customSockopt": "sockopt مخصص",
"addCustomOption": "إضافة خيار مخصص",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "مجموعات التشفير",
"autoOption": "تلقائي",
"minMaxVersion": "إصدار أدنى/أقصى",
"rejectUnknownSni": "رفض SNI غير معروف",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}: اتعطل بنجاح.",
"askToAddUserId": "مافيش إعدادات ليك!\r\nاطلب من الأدمن يضيف الـ Telegram ChatID الخاص بيك في إعداداتك.\r\n\r\nالـ ChatID بتاعك: <code>{{ .TgUserID }}</code>",
"chooseClient": "اختار عميل للإدخال {{ .Inbound }}",
"chooseInbound": "اختار الإدخال"
"chooseInbound": "اختار الإدخال",
"qrCodeForClient": "رمز QR للعميل {{ .Email }}:"
}
},
"discord": {
+2 -1
View File
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}: Disabled successfully.",
"askToAddUserId": "Your configuration is not found!\r\nPlease ask your admin to use your Telegram ChatID in your configuration(s).\r\n\r\nYour ChatID: <code>{{ .TgUserID }}</code>",
"chooseClient": "Choose a Client for Inbound {{ .Inbound }}",
"chooseInbound": "Choose an Inbound"
"chooseInbound": "Choose an Inbound",
"qrCodeForClient": "QRCode for client {{ .Email }}:"
}
},
"discord": {
+3 -2
View File
@@ -596,7 +596,7 @@
"customSockopt": "Sockopt personalizado",
"addCustomOption": "Añadir opción personalizada",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "Conjuntos de cifrado",
"autoOption": "Auto",
"minMaxVersion": "Versión mín/máx",
"rejectUnknownSni": "Rechazar SNI desconocido",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }} : Deshabilitado exitosamente.",
"askToAddUserId": "¡No se encuentra su configuración!\r\nPor favor, pídale a su administrador que use su ChatID de usuario de Telegram en su(s) configuración(es).\r\n\r\nSu ChatID de usuario: <code>{{ .TgUserID }}</code>",
"chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}",
"chooseInbound": "Elige un Inbound"
"chooseInbound": "Elige un Inbound",
"qrCodeForClient": "Código QR para el cliente {{ .Email }}:"
}
},
"discord": {
+2 -1
View File
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }} : با موفقیت غیرفعال شد.",
"askToAddUserId": "پیکربندی شما یافت نشد!\r\nلطفاً از مدیر خود بخواهید که شناسه کاربر تلگرام خود را در پیکربندی (های) خود استفاده کند.\r\n\r\nشناسه کاربری شما: <code>{{ .TgUserID }}</code>",
"chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید",
"chooseInbound": "یک ورودی انتخاب کنید"
"chooseInbound": "یک ورودی انتخاب کنید",
"qrCodeForClient": "کد QR برای کاربر {{ .Email }}:"
}
},
"discord": {
+3 -2
View File
@@ -596,7 +596,7 @@
"customSockopt": "Sockopt kustom",
"addCustomOption": "Tambah opsi kustom",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "Rangkaian Sandi",
"autoOption": "Otomatis",
"minMaxVersion": "Versi Min/Maks",
"rejectUnknownSni": "Tolak SNI tidak dikenal",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}: Dinonaktifkan dengan berhasil.",
"askToAddUserId": "Konfigurasi Anda tidak ditemukan!\r\nSilakan minta admin Anda untuk menggunakan ChatID Telegram Anda dalam konfigurasi Anda.\r\n\r\nChatID Pengguna Anda: <code>{{ .TgUserID }}</code>",
"chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}",
"chooseInbound": "Pilih Inbound"
"chooseInbound": "Pilih Inbound",
"qrCodeForClient": "Kode QR untuk klien {{ .Email }}:"
}
},
"discord": {
+3 -2
View File
@@ -617,7 +617,7 @@
"customSockopt": "カスタム sockopt",
"addCustomOption": "カスタムオプション追加",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "暗号スイート",
"autoOption": "自動",
"minMaxVersion": "最小/最大バージョン",
"rejectUnknownSni": "未知の SNI を拒否",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}:正常に無効化されました。",
"askToAddUserId": "設定が見つかりませんでした!\r\n管理者に問い合わせて、設定にTelegramユーザーのChatIDを使用してください。\r\n\r\nあなたのユーザーChatID:<code>{{ .TgUserID }}</code>",
"chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択",
"chooseInbound": "インバウンドを選択"
"chooseInbound": "インバウンドを選択",
"qrCodeForClient": "クライアント {{ .Email }} のQRコード:"
}
},
"discord": {
+3 -2
View File
@@ -617,7 +617,7 @@
"customSockopt": "Sockopt personalizado",
"addCustomOption": "Adicionar opção personalizada",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "Conjuntos de cifras",
"autoOption": "Auto",
"minMaxVersion": "Versão mín/máx",
"rejectUnknownSni": "Rejeitar SNI desconhecido",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}: Desativado com sucesso.",
"askToAddUserId": "Sua configuração não foi encontrada!\r\nPeça ao seu administrador para usar seu Telegram ChatID em suas configurações.\r\n\r\nSeu ChatID: <code>{{ .TgUserID }}</code>",
"chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}",
"chooseInbound": "Escolha um Inbound"
"chooseInbound": "Escolha um Inbound",
"qrCodeForClient": "QR Code para o cliente {{ .Email }}:"
}
},
"discord": {
+3 -2
View File
@@ -619,7 +619,7 @@
"customSockopt": "Пользовательский sockopt",
"addCustomOption": "Добавить опцию",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "Наборы шифров",
"autoOption": "Авто",
"minMaxVersion": "Мин/Макс версия",
"rejectUnknownSni": "Отклонить неизвестный SNI",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}: Отключено успешно.",
"askToAddUserId": "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: <code>{{ .TgUserID }}</code>",
"chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}",
"chooseInbound": "Выберите входящее подключение"
"chooseInbound": "Выберите входящее подключение",
"qrCodeForClient": "QR-код для клиента {{ .Email }}:"
}
},
"discord": {
+2 -1
View File
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}: Başarıyla devre dışı bırakıldı.",
"askToAddUserId": "Yapılandırmanız bulunamadı!\r\nLütfen yöneticinizden Telegram Chat ID'nizi yapılandırmanıza eklemesini isteyin.\r\n\r\nSizin Chat ID'niz: <code>{{ .TgUserID }}</code>",
"chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin",
"chooseInbound": "Bir Gelen Bağlantı Seçin"
"chooseInbound": "Bir Gelen Bağlantı Seçin",
"qrCodeForClient": "{{ .Email }} istemcisi için QR Kodu:"
}
},
"discord": {
+3 -2
View File
@@ -596,7 +596,7 @@
"customSockopt": "Користувацький sockopt",
"addCustomOption": "Додати опцію",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "Набори шифрів",
"autoOption": "Авто",
"minMaxVersion": "Мін/Макс версія",
"rejectUnknownSni": "Відхиляти невідомий SNI",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}: Успішно вимкнено.",
"askToAddUserId": "Вашу конфігурацію не знайдено!\r\nБудь ласка, попросіть свого адміністратора використовувати ваш ідентифікатор Telegram у вашій конфігурації.\r\n\r\nВаш ідентифікатор користувача: <code>{{ .TgUserID }}</code>",
"chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}",
"chooseInbound": "Виберіть Вхідний"
"chooseInbound": "Виберіть Вхідний",
"qrCodeForClient": "QR-код для клієнта {{ .Email }}:"
}
},
"discord": {
+3 -2
View File
@@ -617,7 +617,7 @@
"customSockopt": "Sockopt tùy chỉnh",
"addCustomOption": "Thêm tùy chọn",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "Bộ mật mã",
"autoOption": "Tự động",
"minMaxVersion": "Phiên bản Min/Max",
"rejectUnknownSni": "Từ chối SNI lạ",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }} : Đã Tắt Thành Công.",
"askToAddUserId": "Cấu hình của bạn không được tìm thấy!\r\nVui lòng yêu cầu Quản trị viên sử dụng ID người dùng telegram của bạn trong cấu hình của bạn.\r\n\r\nID người dùng của bạn: <code>{{ .TgUserID }}</code>",
"chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}",
"chooseInbound": "Chọn một Inbound"
"chooseInbound": "Chọn một Inbound",
"qrCodeForClient": "Mã QR cho khách hàng {{ .Email }}:"
}
},
"discord": {
+3 -2
View File
@@ -616,7 +616,7 @@
"customSockopt": "自定义 sockopt",
"addCustomOption": "添加自定义选项",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "密码套件",
"autoOption": "自动",
"minMaxVersion": "最小/最大版本",
"rejectUnknownSni": "拒绝未知 SNI",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}:已成功禁用。",
"askToAddUserId": "未找到您的配置!\r\n请向管理员询问,在您的配置中使用您的 Telegram 用户 ChatID。\r\n\r\n您的用户 ChatID:<code>{{ .TgUserID }}</code>",
"chooseClient": "为入站 {{ .Inbound }} 选择一个客户",
"chooseInbound": "选择一个入站"
"chooseInbound": "选择一个入站",
"qrCodeForClient": "客户端 {{ .Email }} 的二维码:"
}
},
"discord": {
+3 -2
View File
@@ -596,7 +596,7 @@
"customSockopt": "自訂 sockopt",
"addCustomOption": "新增自訂選項",
"serverNameIndication": "SNI",
"cipherSuites": "Cipher Suites",
"cipherSuites": "加密套件",
"autoOption": "自動",
"minMaxVersion": "最小/最大版本",
"rejectUnknownSni": "拒絕未知 SNI",
@@ -2433,7 +2433,8 @@
"disableSuccess": "✅ {{ .Email }}:已成功禁用。",
"askToAddUserId": "未找到您的配置!\r\n請向管理員詢問,在您的配置中使用您的 Telegram 使用者 ChatID。\r\n\r\n您的使用者 ChatID:<code>{{ .TgUserID }}</code>",
"chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶",
"chooseInbound": "選擇一個入站"
"chooseInbound": "選擇一個入站",
"qrCodeForClient": "客戶端 {{ .Email }} 的二維碼:"
}
},
"discord": {
+9 -3
View File
@@ -6,6 +6,7 @@ import (
"context"
"crypto/tls"
"embed"
"errors"
"fmt"
"io"
"io/fs"
@@ -626,9 +627,14 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
// Opt-in node mTLS: when a trust CA is configured, request and verify
// client certs (VerifyClientCertIfGiven keeps browsers working). With
// no CA the listener is unchanged.
if pool, perr := s.settingService.NodeMtlsClientCAPool(); perr != nil {
logger.Warning("node mTLS: failed to build client CA trust pool:", perr)
} else if pool != nil {
pool, perr := s.settingService.NodeMtlsClientCAPool()
switch {
case errors.Is(perr, service.ErrNodeMtlsTrustBundleInvalid):
logger.Error("Node mTLS is configured but its trust bundle will not parse, so client certificates are not accepted:", perr)
case perr != nil:
logger.Error("Node mTLS trust bundle could not be read, so client certificates are not accepted:", perr)
}
if pool != nil {
applyNodeMtls(c, pool)
logger.Info("Node mTLS enabled: verifying client certificates for the node API")
}