mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 23:35:00 +03:00
feat(amneziawg): add opt-in Linux kernel datapath (#6877)
* feat(amneziawg): add opt-in Linux kernel datapath Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply. Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed. * fix(amneziawg): align readiness and endpoint reporting Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support. Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass. * style(amneziawg): format readiness test * test: reproduce kernel AWG client validation gaps * test(service): cover kernel AWG shared client fanout * fix(amneziawg): validate kernel client fanout and config values * test(amneziawgkern): cover owned interface lifecycle recovery * fix(amneziawgkern): recover owned interfaces on restart * test(amneziawgkern): retain visibility after recovered cleanup failure * fix(amneziawgkern): report recovered interfaces while cleanup retries * style(amneziawgkern): satisfy static analysis * test(amneziawgkern): cover long rendered peer comments * fix(amneziawgkern): accept long generated config comments * test(amneziawgkern): refuse foreign occupied interfaces Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries. * test(amneziawgkern): cover stale interface collisions Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown. * fix(amneziawgkern): verify interface ownership before mutation Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs. Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check. * build: update Go toolchain to 1.27.3 * test(amneziawg): reproduce unsafe key rotation recovery Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure. * fix(amneziawg): recover interrupted key rotations Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership. * fix(scanner): align Go image with module version * fix(docs): pin pnpm release with bootstrap fix pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely. * fix(xray): build core from pinned module The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets. * fix(tests): stabilize local network fixtures Keep the TUIC stream fragmentation check on a working UDP loopback path, allocate the AmneziaWG fixture ports dynamically, and wait for a real Xray stats response before starting API mutation assertions. Recreate failed readiness channels so an early connection cannot stall the probe. Verified host and offline parallel Go suites, repeated occupied-port and UDP tests, race/shuffle runs, and golangci-lint. Production behavior and dependency versions are unchanged. * fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK main gained the sk-SK locale after this branch added its kernel-mode keys to the other 13, so i18n-dead-keys.test.ts failed on the merged tree. * refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick awg-quick evaluates PostUp/PostDown through a root shell, so every value the panel rendered into its config was one validation slip away from root command execution. The interface is now built with direct ip/awg/nft execs from a setconf-only file: link add, awg setconf, address, mtu, an atomically replaced nftables table per inbound, and the IPv4 forwarding sysctl written directly. - Panel-owned names and paths: xui-awg<id> interfaces, xui_awg<id> NAT tables and configs under the panel's data dir, so nothing collides with an operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg. - FORWARD accepts (comment 3x-ui:xui-awg<id>) when iptables is present: Docker and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel. - A full panel stop records the final counters and tears the interfaces down, like the embedded datapath; a stopped panel enforces no quota or expiry. Crash survivors are still adopted without a rebuild. - The job reads counters before reconciling, and from every managed interface, so a client disabled for quota, or a removed inbound, is charged for its last interval instead of losing it with the dropped peer. - An unchanged reconcile tick forks no process (it probed the live key with `awg show` every 10s); readiness no longer loads the module from a GET. Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM teardown leaving no link, table, FORWARD rule or config behind. * fix(amneziawg): gate kernel host readiness only when a save starts the datapath Every save of a kernel-mode inbound re-ran the host preflight, so a host that lost its module (a kernel upgrade without a DKMS rebuild) refused even disabling the inbound or switching it back to embedded mode. Host blockers now apply only when the save starts the kernel datapath: a new inbound, a switch from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded ports, pinned listen) still apply to every save. * feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it A node built before kernel mode drops the unknown kernelMode field and serves the inbound on the embedded datapath while the master's form shows kernel mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses to push a kernel-mode inbound to a node that has not advertised it, learning the caps with an inbound list first when this Remote has not heard from it. * fix(amneziawg): pin the kernel module installer to verified commits The installer ran as root and built whatever upstream tag sorted newest at install time. It now clones fixed tags (module v3.1.20260906, tools v3.1.20260812) and refuses unless each resolves to its recorded commit. The udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat upstream, so the patch never matched. * fix(i18n): translate the kernel-mode AmneziaWG strings All thirteen non-English locales carried the English text verbatim. * fix(amneziawg): skip the kernel traffic flush when no kernel interface runs Shutdown listed the desired AmneziaWG inbounds from the database even on a panel with no kernel interface, an unneeded query on every stop. A server stopped before its database was opened panicked on it, which turned TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
@@ -80,6 +81,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/allLinks", a.getAllInboundLinks)
|
||||
g.GET("/get/:id", a.getInbound)
|
||||
g.GET("/:id/fallbacks", a.getFallbacks)
|
||||
g.GET("/amneziawgKernelReadiness", a.getAmneziaWGKernelReadiness)
|
||||
|
||||
g.POST("/add", a.addInbound)
|
||||
g.POST("/del/:id", a.delInbound)
|
||||
@@ -144,6 +146,12 @@ func (a *InboundController) getInboundOptions(c *gin.Context) {
|
||||
jsonObj(c, options, nil)
|
||||
}
|
||||
|
||||
// getAmneziaWGKernelReadiness reports whether this host can run an AmneziaWG
|
||||
// inbound on the kernel module, so the form can disable the toggle and say why.
|
||||
func (a *InboundController) getAmneziaWGKernelReadiness(c *gin.Context) {
|
||||
jsonObj(c, amneziawgkern.HostReadiness(), nil)
|
||||
}
|
||||
|
||||
// getInbound retrieves a specific inbound by its ID.
|
||||
func (a *InboundController) getInbound(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
|
||||
@@ -2,16 +2,22 @@ package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// AmneziaWGJob converges embedded AmneziaWG interfaces (inbounds AND the
|
||||
// template's "amneziawg" outbounds) every 10s; stats stay with Xray.
|
||||
// endpointWindow is how long a kernel-mode peer's connect location stays
|
||||
// counted toward its IP limit -- Xray's access log never sees these peers.
|
||||
const endpointWindow = 30 * time.Minute
|
||||
|
||||
// AmneziaWGJob converges AmneziaWG interfaces (inbounds on either datapath AND
|
||||
// the template's "amneziawg" outbounds) every 10s; only kernel mode needs stats.
|
||||
type AmneziaWGJob struct {
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
@@ -31,9 +37,53 @@ func (j *AmneziaWGJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
wanted := make([]amneziawgnet.Desired, 0, len(desired))
|
||||
embedded, kernel := splitAmneziaWGDatapaths(desired)
|
||||
// Counters first: reconcile drops disabled peers and removed interfaces,
|
||||
// and their counters with them.
|
||||
j.collectKernelTraffic(kernel)
|
||||
reconcileAmneziaWGDatapaths(embedded, kernel, amneziawgnet.GetManager(), amneziawgkern.GetManager())
|
||||
|
||||
outboundDesired, err := j.desiredOutboundInstances()
|
||||
if err != nil {
|
||||
logger.Warning("amneziawg job: get desired outbound instances failed:", err)
|
||||
return
|
||||
}
|
||||
amneziawgnet.GetOutboundManager().Reconcile(outboundDesired)
|
||||
// Xray's bridges are generated apart from the listener; one that moved needs them regenerated.
|
||||
if amneziawgnet.BridgesStale() {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// FlushKernelTraffic charges what kernel interfaces counted since the last
|
||||
// tick; shutdown runs it before tearing the interfaces and counters down.
|
||||
func (j *AmneziaWGJob) FlushKernelTraffic() {
|
||||
// With no kernel interface there is nothing to charge, so a panel that
|
||||
// never used kernel mode shuts down without touching the database.
|
||||
if !amneziawgkern.GetManager().HasRunning() {
|
||||
return
|
||||
}
|
||||
desired, err := j.inboundService.DesiredAmneziaWGInstances()
|
||||
if err != nil {
|
||||
logger.Warning("amneziawg job: get desired instances for the final traffic flush failed:", err)
|
||||
}
|
||||
_, kernel := splitAmneziaWGDatapaths(desired)
|
||||
j.collectKernelTraffic(kernel)
|
||||
}
|
||||
|
||||
func splitAmneziaWGDatapaths(desired []amneziawg.Instance) ([]amneziawgnet.Desired, []amneziawgkern.Desired) {
|
||||
embedded := make([]amneziawgnet.Desired, 0, len(desired))
|
||||
kernel := make([]amneziawgkern.Desired, 0, len(desired))
|
||||
for _, inst := range desired {
|
||||
wanted = append(wanted, amneziawgnet.Desired{
|
||||
if inst.KernelMode {
|
||||
kernel = append(kernel, amneziawgkern.Desired{
|
||||
Instance: inst,
|
||||
EgressInterface: inst.ExternalInterface,
|
||||
RouteThroughXray: inst.RouteThroughXray,
|
||||
})
|
||||
continue
|
||||
}
|
||||
embedded = append(embedded, amneziawgnet.Desired{
|
||||
Instance: inst,
|
||||
Options: amneziawgnet.DeviceOptions{
|
||||
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
|
||||
@@ -48,18 +98,32 @@ func (j *AmneziaWGJob) Run() {
|
||||
},
|
||||
})
|
||||
}
|
||||
amneziawgnet.GetManager().Reconcile(wanted)
|
||||
return embedded, kernel
|
||||
}
|
||||
|
||||
outboundDesired, err := j.desiredOutboundInstances()
|
||||
if err != nil {
|
||||
logger.Warning("amneziawg job: get desired outbound instances failed:", err)
|
||||
return
|
||||
type embeddedInboundManager interface {
|
||||
Remove(int)
|
||||
Reconcile([]amneziawgnet.Desired)
|
||||
}
|
||||
|
||||
type kernelInboundManager interface {
|
||||
Reconcile([]amneziawgkern.Desired)
|
||||
Running(int) bool
|
||||
}
|
||||
|
||||
func reconcileAmneziaWGDatapaths(embedded []amneziawgnet.Desired, kernel []amneziawgkern.Desired, embeddedManager embeddedInboundManager, kernelManager kernelInboundManager) {
|
||||
for _, d := range kernel {
|
||||
embeddedManager.Remove(d.Instance.Id)
|
||||
}
|
||||
amneziawgnet.GetOutboundManager().Reconcile(outboundDesired)
|
||||
// Xray's bridges are generated apart from the listener; one that moved needs them regenerated.
|
||||
if amneziawgnet.BridgesStale() {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
kernelManager.Reconcile(kernel)
|
||||
readyEmbedded := embedded[:0]
|
||||
for _, d := range embedded {
|
||||
if kernelManager.Running(d.Instance.Id) {
|
||||
continue
|
||||
}
|
||||
readyEmbedded = append(readyEmbedded, d)
|
||||
}
|
||||
embeddedManager.Reconcile(readyEmbedded)
|
||||
}
|
||||
|
||||
// desiredOutboundInstances derives client instances per template "amneziawg" outbound.
|
||||
@@ -114,3 +178,92 @@ func (j *AmneziaWGJob) desiredOutboundInstances() ([]amneziawgnet.OutboundDesire
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// collectKernelTraffic feeds each kernel interface's per-peer counter
|
||||
// increments into the same accounting the rest of the panel uses.
|
||||
func (j *AmneziaWGJob) collectKernelTraffic(kernel []amneziawgkern.Desired) {
|
||||
collectKernelTrafficWith(
|
||||
&j.inboundService, kernel, amneziawgkern.GetManager(),
|
||||
amneziawgkern.GetCollector(), amneziawgkern.Dump,
|
||||
)
|
||||
}
|
||||
|
||||
type kernelTrafficManager interface {
|
||||
ManagedIDs() []int
|
||||
TrafficStateFor(id int) (amneziawgkern.TrafficInterfaceState, bool)
|
||||
PrimeAdoptedTrafficBaseline(id int, generation uint64, stats []amneziawgkern.PeerStat, collector *amneziawgkern.Collector) bool
|
||||
}
|
||||
|
||||
type kernelTrafficDumper func(string) ([]amneziawgkern.PeerStat, error)
|
||||
|
||||
// collectKernelTrafficWith reads every managed interface, not only desired
|
||||
// ones, so a peer or inbound that is about to be dropped is still charged.
|
||||
func collectKernelTrafficWith(inboundService *service.InboundService, kernel []amneziawgkern.Desired, manager kernelTrafficManager, collector *amneziawgkern.Collector, dump kernelTrafficDumper) {
|
||||
emailsByID := make(map[int]map[string]string, len(kernel))
|
||||
for _, d := range kernel {
|
||||
emails := make(map[string]string, len(d.Instance.Peers))
|
||||
for _, p := range d.Instance.Peers {
|
||||
emails[p.PublicKey] = p.Email
|
||||
}
|
||||
emailsByID[d.Instance.Id] = emails
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var traffics []*xray.ClientTraffic
|
||||
for _, id := range manager.ManagedIDs() {
|
||||
state, ok := manager.TrafficStateFor(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stats, err := dump(state.Name)
|
||||
if err != nil {
|
||||
logger.Warning("amneziawg job: reading counters for ", state.Name, ": ", err)
|
||||
continue
|
||||
}
|
||||
if len(stats) == 0 {
|
||||
continue
|
||||
}
|
||||
current, ok := manager.TrafficStateFor(id)
|
||||
if !ok || current.Name != state.Name || current.Generation != state.Generation {
|
||||
continue
|
||||
}
|
||||
if state.Adopted && !manager.PrimeAdoptedTrafficBaseline(id, state.Generation, stats, collector) {
|
||||
continue
|
||||
}
|
||||
|
||||
emails, desired := emailsByID[id]
|
||||
// Adopted: downtime traffic is unknowable without durable accounting state.
|
||||
// Otherwise deltas, never totals: counters restart at zero on every rebuild.
|
||||
if !state.Adopted {
|
||||
for _, delta := range collector.Delta(state.Name, stats) {
|
||||
email := emails[delta.PublicKey]
|
||||
if email == "" {
|
||||
email = collector.EmailFor(state.Name, delta.PublicKey)
|
||||
}
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
traffics = append(traffics, &xray.ClientTraffic{
|
||||
InboundId: id,
|
||||
Email: email,
|
||||
Up: delta.Up,
|
||||
Down: delta.Down,
|
||||
})
|
||||
}
|
||||
}
|
||||
if desired {
|
||||
collector.SetEmails(state.Name, emails)
|
||||
}
|
||||
collector.ObserveEndpoints(state.Name, stats, now, endpointWindow)
|
||||
collector.ObserveHandshakes(state.Name, stats)
|
||||
}
|
||||
|
||||
if len(traffics) == 0 {
|
||||
return
|
||||
}
|
||||
// A client that just crossed its limit is disabled here; the next reconcile
|
||||
// tick drops its peer from the interface, which is what cuts it off.
|
||||
if _, _, err := inboundService.AddTraffic(nil, traffics); err != nil {
|
||||
logger.Warning("amneziawg job: recording kernel-mode traffic:", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
type kernelTrafficTestManager struct {
|
||||
id int
|
||||
state amneziawgkern.TrafficInterfaceState
|
||||
}
|
||||
|
||||
func (m *kernelTrafficTestManager) ManagedIDs() []int { return []int{m.id} }
|
||||
|
||||
func (m *kernelTrafficTestManager) TrafficStateFor(int) (amneziawgkern.TrafficInterfaceState, bool) {
|
||||
return m.state, true
|
||||
}
|
||||
|
||||
func (m *kernelTrafficTestManager) PrimeAdoptedTrafficBaseline(_ int, generation uint64, stats []amneziawgkern.PeerStat, collector *amneziawgkern.Collector) bool {
|
||||
if !m.state.Adopted || m.state.Generation != generation {
|
||||
return false
|
||||
}
|
||||
collector.Prime(m.state.Name, stats)
|
||||
m.state.Adopted = false
|
||||
return true
|
||||
}
|
||||
|
||||
func TestKernelTrafficJobDoesNotRechargeAfterAdoptingInterface(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
db := database.GetDB()
|
||||
inbound := &model.Inbound{
|
||||
UserId: 1, Tag: "kernel-restart", Enable: true, Port: 45201,
|
||||
Protocol: model.AmneziaWG, Settings: `{"clients":[]}`,
|
||||
}
|
||||
if err := db.Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
const email = "kernel-restart@example.test"
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create client traffic: %v", err)
|
||||
}
|
||||
|
||||
const (
|
||||
iface = "awg-restart"
|
||||
publicKey = "peer-public-key"
|
||||
)
|
||||
desired := []amneziawgkern.Desired{{Instance: amneziawg.Instance{
|
||||
Id: inbound.Id, Peers: []amneziawg.Peer{{Email: email, PublicKey: publicKey}},
|
||||
}}}
|
||||
firstCollector := amneziawgkern.NewCollector()
|
||||
firstManager := &kernelTrafficTestManager{id: inbound.Id, state: amneziawgkern.TrafficInterfaceState{Name: iface, Generation: 1}}
|
||||
current := []amneziawgkern.PeerStat{{PublicKey: publicKey, RxBytes: 100, TxBytes: 200}}
|
||||
collect := func(manager *kernelTrafficTestManager, collector *amneziawgkern.Collector, dump kernelTrafficDumper) {
|
||||
t.Helper()
|
||||
collectKernelTrafficWith(&service.InboundService{}, desired, manager, collector, dump)
|
||||
}
|
||||
collect(firstManager, firstCollector, func(string) ([]amneziawgkern.PeerStat, error) { return current, nil })
|
||||
|
||||
// The panel restarts while the kernel interface keeps its cumulative counters.
|
||||
restartedCollector := amneziawgkern.NewCollector()
|
||||
restartedManager := &kernelTrafficTestManager{id: inbound.Id, state: amneziawgkern.TrafficInterfaceState{
|
||||
Name: iface, Generation: 2, Adopted: true,
|
||||
}}
|
||||
collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) {
|
||||
return nil, errors.New("temporary dump error")
|
||||
})
|
||||
collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { return nil, nil })
|
||||
if !restartedManager.state.Adopted {
|
||||
t.Fatal("failed and empty dumps consumed the adopted baseline")
|
||||
}
|
||||
current = []amneziawgkern.PeerStat{{PublicKey: publicKey, RxBytes: 125, TxBytes: 250}}
|
||||
collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { return current, nil })
|
||||
var afterBaseline xray.ClientTraffic
|
||||
if err := db.Where("email = ?", email).First(&afterBaseline).Error; err != nil {
|
||||
t.Fatalf("reload traffic after baseline: %v", err)
|
||||
}
|
||||
if afterBaseline.Up != 100 || afterBaseline.Down != 200 {
|
||||
t.Fatalf("first adopted dump charged traffic: up %d down %d, want unchanged 100/200", afterBaseline.Up, afterBaseline.Down)
|
||||
}
|
||||
if restartedManager.state.Adopted {
|
||||
t.Fatal("successful nonempty dump did not consume the adopted baseline")
|
||||
}
|
||||
current = []amneziawgkern.PeerStat{{PublicKey: publicKey, RxBytes: 150, TxBytes: 300}}
|
||||
collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { return current, nil })
|
||||
|
||||
var got xray.ClientTraffic
|
||||
if err := db.Where("email = ?", email).First(&got).Error; err != nil {
|
||||
t.Fatalf("reload client traffic: %v", err)
|
||||
}
|
||||
if got.Up != 125 || got.Down != 250 {
|
||||
t.Fatalf("usage after adopted-interface restart = up %d down %d, want 125/250 (100/200 before restart plus 25/50 after the baseline)", got.Up, got.Down)
|
||||
}
|
||||
|
||||
const newEmail = "kernel-new-peer@example.test"
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: newEmail, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create new peer traffic: %v", err)
|
||||
}
|
||||
const newPublicKey = "new-peer-public-key"
|
||||
desired[0].Instance.Peers = append(desired[0].Instance.Peers, amneziawg.Peer{Email: newEmail, PublicKey: newPublicKey})
|
||||
current = []amneziawgkern.PeerStat{
|
||||
{PublicKey: publicKey, RxBytes: 150, TxBytes: 300},
|
||||
{PublicKey: newPublicKey, RxBytes: 7, TxBytes: 9},
|
||||
}
|
||||
collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { return current, nil })
|
||||
var newPeerTraffic xray.ClientTraffic
|
||||
if err := db.Where("email = ?", newEmail).First(&newPeerTraffic).Error; err != nil {
|
||||
t.Fatalf("reload new peer traffic: %v", err)
|
||||
}
|
||||
if newPeerTraffic.Up != 7 || newPeerTraffic.Down != 9 {
|
||||
t.Fatalf("new peer first counters = up %d down %d, want 7/9", newPeerTraffic.Up, newPeerTraffic.Down)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelTrafficChargesPeersLeavingTheDesiredSet: reconcile removes a
|
||||
// disabled peer or inbound from the kernel, so its last counters are read first.
|
||||
func TestKernelTrafficChargesPeersLeavingTheDesiredSet(t *testing.T) {
|
||||
cases := map[string]func(id int) []amneziawgkern.Desired{
|
||||
"peer disabled": func(id int) []amneziawgkern.Desired {
|
||||
return []amneziawgkern.Desired{{Instance: amneziawg.Instance{Id: id}}}
|
||||
},
|
||||
"inbound removed": func(int) []amneziawgkern.Desired { return nil },
|
||||
}
|
||||
port := 45210
|
||||
for name, nextDesired := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
db := database.GetDB()
|
||||
port++
|
||||
inbound := &model.Inbound{
|
||||
UserId: 1, Tag: "kernel-leaving-" + strconv.Itoa(port), Enable: true, Port: port,
|
||||
Protocol: model.AmneziaWG, Settings: `{"clients":[]}`,
|
||||
}
|
||||
if err := db.Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
const email = "kernel-leaving@example.test"
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create client traffic: %v", err)
|
||||
}
|
||||
const publicKey = "leaving-peer-key"
|
||||
manager := &kernelTrafficTestManager{id: inbound.Id, state: amneziawgkern.TrafficInterfaceState{Name: "xui-awg-leaving", Generation: 1}}
|
||||
collector := amneziawgkern.NewCollector()
|
||||
dump := func(rx, tx int64) kernelTrafficDumper {
|
||||
return func(string) ([]amneziawgkern.PeerStat, error) {
|
||||
return []amneziawgkern.PeerStat{{PublicKey: publicKey, RxBytes: rx, TxBytes: tx}}, nil
|
||||
}
|
||||
}
|
||||
first := []amneziawgkern.Desired{{Instance: amneziawg.Instance{
|
||||
Id: inbound.Id, Peers: []amneziawg.Peer{{Email: email, PublicKey: publicKey}},
|
||||
}}}
|
||||
collectKernelTrafficWith(&service.InboundService{}, first, manager, collector, dump(100, 200))
|
||||
collectKernelTrafficWith(&service.InboundService{}, nextDesired(inbound.Id), manager, collector, dump(150, 260))
|
||||
|
||||
var got xray.ClientTraffic
|
||||
if err := db.Where("email = ?", email).First(&got).Error; err != nil {
|
||||
t.Fatalf("reload traffic: %v", err)
|
||||
}
|
||||
if got.Up != 150 || got.Down != 260 {
|
||||
t.Fatalf("traffic = up %d down %d, want 150/260 including the final 50/60", got.Up, got.Down)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type transitionEmbeddedManager struct {
|
||||
removed []int
|
||||
started []int
|
||||
}
|
||||
|
||||
func (m *transitionEmbeddedManager) Remove(id int) { m.removed = append(m.removed, id) }
|
||||
func (m *transitionEmbeddedManager) Reconcile(desired []amneziawgnet.Desired) {
|
||||
m.started = m.started[:0]
|
||||
for _, d := range desired {
|
||||
m.started = append(m.started, d.Instance.Id)
|
||||
}
|
||||
}
|
||||
|
||||
type transitionKernelManager struct {
|
||||
id int
|
||||
running bool
|
||||
failRemoval bool
|
||||
}
|
||||
|
||||
func (m *transitionKernelManager) Reconcile(desired []amneziawgkern.Desired) {
|
||||
wanted := false
|
||||
for _, d := range desired {
|
||||
if d.Instance.Id == m.id {
|
||||
wanted = true
|
||||
}
|
||||
}
|
||||
if wanted {
|
||||
m.running = true
|
||||
return
|
||||
}
|
||||
if m.running && m.failRemoval {
|
||||
m.failRemoval = false
|
||||
return
|
||||
}
|
||||
m.running = false
|
||||
}
|
||||
|
||||
func (m *transitionKernelManager) Running(id int) bool { return id == m.id && m.running }
|
||||
|
||||
func TestKernelToEmbeddedWaitsForKernelTeardownRetry(t *testing.T) {
|
||||
const id = 91
|
||||
embedded := []amneziawgnet.Desired{{Instance: amneziawg.Instance{Id: id}}}
|
||||
kernel := &transitionKernelManager{id: id, running: true, failRemoval: true}
|
||||
embeddedManager := &transitionEmbeddedManager{}
|
||||
|
||||
reconcileAmneziaWGDatapaths(embedded, nil, embeddedManager, kernel)
|
||||
if len(embeddedManager.started) != 0 {
|
||||
t.Fatalf("embedded reconcile started inbound %d while kernel teardown was still pending", embeddedManager.started[0])
|
||||
}
|
||||
if !kernel.running {
|
||||
t.Fatal("first reconcile should retain the kernel interface after its simulated teardown failure")
|
||||
}
|
||||
|
||||
reconcileAmneziaWGDatapaths(embedded, nil, embeddedManager, kernel)
|
||||
if kernel.running {
|
||||
t.Fatal("second reconcile should complete the kernel teardown")
|
||||
}
|
||||
if len(embeddedManager.started) != 1 || embeddedManager.started[0] != id {
|
||||
t.Fatalf("embedded reconcile after teardown = %v, want inbound %d", embeddedManager.started, id)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"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"
|
||||
@@ -60,7 +61,25 @@ func NewCheckClientIpJob() *CheckClientIpJob {
|
||||
func (j *CheckClientIpJob) Run() {
|
||||
j.pruneStaleIpRows()
|
||||
observed, apiMode := j.collectFromOnlineAPI()
|
||||
if !apiMode {
|
||||
// Kernel-mode AmneziaWG peers never reach Xray, so its online users cannot
|
||||
// see them; their connect locations come from the kernel interface instead.
|
||||
kernelIPs := amneziawgkern.GetCollector().ClientIPs()
|
||||
if len(kernelIPs) > 0 {
|
||||
if observed == nil {
|
||||
observed = make(map[string]map[string]int64, len(kernelIPs))
|
||||
}
|
||||
for email, ips := range kernelIPs {
|
||||
if _, ok := observed[email]; !ok {
|
||||
observed[email] = make(map[string]int64, len(ips))
|
||||
}
|
||||
for ip, ts := range ips {
|
||||
if prev, seen := observed[email][ip]; !seen || ts > prev {
|
||||
observed[email][ip] = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !apiMode && len(kernelIPs) == 0 {
|
||||
// xray is down or predates the online-stats API. There is no access-log
|
||||
// fallback anymore, so there is nothing to do this run.
|
||||
logger.Debug("[LimitIP] online-stats API unavailable this run; skipping")
|
||||
|
||||
@@ -22,7 +22,7 @@ const maxDecodedConfigBytes = 8 << 20
|
||||
// panels and plain calls keep working (mixed-version safe).
|
||||
func ConfigEnvelopeMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header(wirecodec.CapsHeader, wirecodec.CapZstd)
|
||||
c.Header(wirecodec.CapsHeader, wirecodec.NodeCaps)
|
||||
|
||||
enc := c.GetHeader("Content-Encoding")
|
||||
sum := c.GetHeader(wirecodec.HashHeader)
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
@@ -75,20 +77,7 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
err := amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
|
||||
Instance: inst,
|
||||
Options: amneziawgnet.DeviceOptions{
|
||||
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
|
||||
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
|
||||
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
|
||||
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
|
||||
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
|
||||
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
|
||||
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
|
||||
RandomTrailers: inst.Obfuscation.RandomTrailers,
|
||||
DisableCookies: inst.Obfuscation.DisableCookies,
|
||||
},
|
||||
})
|
||||
err := ensureAmneziaWG(inst)
|
||||
// A brand new inbound can be the first one to qualify for
|
||||
// injectAmneziawgnetSocks's Xray-side relay inbound (e.g. its first
|
||||
// valid peer). Ensure only updates the embedded Device -- flag Xray
|
||||
@@ -125,7 +114,7 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
|
||||
return nil
|
||||
}
|
||||
if ib.Protocol == model.AmneziaWG {
|
||||
amneziawgnet.GetManager().Remove(ib.Id)
|
||||
removeAmneziaWG(ib.Id)
|
||||
// The removed inbound may have been the only one backing Xray's
|
||||
// injectAmneziawgnetSocks relay inbound for this tag -- flag a
|
||||
// resync so the now-stale relay gets torn down promptly.
|
||||
@@ -192,27 +181,13 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In
|
||||
return mtproto.GetManager().Ensure(inst)
|
||||
}
|
||||
|
||||
// updateAmneziaWGInbound mirrors updateMtprotoInbound: it skips the
|
||||
// Remove+Ensure sequence a plain Del+Add would force so that, on an
|
||||
// AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison
|
||||
// can reconfigure the running embedded Device in place via IpcSet instead
|
||||
// of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked --
|
||||
// only an address or effective-MTU change forces a rebuild there, S4
|
||||
// included, not a peer edit).
|
||||
//
|
||||
// Every exit path below only touches the embedded Device via
|
||||
// amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which
|
||||
// is what actually creates/removes injectAmneziawgnetSocks's relay inbound.
|
||||
// A peer edit that changes whether this inbound has a qualifying peer at
|
||||
// all (its first peer added, or its last one removed) must still get that
|
||||
// relay created or torn down, so flag Xray for a resync unconditionally
|
||||
// here rather than trying to enumerate which of the branches below need it.
|
||||
// updateAmneziaWGInbound routes AmneziaWG CRUD through the active datapath managers.
|
||||
func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
||||
if l.deps.SetNeedRestart != nil {
|
||||
l.deps.SetNeedRestart()
|
||||
}
|
||||
if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG {
|
||||
amneziawgnet.GetManager().Remove(oldIb.Id)
|
||||
removeAmneziaWG(oldIb.Id)
|
||||
if !newIb.Enable {
|
||||
return nil
|
||||
}
|
||||
@@ -222,14 +197,31 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
|
||||
_ = l.DelInbound(ctx, oldIb)
|
||||
}
|
||||
if !newIb.Enable {
|
||||
amneziawgnet.GetManager().Remove(newIb.Id)
|
||||
removeAmneziaWG(newIb.Id)
|
||||
return nil
|
||||
}
|
||||
inst, ok := amneziawg.InstanceFromInbound(newIb)
|
||||
if !ok {
|
||||
amneziawgnet.GetManager().Remove(newIb.Id)
|
||||
removeAmneziaWG(newIb.Id)
|
||||
return nil
|
||||
}
|
||||
return ensureAmneziaWG(inst)
|
||||
}
|
||||
|
||||
// ensureAmneziaWG routes an inbound to its own datapath's manager and clears
|
||||
// the other's: both bind the inbound's UDP port, so they must never overlap.
|
||||
func ensureAmneziaWG(inst amneziawg.Instance) error {
|
||||
if inst.KernelMode {
|
||||
amneziawgnet.GetManager().Remove(inst.Id)
|
||||
return amneziawgkern.GetManager().Ensure(amneziawgkern.Desired{
|
||||
Instance: inst,
|
||||
EgressInterface: inst.ExternalInterface,
|
||||
RouteThroughXray: inst.RouteThroughXray,
|
||||
})
|
||||
}
|
||||
if err := amneziawgkern.GetManager().Remove(inst.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
|
||||
Instance: inst,
|
||||
Options: amneziawgnet.DeviceOptions{
|
||||
@@ -246,6 +238,14 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
|
||||
})
|
||||
}
|
||||
|
||||
// removeAmneziaWG tears the inbound down on whichever datapath is holding it.
|
||||
func removeAmneziaWG(id int) {
|
||||
amneziawgnet.GetManager().Remove(id)
|
||||
if err := amneziawgkern.GetManager().Remove(id); err != nil {
|
||||
logger.Warning("amneziawgkern: removing inbound ", id, ": ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
||||
// Account changes also need the generated Xray SOCKS relay refreshed after commit.
|
||||
if l.deps.RequestReconcile != nil {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// datapathInbound builds a minimal AmneziaWG inbound, kernel mode on or off.
|
||||
func datapathInbound(t *testing.T, id, port int, kernelMode bool) *model.Inbound {
|
||||
t.Helper()
|
||||
srvPriv, srvPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate server keypair: %v", err)
|
||||
}
|
||||
_, peerPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate peer keypair: %v", err)
|
||||
}
|
||||
settings := fmt.Sprintf(
|
||||
`{"server":{"privateKey":%q,"publicKey":%q,"subnetIp":"10.209.0.0","subnetCidr":24,"mtu":1420,`+
|
||||
`"jc":4,"jmin":40,"jmax":70,"s1":20,"s2":30,"s3":20,"s4":20,"kernelMode":%t},`+
|
||||
`"clients":[{"email":"peer@example.test","enable":true,"publicKey":%q,"allowedIPs":["10.209.0.2/32"]}]}`,
|
||||
srvPriv, srvPub, kernelMode, peerPub)
|
||||
return &model.Inbound{
|
||||
Id: id,
|
||||
Enable: true,
|
||||
Protocol: model.AmneziaWG,
|
||||
Port: port,
|
||||
Tag: fmt.Sprintf("inbound-%d", id),
|
||||
Settings: settings,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestLocal() *Local {
|
||||
return NewLocal(LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
|
||||
}
|
||||
|
||||
// Both datapaths bind the inbound's own UDP port, so an embedded device raised
|
||||
// here collides with the kernel interface the reconcile tick brings up.
|
||||
func TestAddInboundKeepsKernelModeOffTheEmbeddedDatapath(t *testing.T) {
|
||||
ib := datapathInbound(t, 4181, 58731, true)
|
||||
t.Cleanup(func() { amneziawgnet.GetManager().Remove(ib.Id) })
|
||||
|
||||
// The kernel side wants awg-quick and root, so it is expected to fail on a
|
||||
// dev box; what matters is that amneziawgnet was never asked to take over.
|
||||
_ = newTestLocal().AddInbound(context.Background(), ib)
|
||||
|
||||
if _, _, ok := amneziawgnet.GetManager().Lookup(ib.Id); ok {
|
||||
t.Fatal("a kernel-mode inbound created an embedded device")
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: without the toggle the dispatch must still reach
|
||||
// amneziawgnet, or it would be a blanket opt-out rather than a routing choice.
|
||||
func TestAddInboundStillUsesTheEmbeddedDatapath(t *testing.T) {
|
||||
ib := datapathInbound(t, 4182, 58732, false)
|
||||
t.Cleanup(func() { amneziawgnet.GetManager().Remove(ib.Id) })
|
||||
|
||||
if err := newTestLocal().AddInbound(context.Background(), ib); err != nil {
|
||||
t.Fatalf("AddInbound: %v", err)
|
||||
}
|
||||
if _, _, ok := amneziawgnet.GetManager().Lookup(ib.Id); !ok {
|
||||
t.Fatal("an embedded inbound did not create a device")
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -90,6 +92,10 @@ type Remote struct {
|
||||
// seen, config pushes to this node are zstd-compressed. Old nodes never set
|
||||
// it, so they keep receiving plain bodies (mixed-version safe).
|
||||
supportsZstd bool
|
||||
// capsSeen tells a node that advertised no AmneziaWG kernel support apart
|
||||
// from one this Remote has not heard from yet.
|
||||
capsSeen bool
|
||||
supportsAWGKernel bool
|
||||
|
||||
// Per-node client honoring the TLS verify mode, built once and reused; a
|
||||
// node config change drops the cached Remote so the next one rebuilds it.
|
||||
@@ -130,12 +136,41 @@ func (r *Remote) nodeSupportsZstd() bool {
|
||||
// recordCaps learns the node's capabilities from a response header so later
|
||||
// pushes can use the negotiated envelope.
|
||||
func (r *Remote) recordCaps(h http.Header) {
|
||||
if !strings.Contains(h.Get(wirecodec.CapsHeader), wirecodec.CapZstd) {
|
||||
return
|
||||
}
|
||||
caps := strings.Split(h.Get(wirecodec.CapsHeader), ",")
|
||||
r.mu.Lock()
|
||||
r.supportsZstd = true
|
||||
r.mu.Unlock()
|
||||
defer r.mu.Unlock()
|
||||
r.capsSeen = true
|
||||
r.supportsAWGKernel = slices.Contains(caps, wirecodec.CapAmneziaWGKernel)
|
||||
if slices.Contains(caps, wirecodec.CapZstd) {
|
||||
r.supportsZstd = true
|
||||
}
|
||||
}
|
||||
|
||||
// requireAmneziaWGKernel refuses to push a kernel-mode AmneziaWG inbound to a
|
||||
// node that would drop the flag and silently serve it on the embedded datapath.
|
||||
func (r *Remote) requireAmneziaWGKernel(ctx context.Context, ib *model.Inbound) error {
|
||||
if ib.Protocol != model.AmneziaWG {
|
||||
return nil
|
||||
}
|
||||
var settings amneziawg.InboundSettings
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil || settings.Server == nil || !settings.Server.KernelMode {
|
||||
return nil
|
||||
}
|
||||
r.mu.RLock()
|
||||
seen := r.capsSeen
|
||||
r.mu.RUnlock()
|
||||
if !seen {
|
||||
if err := r.refreshRemoteIDs(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.mu.RLock()
|
||||
supported := r.supportsAWGKernel
|
||||
r.mu.RUnlock()
|
||||
if !supported {
|
||||
return fmt.Errorf("node %s does not support AmneziaWG kernel mode; upgrade its panel or switch the inbound to the embedded datapath", r.node.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpClient lazily builds and caches the per-node client honoring the TLS
|
||||
@@ -432,6 +467,9 @@ func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error {
|
||||
if err := r.requireAmneziaWGKernel(ctx, ib); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := wireInbound(ib, r.node.Id)
|
||||
env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload)
|
||||
if err != nil {
|
||||
@@ -468,6 +506,9 @@ func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
|
||||
if err != nil {
|
||||
return r.AddInbound(ctx, newIb)
|
||||
}
|
||||
if err := r.requireAmneziaWGKernel(ctx, newIb); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := wireInbound(newIb, r.node.Id)
|
||||
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil {
|
||||
return err
|
||||
|
||||
@@ -90,3 +90,44 @@ func TestRemoteSendsPlainWhenNoCap(t *testing.T) {
|
||||
t.Fatalf("integrity hash should always be sent, got %q", capturedHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteRefusesKernelAmneziaWGOnNodeWithoutCap: an older node drops the
|
||||
// kernelMode flag and silently runs the inbound on the embedded datapath.
|
||||
func TestRemoteRefusesKernelAmneziaWGOnNodeWithoutCap(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
caps string
|
||||
kernel bool
|
||||
wantRefuse bool
|
||||
}{
|
||||
{"old node, kernel inbound", wirecodec.CapZstd, true, true},
|
||||
{"node without any caps, kernel inbound", "", true, true},
|
||||
{"kernel-capable node, kernel inbound", wirecodec.NodeCaps, true, false},
|
||||
{"old node, embedded inbound", wirecodec.CapZstd, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var pushed bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if tc.caps != "" {
|
||||
w.Header().Set(wirecodec.CapsHeader, tc.caps)
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
pushed = true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
err := r.AddInbound(context.Background(), datapathInbound(t, 1, 51820, tc.kernel))
|
||||
if refused := err != nil && strings.Contains(err.Error(), "does not support AmneziaWG kernel mode"); refused != tc.wantRefuse {
|
||||
t.Fatalf("refused = %v (err %v), want %v", refused, err, tc.wantRefuse)
|
||||
}
|
||||
if pushed == tc.wantRefuse {
|
||||
t.Fatalf("inbound pushed = %v, want %v", pushed, !tc.wantRefuse)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +274,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
// comment on the Attach path.
|
||||
clientForInbound.AllowedIPs = nil
|
||||
}
|
||||
if err := validateAmneziaWGClientWrite(inbound, []model.Client{clientForInbound}); err != nil {
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, err)
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
|
||||
if mErr != nil {
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, mErr)
|
||||
@@ -824,6 +827,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
// instead.
|
||||
clientForInbound.AllowedIPs = nil
|
||||
}
|
||||
if err := validateAmneziaWGClientWrite(inbound, []model.Client{clientForInbound}); err != nil {
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, err)
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
|
||||
if mErr != nil {
|
||||
return false, mErr
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"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"
|
||||
@@ -29,6 +31,45 @@ func droppedClientNeedsRestart() bool {
|
||||
return on
|
||||
}
|
||||
|
||||
func validateAmneziaWGClientWrite(inbound *model.Inbound, clients []model.Client) error {
|
||||
if inbound.Protocol != model.AmneziaWG {
|
||||
return nil
|
||||
}
|
||||
var settings amneziawg.InboundSettings
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
return fmt.Errorf("amneziawg: invalid settings: %w", err)
|
||||
}
|
||||
for _, client := range clients {
|
||||
for _, value := range []struct{ field, value string }{
|
||||
{"email", client.Email},
|
||||
{"publicKey", client.PublicKey},
|
||||
{"preSharedKey", client.PreSharedKey},
|
||||
} {
|
||||
if err := amneziawg.ValidateConfigValue(value.field, value.value); err != nil {
|
||||
return fmt.Errorf("amneziawg: client %q: %w", client.Email, err)
|
||||
}
|
||||
}
|
||||
for _, allowedIP := range client.AllowedIPs {
|
||||
if err := amneziawg.ValidateConfigValue("allowedIPs", allowedIP); err != nil {
|
||||
return fmt.Errorf("amneziawg: client %q: %w", client.Email, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings.Server == nil || !settings.Server.KernelMode {
|
||||
return nil
|
||||
}
|
||||
inst := amneziawg.Instance{Listen: inbound.Listen, IPv6Enabled: settings.Server.IPv6Enabled}
|
||||
for _, client := range clients {
|
||||
inst.Peers = append(inst.Peers, amneziawg.Peer{
|
||||
AllowedIPs: client.AllowedIPs, ForwardedPorts: client.ForwardedPorts,
|
||||
})
|
||||
}
|
||||
if err := amneziawgkern.ValidateInstanceFeatures(inst, settings.Server.RouteThroughXray); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool {
|
||||
aa := maps.Clone(a)
|
||||
bb := maps.Clone(b)
|
||||
@@ -441,6 +482,9 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := validateAmneziaWGClientWrite(oldInbound, clients); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var portCtx portConflictContext
|
||||
if oldInbound.Protocol == model.AmneziaWG {
|
||||
@@ -801,15 +845,14 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
if clients[0].KeepAlive == nil {
|
||||
clients[0].KeepAlive = old.KeepAlive
|
||||
}
|
||||
// ForwardedPorts is AmneziaWG-only (WireGuard's own inbound never
|
||||
// reads it), same carry-forward reasoning as the fields above: a
|
||||
// partial edit (e.g. a Telegram-bot enable/expiry toggle, or an API
|
||||
// call that omits the field) must not silently drop a client's
|
||||
// existing port-forwarding spec.
|
||||
// AWG partial edits preserve forwarding specs; kernel mode rejects legacy specs.
|
||||
if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts == "" {
|
||||
clients[0].ForwardedPorts = old.ForwardedPorts
|
||||
}
|
||||
}
|
||||
if err := validateAmneziaWGClientWrite(oldInbound, clients); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if oldInbound.Protocol == model.AmneziaWG {
|
||||
portCtx, err := inboundSvc.loadPortConflictContext(database.GetDB(), oldInbound.NodeID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1312,7 +1312,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := s.normalizeAmneziaWGSettings(inbound, ""); err != nil {
|
||||
if err := s.normalizeAmneziaWGSettings(inbound, nil); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
@@ -1458,9 +1458,8 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// The relay port is derived from the id, only known after Save, and only a
|
||||
// local row owns one: checkPortConflictTx ran no relay check with ignoreId==0.
|
||||
if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG {
|
||||
// Embedded AmneziaWG owns a relay port derived from its local row id.
|
||||
if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG && !amneziawgKernelMode(inbound) {
|
||||
if self := amneziawgnetSocksSelfConflict(inbound, inbound.Id); self != "" {
|
||||
return common.NewError(self)
|
||||
}
|
||||
@@ -2003,7 +2002,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
if err := normalizeTuicSettings(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := s.normalizeAmneziaWGSettings(inbound, oldInbound.Settings); err != nil {
|
||||
if err := s.normalizeAmneziaWGSettings(inbound, oldInbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -188,10 +189,14 @@ func storedAmneziaWGServerKeys(oldSettings string) (priv, pub string) {
|
||||
// valid server block, generating one (fresh obfuscation params + keypair) on
|
||||
// first save and validating a manually-edited one so a bad entry can't bring
|
||||
// the interface down on the next apply. A no-op for every other protocol.
|
||||
func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldSettings string) error {
|
||||
func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, old *model.Inbound) error {
|
||||
if inbound.Protocol != model.AmneziaWG {
|
||||
return nil
|
||||
}
|
||||
oldSettings := ""
|
||||
if old != nil {
|
||||
oldSettings = old.Settings
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(inbound.Settings)
|
||||
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
|
||||
@@ -301,6 +306,35 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
|
||||
return err
|
||||
}
|
||||
inbound.Settings = string(bs)
|
||||
|
||||
// Host readiness gates only a save that starts the kernel datapath: a host that
|
||||
// later loses its module must still let the admin edit, disable or switch it.
|
||||
if parsed.Server.KernelMode && inbound.NodeID == nil {
|
||||
inst, ok := amneziawg.InstanceFromInbound(inbound)
|
||||
if !ok {
|
||||
inst = amneziawg.Instance{
|
||||
Listen: inbound.Listen,
|
||||
IPv6Enabled: parsed.Server.IPv6Enabled,
|
||||
RouteThroughXray: parsed.Server.RouteThroughXray,
|
||||
}
|
||||
for _, client := range parsed.Clients {
|
||||
if client.Enable {
|
||||
inst.Peers = append(inst.Peers, amneziawg.Peer{
|
||||
AllowedIPs: client.AllowedIPs,
|
||||
ForwardedPorts: client.ForwardedPorts,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
check := amneziawgkern.ValidateInstanceFeatures
|
||||
wasRunningKernel := old != nil && old.Enable && amneziawgKernelModeSettings(old.Settings)
|
||||
if inbound.Enable && !wasRunningKernel {
|
||||
check = amneziawgkern.Preflight
|
||||
}
|
||||
if err := check(inst, inst.RouteThroughXray); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -387,6 +421,9 @@ func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, fo
|
||||
var socksPort int
|
||||
switch ib.Protocol {
|
||||
case model.AmneziaWG:
|
||||
if amneziawgKernelMode(ib) {
|
||||
continue
|
||||
}
|
||||
socksPort = amneziawgnet.SOCKSPortForInbound(ib.Id)
|
||||
case model.TUIC:
|
||||
socksPort = tuic.SOCKSPortForInbound(ib.Id)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -144,7 +145,7 @@ func TestNormalizeAmneziaWGSettings_GeneratesFull31Set(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &InboundService{}
|
||||
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Settings: ""}
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, nil); err != nil {
|
||||
t.Fatalf("normalize empty settings: %v", err)
|
||||
}
|
||||
|
||||
@@ -199,7 +200,7 @@ func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) {
|
||||
Port: 51820,
|
||||
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` + c.snippet + `},"clients":[]}`,
|
||||
}
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err == nil {
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, nil); err == nil {
|
||||
t.Errorf("%s must be rejected", c.name)
|
||||
}
|
||||
}
|
||||
@@ -214,7 +215,7 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesRangeValues(t *testing.T) {
|
||||
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` +
|
||||
`"rekeyAfterTime":"110 - 140","rejectAfterTime":"190-250","keepaliveTimeout":" "},"clients":[]}`,
|
||||
}
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, nil); err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
var parsed amneziawg.InboundSettings
|
||||
@@ -256,7 +257,7 @@ func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T
|
||||
`"clients":[{"email":"a@x","enable":true,"publicKey":"pk",` +
|
||||
`"allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`,
|
||||
}
|
||||
err := svc.normalizeAmneziaWGSettings(inbound, "")
|
||||
err := svc.normalizeAmneziaWGSettings(inbound, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("an allowedIPs entry carrying a config-injection payload must be rejected; settings became:\n%s", inbound.Settings)
|
||||
}
|
||||
@@ -298,7 +299,7 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T)
|
||||
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
|
||||
`"clients":[{"email":"a@x","enable":true,"publicKey":"pk","allowedIPs":[" 10.8.1.2 "]}]}`,
|
||||
}
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, nil); err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
var parsed amneziawg.InboundSettings
|
||||
@@ -398,6 +399,26 @@ func TestCheckForwardedPortsConflict_CollidesWithAmneziawgnetSocksPort(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckForwardedPortsConflict_KernelAmneziaWGHasNoEmbeddedRelay(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-kernel", "0.0.0.0", 51820, model.AmneziaWG, ``,
|
||||
`{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"kernelMode":true},"clients":[]}`)
|
||||
|
||||
var awgInbound model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "awg-kernel").First(&awgInbound).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
svc := &InboundService{}
|
||||
ctx, err := svc.loadPortConflictContext(database.GetDB(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPortConflictContext: %v", err)
|
||||
}
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
||||
if hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort)); hit != "" {
|
||||
t.Fatalf("kernel-mode AmneziaWG must not reserve embedded relay port %d, got collision %q", relayPort, hit)
|
||||
}
|
||||
}
|
||||
|
||||
// A cleared DNS field is meaningful (no DNS line in client configs) and must
|
||||
// survive the save round-trip instead of resurrecting the frontend defaults.
|
||||
func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) {
|
||||
@@ -413,7 +434,7 @@ func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
inbound := &model.Inbound{Protocol: model.AmneziaWG, Settings: string(bs)}
|
||||
if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, ""); err != nil {
|
||||
if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, nil); err != nil {
|
||||
t.Fatalf("normalizeAmneziaWGSettings: %v", err)
|
||||
}
|
||||
for _, key := range []string{`"primaryDns"`, `"secondaryDns"`} {
|
||||
@@ -432,7 +453,7 @@ func TestNormalizeAmneziaWGSettings_RejectsEmptyClientAllowedIPs(t *testing.T) {
|
||||
"server": {"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey + `","subnetIp":"10.8.1.0","subnetCidr":24},
|
||||
"clients": [{"email":"ghost","enable":true,"publicKey":"` + awgTestPublicKey + `","allowedIPs":[]}]
|
||||
}`}
|
||||
err := svc.normalizeAmneziaWGSettings(inbound, "")
|
||||
err := svc.normalizeAmneziaWGSettings(inbound, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "allowedIPs is required") {
|
||||
t.Fatalf("err = %v, want an allowedIPs refusal naming the client", err)
|
||||
}
|
||||
@@ -449,7 +470,7 @@ func TestNormalizeAmneziaWGSettings_KeepsStoredServerKeysWhenOmitted(t *testing.
|
||||
stored := `{"server":{"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey + `","subnetIp":"10.8.1.0","subnetCidr":24}}`
|
||||
|
||||
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51824, Settings: `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24,"randomTrailers":true}}`}
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, stored); err != nil {
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, &model.Inbound{Settings: stored}); err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
var parsed amneziawg.InboundSettings
|
||||
@@ -467,7 +488,7 @@ func TestNormalizeAmneziaWGSettings_DerivesServerPublicKeyFromPrivate(t *testing
|
||||
setupConflictDB(t)
|
||||
svc := &InboundService{}
|
||||
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51825, Settings: `{"server":{"privateKey":"` + awgTestPrivateKey + `","subnetIp":"10.8.1.0","subnetCidr":24}}`}
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
|
||||
if err := svc.normalizeAmneziaWGSettings(inbound, nil); err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
var parsed amneziawg.InboundSettings
|
||||
@@ -482,3 +503,80 @@ func TestNormalizeAmneziaWGSettings_DerivesServerPublicKeyFromPrivate(t *testing
|
||||
t.Fatalf("server publicKey = %q, want %q derived from the supplied private key", parsed.Server.PublicKey, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmneziaWGSettingsDefersNodeKernelPreflightToRemoteHost(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
server, err := defaultAmneziaWGServer()
|
||||
if err != nil {
|
||||
t.Fatalf("defaultAmneziaWGServer: %v", err)
|
||||
}
|
||||
server.KernelMode = true
|
||||
settings, err := json.Marshal(amneziawg.InboundSettings{Server: server, Clients: []model.Client{}})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Protocol: model.AmneziaWG,
|
||||
NodeID: new(7),
|
||||
Port: 51820,
|
||||
Settings: string(settings),
|
||||
}
|
||||
if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, nil); err != nil {
|
||||
t.Fatalf("master must defer kernel readiness to the target node: %v", err)
|
||||
}
|
||||
var normalized amneziawg.InboundSettings
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &normalized); err != nil {
|
||||
t.Fatalf("decode normalized settings: %v", err)
|
||||
}
|
||||
if normalized.Server == nil || !normalized.Server.KernelMode {
|
||||
t.Fatalf("normalized node settings lost kernel mode: %+v", normalized.Server)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeAmneziaWGSettingsGatesHostReadinessOnlyWhenEnablingKernelMode:
|
||||
// a host that loses its module after enabling must not lock the inbound's edits.
|
||||
func TestNormalizeAmneziaWGSettingsGatesHostReadinessOnlyWhenEnablingKernelMode(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
if amneziawgkern.HostReadiness().Ready {
|
||||
t.Skip("this host can run kernel mode, so host readiness cannot refuse anything here")
|
||||
}
|
||||
server, err := defaultAmneziaWGServer()
|
||||
if err != nil {
|
||||
t.Fatalf("defaultAmneziaWGServer: %v", err)
|
||||
}
|
||||
embeddedServer := *server
|
||||
server.KernelMode = true
|
||||
kernelSettings, err := json.Marshal(amneziawg.InboundSettings{Server: server, Clients: []model.Client{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
embeddedSettings, err := json.Marshal(amneziawg.InboundSettings{Server: &embeddedServer, Clients: []model.Client{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kernelRow := func(enable bool) *model.Inbound {
|
||||
return &model.Inbound{Protocol: model.AmneziaWG, Enable: enable, Settings: string(kernelSettings)}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
enable bool
|
||||
old *model.Inbound
|
||||
wantRefused bool
|
||||
}{
|
||||
{"new inbound turns kernel mode on", true, nil, true},
|
||||
{"embedded inbound switches to kernel mode", true, &model.Inbound{Protocol: model.AmneziaWG, Enable: true, Settings: string(embeddedSettings)}, true},
|
||||
{"disabled kernel inbound is enabled", true, kernelRow(false), true},
|
||||
{"running kernel inbound is edited", true, kernelRow(true), false},
|
||||
{"running kernel inbound is disabled", false, kernelRow(true), false},
|
||||
{"disabled inbound saved with kernel mode", false, nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Enable: tc.enable, Settings: string(kernelSettings)}
|
||||
err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, tc.old)
|
||||
if refused := err != nil && strings.Contains(err.Error(), "kernel mode unavailable"); refused != tc.wantRefused {
|
||||
t.Fatalf("refused = %v (err %v), want %v", refused, err, tc.wantRefused)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"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"
|
||||
@@ -1537,11 +1538,29 @@ func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
|
||||
}
|
||||
|
||||
func (s *InboundService) GetOnlineClients() []string {
|
||||
// Kernel-mode AmneziaWG peers are invisible to Xray's online-users set;
|
||||
// their handshakes are the equivalent signal, unioned in once here.
|
||||
kernelOnline := amneziawgkern.GetCollector().OnlineEmails(time.Now())
|
||||
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return []string{}
|
||||
return kernelOnline
|
||||
}
|
||||
return process.GetOnlineClients()
|
||||
xrayOnline := process.GetOnlineClients()
|
||||
if len(kernelOnline) == 0 {
|
||||
return xrayOnline
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(xrayOnline)+len(kernelOnline))
|
||||
out := make([]string, 0, len(xrayOnline)+len(kernelOnline))
|
||||
for _, email := range append(append([]string{}, xrayOnline...), kernelOnline...) {
|
||||
if _, dup := seen[email]; dup {
|
||||
continue
|
||||
}
|
||||
seen[email] = struct{}{}
|
||||
out = append(out, email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func kernelAWGSettings(clients []model.Client) string {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"server": map[string]any{"subnetIp": "10.8.1.0", "subnetCidr": 24, "kernelMode": true},
|
||||
"clients": clients,
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestKernelAWGClientWritesRejectUnsafeValuesAndUnsupportedFeatures(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, want string
|
||||
client model.Client
|
||||
}{
|
||||
{"public key hook injection", "control characters", model.Client{Email: "bad-key@awg", Enable: true, PublicKey: "peer-key\n[Interface]\nPostDown = touch /tmp/pwned", AllowedIPs: []string{"10.8.1.2/32"}}},
|
||||
{"forwarded ports", "port forwarding is not supported", model.Client{Email: "bad-forward@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: "54321"}},
|
||||
{"IPv6 allowed IP", "per-peer IPv6 egress is not supported", model.Client{Email: "bad-v6@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"2001:db8::2/128"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
inboundSvc := &InboundService{}
|
||||
svc := &ClientService{}
|
||||
ib := mkInbound(t, 52000, model.AmneziaWG, kernelAWGSettings(nil))
|
||||
tc.client.AllowedIPsByInbound = map[int][]string{ib.Id: tc.client.AllowedIPs}
|
||||
_, err := svc.CreateOne(inboundSvc, ib.Id, tc.client)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("CreateOne error = %v, want an error containing %q", err, tc.want)
|
||||
}
|
||||
var records int64
|
||||
if dbErr := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", tc.client.Email).Count(&records).Error; dbErr != nil {
|
||||
t.Fatal(dbErr)
|
||||
}
|
||||
if records != 0 {
|
||||
t.Fatalf("rejected create persisted %d client records", records)
|
||||
}
|
||||
after, getErr := inboundSvc.GetInbound(ib.Id)
|
||||
if getErr != nil {
|
||||
t.Fatal(getErr)
|
||||
}
|
||||
if len(mustAWGClients(t, after)) != 0 {
|
||||
t.Fatal("rejected create changed inbound settings")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustAWGClients(t *testing.T, ib *model.Inbound) []model.Client {
|
||||
t.Helper()
|
||||
clients, err := (&InboundService{}).GetClients(ib)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
func TestKernelAWGClientUpdateRejectsUnsafeValuesAndUnsupportedFeatures(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, want string
|
||||
update model.Client
|
||||
}{
|
||||
{"preshared key hook injection", "control characters", model.Client{Email: "kept@awg", PreSharedKey: "psk\n[Interface]\nPreDown = touch /tmp/pwned"}},
|
||||
{"forwarded ports", "port forwarding is not supported", model.Client{Email: "kept@awg", ForwardedPorts: "54321"}},
|
||||
{"IPv6 allowed IP", "per-peer IPv6 egress is not supported", model.Client{Email: "kept@awg", AllowedIPs: []string{"2001:db8::2/128"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
inboundSvc := &InboundService{}
|
||||
svc := &ClientService{}
|
||||
seed := model.Client{Email: "kept@awg", Enable: true, PublicKey: "valid-key", PrivateKey: "valid-private", AllowedIPs: []string{"10.8.1.2/32"}}
|
||||
ib := mkInbound(t, 52001, model.AmneziaWG, kernelAWGSettings(nil))
|
||||
if _, err := svc.CreateOne(inboundSvc, ib.Id, seed); err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
rec := lookupClientRecord(t, seed.Email)
|
||||
before, getErr := inboundSvc.GetInbound(ib.Id)
|
||||
if getErr != nil {
|
||||
t.Fatal(getErr)
|
||||
}
|
||||
update := tc.update
|
||||
if len(update.AllowedIPs) > 0 {
|
||||
update.AllowedIPsByInbound = map[int][]string{ib.Id: update.AllowedIPs}
|
||||
}
|
||||
_, err := svc.Update(inboundSvc, rec.Id, update, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("ClientService.Update error = %v, want an error containing %q", err, tc.want)
|
||||
}
|
||||
after, err := inboundSvc.GetInbound(ib.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Settings != before.Settings {
|
||||
t.Fatalf("rejected update changed settings")
|
||||
}
|
||||
var unchanged model.ClientRecord
|
||||
if err := database.GetDB().First(&unchanged, rec.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unchanged.PreSharedKey != rec.PreSharedKey || unchanged.AllowedIPs != rec.AllowedIPs {
|
||||
t.Fatalf("rejected update changed client record: before %+v after %+v", rec, unchanged)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelAWGClientFeatureChecksPreserveSupportedPaths(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
inboundSvc := &InboundService{}
|
||||
svc := &ClientService{}
|
||||
kernel := mkInbound(t, 52002, model.AmneziaWG, kernelAWGSettings(nil))
|
||||
if _, err := svc.CreateOne(inboundSvc, kernel.Id, model.Client{Email: "valid@kernel", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.2/32"}}); err != nil {
|
||||
t.Fatalf("valid IPv4 kernel peer was rejected: %v", err)
|
||||
}
|
||||
embedded := mkInbound(t, 52003, model.AmneziaWG, amneziawgClientTestSettings)
|
||||
if _, err := svc.CreateOne(inboundSvc, embedded.Id, model.Client{Email: "forward@embedded", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.3/32"}, ForwardedPorts: "54321"}); err != nil {
|
||||
t.Fatalf("embedded AWG port forwarding was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelAWGSharedCreateAndUpdateValidateAllTargetsBeforeWrites(t *testing.T) {
|
||||
t.Run("create", func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
inboundSvc := &InboundService{}
|
||||
svc := &ClientService{}
|
||||
kernel := mkInbound(t, 52005, model.AmneziaWG, kernelAWGSettings(nil))
|
||||
embedded := mkInbound(t, 52006, model.AmneziaWG, amneziawgClientTestSettings)
|
||||
_, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "shared-create@awg", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: "54321"},
|
||||
InboundIds: []int{embedded.Id, kernel.Id},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "port forwarding is not supported") {
|
||||
t.Fatalf("Create error = %v, want kernel feature rejection", err)
|
||||
}
|
||||
for _, ib := range []*model.Inbound{embedded, kernel} {
|
||||
after, getErr := inboundSvc.GetInbound(ib.Id)
|
||||
if getErr != nil {
|
||||
t.Fatal(getErr)
|
||||
}
|
||||
if len(mustAWGClients(t, after)) != 0 {
|
||||
t.Fatalf("failed shared create partially changed inbound %d", ib.Id)
|
||||
}
|
||||
}
|
||||
var records int64
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", "shared-create@awg").Count(&records).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if records != 0 {
|
||||
t.Fatalf("failed shared create persisted %d client records", records)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
inboundSvc := &InboundService{}
|
||||
svc := &ClientService{}
|
||||
kernel := mkInbound(t, 52007, model.AmneziaWG, kernelAWGSettings(nil))
|
||||
embedded := mkInbound(t, 52008, model.AmneziaWG, amneziawgClientTestSettings)
|
||||
seed := model.Client{Email: "shared-update@awg", Enable: true, PublicKey: "valid-key", AllowedIPsByInbound: map[int][]string{kernel.Id: {"10.8.1.2/32"}, embedded.Id: {"10.8.1.3/32"}}}
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: seed, InboundIds: []int{kernel.Id, embedded.Id}}); err != nil {
|
||||
t.Fatalf("seed shared client: %v", err)
|
||||
}
|
||||
beforeKernel, _ := inboundSvc.GetInbound(kernel.Id)
|
||||
beforeEmbedded, _ := inboundSvc.GetInbound(embedded.Id)
|
||||
rec := lookupClientRecord(t, seed.Email)
|
||||
_, err := svc.Update(inboundSvc, rec.Id, model.Client{Email: seed.Email, ForwardedPorts: "54321"}, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "port forwarding is not supported") {
|
||||
t.Fatalf("Update error = %v, want kernel feature rejection", err)
|
||||
}
|
||||
afterKernel, getErr := inboundSvc.GetInbound(kernel.Id)
|
||||
if getErr != nil {
|
||||
t.Fatal(getErr)
|
||||
}
|
||||
afterEmbedded, getErr := inboundSvc.GetInbound(embedded.Id)
|
||||
if getErr != nil {
|
||||
t.Fatal(getErr)
|
||||
}
|
||||
if afterKernel.Settings != beforeKernel.Settings || afterEmbedded.Settings != beforeEmbedded.Settings {
|
||||
t.Fatal("failed shared update partially changed inbound settings")
|
||||
}
|
||||
var unchanged model.ClientRecord
|
||||
if err := database.GetDB().First(&unchanged, rec.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unchanged.ForwardedPorts != rec.ForwardedPorts {
|
||||
t.Fatal("failed shared update changed the client record")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -129,6 +129,21 @@ func inboundBindAddr(ib *model.Inbound) bindAddr {
|
||||
return bindAddr{listen: ib.Listen, v6only: streamV6Only(ib.StreamSettings)}
|
||||
}
|
||||
|
||||
func amneziawgKernelMode(ib *model.Inbound) bool {
|
||||
if ib == nil || ib.Protocol != model.AmneziaWG {
|
||||
return false
|
||||
}
|
||||
return amneziawgKernelModeSettings(ib.Settings)
|
||||
}
|
||||
|
||||
func amneziawgKernelModeSettings(raw string) bool {
|
||||
var settings amneziawg.InboundSettings
|
||||
if err := json.Unmarshal([]byte(raw), &settings); err != nil || settings.Server == nil {
|
||||
return false
|
||||
}
|
||||
return settings.Server.KernelMode
|
||||
}
|
||||
|
||||
func streamV6Only(streamSettings string) bool {
|
||||
if !strings.Contains(streamSettings, "v6only") {
|
||||
return false
|
||||
@@ -292,14 +307,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Every enabled local AmneziaWG inbound gets its own automatic Xray
|
||||
// SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
|
||||
// port derived purely from its id (amneziawgnet.SOCKSPortForInbound) --
|
||||
// like the internal Xray API inbound above, that relay inbound is not
|
||||
// itself a database row, so the ordinary DB-backed query below can never
|
||||
// see it. Without this check, an unrelated inbound saved onto that exact
|
||||
// port silently fails at the next Xray start, taking every other
|
||||
// protocol down with it, not just AmneziaWG.
|
||||
// Local embedded inbounds reserve an automatically assigned Xray SOCKS relay port.
|
||||
if inbound.NodeID == nil && listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
|
||||
conflict, err := checkAmneziawgnetSocksConflict(db, inbound, ignoreId, newBits)
|
||||
if err != nil {
|
||||
@@ -319,7 +327,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
|
||||
|
||||
// The reverse direction, only meaningful once the id is known -- AddInbound
|
||||
// runs it after Save. Only a local row owns a relay slot (#6537 review).
|
||||
if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG && ignoreId > 0 {
|
||||
if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG && ignoreId > 0 && !amneziawgKernelMode(inbound) {
|
||||
if self := amneziawgnetSocksSelfConflict(inbound, ignoreId); self != "" {
|
||||
return nil, common.NewError(self)
|
||||
}
|
||||
@@ -417,6 +425,9 @@ func amneziawgForwardedPortOwner(db *gorm.DB, inbound *model.Inbound, ignoreId i
|
||||
if !sameNode(row.NodeID, inbound.NodeID) {
|
||||
continue
|
||||
}
|
||||
if amneziawgKernelMode(row) {
|
||||
continue
|
||||
}
|
||||
instance, ok := amneziawg.InstanceFromInbound(row)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -439,8 +450,7 @@ func amneziawgForwardedPortOwner(db *gorm.DB, inbound *model.Inbound, ignoreId i
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// checkAmneziawgnetSocksConflict: inbound's port vs the relay port every matching
|
||||
// local row reserves, emitted or not; db keeps it in the caller's transaction (#6225).
|
||||
// checkAmneziawgnetSocksConflict checks the embedded relay port each local row reserves.
|
||||
func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
|
||||
// A disabled row still owns the slot its id derives: SetInboundEnable flips
|
||||
// the column with no port check, so enabling it later must not collide.
|
||||
@@ -452,9 +462,11 @@ func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreI
|
||||
if err := q.Find(&candidates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ownership does not depend on the peers: the relay appears when the first
|
||||
// client is added, and the client paths run no port check at all.
|
||||
// The row reserves its slot before its first peer is added.
|
||||
for _, c := range candidates {
|
||||
if amneziawgKernelMode(c) {
|
||||
continue
|
||||
}
|
||||
if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port {
|
||||
continue
|
||||
}
|
||||
@@ -470,8 +482,8 @@ func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreI
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// checkAmneziawgnetSocksRelayCollision reports whether id's derived relay port
|
||||
// is already claimed by another local AmneziaWG inbound, disabled rows included.
|
||||
// checkAmneziawgnetSocksRelayCollision reports whether id's embedded relay port
|
||||
// is already claimed by another local AmneziaWG inbound, including disabled rows.
|
||||
func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDetail, error) {
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(id)
|
||||
var candidates []*model.Inbound
|
||||
@@ -481,6 +493,9 @@ func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDet
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if amneziawgKernelMode(c) {
|
||||
continue
|
||||
}
|
||||
if amneziawgnet.SOCKSPortForInbound(c.Id) != relayPort {
|
||||
continue
|
||||
}
|
||||
@@ -500,7 +515,7 @@ func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDet
|
||||
// amneziawgnetSocksSelfConflict: a row's own WireGuard port vs the relay port its
|
||||
// own id derives -- all three checks below exclude that id, so nothing else does.
|
||||
func amneziawgnetSocksSelfConflict(inbound *model.Inbound, id int) string {
|
||||
if id <= 0 || inbound.NodeID != nil || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
|
||||
if id <= 0 || inbound.NodeID != nil || amneziawgKernelMode(inbound) || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
|
||||
return ""
|
||||
}
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(id)
|
||||
@@ -522,6 +537,9 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if amneziawgKernelMode(c) {
|
||||
continue
|
||||
}
|
||||
if !listenOverlaps(loopbackBind, inboundBindAddr(c)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -816,6 +816,41 @@ func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPortConflict_KernelAmneziaWGDoesNotReserveEmbeddedRelay(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-kernel", "0.0.0.0", 51820, model.AmneziaWG, ``,
|
||||
`{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"kernelMode":true},"clients":[]}`)
|
||||
|
||||
var awgInbound model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "awg-kernel").First(&awgInbound).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "vless-kernel-relay-port",
|
||||
Listen: "0.0.0.0",
|
||||
Port: amneziawgnet.SOCKSPortForInbound(awgInbound.Id),
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
if got, err := (&InboundService{}).checkPortConflict(candidate, 0); err != nil || got != nil {
|
||||
t.Fatalf("kernel-mode AmneziaWG must not reserve an embedded relay port, including with no peers; got=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPortConflict_KernelAmneziaWGAllowsItsUDPPortToMatchRelayRange(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-kernel", "0.0.0.0", 51820, model.AmneziaWG, ``,
|
||||
`{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"kernelMode":true},"clients":[]}`)
|
||||
|
||||
var awgInbound model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "awg-kernel").First(&awgInbound).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
awgInbound.Port = amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
||||
if got, err := (&InboundService{}).checkPortConflict(&awgInbound, awgInbound.Id); err != nil || got != nil {
|
||||
t.Fatalf("kernel-mode AmneziaWG UDP port may use a port in the embedded TCP relay range; got=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes run their own Xray, so a node inbound landing on the central panel's
|
||||
// AmneziaWG relay port must be allowed -- the relay inbound only ever binds
|
||||
// 127.0.0.1 on the local panel's own Xray.
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -723,7 +724,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||
logger.Warning("count amneziawg inbounds failed:", err)
|
||||
}
|
||||
status.AmneziaWG.Configured = amneziawgCount > 0
|
||||
status.AmneziaWG.Running = amneziawgnet.GetManager().HasRunning()
|
||||
status.AmneziaWG.Running = amneziawgnet.GetManager().HasRunning() || amneziawgkern.GetManager().HasRunning()
|
||||
|
||||
status.PanelVersion = config.GetPanelVersion()
|
||||
if guid, err := s.settingService.GetPanelGuid(); err == nil {
|
||||
|
||||
@@ -790,6 +790,11 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Kernel-mode inbounds forward in the kernel and never dial Xray, so a
|
||||
// relay inbound for them would listen forever with nothing behind it.
|
||||
if inst.KernelMode {
|
||||
continue
|
||||
}
|
||||
if _, taken := existingTags[inbound.Tag]; taken {
|
||||
logger.Warning("amneziawgnet socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound")
|
||||
continue
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "عدم إرسال ردود الكوكي — يزيل بصمة DPI لكنه يضعف الحماية من الفيضانات.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "هذا الخادم لا يمكنه تشغيل وضع النواة بعد",
|
||||
"kernelBlocker": {
|
||||
"root": "اللوحة لا تعمل بصلاحيات root، لذا لا يمكنها إنشاء واجهة شبكة",
|
||||
"tools": "أمر مطلوب غير موجود؛ ثبّت amneziawg-tools و iproute2",
|
||||
"module": "وحدة النواة amneziawg غير مثبتة",
|
||||
"nft": "nftables غير مثبت؛ وهو مطلوب لقواعد NAT الخاصة بالنفق",
|
||||
"ipv6": "الخروج عبر IPv6 لكل نظير غير مدعوم بعد في وضع النواة",
|
||||
"portForwarding": "إعادة توجيه المنافذ لكل عميل غير مدعومة بعد في وضع النواة",
|
||||
"routeThroughXray": "لا يوجد تنفيذ لـ routeThroughXray في وضع النواة؛ سيترك النفق بلا مسار خروج"
|
||||
},
|
||||
"kernelMode": "وضع النواة",
|
||||
"kernelModeHint": "تشغيل هذا الـ inbound على وحدة نواة AmneziaWG بدلاً من مكدس مساحة المستخدم المدمج. عادةً أسرع، خاصة على الروابط ذات التأخير العالي، لكن توجيه Xray والـ sniffing و IPv6 وإعادة توجيه المنافذ لا تنطبق على نظرائه. يتطلب وحدة نواة AmneziaWG و amneziawg-tools على الخادم."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "التحكم في الازدحام",
|
||||
|
||||
@@ -2357,7 +2357,19 @@
|
||||
"disableCookiesHint": "Never send cookie replies — removes a DPI fingerprint; weakens flood mitigation.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "This host cannot run kernel mode yet",
|
||||
"kernelBlocker": {
|
||||
"root": "The panel is not running as root, so it cannot create a network interface",
|
||||
"tools": "A required command is missing; install amneziawg-tools and iproute2",
|
||||
"module": "The amneziawg kernel module is not installed",
|
||||
"nft": "nftables is not installed; it is needed for the tunnel's NAT rules",
|
||||
"ipv6": "Per-peer IPv6 egress is not supported in kernel mode yet",
|
||||
"portForwarding": "Per-client port forwarding is not supported in kernel mode yet",
|
||||
"routeThroughXray": "routeThroughXray has no kernel-mode implementation; it would leave the tunnel with no egress path"
|
||||
},
|
||||
"kernelMode": "Kernel mode",
|
||||
"kernelModeHint": "Run this inbound on the AmneziaWG kernel module instead of the built-in userspace stack. Usually faster, especially over high-latency links, but Xray routing, sniffing, IPv6 and port forwarding do not apply to its peers. Requires the AmneziaWG kernel module and amneziawg-tools on the host."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Congestion Control",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "No enviar cookie replies — elimina una huella para DPI, pero debilita la mitigación de inundaciones.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "Este host aún no puede ejecutar el modo kernel",
|
||||
"kernelBlocker": {
|
||||
"root": "El panel no se ejecuta como root, así que no puede crear una interfaz de red",
|
||||
"tools": "Falta un comando necesario; instala amneziawg-tools e iproute2",
|
||||
"module": "El módulo del kernel amneziawg no está instalado",
|
||||
"nft": "nftables no está instalado; es necesario para las reglas NAT del túnel",
|
||||
"ipv6": "La salida IPv6 por peer aún no es compatible con el modo kernel",
|
||||
"portForwarding": "El reenvío de puertos por cliente aún no es compatible con el modo kernel",
|
||||
"routeThroughXray": "routeThroughXray no tiene implementación en modo kernel; dejaría el túnel sin ruta de salida"
|
||||
},
|
||||
"kernelMode": "Modo kernel",
|
||||
"kernelModeHint": "Ejecuta este inbound en el módulo del kernel de AmneziaWG en lugar de la pila integrada en espacio de usuario. Suele ser más rápido, sobre todo en enlaces de alta latencia, pero el enrutamiento de Xray, el sniffing, IPv6 y el reenvío de puertos no se aplican a sus peers. Requiere el módulo del kernel de AmneziaWG y amneziawg-tools en el host."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Control de congestión",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "هرگز پاسخ کوکی ارسال نشود — اثر انگشت DPI را حذف میکند اما دفاع در برابر سیلآسا را ضعیف میکند.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "این سرور هنوز نمیتواند حالت کرنل را اجرا کند",
|
||||
"kernelBlocker": {
|
||||
"root": "پنل با دسترسی root اجرا نمیشود و نمیتواند رابط شبکه بسازد",
|
||||
"tools": "یک فرمان لازم پیدا نشد؛ amneziawg-tools و iproute2 را نصب کنید",
|
||||
"module": "ماژول کرنل amneziawg نصب نیست",
|
||||
"nft": "nftables نصب نیست؛ برای قواعد NAT تونل لازم است",
|
||||
"ipv6": "خروجی IPv6 برای هر peer هنوز در حالت کرنل پشتیبانی نمیشود",
|
||||
"portForwarding": "فوروارد پورت برای هر کلاینت هنوز در حالت کرنل پشتیبانی نمیشود",
|
||||
"routeThroughXray": "routeThroughXray در حالت کرنل پیادهسازی ندارد و تونل را بدون مسیر خروجی میگذارد"
|
||||
},
|
||||
"kernelMode": "حالت کرنل",
|
||||
"kernelModeHint": "این inbound را بهجای پشتهٔ داخلی userspace روی ماژول کرنل AmneziaWG اجرا میکند. معمولاً سریعتر است، بهویژه روی لینکهای با تأخیر بالا، اما مسیریابی Xray، sniffing، IPv6 و فوروارد پورت روی peerهای آن اعمال نمیشود. به ماژول کرنل AmneziaWG و amneziawg-tools روی سرور نیاز دارد."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "کنترل ازدحام",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "Tidak pernah mengirim cookie reply — menghapus sidik jari DPI, tetapi melemahkan mitigasi banjir.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "Host ini belum bisa menjalankan mode kernel",
|
||||
"kernelBlocker": {
|
||||
"root": "Panel tidak berjalan sebagai root, sehingga tidak bisa membuat antarmuka jaringan",
|
||||
"tools": "Perintah yang diperlukan tidak ada; pasang amneziawg-tools dan iproute2",
|
||||
"module": "Modul kernel amneziawg belum terpasang",
|
||||
"nft": "nftables belum terpasang; diperlukan untuk aturan NAT terowongan",
|
||||
"ipv6": "Egress IPv6 per peer belum didukung dalam mode kernel",
|
||||
"portForwarding": "Penerusan port per klien belum didukung dalam mode kernel",
|
||||
"routeThroughXray": "routeThroughXray tidak punya implementasi mode kernel; terowongan akan tanpa jalur keluar"
|
||||
},
|
||||
"kernelMode": "Mode kernel",
|
||||
"kernelModeHint": "Jalankan inbound ini pada modul kernel AmneziaWG alih-alih stack userspace bawaan. Biasanya lebih cepat, terutama pada tautan berlatensi tinggi, tetapi routing Xray, sniffing, IPv6, dan penerusan port tidak berlaku untuk peer-nya. Memerlukan modul kernel AmneziaWG dan amneziawg-tools di host."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Kontrol Kemacetan",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "cookie replyを送信しません。DPIの指紋を消しますが、フラッド緩和は弱まります。",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "このホストではまだカーネルモードを実行できません",
|
||||
"kernelBlocker": {
|
||||
"root": "パネルが root で実行されていないため、ネットワークインターフェースを作成できません",
|
||||
"tools": "必要なコマンドがありません。amneziawg-tools と iproute2 をインストールしてください",
|
||||
"module": "amneziawg カーネルモジュールがインストールされていません",
|
||||
"nft": "nftables がインストールされていません。トンネルの NAT ルールに必要です",
|
||||
"ipv6": "カーネルモードではピアごとの IPv6 送信はまだサポートされていません",
|
||||
"portForwarding": "カーネルモードではクライアントごとのポート転送はまだサポートされていません",
|
||||
"routeThroughXray": "routeThroughXray にはカーネルモードの実装がなく、トンネルの出口がなくなります"
|
||||
},
|
||||
"kernelMode": "カーネルモード",
|
||||
"kernelModeHint": "このインバウンドを内蔵のユーザースペーススタックではなく AmneziaWG カーネルモジュールで実行します。特に遅延の大きい回線では通常高速ですが、Xray のルーティング、スニッフィング、IPv6、ポート転送はそのピアに適用されません。ホストに AmneziaWG カーネルモジュールと amneziawg-tools が必要です。"
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "輻輳制御",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "Nunca enviar cookie replies — remove uma impressão digital de DPI, mas enfraquece a mitigação de inundações.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "Este host ainda não pode executar o modo kernel",
|
||||
"kernelBlocker": {
|
||||
"root": "O painel não está rodando como root, então não pode criar uma interface de rede",
|
||||
"tools": "Falta um comando necessário; instale amneziawg-tools e iproute2",
|
||||
"module": "O módulo de kernel amneziawg não está instalado",
|
||||
"nft": "nftables não está instalado; ele é necessário para as regras NAT do túnel",
|
||||
"ipv6": "Saída IPv6 por peer ainda não é suportada no modo kernel",
|
||||
"portForwarding": "Encaminhamento de portas por cliente ainda não é suportado no modo kernel",
|
||||
"routeThroughXray": "routeThroughXray não tem implementação no modo kernel; deixaria o túnel sem rota de saída"
|
||||
},
|
||||
"kernelMode": "Modo kernel",
|
||||
"kernelModeHint": "Executa este inbound no módulo de kernel do AmneziaWG em vez da pilha embutida em espaço de usuário. Geralmente mais rápido, principalmente em links de alta latência, mas o roteamento do Xray, o sniffing, IPv6 e o encaminhamento de portas não se aplicam aos seus peers. Requer o módulo de kernel do AmneziaWG e o amneziawg-tools no host."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Controle de congestionamento",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "Не отправлять cookie reply — убирает сигнатуру для DPI, но ослабляет защиту от флуда.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "Этот сервер пока не может работать в режиме ядра",
|
||||
"kernelBlocker": {
|
||||
"root": "Панель запущена не от root, поэтому не может создать сетевой интерфейс",
|
||||
"tools": "Не найдена нужная команда; установите amneziawg-tools и iproute2",
|
||||
"module": "Модуль ядра amneziawg не установлен",
|
||||
"nft": "nftables не установлен; он нужен для правил NAT туннеля",
|
||||
"ipv6": "Выход по IPv6 для отдельных пиров в режиме ядра пока не поддерживается",
|
||||
"portForwarding": "Проброс портов для клиентов в режиме ядра пока не поддерживается",
|
||||
"routeThroughXray": "routeThroughXray не реализован в режиме ядра: у туннеля не останется пути наружу"
|
||||
},
|
||||
"kernelMode": "Режим ядра",
|
||||
"kernelModeHint": "Запускать этот inbound на модуле ядра AmneziaWG вместо встроенного стека в пространстве пользователя. Обычно быстрее, особенно на каналах с большой задержкой, но маршрутизация Xray, sniffing, IPv6 и проброс портов к его пирам не применяются. Требуются модуль ядра AmneziaWG и amneziawg-tools на сервере."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Контроль перегрузок",
|
||||
|
||||
@@ -2357,7 +2357,19 @@
|
||||
"disableCookiesHint": "Nikdy neposielať odpovede cookie. Odstráni rozpoznateľný znak pre DPI, ale oslabí ochranu pred zahltením.",
|
||||
"listenPort": "Port na počúvanie (voliteľné)",
|
||||
"listenPortHint": "Pevný miestny zdrojový port UDP. Hodnota 0 vyberie port automaticky.",
|
||||
"outboundObfuscationHint": "Musia sa presne zhodovať s parametrami servera."
|
||||
"outboundObfuscationHint": "Musia sa presne zhodovať s parametrami servera.",
|
||||
"kernelUnavailable": "Tento server zatiaľ nemôže spustiť režim jadra",
|
||||
"kernelBlocker": {
|
||||
"root": "Panel nebeží ako root, preto nemôže vytvoriť sieťové rozhranie",
|
||||
"tools": "Chýba potrebný príkaz; nainštalujte amneziawg-tools a iproute2",
|
||||
"module": "Modul jadra amneziawg nie je nainštalovaný",
|
||||
"nft": "nftables nie je nainštalovaný; je potrebný pre pravidlá NAT tunela",
|
||||
"ipv6": "Výstup IPv6 pre jednotlivé peery režim jadra zatiaľ nepodporuje",
|
||||
"portForwarding": "Presmerovanie portov pre jednotlivých klientov režim jadra zatiaľ nepodporuje",
|
||||
"routeThroughXray": "routeThroughXray nemá implementáciu v režime jadra; tunel by zostal bez výstupnej cesty"
|
||||
},
|
||||
"kernelMode": "Režim jadra",
|
||||
"kernelModeHint": "Spustí tento inbound na module jadra AmneziaWG namiesto vstavaného zásobníka v používateľskom priestore. Zvyčajne je rýchlejší, najmä na linkách s vysokou latenciou, ale smerovanie Xray, sniffing, IPv6 a presmerovanie portov sa na jeho peery neuplatňujú. Vyžaduje modul jadra AmneziaWG a amneziawg-tools na serveri."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Riadenie zahltenia",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "Cookie reply asla gönderilmez — bir DPI parmak izini kaldırır ancak taşma korumasını zayıflatır.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "Bu sunucu henüz çekirdek modunu çalıştıramıyor",
|
||||
"kernelBlocker": {
|
||||
"root": "Panel root olarak çalışmıyor, bu yüzden ağ arayüzü oluşturamıyor",
|
||||
"tools": "Gerekli bir komut eksik; amneziawg-tools ve iproute2'yi kurun",
|
||||
"module": "amneziawg çekirdek modülü kurulu değil",
|
||||
"nft": "nftables kurulu değil; tünelin NAT kuralları için gerekli",
|
||||
"ipv6": "Eş başına IPv6 çıkışı çekirdek modunda henüz desteklenmiyor",
|
||||
"portForwarding": "İstemci başına port yönlendirme çekirdek modunda henüz desteklenmiyor",
|
||||
"routeThroughXray": "routeThroughXray'in çekirdek modunda bir uygulaması yok; tüneli çıkış yolu olmadan bırakır"
|
||||
},
|
||||
"kernelMode": "Çekirdek modu",
|
||||
"kernelModeHint": "Bu inbound'u yerleşik kullanıcı alanı yığını yerine AmneziaWG çekirdek modülünde çalıştırır. Özellikle yüksek gecikmeli bağlantılarda genellikle daha hızlıdır, ancak Xray yönlendirmesi, sniffing, IPv6 ve port yönlendirme eşlerine uygulanmaz. Sunucuda AmneziaWG çekirdek modülü ve amneziawg-tools gerekir."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Tıkanıklık Kontrolü",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "Ніколи не надсилати cookie reply — прибирає відбиток для DPI, але послаблює захист від флуду.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "Цей сервер поки не може працювати в режимі ядра",
|
||||
"kernelBlocker": {
|
||||
"root": "Панель запущена не від root, тому не може створити мережевий інтерфейс",
|
||||
"tools": "Не знайдено потрібну команду; встановіть amneziawg-tools та iproute2",
|
||||
"module": "Модуль ядра amneziawg не встановлено",
|
||||
"nft": "nftables не встановлено; він потрібен для правил NAT тунелю",
|
||||
"ipv6": "Вихід через IPv6 для окремих пірів у режимі ядра поки не підтримується",
|
||||
"portForwarding": "Переадресація портів для клієнтів у режимі ядра поки не підтримується",
|
||||
"routeThroughXray": "routeThroughXray не реалізовано в режимі ядра: тунель залишиться без шляху назовні"
|
||||
},
|
||||
"kernelMode": "Режим ядра",
|
||||
"kernelModeHint": "Запускати цей inbound на модулі ядра AmneziaWG замість вбудованого стеку в просторі користувача. Зазвичай швидше, особливо на каналах із великою затримкою, але маршрутизація Xray, sniffing, IPv6 та переадресація портів до його пірів не застосовуються. Потрібні модуль ядра AmneziaWG та amneziawg-tools на сервері."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Контроль перевантаження",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "Không bao giờ gửi cookie reply — xóa một dấu vết DPI nhưng làm yếu khả năng chống flood.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "Máy chủ này chưa thể chạy chế độ kernel",
|
||||
"kernelBlocker": {
|
||||
"root": "Bảng điều khiển không chạy với quyền root nên không thể tạo giao diện mạng",
|
||||
"tools": "Thiếu một lệnh cần thiết; hãy cài amneziawg-tools và iproute2",
|
||||
"module": "Chưa cài mô-đun kernel amneziawg",
|
||||
"nft": "Chưa cài nftables; cần cho các quy tắc NAT của đường hầm",
|
||||
"ipv6": "Chế độ kernel chưa hỗ trợ đầu ra IPv6 theo từng peer",
|
||||
"portForwarding": "Chế độ kernel chưa hỗ trợ chuyển tiếp cổng theo từng client",
|
||||
"routeThroughXray": "routeThroughXray chưa có triển khai cho chế độ kernel; đường hầm sẽ không có đường ra"
|
||||
},
|
||||
"kernelMode": "Chế độ kernel",
|
||||
"kernelModeHint": "Chạy inbound này trên mô-đun kernel AmneziaWG thay cho ngăn xếp userspace tích hợp. Thường nhanh hơn, nhất là trên đường truyền có độ trễ cao, nhưng định tuyến Xray, sniffing, IPv6 và chuyển tiếp cổng không áp dụng cho các peer của nó. Cần mô-đun kernel AmneziaWG và amneziawg-tools trên máy chủ."
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "Kiểm soát tắc nghẽn",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "从不发送 cookie reply——消除一个 DPI 指纹,但会削弱抗洪泛能力。",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "此主机暂时无法运行内核模式",
|
||||
"kernelBlocker": {
|
||||
"root": "面板未以 root 身份运行,无法创建网络接口",
|
||||
"tools": "缺少必需的命令;请安装 amneziawg-tools 和 iproute2",
|
||||
"module": "未安装 amneziawg 内核模块",
|
||||
"nft": "未安装 nftables;隧道的 NAT 规则需要它",
|
||||
"ipv6": "内核模式暂不支持按对等端的 IPv6 出站",
|
||||
"portForwarding": "内核模式暂不支持按客户端的端口转发",
|
||||
"routeThroughXray": "routeThroughXray 没有内核模式实现,会使隧道没有出站路径"
|
||||
},
|
||||
"kernelMode": "内核模式",
|
||||
"kernelModeHint": "使用 AmneziaWG 内核模块而非内置用户态协议栈运行此入站。通常更快,尤其在高延迟链路上,但 Xray 路由、嗅探、IPv6 和端口转发不适用于其对等端。需要主机上安装 AmneziaWG 内核模块和 amneziawg-tools。"
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "拥塞控制算法",
|
||||
|
||||
@@ -2236,7 +2236,19 @@
|
||||
"disableCookiesHint": "永不傳送 cookie reply——消除一個 DPI 指紋,但會削弱抗洪泛能力。",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters.",
|
||||
"kernelUnavailable": "此主機暫時無法執行核心模式",
|
||||
"kernelBlocker": {
|
||||
"root": "面板未以 root 身分執行,無法建立網路介面",
|
||||
"tools": "缺少必要的指令;請安裝 amneziawg-tools 與 iproute2",
|
||||
"module": "未安裝 amneziawg 核心模組",
|
||||
"nft": "未安裝 nftables;通道的 NAT 規則需要它",
|
||||
"ipv6": "核心模式尚不支援每個對等端的 IPv6 出口",
|
||||
"portForwarding": "核心模式尚不支援每個用戶端的連接埠轉發",
|
||||
"routeThroughXray": "routeThroughXray 沒有核心模式實作,會讓通道沒有出口路徑"
|
||||
},
|
||||
"kernelMode": "核心模式",
|
||||
"kernelModeHint": "以 AmneziaWG 核心模組取代內建的使用者空間堆疊來執行此入站。通常更快,尤其是在高延遲連線上,但 Xray 路由、嗅探、IPv6 與連接埠轉發不適用於其對等端。主機需安裝 AmneziaWG 核心模組與 amneziawg-tools。"
|
||||
},
|
||||
"tuic": {
|
||||
"congestionControl": "壅塞控制演算法",
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
@@ -803,6 +804,8 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||||
}
|
||||
mtproto.GetManager().StopAll()
|
||||
amneziawgnet.GetManager().StopAll()
|
||||
job.NewAmneziaWGJob().FlushKernelTraffic()
|
||||
amneziawgkern.GetManager().StopAll()
|
||||
amneziawgnet.GetOutboundManager().StopAll()
|
||||
}
|
||||
if stopXray {
|
||||
|
||||
Reference in New Issue
Block a user