From 06dfeaf0c451e0ac03dfff6242bd8b065fee825a Mon Sep 17 00:00:00 2001 From: Ain Ghazal <99027643+ainghazal@users.noreply.github.com> Date: Wed, 10 Jan 2024 16:03:07 +0100 Subject: [PATCH] refactor: introduce the networkio layer (#46) This commit introduces the lower layer in the new layered minivpn architecture. This new architecture has already been implemented in an integration branch. The general idea is to split functionality by layers that we can reason about and write tests for more easily. Each layer will communicate with other layers through channels. Also, each layer will run a bunch of worker for performing channel or network-based I/O. Here, in particular, we're adding the network I/O layer on top of which the rest of the architecture is based, as well as all the required dependencies for making the code compile. Reference issue: https://github.com/ooni/minivpn/issues/47 --- .editorconfig | 18 + .gitignore | 11 +- go.mod | 27 +- go.sum | 55 +-- internal/bytesx/bytesx.go | 157 ++++++++ internal/model/dialer.go | 11 + internal/model/doc.go | 2 + internal/model/logger.go | 23 ++ internal/model/notification.go | 12 + internal/model/options.go | 649 ++++++++++++++++++++++++++++++++ internal/model/packet.go | 308 +++++++++++++++ internal/networkio/closeonce.go | 35 ++ internal/networkio/datagram.go | 33 ++ internal/networkio/dialer.go | 49 +++ internal/networkio/doc.go | 2 + internal/networkio/framing.go | 31 ++ internal/networkio/service.go | 115 ++++++ internal/networkio/stream.go | 45 +++ internal/runtimex/runtimex.go | 19 + internal/workers/workers.go | 63 ++++ 20 files changed, 1625 insertions(+), 40 deletions(-) create mode 100644 .editorconfig create mode 100644 internal/bytesx/bytesx.go create mode 100644 internal/model/dialer.go create mode 100644 internal/model/doc.go create mode 100644 internal/model/logger.go create mode 100644 internal/model/notification.go create mode 100644 internal/model/options.go create mode 100644 internal/model/packet.go create mode 100644 internal/networkio/closeonce.go create mode 100644 internal/networkio/datagram.go create mode 100644 internal/networkio/dialer.go create mode 100644 internal/networkio/doc.go create mode 100644 internal/networkio/framing.go create mode 100644 internal/networkio/service.go create mode 100644 internal/networkio/stream.go create mode 100644 internal/runtimex/runtimex.go create mode 100644 internal/workers/workers.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..28afb69c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = tab +indent_size = 4 +trim_trailing_whitespace = true + +[*.py] +indent_style = space + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.java] +indent_style = space diff --git a/.gitignore b/.gitignore index 90c93439..6b0e6c86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -minivpn -./obfs4vpn -./vpnping -./geturl -ndt7 +/minivpn +/vpnping +/obfs4vpn +/geturl +/ndt7 +.vscode *.swp *.swo *.pem diff --git a/go.mod b/go.mod index 266b6af7..3b12fd46 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ooni/minivpn -go 1.18 +go 1.20 // pinning for backwards-incompatible change // replace gitlab.com/yawning/obfs4.git v0.0.0-20220204003609-77af0cba934d => gitlab.com/yawning/obfs4.git v0.0.0-20210511220700-e330d1b7024b @@ -19,15 +19,15 @@ require ( github.com/pborman/getopt/v2 v2.1.0 github.com/refraction-networking/utls v1.3.1 gitlab.com/yawning/obfs4.git v0.0.0-20220904064028-336a71d6e4cf - golang.org/x/net v0.8.0 - golang.org/x/sync v0.1.0 - golang.zx2c4.com/wireguard v0.0.0-20230313165553-0ad14a89f5f9 + golang.org/x/net v0.17.0 + golang.org/x/sync v0.4.0 + golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 ) require ( filippo.io/edwards25519 v1.0.0-rc.1.0.20210721174708-390f27c3be20 // indirect github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect - github.com/Microsoft/go-winio v0.5.2 // indirect + github.com/Microsoft/go-winio v0.6.0 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/andybalholm/brotli v1.0.4 // indirect github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 // indirect @@ -39,7 +39,7 @@ require ( github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/btree v1.0.1 // indirect + github.com/google/btree v1.1.2 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/klauspost/compress v1.15.15 // indirect @@ -53,15 +53,18 @@ require ( github.com/opencontainers/image-spec v1.0.2 // indirect github.com/opencontainers/runc v1.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/sirupsen/logrus v1.8.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect gitlab.com/yawning/edwards25519-extra.git v0.0.0-20211229043746-2f91fcc9fbdb // indirect - golang.org/x/crypto v0.6.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.14.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - gopkg.in/yaml.v2 v2.3.0 // indirect - gvisor.dev/gvisor v0.0.0-20221203005347-703fd9b7fbc0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect ) diff --git a/go.sum b/go.sum index 1ae4137a..34049e97 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,8 @@ github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7O github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= +github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -148,8 +148,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= -github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +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.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -298,9 +298,9 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= @@ -311,7 +311,9 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= @@ -348,8 +350,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -380,6 +382,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -405,8 +409,8 @@ golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -420,8 +424,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -462,8 +466,9 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -472,8 +477,9 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -512,14 +518,16 @@ golang.org/x/tools v0.0.0-20200409170454-77362c5149f0/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200422205258-72e4a01eba43/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-20230313165553-0ad14a89f5f9 h1:33IsKfBTQLVYccOgWKaE7X+lVqZVN9EjcDSNfI10Lmc= -golang.zx2c4.com/wireguard v0.0.0-20230313165553-0ad14a89f5f9/go.mod h1:KNrjddgin1zD9sfQawwoXCUwWboceZH78ASVHkXu6GM= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -584,8 +592,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.2-0.20230118093459-a9481185b34d h1:qp0AnQCvRCMlu9jBjtdbTaaEmThIgZOrbVyDEOcmKhQ= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -601,15 +609,16 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c h1:grhR+C34yXImVGp7EzNk+DTIk+323eIUWOmEevy6bDo= gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= -gvisor.dev/gvisor v0.0.0-20221203005347-703fd9b7fbc0 h1:Wobr37noukisGxpKo5jAsLREcpj61RxrWYzD8uwveOY= -gvisor.dev/gvisor v0.0.0-20221203005347-703fd9b7fbc0/go.mod h1:Dn5idtptoW1dIos9U6A2rpebLs/MtTwFacjKb8jLdQA= +gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ= +gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/bytesx/bytesx.go b/internal/bytesx/bytesx.go new file mode 100644 index 00000000..fbaa75d6 --- /dev/null +++ b/internal/bytesx/bytesx.go @@ -0,0 +1,157 @@ +// Package bytesx provides functions operating on bytes. +// +// Specifically we implement these operations: +// +// 1. generating random bytes; +// +// 2. OpenVPN options encoding and decoding; +// +// 3. PKCS#7 padding and unpadding. +package bytesx + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + + "github.com/ooni/minivpn/internal/runtimex" +) + +var ( + // ErrEncodeOption indicates an option encoding error occurred. + ErrEncodeOption = errors.New("can't encode option") + + // ErrDecodeOption indicates an option decoding error occurred. + ErrDecodeOption = errors.New("can't decode option") + + // ErrPaddingPKCS7 indicates that a PKCS#7 padding error has occurred. + ErrPaddingPKCS7 = errors.New("PKCS#7 padding error") + + // ErrUnpaddingPKCS7 indicates that a PKCS#7 unpadding error has occurred. + ErrUnpaddingPKCS7 = errors.New("PKCS#7 unpadding error") +) + +// genRandomBytes returns an array of bytes with the given size using +// a CSRNG, on success, or an error, in case of failure. +func GenRandomBytes(size int) ([]byte, error) { + b := make([]byte, size) + _, err := rand.Read(b) + return b, err +} + +// EncodeOptionStringToBytes is used to encode the options string, username and password. +// +// According to the OpenVPN protocol, options are represented as a two-byte word, +// plus the byte representation of the string, null-terminated. +// +// See https://openvpn.net/community-resources/openvpn-protocol/. +// +// This function returns errEncodeOption in case of failure. +func EncodeOptionStringToBytes(s string) ([]byte, error) { + if len(s) >= math.MaxUint16 { // Using >= b/c we need to account for the final \0 + return nil, fmt.Errorf("%w:%s", ErrEncodeOption, "string too large") + } + data := make([]byte, 2) + binary.BigEndian.PutUint16(data, uint16(len(s))+1) + data = append(data, []byte(s)...) + data = append(data, 0x00) + return data, nil +} + +// DecodeOptionStringFromBytes returns the string-value for the null-terminated string +// returned by the server when sending remote options to us. +// +// This function returns errDecodeOption on failure. +func DecodeOptionStringFromBytes(b []byte) (string, error) { + if len(b) < 2 { + return "", fmt.Errorf("%w: expected at least two bytes", ErrDecodeOption) + } + length := int(binary.BigEndian.Uint16(b[:2])) + b = b[2:] // skip over the length + // the server sends padding, so we cannot do a strict check + if len(b) < length { + return "", fmt.Errorf("%w: got %d, expected %d", ErrDecodeOption, len(b), length) + } + if len(b) <= 0 || length == 0 { + return "", fmt.Errorf("%w: zero length encoded option is not possible: %s", ErrDecodeOption, + "we need at least one byte for the trailing \\0") + } + if b[length-1] != 0x00 { + return "", fmt.Errorf("%w: missing trailing \\0", ErrDecodeOption) + } + return string(b[:len(b)-1]), nil +} + +// BytesUnpadPKCS7 performs the PKCS#7 unpadding of a byte array. +func BytesUnpadPKCS7(b []byte, blockSize int) ([]byte, error) { + // 1. check whether we can unpad at all + if blockSize > math.MaxUint8 { + return nil, fmt.Errorf("%w: blockSize too large", ErrUnpaddingPKCS7) + } + // 2. trivial case + if len(b) <= 0 { + return nil, fmt.Errorf("%w: passed empty buffer", ErrUnpaddingPKCS7) + } + // 4. read the padding size + psiz := int(b[len(b)-1]) + // 5. enforce padding size constraints + if psiz <= 0x00 { + return nil, fmt.Errorf("%w: padding size cannot be zero", ErrUnpaddingPKCS7) + } + if psiz > blockSize { + return nil, fmt.Errorf("%w: padding size cannot be larger than blockSize", ErrUnpaddingPKCS7) + } + // 6. compute the padding offset + off := len(b) - psiz + // 7. return unpadded bytes + runtimex.Assert(off >= 0 && off <= len(b), "off is out of bounds") + return b[:off], nil +} + +// bytesPadPKCS7 returns the PKCS#7 padding of a byte array. +func BytesPadPKCS7(b []byte, blockSize int) ([]byte, error) { + runtimex.PanicIfTrue(blockSize <= 0, "blocksize cannot be negative or zero") + + // If lth mod blockSize == 0, then the input gets appended a whole block size + // See https://datatracker.ietf.org/doc/html/rfc5652#section-6.3 + if blockSize > math.MaxUint8 { + // This padding method is well defined iff blockSize is less than 256. + return nil, ErrPaddingPKCS7 + } + psiz := blockSize - len(b)%blockSize + padding := bytes.Repeat([]byte{byte(psiz)}, psiz) + return append(b, padding...), nil +} + +// ReadUint32 is a convenience function that reads a uint32 from a 4-byte +// buffer, returning an error if the operation failed. +func ReadUint32(buf *bytes.Buffer) (uint32, error) { + var numBuf [4]byte + _, err := io.ReadFull(buf, numBuf[:]) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint32(numBuf[:]), nil +} + +// WriteUint32 is a convenience function that appends to the given buffer +// 4 bytes containing the big-endian representation of the given uint32 value. +func WriteUint32(buf *bytes.Buffer, val uint32) { + var numBuf [4]byte + binary.BigEndian.PutUint32(numBuf[:], val) + buf.Write(numBuf[:]) +} + +// WriteUint24 is a convenience function that appends to the given buffer +// 3 bytes containing the big-endian representation of the given uint32 value. +// Caller is responsible to ensure the passed value does not overflow the +// maximal capacity of 3 bytes. +func WriteUint24(buf *bytes.Buffer, val uint32) { + b := &bytes.Buffer{} + WriteUint32(b, val) + buf.Write(b.Bytes()[1:]) +} diff --git a/internal/model/dialer.go b/internal/model/dialer.go new file mode 100644 index 00000000..90915329 --- /dev/null +++ b/internal/model/dialer.go @@ -0,0 +1,11 @@ +package model + +import ( + "context" + "net" +) + +// Dialer is a type allowing to dial network connections. +type Dialer interface { + DialContext(context.Context, string, string) (net.Conn, error) +} diff --git a/internal/model/doc.go b/internal/model/doc.go new file mode 100644 index 00000000..4232563e --- /dev/null +++ b/internal/model/doc.go @@ -0,0 +1,2 @@ +// Package model implements common models for the vpn data structures. +package model diff --git a/internal/model/logger.go b/internal/model/logger.go new file mode 100644 index 00000000..f008b2b3 --- /dev/null +++ b/internal/model/logger.go @@ -0,0 +1,23 @@ +// Package model contains common data models. +package model + +// Logger is the generic logger definition. +type Logger interface { + // Debug emits a debug message. + Debug(msg string) + + // Debugf formats and emits a debug message. + Debugf(format string, v ...any) + + // Info emits an informational message. + Info(msg string) + + // Infof formats and emits an informational message. + Infof(format string, v ...any) + + // Warn emits a warning message. + Warn(msg string) + + // Warnf formats and emits a warning message. + Warnf(format string, v ...any) +} diff --git a/internal/model/notification.go b/internal/model/notification.go new file mode 100644 index 00000000..629b4315 --- /dev/null +++ b/internal/model/notification.go @@ -0,0 +1,12 @@ +package model + +const ( + // NotificationReset indicates that a SOFT or HARD reset occurred. + NotificationReset = 1 << iota +) + +// Notification is a notification for a service worker. +type Notification struct { + // Flags contains flags explaining what happened. + Flags int64 +} diff --git a/internal/model/options.go b/internal/model/options.go new file mode 100644 index 00000000..9a344a33 --- /dev/null +++ b/internal/model/options.go @@ -0,0 +1,649 @@ +package model + +// +// Parse VPN options. +// +// Mostly, this file conforms to the format in the reference implementation. +// However, there are some additions that are specific. To avoid feature creep +// and fat dependencies, the main `vpn` module only supports mainline +// capabilities. It is still useful to carry all options in a single type, +// so it's up to the user of this library to do something useful with +// such options. The `extra` package provides some of these extra features, like +// obfuscation support. +// +// Following the configuration format in the reference implementation, `minivpn` +// allows including files in the main configuration file, but only for the `ca`, +// `cert` and `key` options. +// +// Each inline file is started by the line . +// +// Here is an example of an inline file usage: +// +// ``` +// +// -----BEGIN CERTIFICATE----- +// [...] +// -----END CERTIFICATE----- +// +// ``` + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" +) + +type ( + // Compression describes a Compression type (e.g., stub). + Compression string +) + +const ( + // CompressionStub adds the (empty) compression stub to the packets. + CompressionStub = Compression("stub") + + // CompressionEmpty is the empty compression. + CompressionEmpty = Compression("empty") + + // CompressionLZONo is lzo-no (another type of no-compression, older). + CompressionLZONo = Compression("lzo-no") +) + +// Proto is the main vpn mode (e.g., TCP or UDP). +type Proto string + +var _ fmt.Stringer = Proto("") + +// String implements fmt.Stringer +func (p Proto) String() string { + return string(p) +} + +// ProtoTCP is used for vpn in TCP mode. +const ProtoTCP = Proto("tcp") + +// ProtoUDP is used for vpn in UDP mode. +const ProtoUDP = Proto("udp") + +// ErrBadConfig is the generic error returned for invalid config files +var ErrBadConfig = errors.New("openvpn: bad config") + +// SupportCiphers defines the supported ciphers. +var SupportedCiphers = []string{ + "AES-128-CBC", + "AES-192-CBC", + "AES-256-CBC", + "AES-128-GCM", + "AES-192-GCM", + "AES-256-GCM", +} + +// SupportedAuth defines the supported authentication methods. +var SupportedAuth = []string{ + "SHA1", + "SHA256", + "SHA512", +} + +// Options make all the relevant configuration options accessible to the +// different modules that need it. +type Options struct { + // These options have the same name of OpenVPN options: + Remote string + Port string + Proto Proto + Username string + Password string + CAPath string + CertPath string + KeyPath string + CA []byte + Cert []byte + Key []byte + Cipher string + Auth string + TLSMaxVer string + + // Below are options that do not conform to the OpenVPN configuration format: + Compress Compression + ProxyOBFS4 string +} + +// ReadConfigFile expects a string with a path to a valid config file, +// and returns a pointer to a Options struct after parsing the file, and an +// error if the operation could not be completed. +func ReadConfigFile(filePath string) (*Options, error) { + lines, err := getLinesFromFile(filePath) + dir, _ := filepath.Split(filePath) + if err != nil { + return nil, err + } + return getOptionsFromLines(lines, dir) +} + +// ShouldLoadCertsFromPath returns true when the options object is configured to load +// certificates from paths; false when we have inline certificates. +func (o *Options) ShouldLoadCertsFromPath() bool { + return o.CertPath != "" && o.KeyPath != "" && o.CAPath != "" +} + +// HasAuthInfo returns true if: +// - we have paths for cert, key and ca; or +// - we have inline byte arrays for cert, key and ca; or +// - we have username + password info. +func (o *Options) HasAuthInfo() bool { + if o.CertPath != "" && o.KeyPath != "" && o.CAPath != "" { + return true + } + if len(o.Cert) != 0 && len(o.Key) != 0 && len(o.CA) != 0 { + return true + } + if o.Username != "" && o.Password != "" { + return true + } + return false +} + +// clientOptions is the options line we're passing to the OpenVPN server during the handshake. +const clientOptions = "V4,dev-type tun,link-mtu 1549,tun-mtu 1500,proto %sv4,cipher %s,auth %s,keysize %s,key-method 2,tls-client" + +// ServerOptionsString produces a comma-separated representation of the options, in the same +// order and format that the OpenVPN server expects from us. +func (o *Options) ServerOptionsString() string { + if o.Cipher == "" { + return "" + } + // TODO(ainghazal): this line of code crashes if the ciphers are not well formed + keysize := strings.Split(o.Cipher, "-")[1] + proto := strings.ToUpper(ProtoUDP.String()) + if o.Proto == ProtoTCP { + proto = strings.ToUpper(ProtoTCP.String()) + } + s := fmt.Sprintf(clientOptions, proto, o.Cipher, o.Auth, keysize) + if o.Compress == CompressionStub { + s = s + ",compress stub" + } else if o.Compress == "lzo-no" { + s = s + ",lzo-comp no" + } else if o.Compress == CompressionEmpty { + s = s + ",compress" + } + return s +} + +// TunnelInfo holds state about the VPN TunnelInfo that has longer duration than a +// given session. This information is gathered at different stages: +// - during the handshake (mtu). +// - after server pushes config options(ip, gw). +type TunnelInfo struct { + MTU int + IP string + GW string + PeerID int +} + +// NewTunnelInfoFromPushedOptions takes a map of string to array of strings, and returns +// a new tunnel struct with the relevant info. +func NewTunnelInfoFromPushedOptions(opts map[string][]string) *TunnelInfo { + t := &TunnelInfo{} + if r := opts["route"]; len(r) >= 1 { + t.GW = r[0] + } else if r := opts["route-gateway"]; len(r) >= 1 { + t.GW = r[0] + } + ip := opts["ifconfig"] + if len(ip) >= 1 { + t.IP = ip[0] + } + peerID := opts["peer-id"] + if len(peerID) == 1 { + peer, err := strconv.Atoi(peerID[0]) + if err != nil { + log.Println("Cannot parse peer-id:", err.Error()) + } else { + t.PeerID = peer + } + } + return t +} + +// parseIntFromOption parses an int from a null-terminated string +func parseIntFromOption(s string) (int, error) { + str := "" + for i := 0; i < len(s); i++ { + if byte(s[i]) == 0x00 { + return strconv.Atoi(str) + } + str = str + string(s[i]) + } + return 0, nil +} + +// PushedOptionsAsMap returns a map for the server-pushed options, +// where the options are the keys and each space-separated value is the value. +// This function always returns an initialized map, even if empty. +func PushedOptionsAsMap(pushedOptions []byte) map[string][]string { + optMap := make(map[string][]string) + if len(pushedOptions) == 0 { + return optMap + } + + optStr := string(pushedOptions[:len(pushedOptions)-1]) + + opts := strings.Split(optStr, ",") + for _, opt := range opts { + vals := strings.Split(opt, " ") + k, v := vals[0], vals[1:] + optMap[k] = v + } + return optMap +} + +func parseProto(p []string, o *Options) error { + if len(p) != 1 { + return fmt.Errorf("%w: %s", ErrBadConfig, "proto needs one arg") + } + m := p[0] + switch m { + case ProtoUDP.String(): + o.Proto = ProtoUDP + case ProtoTCP.String(): + o.Proto = ProtoTCP + default: + return fmt.Errorf("%w: bad proto: %s", ErrBadConfig, m) + + } + return nil +} + +// TODO(ainghazal): all these little functions can be better tested if we return the options object too + +func parseRemote(p []string, o *Options) error { + if len(p) != 2 { + return fmt.Errorf("%w: %s", ErrBadConfig, "remote needs two args") + } + o.Remote, o.Port = p[0], p[1] + return nil +} + +func parseCipher(p []string, o *Options) error { + if len(p) != 1 { + return fmt.Errorf("%w: %s", ErrBadConfig, "cipher expects one arg") + } + cipher := p[0] + if !hasElement(cipher, SupportedCiphers) { + return fmt.Errorf("%w: unsupported cipher: %s", ErrBadConfig, cipher) + } + o.Cipher = cipher + return nil +} + +func parseAuth(p []string, o *Options) error { + if len(p) != 1 { + return fmt.Errorf("%w: %s", ErrBadConfig, "invalid auth entry") + } + auth := p[0] + if !hasElement(auth, SupportedAuth) { + return fmt.Errorf("%w: unsupported auth: %s", ErrBadConfig, auth) + } + o.Auth = auth + return nil +} + +func parseCA(p []string, o *Options, basedir string) error { + e := fmt.Errorf("%w: %s", ErrBadConfig, "ca expects a valid file") + if len(p) != 1 { + return e + } + ca := toAbs(p[0], basedir) + if sub, _ := isSubdir(basedir, ca); !sub { + return fmt.Errorf("%w: %s", ErrBadConfig, "ca must be below config path") + } + if !existsFile(ca) { + return e + } + o.CAPath = ca + return nil +} + +func parseCert(p []string, o *Options, basedir string) error { + e := fmt.Errorf("%w: %s", ErrBadConfig, "cert expects a valid file") + if len(p) != 1 { + return e + } + cert := toAbs(p[0], basedir) + if sub, _ := isSubdir(basedir, cert); !sub { + return fmt.Errorf("%w: %s", ErrBadConfig, "cert must be below config path") + } + if !existsFile(cert) { + return e + } + o.CertPath = cert + return nil +} + +func parseKey(p []string, o *Options, basedir string) error { + e := fmt.Errorf("%w: %s", ErrBadConfig, "key expects a valid file") + if len(p) != 1 { + return e + } + key := toAbs(p[0], basedir) + if sub, _ := isSubdir(basedir, key); !sub { + return fmt.Errorf("%w: %s", ErrBadConfig, "key must be below config path") + } + if !existsFile(key) { + return e + } + o.KeyPath = key + return nil +} + +// parseAuthUser reads credentials from a given file, according to the openvpn +// format (user and pass on a line each). To avoid path traversal / LFI, the +// credentials file is expected to be in a subdirectory of the base dir. +func parseAuthUser(p []string, o *Options, basedir string) error { + e := fmt.Errorf("%w: %s", ErrBadConfig, "auth-user-pass expects a valid file") + if len(p) != 1 { + return e + } + auth := toAbs(p[0], basedir) + if sub, _ := isSubdir(basedir, auth); !sub { + return fmt.Errorf("%w: %s", ErrBadConfig, "auth must be below config path") + } + if !existsFile(auth) { + return e + } + creds, err := getCredentialsFromFile(auth) + if err != nil { + return err + } + o.Username, o.Password = creds[0], creds[1] + return nil +} + +func parseCompress(p []string, o *Options) error { + if len(p) > 1 { + return fmt.Errorf("%w: %s", ErrBadConfig, "compress: only empty/stub options supported") + } + if len(p) == 0 { + o.Compress = CompressionEmpty + return nil + } + if p[0] == "stub" { + o.Compress = CompressionStub + return nil + } + return fmt.Errorf("%w: %s", ErrBadConfig, "compress: only empty/stub options supported") +} + +func parseCompLZO(p []string, o *Options) error { + if p[0] != "no" { + return fmt.Errorf("%w: %s", ErrBadConfig, "comp-lzo: compression not supported") + } + o.Compress = "lzo-no" + return nil +} + +// parseTLSVerMax sets the maximum TLS version. This is currently ignored +// because we're using uTLS to parrot the Client Hello. +func parseTLSVerMax(p []string, o *Options) error { + if len(p) == 0 { + o.TLSMaxVer = "1.3" + return nil + } + if p[0] == "1.2" { + o.TLSMaxVer = "1.2" + } + return nil +} + +func parseProxyOBFS4(p []string, o *Options) error { + if len(p) != 1 { + return fmt.Errorf("%w: %s", ErrBadConfig, "proto-obfs4: need a properly configured proxy") + } + // TODO(ainghazal): can validate the obfs4://... scheme here + o.ProxyOBFS4 = p[0] + return nil +} + +var pMap = map[string]interface{}{ + "proto": parseProto, + "remote": parseRemote, + "cipher": parseCipher, + "auth": parseAuth, + "compress": parseCompress, + "comp-lzo": parseCompLZO, + "proxy-obfs4": parseProxyOBFS4, + "tls-version-max": parseTLSVerMax, // this is currently ignored because of uTLS +} + +var pMapDir = map[string]interface{}{ + "ca": parseCA, + "cert": parseCert, + "key": parseKey, + "auth-user-pass": parseAuthUser, +} + +func parseOption(o *Options, dir, key string, p []string, lineno int) error { + switch key { + case "proto", "remote", "cipher", "auth", "compress", "comp-lzo", "tls-version-max", "proxy-obfs4": + fn := pMap[key].(func([]string, *Options) error) + if e := fn(p, o); e != nil { + return e + } + case "ca", "cert", "key", "auth-user-pass": + fn := pMapDir[key].(func([]string, *Options, string) error) + if e := fn(p, o, dir); e != nil { + return e + } + default: + log.Printf("warn: unsupported key in line %d\n", lineno) + } + return nil +} + +// getOptionsFromLines tries to parse all the lines coming from a config file +// and raises validation errors if the values do not conform to the expected +// format. The config file supports inline file inclusion for , and . +func getOptionsFromLines(lines []string, dir string) (*Options, error) { + opt := &Options{} + + // tag and inlineBuf are used to parse inline files. + // these follow the format used by the reference openvpn implementation. + // each block (any of ca, key, cert) is marked by a line; lines in between are expected to contain + // the crypto block. + tag := "" + inlineBuf := new(bytes.Buffer) + + for lineno, l := range lines { + if strings.HasPrefix(l, "#") { + continue + } + l = strings.TrimSpace(l) + + // inline certs + if isClosingTag(l) { + // we expect an already existing inlineBuf + e := parseInlineTag(opt, tag, inlineBuf) + if e != nil { + return nil, e + } + tag = "" + inlineBuf = new(bytes.Buffer) + continue + } + if tag != "" { + inlineBuf.Write([]byte(l)) + inlineBuf.Write([]byte("\n")) + continue + } + if isOpeningTag(l) { + if len(inlineBuf.Bytes()) != 0 { + // something wrong: an opening tag should not be found + // when we still have bytes in the inline buffer. + return opt, fmt.Errorf("%w: %s", ErrBadConfig, "tag not closed") + } + tag = parseTag(l) + continue + } + + // parse parts in the same line + p := strings.Split(l, " ") + if len(p) == 0 { + continue + } + var ( + key string + parts []string + ) + if len(p) == 1 { + key = p[0] + } else { + key, parts = p[0], p[1:] + } + e := parseOption(opt, dir, key, parts, lineno) + if e != nil { + return nil, e + } + } + return opt, nil +} + +func isOpeningTag(key string) bool { + switch key { + case "", "", "": + return true + default: + return false + } +} + +func isClosingTag(key string) bool { + switch key { + case "", "", "": + return true + default: + return false + } +} + +func parseTag(tag string) string { + switch tag { + case "", "": + return "ca" + case "", "": + return "cert" + case "", "": + return "key" + default: + return "" + } +} + +// parseInlineTag +func parseInlineTag(o *Options, tag string, buf *bytes.Buffer) error { + b := buf.Bytes() + if len(b) == 0 { + return fmt.Errorf("%w: empty inline tag: %d", ErrBadConfig, len(b)) + } + switch tag { + case "ca": + o.CA = b + case "cert": + o.Cert = b + case "key": + o.Key = b + default: + return fmt.Errorf("%w: unknown tag: %s", ErrBadConfig, tag) + } + return nil +} + +// hasElement checks if a given string is present in a string array. returns +// true if that is the case, false otherwise. +func hasElement(el string, arr []string) bool { + for _, v := range arr { + if v == el { + return true + } + } + return false +} + +// existsFile returns true if the file to which the path refers to exists and +// is a regular file. +func existsFile(path string) bool { + statbuf, err := os.Stat(path) + return !errors.Is(err, os.ErrNotExist) && statbuf.Mode().IsRegular() +} + +// getLinesFromFile accepts a path parameter, and return a string array with +// its content and an error if the operation cannot be completed. +func getLinesFromFile(path string) ([]string, error) { + f, err := os.Open(path) + defer f.Close() + if err != nil { + return nil, err + } + + lines := make([]string, 0) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + err = scanner.Err() + if err != nil { + return nil, err + } + return lines, nil +} + +// getCredentialsFromFile accepts a path string parameter, and return a string +// array containing the credentials in that file, and an error if the operation +// could not be completed. +func getCredentialsFromFile(path string) ([]string, error) { + lines, err := getLinesFromFile(path) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrBadConfig, err) + } + if len(lines) != 2 { + return nil, fmt.Errorf("%w: %s", ErrBadConfig, "malformed credentials file") + } + if len(lines[0]) == 0 { + return nil, fmt.Errorf("%w: %s", ErrBadConfig, "empty username in creds file") + } + if len(lines[1]) == 0 { + return nil, fmt.Errorf("%w: %s", ErrBadConfig, "empty password in creds file") + } + return lines, nil +} + +// toAbs return an absolute path if the given path is not already absolute; to +// do so, it will append the path to the given basedir. +func toAbs(path, basedir string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(basedir, path) +} + +// isSubdir checks if a given path is a subdirectory of another. It returns +// true if that's the case, and any error raise during the check. +func isSubdir(parent, sub string) (bool, error) { + p, err := filepath.Abs(parent) + if err != nil { + return false, err + } + s, err := filepath.Abs(sub) + if err != nil { + return false, err + } + return strings.HasPrefix(s, p), nil +} diff --git a/internal/model/packet.go b/internal/model/packet.go new file mode 100644 index 00000000..828faded --- /dev/null +++ b/internal/model/packet.go @@ -0,0 +1,308 @@ +package model + +// +// Packet +// +// Parsing and serializing OpenVPN packets. +// + +import ( + "bytes" + "errors" + "fmt" + "io" + "math" + + "github.com/ooni/minivpn/internal/bytesx" +) + +// Opcode is an OpenVPN packet opcode. +type Opcode byte + +// OpenVPN packets opcodes. +const ( + P_CONTROL_HARD_RESET_CLIENT_V1 = Opcode(iota + 1) // 1 + P_CONTROL_HARD_RESET_SERVER_V1 // 2 + P_CONTROL_SOFT_RESET_V1 // 3 + P_CONTROL_V1 // 4 + P_ACK_V1 // 5 + P_DATA_V1 // 6 + P_CONTROL_HARD_RESET_CLIENT_V2 // 7 + P_CONTROL_HARD_RESET_SERVER_V2 // 8 + P_DATA_V2 // 9 +) + +// String returns the opcode string representation +func (op Opcode) String() string { + switch op { + case P_CONTROL_HARD_RESET_CLIENT_V1: + return "P_CONTROL_HARD_RESET_CLIENT_V1" + + case P_CONTROL_HARD_RESET_SERVER_V1: + return "P_CONTROL_HARD_RESET_SERVER_V1" + + case P_CONTROL_SOFT_RESET_V1: + return "P_CONTROL_SOFT_RESET_V1" + + case P_CONTROL_V1: + return "P_CONTROL_V1" + + case P_ACK_V1: + return "P_ACK_V1" + + case P_DATA_V1: + return "P_DATA_V1" + + case P_CONTROL_HARD_RESET_CLIENT_V2: + return "P_CONTROL_HARD_RESET_CLIENT_V2" + + case P_CONTROL_HARD_RESET_SERVER_V2: + return "P_CONTROL_HARD_RESET_SERVER_V2" + + case P_DATA_V2: + return "P_DATA_V2" + + default: + return "P_UNKNOWN" + } +} + +// IsControl returns true when this opcode is a control opcode. +func (op Opcode) IsControl() bool { + switch op { + case P_CONTROL_HARD_RESET_CLIENT_V1, + P_CONTROL_HARD_RESET_SERVER_V1, + P_CONTROL_SOFT_RESET_V1, + P_CONTROL_V1, + P_CONTROL_HARD_RESET_CLIENT_V2, + P_CONTROL_HARD_RESET_SERVER_V2: + return true + default: + return false + } +} + +// IsData returns true when this opcode is a data opcode. +func (op Opcode) IsData() bool { + switch op { + case P_DATA_V1, P_DATA_V2: + return true + default: + return false + } +} + +// SessionID is the session identifier. +type SessionID [8]byte + +// PacketID is a packet identifier. +type PacketID uint32 + +// PeerID is the type of the P_DATA_V2 peer ID. +type PeerID [3]byte + +// Packet is an OpenVPN packet. +type Packet struct { + // Opcode is the packet message type (a P_* constant; high 5-bits of + // the first packet byte). + Opcode Opcode + + // The key_id refers to an already negotiated TLS session. + // This is the shortened version of the key-id (low 3-bits of the first + // packet byte). + KeyID byte + + // PeerID is the peer ID. + PeerID PeerID + + // LocalSessionID is the local session ID. + LocalSessionID SessionID + + // Acks contains the remote packets we're ACKing. + ACKs []PacketID + + // RemoteSessionID is the remote session ID. + RemoteSessionID SessionID + + // ID is the packet-id for replay protection. According to the spec: "4 or 8 bytes, + // includes sequence number and optional time_t timestamp". + // + // This library does not use the timestamp. + ID PacketID + + // Payload is the packet's payload. + Payload []byte +} + +// ErrPacketTooShort indicates that a packet is too short. +var ErrPacketTooShort = errors.New("openvpn: packet too short") + +// ParsePacket produces a packet after parsing the common header. We assume that +// the underlying connection has already stripped out the framing. +func ParsePacket(buf []byte) (*Packet, error) { + // parsing opcode and keyID + if len(buf) < 2 { + return nil, ErrPacketTooShort + } + opcode := Opcode(buf[0] >> 3) + keyID := buf[0] & 0x07 + + // extract the packet payload and possibly the peerID + var ( + payload []byte + peerID PeerID + ) + switch opcode { + case P_DATA_V2: + if len(buf) < 4 { + return nil, ErrPacketTooShort + } + copy(peerID[:], buf[1:4]) + payload = buf[4:] + default: + payload = buf[1:] + } + + // ACKs and control packets require more complex parsing + if opcode.IsControl() || opcode == P_ACK_V1 { + return parseControlOrACKPacket(opcode, keyID, payload) + } + + // otherwise just return the data packet. + p := &Packet{ + Opcode: opcode, + KeyID: keyID, + PeerID: peerID, + LocalSessionID: [8]byte{}, + ACKs: []PacketID{}, + RemoteSessionID: [8]byte{}, + ID: 0, + Payload: payload, + } + return p, nil +} + +// ErrEmptyPayload indicates tha the payload of an OpenVPN control packet is empty. +var ErrEmptyPayload = errors.New("openvpn: empty payload") + +// ErrParsePacket is a generic packet parse error which may be further qualified. +var ErrParsePacket = errors.New("openvpn: packet parse error") + +// parseControlOrACKPacket parses the contents of a control or ACK packet. +func parseControlOrACKPacket(opcode Opcode, keyID byte, payload []byte) (*Packet, error) { + // make sure we have payload to parse and we're parsing control or ACK + if len(payload) <= 0 { + return nil, ErrEmptyPayload + } + if !opcode.IsControl() && opcode != P_ACK_V1 { + return nil, fmt.Errorf("%w: %s", ErrParsePacket, "expected control/ack packet") + } + + // create a buffer for parsing the packet + buf := bytes.NewBuffer(payload) + + p := NewPacket(opcode, keyID, payload) + + // local session id + if _, err := io.ReadFull(buf, p.LocalSessionID[:]); err != nil { + return p, fmt.Errorf("%w: bad sessionID: %s", ErrParsePacket, err) + } + + // ack array length + ackArrayLenByte, err := buf.ReadByte() + if err != nil { + return p, fmt.Errorf("%w: bad ack: %s", ErrParsePacket, err) + } + ackArrayLen := int(ackArrayLenByte) + + // ack array + p.ACKs = make([]PacketID, ackArrayLen) + for i := 0; i < ackArrayLen; i++ { + val, err := bytesx.ReadUint32(buf) + if err != nil { + return p, fmt.Errorf("%w: cannot parse ack id: %s", ErrParsePacket, err) + } + p.ACKs[i] = PacketID(val) + } + + // remote session id + if ackArrayLen > 0 { + if _, err = io.ReadFull(buf, p.RemoteSessionID[:]); err != nil { + return p, fmt.Errorf("%w: bad remote sessionID: %s", ErrParsePacket, err) + } + } + + // packet id + if p.Opcode != P_ACK_V1 { + val, err := bytesx.ReadUint32(buf) + if err != nil { + return p, fmt.Errorf("%w: bad packetID: %s", ErrParsePacket, err) + } + p.ID = PacketID(val) + } + + // payload + p.Payload = buf.Bytes() + return p, nil +} + +// NewPacket returns a packet from the passed arguments: opcode, keyID and a raw payload. +func NewPacket(opcode Opcode, keyID uint8, payload []byte) *Packet { + return &Packet{ + Opcode: opcode, + KeyID: keyID, + PeerID: [3]byte{}, + LocalSessionID: [8]byte{}, + ACKs: []PacketID{}, + RemoteSessionID: [8]byte{}, + ID: 0, + Payload: payload, + } +} + +// ErrMarshalPacket is the error returned when we cannot marshal a packet. +var ErrMarshalPacket = errors.New("openvpn: cannot marshal packet") + +// Bytes returns a byte array that is ready to be sent on the wire. +func (p *Packet) Bytes() ([]byte, error) { + buf := &bytes.Buffer{} + + switch p.Opcode { + case P_DATA_V2: + // we assume this is an encrypted data packet, + // so we serialize just the encrypted payload + + default: + buf.WriteByte((byte(p.Opcode) << 3) | (p.KeyID & 0x07)) + buf.Write(p.LocalSessionID[:]) + // we write a byte with the number of acks, and then serialize each ack. + nAcks := len(p.ACKs) + if nAcks > math.MaxUint8 { + return nil, fmt.Errorf("%w: too many ACKs", ErrMarshalPacket) + } + buf.WriteByte(byte(nAcks)) + for i := 0; i < nAcks; i++ { + bytesx.WriteUint32(buf, uint32(p.ACKs[i])) + } + // remote session id + if len(p.ACKs) > 0 { + buf.Write(p.RemoteSessionID[:]) + } + if p.Opcode != P_ACK_V1 { + bytesx.WriteUint32(buf, uint32(p.ID)) + } + } + // payload + buf.Write(p.Payload) + return buf.Bytes(), nil +} + +// IsControl returns true if the packet is any of the control types. +func (p *Packet) IsControl() bool { + return p.Opcode.IsControl() +} + +// IsData returns true if the packet is of data type. +func (p *Packet) IsData() bool { + return p.Opcode.IsData() +} diff --git a/internal/networkio/closeonce.go b/internal/networkio/closeonce.go new file mode 100644 index 00000000..20c043c0 --- /dev/null +++ b/internal/networkio/closeonce.go @@ -0,0 +1,35 @@ +package networkio + +import ( + "net" + "sync" +) + +// closeOnceConn is a [net.Conn] where the Close method has once semantics. +// +// The zero value is invalid; use [newCloseOnceConn]. +type closeOnceConn struct { + // once ensures we close just once. + once sync.Once + + // Conn is the underlying conn. + net.Conn +} + +var _ net.Conn = &closeOnceConn{} + +// newCloseOnceConn creates a [closeOnceConn]. +func newCloseOnceConn(conn net.Conn) *closeOnceConn { + return &closeOnceConn{ + once: sync.Once{}, + Conn: conn, + } +} + +// Close implements net.Conn +func (c *closeOnceConn) Close() (err error) { + c.once.Do(func() { + err = c.Conn.Close() + }) + return +} diff --git a/internal/networkio/datagram.go b/internal/networkio/datagram.go new file mode 100644 index 00000000..efd3ffe0 --- /dev/null +++ b/internal/networkio/datagram.go @@ -0,0 +1,33 @@ +package networkio + +import ( + "math" + "net" +) + +// datagramConn wraps a datagram socket and implements OpenVPN framing. +type datagramConn struct { + net.Conn +} + +var _ FramingConn = &datagramConn{} + +// ReadRawPacket implements FramingConn +func (c *datagramConn) ReadRawPacket() ([]byte, error) { + buffer := make([]byte, math.MaxUint16) // maximum UDP datagram size + count, err := c.Read(buffer) + if err != nil { + return nil, err + } + pkt := buffer[:count] + return pkt, nil +} + +// WriteRawPacket implements FramingConn +func (c *datagramConn) WriteRawPacket(pkt []byte) error { + if len(pkt) > math.MaxUint16 { + return ErrPacketTooLarge + } + _, err := c.Conn.Write(pkt) + return err +} diff --git a/internal/networkio/dialer.go b/internal/networkio/dialer.go new file mode 100644 index 00000000..d68b5266 --- /dev/null +++ b/internal/networkio/dialer.go @@ -0,0 +1,49 @@ +package networkio + +import ( + "context" + + "github.com/ooni/minivpn/internal/model" +) + +// Dialer dials network connections. The zero value of this structure is +// invalid; please, use the [NewDialer] constructor. +type Dialer struct { + // dialer is the underlying [DialerContext] we use to dial. + dialer model.Dialer + + // logger is the [Logger] with which we log. + logger model.Logger +} + +// NewDialer creates a new [Dialer] instance. +func NewDialer(logger model.Logger, dialer model.Dialer) *Dialer { + return &Dialer{ + dialer: dialer, + logger: logger, + } +} + +// DialContext establishes a connection and, on success, automatically wraps the +// returned connection to implement OpenVPN framing when not using UDP. +func (d *Dialer) DialContext(ctx context.Context, network, address string) (FramingConn, error) { + // dial with the underlying dialer + conn, err := d.dialer.DialContext(ctx, network, address) + if err != nil { + d.logger.Warnf("networkio: dial failed: %s", err.Error()) + return nil, err + } + + d.logger.Debugf("networkio: connected to %s/%s", address, network) + + // make sure the conn has close once semantics + conn = newCloseOnceConn(conn) + + // wrap the conn and return + switch conn.LocalAddr().Network() { + case "udp", "udp4", "udp6": + return &datagramConn{conn}, nil + default: + return &streamConn{conn}, nil + } +} diff --git a/internal/networkio/doc.go b/internal/networkio/doc.go new file mode 100644 index 00000000..e09162a6 --- /dev/null +++ b/internal/networkio/doc.go @@ -0,0 +1,2 @@ +// Package networkio implements raw packets network I/O. +package networkio diff --git a/internal/networkio/framing.go b/internal/networkio/framing.go new file mode 100644 index 00000000..232327a4 --- /dev/null +++ b/internal/networkio/framing.go @@ -0,0 +1,31 @@ +package networkio + +import ( + "net" + "time" +) + +// FramingConn is an OpenVPN network connection that knows about +// the framing used by OpenVPN to read and write raw packets. +type FramingConn interface { + // ReadRawPacket reads and return a raw OpenVPN packet. + ReadRawPacket() ([]byte, error) + + // WriteRawPacket writes a raw OpenVPN packet. + WriteRawPacket(pkt []byte) error + + // SetReadDeadline is like net.Conn.SetReadDeadline. + SetReadDeadline(t time.Time) error + + // SetWriteDeadline is like net.Conn.SetWriteDeadline. + SetWriteDeadline(t time.Time) error + + // LocalAddr is like net.Conn.LocalAddr. + LocalAddr() net.Addr + + // RemoteAddr is like net.Conn.RemoteAddr. + RemoteAddr() net.Addr + + // Close is like net.Conn.Close. + Close() error +} diff --git a/internal/networkio/service.go b/internal/networkio/service.go new file mode 100644 index 00000000..77e25675 --- /dev/null +++ b/internal/networkio/service.go @@ -0,0 +1,115 @@ +package networkio + +import ( + "github.com/ooni/minivpn/internal/model" + "github.com/ooni/minivpn/internal/workers" +) + +// Service is the network I/O service. Make sure you initialize +// the channels before invoking [Service.StartWorkers]. +type Service struct { + // MuxerToNetwork moves bytes down from the muxer to the network IO layer + MuxerToNetwork chan []byte + + // NetworkToMuxer moves bytes up from the network IO layer to the muxer + NetworkToMuxer *chan []byte +} + +// StartWorkers starts the network I/O workers. See the [ARCHITECTURE] +// file for more information about the network I/O workers. +// +// [ARCHITECTURE]: https://github.com/ooni/minivpn/blob/main/ARCHITECTURE.md +func (svc *Service) StartWorkers( + logger model.Logger, + manager *workers.Manager, + conn FramingConn, +) { + ws := &workersState{ + conn: conn, + logger: logger, + manager: manager, + muxerToNetwork: svc.MuxerToNetwork, + networkToMuxer: *svc.NetworkToMuxer, + } + + manager.StartWorker(ws.moveUpWorker) + manager.StartWorker(ws.moveDownWorker) +} + +// workersState contains the service workers state +type workersState struct { + // conn is the connection to use + conn FramingConn + + // logger is the logger to use + logger model.Logger + + // manager controls the workers lifecycle + manager *workers.Manager + + // muxerToNetwork is the channel for reading outgoing packets + // that are coming down to us + muxerToNetwork <-chan []byte + + // networkToMuxer is the channel for writing incoming packets + // that are coming up to us from the net + networkToMuxer chan<- []byte +} + +// moveUpWorker moves packets up the stack. +func (ws *workersState) moveUpWorker() { + defer func() { + // make sure the manager knows we're done + ws.manager.OnWorkerDone() + + // tear down everything else because a workers exited + ws.manager.StartShutdown() + + // emit useful debug message + ws.logger.Debug("networkio: moveUpWorker: done") + }() + + ws.logger.Debug("networkio: moveUpWorker: started") + + for { + // POSSIBLY BLOCK on the connection to read a new packet + pkt, err := ws.conn.ReadRawPacket() + if err != nil { + ws.logger.Debugf("networkio: moveUpWorker: ReadRawPacket: %s", err.Error()) + return + } + + // POSSIBLY BLOCK on the channel to deliver the packet + select { + case ws.networkToMuxer <- pkt: + case <-ws.manager.ShouldShutdown(): + return + } + } +} + +// moveDownWorker moves packets down the stack +func (ws *workersState) moveDownWorker() { + defer func() { + ws.manager.StartShutdown() + ws.manager.OnWorkerDone() + ws.logger.Debug("networkio: moveDownWorker: done") + }() + + ws.logger.Debug("networkio: moveDownWorker: started") + + for { + // POSSIBLY BLOCK when receiving from channel. + select { + case pkt := <-ws.muxerToNetwork: + // POSSIBLY BLOCK on the connection to write the packet + if err := ws.conn.WriteRawPacket(pkt); err != nil { + ws.logger.Infof("networkio: moveDownWorker: WriteRawPacket: %s", err.Error()) + return + } + + case <-ws.manager.ShouldShutdown(): + return + } + } +} diff --git a/internal/networkio/stream.go b/internal/networkio/stream.go new file mode 100644 index 00000000..2fb22726 --- /dev/null +++ b/internal/networkio/stream.go @@ -0,0 +1,45 @@ +package networkio + +import ( + "encoding/binary" + "errors" + "io" + "math" + "net" +) + +// streamConn wraps a stream socket and implements OpenVPN framing. +type streamConn struct { + net.Conn +} + +var _ FramingConn = &streamConn{} + +// ReadRawPacket implements FramingConn +func (c *streamConn) ReadRawPacket() ([]byte, error) { + lenbuf := make([]byte, 2) + if _, err := io.ReadFull(c.Conn, lenbuf); err != nil { + return nil, err + } + length := binary.BigEndian.Uint16(lenbuf) + buf := make([]byte, length) + if _, err := io.ReadFull(c.Conn, buf); err != nil { + return nil, err + } + return buf, nil +} + +// ErrPacketTooLarge means that a packet is larger than [math.MaxUint16]. +var ErrPacketTooLarge = errors.New("openvpn: packet too large") + +// WriteRawPacket implements FramingConn +func (c *streamConn) WriteRawPacket(pkt []byte) error { + if len(pkt) > math.MaxUint16 { + return ErrPacketTooLarge + } + length := make([]byte, 2) + binary.BigEndian.PutUint16(length, uint16(len(pkt))) + pkt = append(length, pkt...) + _, err := c.Conn.Write(pkt) + return err +} diff --git a/internal/runtimex/runtimex.go b/internal/runtimex/runtimex.go new file mode 100644 index 00000000..5e135484 --- /dev/null +++ b/internal/runtimex/runtimex.go @@ -0,0 +1,19 @@ +// Package runtimex contains [runtime] extensions. +package runtimex + +// PanicIfFalse calls panic with the given message if the given statement is false. +func PanicIfFalse(stmt bool, message interface{}) { + if !stmt { + panic(message) + } +} + +// PanicIfTrue calls panic with the given message if the given statement is true. +func PanicIfTrue(stmt bool, message interface{}) { + if stmt { + panic(message) + } +} + +// Assert calls panic with the given message if the given statement is false. +var Assert = PanicIfFalse diff --git a/internal/workers/workers.go b/internal/workers/workers.go new file mode 100644 index 00000000..52fc4033 --- /dev/null +++ b/internal/workers/workers.go @@ -0,0 +1,63 @@ +// Package workers contains code to manage workers. +// +// A worker is a goroutine running in the background that performs some +// activity related to implementing the OpenVPN protocol. +package workers + +import ( + "errors" + "sync" +) + +// ErrShutdown is the error returned by a worker that is shutting down. +var ErrShutdown = errors.New("worker is shutting down") + +// Manager coordinates the lifeycles of the workers implementing the OpenVPN +// protocol. The zero value is invalid; use [NewManager]. +type Manager struct { + // shouldShutdown is closed to signal all workers to shut down. + shouldShutdown chan any + + // shutdownOnce ensures we close shutdownSignal once. + shutdownOnce sync.Once + + // wg tracks the running workers. + wg *sync.WaitGroup +} + +// NewManager creates a new [*Manager]. +func NewManager() *Manager { + return &Manager{ + shouldShutdown: make(chan any), + shutdownOnce: sync.Once{}, + wg: &sync.WaitGroup{}, + } +} + +// StartWorker starts a worker in a background goroutine. +func (m *Manager) StartWorker(fx func()) { + m.wg.Add(1) + go fx() +} + +// OnWorkerDone MUST be called when a worker goroutine terminates. +func (m *Manager) OnWorkerDone() { + m.wg.Done() +} + +// StartShutdown initiates the shutdown of all workers. +func (m *Manager) StartShutdown() { + m.shutdownOnce.Do(func() { + close(m.shouldShutdown) + }) +} + +// ShouldShutdown returns the channel closed when workers should shut down. +func (m *Manager) ShouldShutdown() <-chan any { + return m.shouldShutdown +} + +// WaitWorkersShutdown blocks until all workers have shut down. +func (m *Manager) WaitWorkersShutdown() { + m.wg.Wait() +}