Compare commits

...

6 Commits

Author SHA1 Message Date
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
42 changed files with 467 additions and 41 deletions
+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
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)
}
}
+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")
}