diff --git a/go.mod b/go.mod index 76465b1..b7ffe21 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/PharosVPN/node -go 1.25.6 +go 1.26 require ( github.com/knadh/koanf/parsers/yaml v1.1.0 @@ -9,22 +9,54 @@ require ( github.com/knadh/koanf/providers/structs v1.0.0 github.com/knadh/koanf/v2 v2.3.4 github.com/spf13/cobra v1.10.2 + github.com/xtls/xray-core v1.260327.0 + golang.org/x/crypto v0.52.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/btree v1.1.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/juju/ratelimit v1.0.2 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/miekg/dns v1.1.72 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pires/go-proxyproto v0.11.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect + github.com/sagernet/sing v0.5.1 // indirect + github.com/sagernet/sing-shadowsocks v0.2.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/vishvananda/netlink v1.3.1 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.44.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect + lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index 8cddd75..ce2f3d1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU= +github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -7,20 +13,34 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= +github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= +github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= @@ -33,23 +53,47 @@ github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w= github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc= github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs= +github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= +github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8= +github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= +github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= +github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE= +github.com/xtls/xray-core v1.260327.0/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -62,14 +106,36 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= @@ -79,7 +145,14 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= +gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/internal/cli/run.go b/internal/cli/run.go index 7b4039d..f0759c6 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -14,6 +14,7 @@ import ( "github.com/PharosVPN/node/internal/config" "github.com/PharosVPN/node/internal/control" "github.com/PharosVPN/node/internal/netpolicy" + "github.com/PharosVPN/node/internal/xray" "github.com/spf13/cobra" ) @@ -80,6 +81,19 @@ func newRunCmd() *cobra.Command { }) }) + // The node's XRay/REALITY identity, like the AmneziaWG one, is + // generated once and reused so the public key coxswain caches stays + // stable (DESIGN §3). The runtime starts down; coxswain brings it up + // with PushConfig once it provisions a REALITY device. + xrayID, err := xray.Load(cfg.XRayStatePath()) + if err != nil { + return err + } + xrayRT := xray.NewRuntime(xrayID, log) + log.Info("XRay/REALITY node identity ready", + "public_key", xrayID.PublicKey(), + "state_file", cfg.XRayStatePath()) + // The network-policy applier owns the node's forwarding / // masquerade / isolation firewall state (decision 16). netPolicy, err := netpolicy.New(netpolicy.Options{ @@ -102,6 +116,7 @@ func newRunCmd() *cobra.Command { AWGNode: awgNode, AWGRegistry: awgReg, NetPolicy: netPolicy, + XRay: xrayRT, Log: log, }) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 7b2663f..b480d52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,9 @@ const ( // AWGStateFile holds the node's AmneziaWG server identity — its keypair // and obfuscation set. node generates it once and reuses it (DESIGN §3). AWGStateFile = "awg-node.json" + // XRayStateFile holds the node's XRay/REALITY server identity — its + // Curve25519 keypair. node generates it once and reuses it (DESIGN §3). + XRayStateFile = "xray-node.json" // AWGRevisionFile persists the last applied PushConfig revision so the // optimistic-concurrency guard survives a restart. AWGRevisionFile = "awg-revision" @@ -80,6 +83,9 @@ func (c Config) CACertPath() string { return filepath.Join(c.Dir, CACertFile) } // AWGStatePath is the absolute path to the node's AmneziaWG identity file. func (c Config) AWGStatePath() string { return filepath.Join(c.Dir, AWGStateFile) } +// XRayStatePath is the absolute path to the node's XRay/REALITY identity file. +func (c Config) XRayStatePath() string { return filepath.Join(c.Dir, XRayStateFile) } + // AWGRevisionPath is the absolute path to the last-applied PushConfig // revision file. func (c Config) AWGRevisionPath() string { return filepath.Join(c.Dir, AWGRevisionFile) } diff --git a/internal/control/server.go b/internal/control/server.go index 4321768..f2a8c72 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -23,6 +23,7 @@ import ( "github.com/PharosVPN/node/internal/awg" nodev1 "github.com/PharosVPN/node/internal/gen/pharos/node/v1" "github.com/PharosVPN/node/internal/netpolicy" + "github.com/PharosVPN/node/internal/xray" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) @@ -55,6 +56,10 @@ type Options struct { // NetPolicy applies the node's forwarding / masquerade / isolation policy // and backs SetNetworkConfig (decision 16). NetPolicy *netpolicy.Applier + // XRay is the node's embedded XRay/REALITY data plane — its REALITY + // identity (reported by GetStatus) and the VLESS client set / camouflage + // policy coxswain pushes. Optional; nil leaves XRay calls Unimplemented. + XRay *xray.Runtime // Log receives server diagnostics. Log *slog.Logger } @@ -68,7 +73,7 @@ func NewServer(opts Options) (*Server, error) { } gs := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg))) - nodev1.RegisterNodeControlServer(gs, newService(opts.Version, opts.AWGNode, opts.AWGRegistry, opts.NetPolicy)) + nodev1.RegisterNodeControlServer(gs, newService(opts.Version, opts.AWGNode, opts.AWGRegistry, opts.NetPolicy, opts.XRay)) return &Server{addr: opts.ListenAddr, grpc: gs, log: opts.Log}, nil } diff --git a/internal/control/service.go b/internal/control/service.go index 3fa8b7c..39f2d68 100644 --- a/internal/control/service.go +++ b/internal/control/service.go @@ -11,6 +11,7 @@ import ( "github.com/PharosVPN/node/internal/awg" nodev1 "github.com/PharosVPN/node/internal/gen/pharos/node/v1" "github.com/PharosVPN/node/internal/netpolicy" + "github.com/PharosVPN/node/internal/xray" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" @@ -21,11 +22,12 @@ import ( // GetStatus reports the node's AmneziaWG identity (coxswain refuses to provision // devices until it has it — DESIGN §3) and the AmneziaWG service health. // PushConfig, AddPeer, RemovePeer, ListPeers manage the AmneziaWG peer set -// (B2). GetMetrics reports counters (B4) — totals from the conf+live join -// plus cumulative handshakes_total / errors_total fed by the observer. -// WatchEvents streams the observer's live events (B5). SetNetworkConfig applies -// the node's forwarding / masquerade / isolation policy (decision 16). XRay -// management lands in B3 — those calls return Unimplemented for now. +// (B2) and the XRay/REALITY VLESS client set (B3). GetMetrics reports counters +// (B4) — totals from the conf+live join plus cumulative handshakes_total / +// errors_total fed by the observer. WatchEvents streams the observer's live +// events (B5). SetNetworkConfig applies the node's forwarding / masquerade / +// isolation policy (decision 16). RestartService reloads a protocol's data +// plane in place. type service struct { nodev1.UnimplementedNodeControlServer @@ -34,16 +36,18 @@ type service struct { awgNode *awg.Node awgReg *awg.Registry netPolicy *netpolicy.Applier + xray *xray.Runtime } // newService returns a NodeControl service implementation. -func newService(version string, awgNode *awg.Node, awgReg *awg.Registry, netPolicy *netpolicy.Applier) *service { +func newService(version string, awgNode *awg.Node, awgReg *awg.Registry, netPolicy *netpolicy.Applier, xrayRT *xray.Runtime) *service { return &service{ version: version, started: time.Now(), awgNode: awgNode, awgReg: awgReg, netPolicy: netPolicy, + xray: xrayRT, } } @@ -75,18 +79,36 @@ func (s *service) GetMetrics(ctx context.Context, _ *nodev1.GetMetricsRequest) ( // identity, and per-protocol service health. func (s *service) GetStatus(ctx context.Context, _ *nodev1.GetStatusRequest) (*nodev1.GetStatusResponse, error) { running, listening, peerCount, detail := s.primary().Status(ctx) - return &nodev1.GetStatusResponse{ + services := []*nodev1.ServiceStatus{{ + Protocol: nodev1.Protocol_PROTOCOL_AMNEZIAWG, + Running: running, + Listening: listening, + PeerCount: peerCount, + Detail: detail, + }} + + resp := &nodev1.GetStatusResponse{ AgentVersion: s.version, UptimeSeconds: int64(time.Since(s.started).Seconds()), - Services: []*nodev1.ServiceStatus{{ - Protocol: nodev1.Protocol_PROTOCOL_AMNEZIAWG, - Running: running, - Listening: listening, - PeerCount: peerCount, - Detail: detail, - }}, - Amneziawg: s.awgNode.Info(), - }, nil + Services: services, + Amneziawg: s.awgNode.Info(), + } + + // XRay/REALITY identity + health. The node always owns a REALITY keypair + // (coxswain needs the public key to provision a matching client), even + // before the service is configured up. + if s.xray != nil { + xRunning, xListening, xPeers, xDetail := s.xray.Status() + resp.Services = append(resp.Services, &nodev1.ServiceStatus{ + Protocol: nodev1.Protocol_PROTOCOL_XRAY_REALITY, + Running: xRunning, + Listening: xListening, + PeerCount: xPeers, + Detail: xDetail, + }) + resp.Xray = s.xray.Info() + } + return resp, nil } // WatchEvents streams live data-plane events to coxswain: handshake up/down, @@ -120,16 +142,24 @@ func (s *service) WatchEvents(_ *nodev1.WatchEventsRequest, stream nodev1.NodeCo // // The wire encoding of req.config is fixed by the proto comment: // PROTOCOL_AMNEZIAWG carries proto.Marshal of AmneziaWGConfig (decision: docs -// PR #11 / coxswain PR #28). XRay's encoding lands in B3; other protocols are -// Unimplemented. The node-level obfuscation parameters are deliberately not -// in AmneziaWGConfig — node owns them (awg-node.json), and a request that -// somehow carries them would be ignored. +// PR #11 / coxswain PR #28); PROTOCOL_XRAY_REALITY carries proto.Marshal of +// XRayRealityConfig (the VLESS client set + REALITY camouflage policy). The +// node-level secrets are deliberately not in either config — node owns them +// (awg-node.json / xray-node.json), and a request that somehow carries them +// would be ignored. func (s *service) PushConfig(ctx context.Context, req *nodev1.PushConfigRequest) (*nodev1.PushConfigResponse, error) { - if req.GetProtocol() != nodev1.Protocol_PROTOCOL_AMNEZIAWG { + switch req.GetProtocol() { + case nodev1.Protocol_PROTOCOL_AMNEZIAWG: + return s.pushAmneziaWG(ctx, req) + case nodev1.Protocol_PROTOCOL_XRAY_REALITY: + return s.pushXRay(ctx, req) + default: return nil, status.Errorf(codes.Unimplemented, "PushConfig: protocol %s not yet implemented", req.GetProtocol()) } +} +func (s *service) pushAmneziaWG(ctx context.Context, req *nodev1.PushConfigRequest) (*nodev1.PushConfigResponse, error) { var cfg nodev1.AmneziaWGConfig if err := proto.Unmarshal(req.GetConfig(), &cfg); err != nil { return nil, status.Errorf(codes.InvalidArgument, @@ -165,38 +195,117 @@ func (s *service) PushConfig(ctx context.Context, req *nodev1.PushConfigRequest) }, nil } -// AddPeer adds one peer live. Only AmneziaWG is supported in B2; XRay -// returns Unimplemented (lands in B3). +// pushXRay applies the full XRay/REALITY config: the VLESS client set plus the +// REALITY camouflage policy (decoy dest, accepted SNIs, shortIds, port). The +// node's REALITY keypair is its own identity and is not carried on the wire. +func (s *service) pushXRay(_ context.Context, req *nodev1.PushConfigRequest) (*nodev1.PushConfigResponse, error) { + if s.xray == nil { + return nil, status.Error(codes.Unimplemented, "PushConfig: XRay runtime not enabled on this node") + } + var cfg nodev1.XRayRealityConfig + if err := proto.Unmarshal(req.GetConfig(), &cfg); err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "PushConfig: decode XRayRealityConfig: %v", err) + } + if cfg.GetPort() == 0 { + return nil, status.Error(codes.InvalidArgument, "PushConfig: XRay port required") + } + clients := make([]xray.Client, 0, len(cfg.GetPeers())) + for _, p := range cfg.GetPeers() { + uuid := xrayClientID(p) + if uuid == "" { + return nil, status.Error(codes.InvalidArgument, + "PushConfig: XRay peer missing id (VLESS UUID)") + } + clients = append(clients, xray.Client{UUID: uuid, Flow: p.GetFlow()}) + } + applied, reloaded, err := s.xray.Apply(req.GetRevision(), xray.Config{ + Port: cfg.GetPort(), + Dest: cfg.GetDest(), + ServerNames: append([]string(nil), cfg.GetServerNames()...), + ShortIDs: append([]string(nil), cfg.GetShortIds()...), + Clients: clients, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "PushConfig: %v", err) + } + return &nodev1.PushConfigResponse{AppliedRevision: applied, Reloaded: reloaded}, nil +} + +// xrayClientID returns the VLESS UUID for an XRay peer. coxswain carries the +// UUID in the peer's public_key field (peers.public_key holds the UUID for +// XRay, the WireGuard key for AmneziaWG); peer.id is coxswain's row id, not the +// UUID. RemovePeer likewise identifies an XRay client by public_key. id is only +// a fallback for a caller that put the UUID there. +func xrayClientID(p *nodev1.Peer) string { + if pk := p.GetPublicKey(); pk != "" { + return pk + } + return p.GetId() +} + +// AddPeer adds one peer live. AmneziaWG adds a WireGuard peer; XRay/REALITY +// adds a VLESS client (its UUID + flow) and live-reloads the server. func (s *service) AddPeer(ctx context.Context, req *nodev1.AddPeerRequest) (*nodev1.PeerResponse, error) { peer := req.GetPeer() if peer == nil { return nil, status.Error(codes.InvalidArgument, "AddPeer: missing peer") } - if peer.GetProtocol() != nodev1.Protocol_PROTOCOL_AMNEZIAWG { + switch peer.GetProtocol() { + case nodev1.Protocol_PROTOCOL_AMNEZIAWG: + applied, err := s.primary().AddPeer(ctx, peer) + if err != nil { + return nil, status.Errorf(codes.Internal, "AddPeer: %v", err) + } + return &nodev1.PeerResponse{PeerId: peer.GetId(), Applied: applied}, nil + case nodev1.Protocol_PROTOCOL_XRAY_REALITY: + if s.xray == nil { + return nil, status.Error(codes.Unimplemented, "AddPeer: XRay runtime not enabled on this node") + } + uuid := xrayClientID(peer) + if uuid == "" { + return nil, status.Error(codes.InvalidArgument, "AddPeer: XRay peer missing id (VLESS UUID)") + } + applied, err := s.xray.AddClient(xray.Client{UUID: uuid, Flow: peer.GetFlow()}) + if err != nil { + return nil, status.Errorf(codes.Internal, "AddPeer: %v", err) + } + return &nodev1.PeerResponse{PeerId: uuid, Applied: applied}, nil + default: return nil, status.Errorf(codes.Unimplemented, "AddPeer: protocol %s not yet implemented", peer.GetProtocol()) } - applied, err := s.primary().AddPeer(ctx, peer) - if err != nil { - return nil, status.Errorf(codes.Internal, "AddPeer: %v", err) - } - return &nodev1.PeerResponse{PeerId: peer.GetId(), Applied: applied}, nil } -// RemovePeer revokes one peer live. Only AmneziaWG is supported in B2. +// RemovePeer revokes one peer live. For AmneziaWG public_key is the WireGuard +// key; for XRay/REALITY public_key carries the VLESS UUID to drop. func (s *service) RemovePeer(ctx context.Context, req *nodev1.RemovePeerRequest) (*nodev1.PeerResponse, error) { - if req.GetProtocol() != nodev1.Protocol_PROTOCOL_AMNEZIAWG { + switch req.GetProtocol() { + case nodev1.Protocol_PROTOCOL_AMNEZIAWG: + if req.GetPublicKey() == "" { + return nil, status.Error(codes.InvalidArgument, "RemovePeer: missing public_key") + } + applied, err := s.primary().RemovePeer(ctx, req.GetPublicKey()) + if err != nil { + return nil, status.Errorf(codes.Internal, "RemovePeer: %v", err) + } + return &nodev1.PeerResponse{Applied: applied}, nil + case nodev1.Protocol_PROTOCOL_XRAY_REALITY: + if s.xray == nil { + return nil, status.Error(codes.Unimplemented, "RemovePeer: XRay runtime not enabled on this node") + } + if req.GetPublicKey() == "" { + return nil, status.Error(codes.InvalidArgument, "RemovePeer: missing public_key (VLESS UUID)") + } + applied, err := s.xray.RemoveClient(req.GetPublicKey()) + if err != nil { + return nil, status.Errorf(codes.Internal, "RemovePeer: %v", err) + } + return &nodev1.PeerResponse{Applied: applied}, nil + default: return nil, status.Errorf(codes.Unimplemented, "RemovePeer: protocol %s not yet implemented", req.GetProtocol()) } - if req.GetPublicKey() == "" { - return nil, status.Error(codes.InvalidArgument, "RemovePeer: missing public_key") - } - applied, err := s.primary().RemovePeer(ctx, req.GetPublicKey()) - if err != nil { - return nil, status.Errorf(codes.Internal, "RemovePeer: %v", err) - } - return &nodev1.PeerResponse{Applied: applied}, nil } // SetNetworkConfig applies the node's forwarding / masquerade / isolation @@ -297,19 +406,63 @@ func (s *service) RemoveInnerLink(ctx context.Context, req *nodev1.RemoveInnerLi return &nodev1.RemoveInnerLinkResponse{Removed: true}, nil } -// ListPeers returns configured peers joined with their live state on awg0. -// Filtering by XRay returns Unimplemented; PROTOCOL_UNSPECIFIED is treated -// as AmneziaWG-only until B3 lands XRay. +// ListPeers returns the configured peers for one protocol. AmneziaWG (also the +// default for PROTOCOL_UNSPECIFIED) joins the conf with live awg0 state; XRay +// returns the configured VLESS client set (REALITY exposes no per-client live +// state without the stats API, so those fields stay zero). func (s *service) ListPeers(ctx context.Context, req *nodev1.ListPeersRequest) (*nodev1.ListPeersResponse, error) { switch req.GetProtocol() { case nodev1.Protocol_PROTOCOL_AMNEZIAWG, nodev1.Protocol_PROTOCOL_UNSPECIFIED: + peers, err := s.primary().ListPeers(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "ListPeers: %v", err) + } + return &nodev1.ListPeersResponse{Peers: peers}, nil + case nodev1.Protocol_PROTOCOL_XRAY_REALITY: + if s.xray == nil { + return nil, status.Error(codes.Unimplemented, "ListPeers: XRay runtime not enabled on this node") + } + clients := s.xray.Clients() + states := make([]*nodev1.PeerState, 0, len(clients)) + for _, c := range clients { + states = append(states, &nodev1.PeerState{ + Peer: &nodev1.Peer{ + Id: c.UUID, + PublicKey: c.UUID, // identity carried in both fields for matching + Protocol: nodev1.Protocol_PROTOCOL_XRAY_REALITY, + Flow: c.Flow, + }, + }) + } + return &nodev1.ListPeersResponse{Peers: states}, nil default: return nil, status.Errorf(codes.Unimplemented, "ListPeers: protocol %s not yet implemented", req.GetProtocol()) } - peers, err := s.primary().ListPeers(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "ListPeers: %v", err) +} + +// RestartService reloads a protocol's data plane in place. AmneziaWG +// re-applies the persisted interface conf (bringing it up if down, live- +// reloading it if up — no established tunnel is dropped); XRay/REALITY swaps +// in a fresh xray-core instance from the current config. It is the +// last-resort recovery action coxswain drives from the admin UI. +func (s *service) RestartService(ctx context.Context, req *nodev1.RestartServiceRequest) (*nodev1.RestartServiceResponse, error) { + switch req.GetProtocol() { + case nodev1.Protocol_PROTOCOL_AMNEZIAWG: + if err := s.primary().Reconcile(ctx); err != nil { + return nil, status.Errorf(codes.Internal, "RestartService: %v", err) + } + return &nodev1.RestartServiceResponse{Restarted: true}, nil + case nodev1.Protocol_PROTOCOL_XRAY_REALITY: + if s.xray == nil { + return nil, status.Error(codes.Unimplemented, "RestartService: XRay runtime not enabled on this node") + } + if err := s.xray.Restart(); err != nil { + return nil, status.Errorf(codes.Internal, "RestartService: %v", err) + } + return &nodev1.RestartServiceResponse{Restarted: true}, nil + default: + return nil, status.Errorf(codes.Unimplemented, + "RestartService: protocol %s not yet implemented", req.GetProtocol()) } - return &nodev1.ListPeersResponse{Peers: peers}, nil } diff --git a/internal/netpolicy/netpolicy_test.go b/internal/netpolicy/netpolicy_test.go index fd96af9..3c9366b 100644 --- a/internal/netpolicy/netpolicy_test.go +++ b/internal/netpolicy/netpolicy_test.go @@ -226,9 +226,9 @@ func TestResolveSubstitutesTokens(t *testing.T) { // fakeExec records every command and can be told to fail on a chosen one. type fakeExec struct { - runs [][]string - failOn string // substring; if a command joins to contain it, Run errors - missOnDel bool // delete commands (-D) error, simulating "rule not present" + runs [][]string + failOn string // substring; if a command joins to contain it, Run errors + missOnDel bool // delete commands (-D) error, simulating "rule not present" } func (f *fakeExec) Run(_ context.Context, argv []string) error { diff --git a/internal/xray/identity.go b/internal/xray/identity.go new file mode 100644 index 0000000..0cbd639 --- /dev/null +++ b/internal/xray/identity.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 The PharosVPN Authors + +// Package xray runs the node's embedded XRay/REALITY data plane (DESIGN §3). +// The node owns its REALITY keypair (its identity, like the AmneziaWG one) and +// reports the public key so caravel can build a matching REALITY client; the +// controller pushes only the VLESS clients and the camouflage policy. +package xray + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + nodev1 "github.com/PharosVPN/node/internal/gen/pharos/node/v1" + "golang.org/x/crypto/curve25519" +) + +// stateFileMode is restrictive: the state file holds the node's REALITY private key. +const stateFileMode = 0o600 + +// Identity is the node's XRay/REALITY server identity: a Curve25519 keypair, +// generated once and persisted, so the values stay stable across restarts — +// coxswain caches the public key. Keys are base64url-encoded as XRay/REALITY +// tooling expects. +type Identity struct { + priv []byte // 32-byte clamped Curve25519 private scalar + pub []byte // 32-byte Curve25519 public key +} + +// state is the on-disk JSON form. The public key is derived on load. +type state struct { + PrivateKey string `json:"private_key"` // base64url, REALITY format +} + +// Load returns the node's REALITY identity from path, generating + persisting a +// fresh keypair on first run. +func Load(path string) (*Identity, error) { + switch raw, err := os.ReadFile(path); { + case err == nil: + return loadState(raw) + case errors.Is(err, os.ErrNotExist): + return generate(path) + default: + return nil, fmt.Errorf("xray: read %s: %w", path, err) + } +} + +func loadState(raw []byte) (*Identity, error) { + var s state + if err := json.Unmarshal(raw, &s); err != nil { + return nil, fmt.Errorf("xray: decode state: %w", err) + } + priv, err := base64.RawURLEncoding.DecodeString(s.PrivateKey) + if err != nil || len(priv) != 32 { + return nil, fmt.Errorf("xray: invalid private key in state") + } + pub, err := curve25519.X25519(priv, curve25519.Basepoint) + if err != nil { + return nil, fmt.Errorf("xray: derive public key: %w", err) + } + return &Identity{priv: priv, pub: pub}, nil +} + +func generate(path string) (*Identity, error) { + priv := make([]byte, 32) + if _, err := rand.Read(priv); err != nil { + return nil, fmt.Errorf("xray: generate key: %w", err) + } + // Standard X25519 clamp (matches `xray x25519`). + priv[0] &= 248 + priv[31] &= 127 + priv[31] |= 64 + pub, err := curve25519.X25519(priv, curve25519.Basepoint) + if err != nil { + return nil, fmt.Errorf("xray: derive public key: %w", err) + } + id := &Identity{priv: priv, pub: pub} + if err := id.persist(path); err != nil { + return nil, err + } + return id, nil +} + +func (i *Identity) persist(path string) error { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("xray: create %s: %w", dir, err) + } + } + raw, err := json.MarshalIndent(state{PrivateKey: i.PrivateKey()}, "", " ") + if err != nil { + return fmt.Errorf("xray: encode state: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, raw, stateFileMode); err != nil { + return fmt.Errorf("xray: write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("xray: replace %s: %w", path, err) + } + return nil +} + +// PrivateKey returns the REALITY private key, base64url-encoded (server config). +func (i *Identity) PrivateKey() string { return base64.RawURLEncoding.EncodeToString(i.priv) } + +// PublicKey returns the REALITY public key, base64url-encoded (the client needs it). +func (i *Identity) PublicKey() string { return base64.RawURLEncoding.EncodeToString(i.pub) } + +// Info is the node's REALITY identity for GetStatus. +func (i *Identity) Info() *nodev1.XRayRealityInfo { + return &nodev1.XRayRealityInfo{PublicKey: i.PublicKey()} +} diff --git a/internal/xray/runtime.go b/internal/xray/runtime.go new file mode 100644 index 0000000..1598eb6 --- /dev/null +++ b/internal/xray/runtime.go @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 The PharosVPN Authors + +package xray + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "sync" + + nodev1 "github.com/PharosVPN/node/internal/gen/pharos/node/v1" + "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/infra/conf/serial" + + // distro/all registers every protocol/transport (VLESS, REALITY, freedom, …) + // so a JSON config can reference them. Pulls the full xray-core; the binary + // stays a single static build (CGO_ENABLED=0). + _ "github.com/xtls/xray-core/main/distro/all" +) + +// Client is one VLESS client (an end-user device) the node accepts. +type Client struct { + UUID string + Flow string // e.g. "xtls-rprx-vision"; empty = no flow +} + +// Config is the REALITY server policy coxswain pushes (the keypair is the node's +// own Identity, not here). Port 0 means "not configured" — the runtime stays down. +type Config struct { + Port uint32 + Dest string // REALITY decoy, host:port + ServerNames []string // accepted SNIs (must include the decoy host) + ShortIDs []string // allowed REALITY shortIds ("" allows none) + Clients []Client +} + +// Runtime runs an embedded xray-core VLESS+REALITY server and reloads it on +// config or client changes (a full instance swap — matches PushConfig's +// full-replace semantics). +type Runtime struct { + id *Identity + log *slog.Logger + + mu sync.Mutex + inst *core.Instance + cfg Config + rev int64 +} + +// NewRuntime returns an XRay runtime bound to the node's REALITY identity. It +// starts down; coxswain brings it up with PushConfig. +func NewRuntime(id *Identity, log *slog.Logger) *Runtime { + if log == nil { + log = slog.Default() + } + return &Runtime{id: id, log: log} +} + +// Apply installs the full config + client set (PushConfig). A revision older +// than the applied one is rejected; an equal one is a no-op. +func (r *Runtime) Apply(rev int64, cfg Config) (applied int64, reloaded bool, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if rev < r.rev { + return r.rev, false, fmt.Errorf("xray: stale revision %d (applied %d)", rev, r.rev) + } + if rev == r.rev && r.inst != nil { + return r.rev, false, nil + } + prev := r.cfg + r.cfg = cfg + if err := r.rebuild(); err != nil { + r.cfg = prev // roll back the intended config on failure + return r.rev, false, err + } + r.rev = rev + return rev, true, nil +} + +// AddClient adds one VLESS client live and reloads. +func (r *Runtime) AddClient(c Client) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, e := range r.cfg.Clients { + if e.UUID == c.UUID { + return false, nil // already present + } + } + r.cfg.Clients = append(r.cfg.Clients, c) + if err := r.rebuild(); err != nil { + return false, err + } + return true, nil +} + +// RemoveClient removes one VLESS client live and reloads. +func (r *Runtime) RemoveClient(uuid string) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := r.cfg.Clients[:0:0] + removed := false + for _, e := range r.cfg.Clients { + if e.UUID == uuid { + removed = true + continue + } + out = append(out, e) + } + if !removed { + return false, nil + } + r.cfg.Clients = out + if err := r.rebuild(); err != nil { + return false, err + } + return true, nil +} + +// Clients returns a copy of the current VLESS client set. +func (r *Runtime) Clients() []Client { + r.mu.Lock() + defer r.mu.Unlock() + return append([]Client(nil), r.cfg.Clients...) +} + +// Status reports the XRay service health for GetStatus. +func (r *Runtime) Status() (running, listening bool, count uint32, detail string) { + r.mu.Lock() + defer r.mu.Unlock() + up := r.inst != nil + if r.cfg.Port == 0 { + return false, false, 0, "not configured" + } + return up, up, uint32(len(r.cfg.Clients)), fmt.Sprintf("REALITY :%d", r.cfg.Port) +} + +// Info returns the node's REALITY identity (its public key) for GetStatus. +func (r *Runtime) Info() *nodev1.XRayRealityInfo { return r.id.Info() } + +// Restart re-applies the current config (a fresh instance) — the last-resort +// RestartService for XRay. +func (r *Runtime) Restart() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.rebuild() +} + +// Stop shuts the instance down. +func (r *Runtime) Stop() { + r.mu.Lock() + defer r.mu.Unlock() + if r.inst != nil { + _ = r.inst.Close() + r.inst = nil + } +} + +// rebuild renders the xray JSON from the identity + current config, builds a new +// instance (validating the config while the old one still serves), then swaps: +// close old, start new. With Port 0 it tears the instance down. Caller holds mu. +func (r *Runtime) rebuild() error { + if r.cfg.Port == 0 { + if r.inst != nil { + _ = r.inst.Close() + r.inst = nil + } + return nil + } + raw, err := r.renderJSON() + if err != nil { + return err + } + coreCfg, err := serial.LoadJSONConfig(bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("xray: load config: %w", err) + } + next, err := core.New(coreCfg) // validate before disturbing the running one + if err != nil { + return fmt.Errorf("xray: build instance: %w", err) + } + if r.inst != nil { + _ = r.inst.Close() + r.inst = nil + } + if err := next.Start(); err != nil { + return fmt.Errorf("xray: start instance: %w", err) + } + r.inst = next + r.log.Info("xray REALITY server (re)started", "port", r.cfg.Port, "clients", len(r.cfg.Clients)) + return nil +} + +func (r *Runtime) renderJSON() ([]byte, error) { + clients := make([]jsonClient, 0, len(r.cfg.Clients)) + for _, c := range r.cfg.Clients { + clients = append(clients, jsonClient{ID: c.UUID, Flow: c.Flow}) + } + cfg := jsonConfig{ + Inbounds: []jsonInbound{{ + Port: r.cfg.Port, + Protocol: "vless", + Settings: jsonVLESS{Clients: clients, Decryption: "none"}, + StreamSettings: jsonStream{ + Network: "tcp", + Security: "reality", + Reality: jsonReality{ + Dest: r.cfg.Dest, + ServerNames: r.cfg.ServerNames, + PrivateKey: r.id.PrivateKey(), + ShortIDs: r.cfg.ShortIDs, + }, + }, + }}, + Outbounds: []jsonOutbound{{Protocol: "freedom"}}, + } + return json.Marshal(cfg) +} + +// xray JSON config (the subset the node uses). +type jsonConfig struct { + Inbounds []jsonInbound `json:"inbounds"` + Outbounds []jsonOutbound `json:"outbounds"` +} +type jsonInbound struct { + Port uint32 `json:"port"` + Protocol string `json:"protocol"` + Settings jsonVLESS `json:"settings"` + StreamSettings jsonStream `json:"streamSettings"` +} +type jsonVLESS struct { + Clients []jsonClient `json:"clients"` + Decryption string `json:"decryption"` +} +type jsonClient struct { + ID string `json:"id"` + Flow string `json:"flow,omitempty"` +} +type jsonStream struct { + Network string `json:"network"` + Security string `json:"security"` + Reality jsonReality `json:"realitySettings"` +} +type jsonReality struct { + Dest string `json:"dest"` + ServerNames []string `json:"serverNames"` + PrivateKey string `json:"privateKey"` + ShortIDs []string `json:"shortIds"` +} +type jsonOutbound struct { + Protocol string `json:"protocol"` +} diff --git a/internal/xray/xray_test.go b/internal/xray/xray_test.go new file mode 100644 index 0000000..f9c7519 --- /dev/null +++ b/internal/xray/xray_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 The PharosVPN Authors + +package xray + +import ( + "encoding/base64" + "io" + "log/slog" + "net" + "path/filepath" + "strconv" + "testing" +) + +// TestIdentityPersistAndReload checks the REALITY identity is generated once, +// persisted, and reloads to the same keypair — coxswain caches the public key, +// so it must be stable across restarts. +func TestIdentityPersistAndReload(t *testing.T) { + path := filepath.Join(t.TempDir(), "xray-node.json") + + id, err := Load(path) + if err != nil { + t.Fatalf("Load (generate): %v", err) + } + priv, pub := id.PrivateKey(), id.PublicKey() + + // Keys must be valid base64url-encoded 32-byte Curve25519 values. + for name, key := range map[string]string{"private": priv, "public": pub} { + raw, err := base64.RawURLEncoding.DecodeString(key) + if err != nil { + t.Fatalf("%s key not base64url: %v", name, err) + } + if len(raw) != 32 { + t.Fatalf("%s key is %d bytes, want 32", name, len(raw)) + } + } + + reloaded, err := Load(path) + if err != nil { + t.Fatalf("Load (reload): %v", err) + } + if reloaded.PrivateKey() != priv || reloaded.PublicKey() != pub { + t.Fatalf("identity changed across reload:\n priv %s -> %s\n pub %s -> %s", + priv, reloaded.PrivateKey(), pub, reloaded.PublicKey()) + } + if reloaded.Info().GetPublicKey() != pub { + t.Fatalf("Info public key %q, want %q", reloaded.Info().GetPublicKey(), pub) + } +} + +// TestRuntimeStartsRealityServer drives the full render → load → start path of +// the embedded xray-core: a VLESS+REALITY server actually binds a port. It also +// exercises live client add/remove and teardown (port 0). This is the in-repo +// equivalent of the embedding de-risk probe. +func TestRuntimeStartsRealityServer(t *testing.T) { + id, err := Load(filepath.Join(t.TempDir(), "xray-node.json")) + if err != nil { + t.Fatalf("Load: %v", err) + } + rt := NewRuntime(id, slog.New(slog.NewTextHandler(io.Discard, nil))) + + port := freePort(t) + cfg := Config{ + Port: uint32(port), + Dest: "www.microsoft.com:443", + ServerNames: []string{"www.microsoft.com"}, + ShortIDs: []string{""}, + Clients: []Client{{UUID: "11111111-1111-1111-1111-111111111111", Flow: "xtls-rprx-vision"}}, + } + + applied, reloaded, err := rt.Apply(1, cfg) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if applied != 1 || !reloaded { + t.Fatalf("Apply = (%d, %v), want (1, true)", applied, reloaded) + } + running, listening, count, _ := rt.Status() + if !running || !listening || count != 1 { + t.Fatalf("Status = (running=%v, listening=%v, count=%d), want (true, true, 1)", running, listening, count) + } + // The REALITY server must actually be accepting TCP on the port. + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err != nil { + t.Fatalf("dial running REALITY server: %v", err) + } + _ = conn.Close() + + // Stale revision is rejected; an equal one is a no-op. + if _, _, err := rt.Apply(0, cfg); err == nil { + t.Fatal("Apply with stale revision 0 should fail") + } + + // Live client add/remove reload the server. + added, err := rt.AddClient(Client{UUID: "22222222-2222-2222-2222-222222222222"}) + if err != nil || !added { + t.Fatalf("AddClient = (%v, %v), want (true, nil)", added, err) + } + if got := len(rt.Clients()); got != 2 { + t.Fatalf("Clients = %d, want 2", got) + } + removed, err := rt.RemoveClient("22222222-2222-2222-2222-222222222222") + if err != nil || !removed { + t.Fatalf("RemoveClient = (%v, %v), want (true, nil)", removed, err) + } + if got := len(rt.Clients()); got != 1 { + t.Fatalf("Clients after remove = %d, want 1", got) + } + + // Port 0 tears the service down. + if _, _, err := rt.Apply(2, Config{}); err != nil { + t.Fatalf("Apply (teardown): %v", err) + } + if running, _, _, _ := rt.Status(); running { + t.Fatal("Status running after teardown, want down") + } + rt.Stop() +} + +// freePort returns a TCP port that was free at the moment of the call. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port +}