mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-18 16:17:16 +00:00
fix(amneziawg): make RouteViaXray an inbound-level option too
RouteThroughXray/RouteOutboundTag were client-only, but the more common case is "route this whole AmneziaWG server's traffic through Xray", not configuring every peer individually. Add the same pair to ServerSettings (inbound-level) while keeping the per-client fields as an override — matching how ExternalInterface/IPv6Enabled already work at the server level next to per-client settings like ForwardedPorts. Effective per-peer decision (computed once, in InstanceFromInbound, not duplicated at each consumer): - routed = client.RouteThroughXray || server.RouteThroughXray - outbound tag = client's own if set, else the server's default This means a peer can be routed by the inbound-wide default with no config of its own, opt in on its own even when the default is off, or keep the default's on/off but pick a different outbound than everyone else. internal/web/service/xray.go's injectAmneziawgEgress now calls amneziawg.InstanceFromInbound instead of re-parsing InboundSettings and reading model.Client fields directly — the same effective-routing computation the kernel-side TPROXY rules use, so the two independent reconcile loops (Xray-config generation and the AWG manager) can never quietly disagree about which peers are actually routed. Frontend: Switch + conditional outbound Select added to the AWG inbound form (mirroring the client-form version and mtproto's own UI), plus the inbound-defaults.ts default-object fix that's bitten this project's CI before (Phase 2a) whenever ServerSettings gains a new required-shaped field. Test fixtures in xray_config_inject_test.go needed a real Server block and PublicKey once injectAmneziawgEgress started requiring a usable InstanceFromInbound result — both were implicit fixture gaps, not behavior the old tests were actually asserting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2878,6 +2878,13 @@
|
|||||||
"publicKey": {
|
"publicKey": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"routeOutboundTag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"routeThroughXray": {
|
||||||
|
"description": "RouteThroughXray, when true, is the inbound-wide default: every peer\nTPROXYs into Xray unless it explicitly turns its own RouteThroughXray\noff... except a plain bool can't distinguish \"peer left it unset\" from\n\"peer explicitly opted out\", so in practice this ORs with each peer's\nown flag (see Peer.RouteThroughXray) — turning this on routes every\npeer, turning it off still lets individual peers opt in on their own.\nRouteOutboundTag is the default outbound/balancer tag used when a\nrouted peer didn't set its own; empty means Xray's default routing.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"s1": {
|
"s1": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -670,6 +670,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"primaryDns": "",
|
"primaryDns": "",
|
||||||
"privateKey": "",
|
"privateKey": "",
|
||||||
"publicKey": "",
|
"publicKey": "",
|
||||||
|
"routeOutboundTag": "",
|
||||||
|
"routeThroughXray": false,
|
||||||
"s1": 0,
|
"s1": 0,
|
||||||
"s2": 0,
|
"s2": 0,
|
||||||
"s3": 0,
|
"s3": 0,
|
||||||
|
|||||||
@@ -2852,6 +2852,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"publicKey": {
|
"publicKey": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"routeOutboundTag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"routeThroughXray": {
|
||||||
|
"description": "RouteThroughXray, when true, is the inbound-wide default: every peer\nTPROXYs into Xray unless it explicitly turns its own RouteThroughXray\noff... except a plain bool can't distinguish \"peer left it unset\" from\n\"peer explicitly opted out\", so in practice this ORs with each peer's\nown flag (see Peer.RouteThroughXray) — turning this on routes every\npeer, turning it off still lets individual peers opt in on their own.\nRouteOutboundTag is the default outbound/balancer tag used when a\nrouted peer didn't set its own; empty means Xray's default routing.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"s1": {
|
"s1": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -653,6 +653,8 @@ export interface ServerSettings {
|
|||||||
primaryDns?: string;
|
primaryDns?: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
routeOutboundTag?: string;
|
||||||
|
routeThroughXray?: boolean;
|
||||||
s1: number;
|
s1: number;
|
||||||
s2: number;
|
s2: number;
|
||||||
s3: number;
|
s3: number;
|
||||||
|
|||||||
@@ -692,6 +692,8 @@ export const ServerSettingsSchema = z.object({
|
|||||||
primaryDns: z.string().optional(),
|
primaryDns: z.string().optional(),
|
||||||
privateKey: z.string(),
|
privateKey: z.string(),
|
||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
|
routeOutboundTag: z.string().optional(),
|
||||||
|
routeThroughXray: z.boolean().optional(),
|
||||||
s1: z.number().int(),
|
s1: z.number().int(),
|
||||||
s2: z.number().int(),
|
s2: z.number().int(),
|
||||||
s3: z.number().int(),
|
s3: z.number().int(),
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
|
|||||||
ipv6Enabled: false,
|
ipv6Enabled: false,
|
||||||
ipv6Subnet: '',
|
ipv6Subnet: '',
|
||||||
ipv6ExternalInterface: '',
|
ipv6ExternalInterface: '',
|
||||||
|
routeThroughXray: false,
|
||||||
|
routeOutboundTag: '',
|
||||||
jc: 5,
|
jc: 5,
|
||||||
jmin: 10,
|
jmin: 10,
|
||||||
jmax: 50,
|
jmax: 50,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
|
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField } from '@/components/form/rhf';
|
||||||
|
import { useOutboundTags } from '@/api/queries/useOutboundTags';
|
||||||
|
|
||||||
interface AmneziawgFieldsProps {
|
interface AmneziawgFieldsProps {
|
||||||
awgPubKey: string;
|
awgPubKey: string;
|
||||||
@@ -12,6 +14,9 @@ interface AmneziawgFieldsProps {
|
|||||||
|
|
||||||
export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInboundAwgObfuscation }: AmneziawgFieldsProps) {
|
export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInboundAwgObfuscation }: AmneziawgFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { control } = useFormContext();
|
||||||
|
const routeThroughXray = useWatch({ control, name: 'settings.server.routeThroughXray' }) as boolean | undefined;
|
||||||
|
const { data: outboundTags } = useOutboundTags();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
|
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
|
||||||
@@ -68,6 +73,28 @@ export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInbou
|
|||||||
>
|
>
|
||||||
<Input placeholder="eth0" />
|
<Input placeholder="eth0" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'routeThroughXray']}
|
||||||
|
label={t('pages.xray.amneziawg.routeThroughXray')}
|
||||||
|
tooltip={t('pages.xray.amneziawg.routeThroughXrayHint')}
|
||||||
|
valueProp="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
{routeThroughXray && (
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'routeOutboundTag']}
|
||||||
|
label={t('pages.xray.amneziawg.routeOutboundTag')}
|
||||||
|
tooltip={t('pages.xray.amneziawg.routeOutboundTagHint')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder={t('pages.xray.amneziawg.routeOutboundTagPlaceholder')}
|
||||||
|
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
|
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
|
||||||
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
|
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
|
||||||
{t('pages.xray.amneziawg.regenerateObfuscation')}
|
{t('pages.xray.amneziawg.regenerateObfuscation')}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export const AmneziawgServerSchema = z.object({
|
|||||||
ipv6Enabled: z.boolean().default(false),
|
ipv6Enabled: z.boolean().default(false),
|
||||||
ipv6Subnet: z.string().default(''),
|
ipv6Subnet: z.string().default(''),
|
||||||
ipv6ExternalInterface: z.string().default(''),
|
ipv6ExternalInterface: z.string().default(''),
|
||||||
|
routeThroughXray: z.boolean().default(false),
|
||||||
|
routeOutboundTag: z.string().default(''),
|
||||||
jc: z.number().int().min(0).default(5),
|
jc: z.number().int().min(0).default(5),
|
||||||
jmin: z.number().int().min(0).default(10),
|
jmin: z.number().int().min(0).default(10),
|
||||||
jmax: z.number().int().min(0).default(50),
|
jmax: z.number().int().min(0).default(50),
|
||||||
|
|||||||
@@ -52,14 +52,21 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
|
if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
routeTag := c.RouteOutboundTag
|
||||||
|
if routeTag == "" {
|
||||||
|
routeTag = server.RouteOutboundTag
|
||||||
|
}
|
||||||
peers = append(peers, Peer{
|
peers = append(peers, Peer{
|
||||||
Email: c.Email,
|
Email: c.Email,
|
||||||
PublicKey: c.PublicKey,
|
PublicKey: c.PublicKey,
|
||||||
PresharedKey: c.PreSharedKey,
|
PresharedKey: c.PreSharedKey,
|
||||||
AllowedIPs: c.AllowedIPs,
|
AllowedIPs: c.AllowedIPs,
|
||||||
ForwardedPorts: c.ForwardedPorts,
|
ForwardedPorts: c.ForwardedPorts,
|
||||||
RouteThroughXray: c.RouteThroughXray,
|
// Effective routing: the inbound-wide default routes every peer
|
||||||
RouteOutboundTag: c.RouteOutboundTag,
|
// unless the peer's own flag already does; the client's own
|
||||||
|
// outbound tag wins when set, else the inbound's default tag.
|
||||||
|
RouteThroughXray: c.RouteThroughXray || server.RouteThroughXray,
|
||||||
|
RouteOutboundTag: routeTag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(peers) == 0 {
|
if len(peers) == 0 {
|
||||||
|
|||||||
@@ -98,6 +98,79 @@ func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstanceFromInboundComputesEffectiveRouting(t *testing.T) {
|
||||||
|
serverRouted := validServer()
|
||||||
|
serverRouted.RouteThroughXray = true
|
||||||
|
serverRouted.RouteOutboundTag = "warp"
|
||||||
|
|
||||||
|
t.Run("server default routes a peer with no flag of its own", func(t *testing.T) {
|
||||||
|
settings := mkInboundSettings(t, serverRouted, []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||||
|
})
|
||||||
|
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a usable instance")
|
||||||
|
}
|
||||||
|
p := inst.Peers[0]
|
||||||
|
if !p.RouteThroughXray || p.RouteOutboundTag != "warp" {
|
||||||
|
t.Fatalf("expected the peer to inherit the server default, got %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("client's own tag overrides the server default", func(t *testing.T) {
|
||||||
|
settings := mkInboundSettings(t, serverRouted, []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "direct"},
|
||||||
|
})
|
||||||
|
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a usable instance")
|
||||||
|
}
|
||||||
|
p := inst.Peers[0]
|
||||||
|
if !p.RouteThroughXray || p.RouteOutboundTag != "direct" {
|
||||||
|
t.Fatalf("expected the client's own tag to win, got %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("client can opt in on its own when the server default is off", func(t *testing.T) {
|
||||||
|
settings := mkInboundSettings(t, validServer(), []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
||||||
|
{Email: "b@x", Enable: true, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
|
||||||
|
})
|
||||||
|
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a usable instance")
|
||||||
|
}
|
||||||
|
var a, b Peer
|
||||||
|
for _, p := range inst.Peers {
|
||||||
|
switch p.Email {
|
||||||
|
case "a@x":
|
||||||
|
a = p
|
||||||
|
case "b@x":
|
||||||
|
b = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !a.RouteThroughXray || a.RouteOutboundTag != "direct" {
|
||||||
|
t.Fatalf("a@x opted in on its own, expected it routed to direct, got %+v", a)
|
||||||
|
}
|
||||||
|
if b.RouteThroughXray {
|
||||||
|
t.Fatalf("b@x has no flag of its own and the server default is off, expected unrouted, got %+v", b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("neither level set means not routed", func(t *testing.T) {
|
||||||
|
settings := mkInboundSettings(t, validServer(), []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||||
|
})
|
||||||
|
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a usable instance")
|
||||||
|
}
|
||||||
|
if inst.Peers[0].RouteThroughXray || inst.Peers[0].RouteOutboundTag != "" {
|
||||||
|
t.Fatalf("expected no routing at all, got %+v", inst.Peers[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestServerAddress(t *testing.T) {
|
func TestServerAddress(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
subnet string
|
subnet string
|
||||||
|
|||||||
@@ -39,15 +39,22 @@ type Peer struct {
|
|||||||
// DNAT'd to this peer's tunnel address. Empty means no port-forwarding.
|
// DNAT'd to this peer's tunnel address. Empty means no port-forwarding.
|
||||||
ForwardedPorts string
|
ForwardedPorts string
|
||||||
|
|
||||||
// RouteThroughXray, when true, TPROXYs this peer's traffic (matched by its
|
// RouteThroughXray and RouteOutboundTag are this peer's EFFECTIVE routing
|
||||||
// tunnel source IP) into the single shared loopback Xray dokodemo-door
|
// decision — already resolved by InstanceFromInbound from the per-client
|
||||||
// bridge (see amneziawgEgressPort in internal/web/service/xray.go) instead
|
// setting OR'd with the inbound-wide ServerSettings.RouteThroughXray
|
||||||
// of letting it NAT straight out through ExternalInterface. All routed
|
// default (and the client's own RouteOutboundTag falling back to the
|
||||||
// peers, across every AmneziaWG instance, share that one bridge and one
|
// server's when the client didn't set one). Callers never need to look at
|
||||||
// fwmark/policy-route pair; the per-peer distinction happens downstream in
|
// the raw client/server fields separately.
|
||||||
// Xray's own router, which the web service feeds a source-IP-matched rule
|
//
|
||||||
// per peer. RouteOutboundTag is the Xray outbound/balancer tag that rule
|
// When true, TPROXYs this peer's traffic (matched by its tunnel source
|
||||||
// targets; empty means Xray's default routing decides.
|
// IP) into the single shared loopback Xray dokodemo-door bridge (see
|
||||||
|
// EgressPort in route_egress.go) instead of letting it NAT straight out
|
||||||
|
// through ExternalInterface. All routed peers, across every AmneziaWG
|
||||||
|
// instance, share that one bridge and one fwmark/policy-route pair; the
|
||||||
|
// per-peer distinction happens downstream in Xray's own router, which the
|
||||||
|
// web service feeds a source-IP-matched rule per peer. RouteOutboundTag
|
||||||
|
// is the Xray outbound/balancer tag that rule targets; empty means
|
||||||
|
// Xray's default routing decides.
|
||||||
RouteThroughXray bool
|
RouteThroughXray bool
|
||||||
RouteOutboundTag string
|
RouteOutboundTag string
|
||||||
}
|
}
|
||||||
@@ -113,6 +120,17 @@ type ServerSettings struct {
|
|||||||
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
|
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
|
||||||
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
|
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
|
||||||
|
|
||||||
|
// RouteThroughXray, when true, is the inbound-wide default: every peer
|
||||||
|
// TPROXYs into Xray unless it explicitly turns its own RouteThroughXray
|
||||||
|
// off... except a plain bool can't distinguish "peer left it unset" from
|
||||||
|
// "peer explicitly opted out", so in practice this ORs with each peer's
|
||||||
|
// own flag (see Peer.RouteThroughXray) — turning this on routes every
|
||||||
|
// peer, turning it off still lets individual peers opt in on their own.
|
||||||
|
// RouteOutboundTag is the default outbound/balancer tag used when a
|
||||||
|
// routed peer didn't set its own; empty means Xray's default routing.
|
||||||
|
RouteThroughXray bool `json:"routeThroughXray,omitempty"`
|
||||||
|
RouteOutboundTag string `json:"routeOutboundTag,omitempty"`
|
||||||
|
|
||||||
// Obfuscation20's fields, repeated flat (not embedded) rather than
|
// Obfuscation20's fields, repeated flat (not embedded) rather than
|
||||||
// nested under their own key: encoding/json would happily inline an
|
// nested under their own key: encoding/json would happily inline an
|
||||||
// embedded Obfuscation20 the same way, but the frontend's Go->Zod/TS
|
// embedded Obfuscation20 the same way, but the frontend's Go->Zod/TS
|
||||||
|
|||||||
@@ -653,7 +653,7 @@ type amneziawgRouteRule struct {
|
|||||||
outboundTag string
|
outboundTag string
|
||||||
}
|
}
|
||||||
|
|
||||||
// injectAmneziawgEgress wires every RouteThroughXray AmneziaWG peer, across
|
// injectAmneziawgEgress wires every effectively-routed AmneziaWG peer, across
|
||||||
// every enabled AmneziaWG inbound, into the generated config through one
|
// every enabled AmneziaWG inbound, into the generated config through one
|
||||||
// loopback dokodemo-door bridge shared by all of them (tag
|
// loopback dokodemo-door bridge shared by all of them (tag
|
||||||
// amneziawg.EgressTag, port amneziawg.EgressPort) rather than one bridge per
|
// amneziawg.EgressTag, port amneziawg.EgressPort) rather than one bridge per
|
||||||
@@ -661,7 +661,13 @@ type amneziawgRouteRule struct {
|
|||||||
// (see internal/amneziawg's defaultPostUpDown), but distinguishing which peer
|
// (see internal/amneziawg's defaultPostUpDown), but distinguishing which peer
|
||||||
// a given connection came from — and picking its own outbound — happens
|
// a given connection came from — and picking its own outbound — happens
|
||||||
// here, in Xray's own router, matched against the TPROXY-preserved source
|
// here, in Xray's own router, matched against the TPROXY-preserved source
|
||||||
// IP. Mirrors injectMtprotoEgress/injectPanelEgress: an invalid or missing
|
// IP. Reuses amneziawg.InstanceFromInbound for the peer list (rather than
|
||||||
|
// re-parsing Settings itself) specifically so "is this peer routed, and to
|
||||||
|
// which outbound" is computed in exactly one place — the same place the
|
||||||
|
// kernel-side TPROXY rules read it from — and can never quietly diverge
|
||||||
|
// between the two independent reconcile loops.
|
||||||
|
//
|
||||||
|
// Mirrors injectMtprotoEgress/injectPanelEgress: an invalid or missing
|
||||||
// outbound target skips that one peer's rule, not the whole bridge; the
|
// outbound target skips that one peer's rule, not the whole bridge; the
|
||||||
// bridge itself is skipped entirely when no peer needs it or its tag is
|
// bridge itself is skipped entirely when no peer needs it or its tag is
|
||||||
// already taken by a real inbound. Generated state is hot-appliable and
|
// already taken by a real inbound. Generated state is hot-appliable and
|
||||||
@@ -672,19 +678,19 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
|
|||||||
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
|
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var parsed amneziawg.InboundSettings
|
inst, ok := amneziawg.InstanceFromInbound(inbound)
|
||||||
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, c := range parsed.Clients {
|
for _, p := range inst.Peers {
|
||||||
if !c.Enable || !c.RouteThroughXray {
|
if !p.RouteThroughXray {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sourceIP := amneziawg.FirstIPv4(c.AllowedIPs)
|
sourceIP := amneziawg.FirstIPv4(p.AllowedIPs)
|
||||||
if sourceIP == "" {
|
if sourceIP == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rules = append(rules, amneziawgRouteRule{sourceIP: sourceIP, outboundTag: c.RouteOutboundTag})
|
rules = append(rules, amneziawgRouteRule{sourceIP: sourceIP, outboundTag: p.RouteOutboundTag})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(rules) == 0 {
|
if len(rules) == 0 {
|
||||||
|
|||||||
@@ -561,7 +561,23 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
|
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
|
||||||
settings, _ := json.Marshal(amneziawg.InboundSettings{Clients: clients})
|
return amneziawgInboundWithServer(id, tag, amneziawg.ServerSettings{}, clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// amneziawgInboundWithServer lets a test set inbound-wide fields (namely
|
||||||
|
// RouteThroughXray/RouteOutboundTag) that InstanceFromInbound now needs —
|
||||||
|
// injectAmneziawgEgress computes each peer's EFFECTIVE routing decision via
|
||||||
|
// InstanceFromInbound rather than reading model.Client fields raw, so a
|
||||||
|
// server block (even a minimal valid one) is required for any of these
|
||||||
|
// tests to see peers at all.
|
||||||
|
func amneziawgInboundWithServer(id int, tag string, server amneziawg.ServerSettings, clients []model.Client) *model.Inbound {
|
||||||
|
if server.SubnetIP == "" {
|
||||||
|
server.SubnetIP = "10.8.1.0"
|
||||||
|
}
|
||||||
|
if server.SubnetCIDR == 0 {
|
||||||
|
server.SubnetCIDR = 24
|
||||||
|
}
|
||||||
|
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
|
||||||
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,7 +594,7 @@ type amneziawgRouting struct {
|
|||||||
func TestInjectAmneziawgEgress_WithOutbound(t *testing.T) {
|
func TestInjectAmneziawgEgress_WithOutbound(t *testing.T) {
|
||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
@@ -617,8 +633,8 @@ func TestInjectAmneziawgEgress_WithOutbound(t *testing.T) {
|
|||||||
func TestInjectAmneziawgEgress_MultiplePeersDifferentOutbounds(t *testing.T) {
|
func TestInjectAmneziawgEgress_MultiplePeersDifferentOutbounds(t *testing.T) {
|
||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
{Email: "b@x", Enable: true, AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
@@ -647,7 +663,7 @@ func TestInjectAmneziawgEgress_NoOutboundLeavesRouting(t *testing.T) {
|
|||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
before := string(cfg.RouterConfig)
|
before := string(cfg.RouterConfig)
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
@@ -663,7 +679,7 @@ func TestInjectAmneziawgEgress_BalancerTag(t *testing.T) {
|
|||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "lb"},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "lb"},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
@@ -685,7 +701,7 @@ func TestInjectAmneziawgEgress_Disabled(t *testing.T) {
|
|||||||
{"client disabled", model.Client{Email: "a@x", Enable: false, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
|
{"client disabled", model.Client{Email: "a@x", Enable: false, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
|
||||||
{"RouteThroughXray off", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "warp"}, true},
|
{"RouteThroughXray off", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "warp"}, true},
|
||||||
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
|
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
|
||||||
{"inbound disabled", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, false},
|
{"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
@@ -706,7 +722,7 @@ func TestInjectAmneziawgEgress_TagCollisionSkips(t *testing.T) {
|
|||||||
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: amneziawg.EgressTag})
|
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: amneziawg.EgressTag})
|
||||||
before := string(cfg.RouterConfig)
|
before := string(cfg.RouterConfig)
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
||||||
@@ -717,8 +733,8 @@ func TestInjectAmneziawgEgress_TagCollisionSkips(t *testing.T) {
|
|||||||
func TestInjectAmneziawgEgress_MissingTargetSkipsOnlyThatPeer(t *testing.T) {
|
func TestInjectAmneziawgEgress_MissingTargetSkipsOnlyThatPeer(t *testing.T) {
|
||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
{Email: "b@x", Enable: true, AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "removed-subscription-outbound"},
|
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "removed-subscription-outbound"},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
@@ -742,7 +758,7 @@ func TestInjectAmneziawgEgress_BadOutboundsSkipsRulesKeepsBridge(t *testing.T) {
|
|||||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
||||||
before := string(cfg.RouterConfig)
|
before := string(cfg.RouterConfig)
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
@@ -758,7 +774,7 @@ func TestInjectAmneziawgEgress_BadRoutingSkipsEverything(t *testing.T) {
|
|||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
@@ -774,7 +790,7 @@ func TestInjectAmneziawgEgress_NoRoutingSection(t *testing.T) {
|
|||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
cfg.RouterConfig = nil
|
cfg.RouterConfig = nil
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
@@ -786,3 +802,62 @@ func TestInjectAmneziawgEgress_NoRoutingSection(t *testing.T) {
|
|||||||
t.Fatalf("a routing section must be created with the egress rule, got %+v", routing.Rules)
|
t.Fatalf("a routing section must be created with the egress rule, got %+v", routing.Rules)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInjectAmneziawgEgress_ServerLevelRoutesPeerWithNoOwnFlag(t *testing.T) {
|
||||||
|
cfg := egressTestConfig()
|
||||||
|
inbound := amneziawgInboundWithServer(1, "awg-1",
|
||||||
|
amneziawg.ServerSettings{RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
|
[]model.Client{
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||||
|
})
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
|
if len(cfg.InboundConfigs) != 2 {
|
||||||
|
t.Fatalf("the inbound-wide default must route a peer with no client-level flag of its own, got %+v", cfg.InboundConfigs)
|
||||||
|
}
|
||||||
|
var routing amneziawgRouting
|
||||||
|
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "warp" || routing.Rules[0].Source[0] != "10.8.1.2/32" {
|
||||||
|
t.Fatalf("expected a rule routing the peer to the inbound's default outbound, got %+v", routing.Rules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectAmneziawgEgress_ClientTagOverridesServerTag(t *testing.T) {
|
||||||
|
cfg := egressTestConfig()
|
||||||
|
inbound := amneziawgInboundWithServer(1, "awg-1",
|
||||||
|
amneziawg.ServerSettings{RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
|
[]model.Client{
|
||||||
|
// Routed by the server-wide default, but picks its own outbound.
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "direct"},
|
||||||
|
})
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
|
var routing amneziawgRouting
|
||||||
|
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "direct" {
|
||||||
|
t.Fatalf("a client's own outbound tag must win over the inbound's default, got %+v", routing.Rules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectAmneziawgEgress_ServerLevelOffClientCanStillOptIn(t *testing.T) {
|
||||||
|
cfg := egressTestConfig()
|
||||||
|
inbound := amneziawgInboundWithServer(1, "awg-1",
|
||||||
|
amneziawg.ServerSettings{}, // server-wide routing off
|
||||||
|
[]model.Client{
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
||||||
|
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}},
|
||||||
|
})
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
|
var routing amneziawgRouting
|
||||||
|
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routing.Rules) != 2 || routing.Rules[0].Source[0] != "10.8.1.2/32" {
|
||||||
|
t.Fatalf("a@x must be routed on its own opt-in even with the inbound default off, got %+v", routing.Rules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1945,6 +1945,11 @@
|
|||||||
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
|
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
|
||||||
"ipv6ExternalInterface": "IPv6 External Interface",
|
"ipv6ExternalInterface": "IPv6 External Interface",
|
||||||
"ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.",
|
"ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.",
|
||||||
|
"routeThroughXray": "Route via Xray (all clients)",
|
||||||
|
"routeThroughXrayHint": "Send every client's traffic through Xray by default instead of straight out the server's network interface. A client can still enable this on its own even when it's off here.",
|
||||||
|
"routeOutboundTag": "Default Outbound",
|
||||||
|
"routeOutboundTagHint": "Default Xray outbound (or balancer) for clients routed through Xray. A client's own outbound choice overrides this. Leave empty to use Xray's default routing.",
|
||||||
|
"routeOutboundTagPlaceholder": "Select an outbound",
|
||||||
"obfuscation": "Obfuscation parameters",
|
"obfuscation": "Obfuscation parameters",
|
||||||
"regenerateObfuscation": "Regenerate",
|
"regenerateObfuscation": "Regenerate",
|
||||||
"jc": "Jc (junk packet count)",
|
"jc": "Jc (junk packet count)",
|
||||||
|
|||||||
@@ -1828,6 +1828,11 @@
|
|||||||
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
|
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
|
||||||
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
|
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
|
||||||
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
|
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
|
||||||
|
"routeThroughXray": "Маршрутизировать через Xray (все клиенты)",
|
||||||
|
"routeThroughXrayHint": "Направлять трафик всех клиентов через Xray по умолчанию вместо прямого выхода через сетевой интерфейс сервера. Клиент может включить это индивидуально, даже если здесь выключено.",
|
||||||
|
"routeOutboundTag": "Исходящий по умолчанию",
|
||||||
|
"routeOutboundTagHint": "Исходящий (outbound) или балансировщик Xray по умолчанию для клиентов, маршрутизируемых через Xray. Собственный выбор клиента имеет приоритет. Оставьте пустым для маршрутизации Xray по умолчанию.",
|
||||||
|
"routeOutboundTagPlaceholder": "Выберите исходящий",
|
||||||
"obfuscation": "Параметры обфускации",
|
"obfuscation": "Параметры обфускации",
|
||||||
"regenerateObfuscation": "Сгенерировать заново",
|
"regenerateObfuscation": "Сгенерировать заново",
|
||||||
"jc": "Jc (кол-во мусорных пакетов)",
|
"jc": "Jc (кол-во мусорных пакетов)",
|
||||||
|
|||||||
Reference in New Issue
Block a user