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()
+}