From 0370938ff057c994096d418da15085c1e0177097 Mon Sep 17 00:00:00 2001 From: Simone Basso Date: Tue, 6 Sep 2022 09:26:58 +0200 Subject: [PATCH 1/2] feat(miniooni): use a remote miniooni instance to measure This commit extends miniooni to be able to use a remote instance of miniooni for performing OONI measurements. The main use case for this functionality is to run measurements inside another network that has censorship for QA purposes. We currently support two transports for implementing remoting: 1. "tcp", which uses a cleartext TCP connection 2. "ssh", which uses SSH The tcp transport is useful when you trust the network that transports packets from and to the two miniooni instances. The ssh transport covers the case where you want a secure channel to transport packets between the two miniooni instances. To start the remotetcp server, run this command as `root`: ``` ./miniooni remotetcp ``` To start the remotessh server, run this command as `root`: ``` ./miniooni remotessh ``` On the local side, you need to create `$HOME/.miniooni/remote/config.yaml`, which must look like the following: ```YAML remotes: foobar_tcp: address: "1.2.3.4:5555" transport: "tcp" foobar_ssh: address: "1.2.3.4:2222" transport: "ssh" ssh: user: "root" ``` The ports in the above example are the default ones used by `remotetcp` and `remotessh`. On the command line, you can enable remoting by adding `--remote=NAME`, where NAME is the remote name. For example: ``` ./miniooni -n --remote foobar_ssh example ``` Note that this command will only work if you have an active instance of the ssh-agent (i.e., `SSH_AGENT_SOCK` is defined). Having described the functionality, let us explain how we implemented all of this. We have reintroduced the possibility of completely taking over the basic `netxlite` primitives: 1. dialing a TCP or UDP conn 2. looking up a domain name using getaddrinfo 3. creating a listening UDP socket When you use `--remote NAME`, the code will do the following: 1. read the config file 2. figure out the right remote 3. establish a connection with the remote 4. create a TCP/IP stack in userspace 5. create a TUN device in userspace that gets all the packets generated by the TCP/IP stack in userspace 6. overwrite netxlite primitives so they use the TCP/IP stack in userspace instead of the Go standard library 7. setup routing between the connection with the remote and the TUN device in userspace so we route packets 8. run the desired experiments as usual On the server side, we do something similar, except that we use a real TUN device as implemented by Linux. (We only support Linux servers at the moment.) Once the real TUN device has been created we setup masquerading for it, and then we we route between the connection with the miniooni client and the real TUN device. We don't currently implement censorship, but the real TUN device is clearly the right place where to do that. --- go.mod | 11 +- go.sum | 24 +- internal/cmd/miniooni/main.go | 17 +- internal/cmd/miniooni/remotecore.go | 430 ++++++++++++++++++ internal/cmd/miniooni/remotedialer.go | 31 ++ internal/cmd/miniooni/remotehijack.go | 98 ++++ internal/cmd/miniooni/remotelistener.go | 143 ++++++ internal/cmd/miniooni/remotessh.go | 299 ++++++++++++ internal/cmd/miniooni/remotetcp.go | 77 ++++ internal/cmd/miniooni/utils.go | 12 + internal/engine/geolocate/cloudflare.go | 1 + internal/engine/geolocate/cloudflare_test.go | 2 + internal/engine/geolocate/geolocate.go | 4 +- internal/engine/geolocate/invalid_test.go | 1 + internal/engine/geolocate/iplookup.go | 3 +- internal/engine/geolocate/resolverlookup.go | 9 +- .../engine/geolocate/resolverlookup_test.go | 16 +- internal/engine/geolocate/stun.go | 40 +- internal/engine/geolocate/stun_test.go | 45 +- internal/engine/geolocate/ubuntu.go | 1 + internal/engine/geolocate/ubuntu_test.go | 3 + internal/netxlite/dialer.go | 85 ++-- internal/netxlite/dialer_test.go | 4 +- internal/netxlite/dnsovergetaddrinfo.go | 2 +- internal/netxlite/doc.go | 12 + internal/netxlite/quic.go | 2 +- internal/netxlite/tproxy.go | 27 ++ 27 files changed, 1307 insertions(+), 92 deletions(-) create mode 100644 internal/cmd/miniooni/remotecore.go create mode 100644 internal/cmd/miniooni/remotedialer.go create mode 100644 internal/cmd/miniooni/remotehijack.go create mode 100644 internal/cmd/miniooni/remotelistener.go create mode 100644 internal/cmd/miniooni/remotessh.go create mode 100644 internal/cmd/miniooni/remotetcp.go create mode 100644 internal/netxlite/tproxy.go diff --git a/go.mod b/go.mod index 64cbb7fdd1..135bee79b9 100644 --- a/go.mod +++ b/go.mod @@ -35,18 +35,23 @@ require ( github.com/pkg/errors v0.9.1 github.com/rogpeppe/go-internal v1.9.0 github.com/rubenv/sql-migrate v1.2.0 + github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/upper/db/v4 v4.6.0 gitlab.com/yawning/obfs4.git v0.0.0-20220904064028-336a71d6e4cf gitlab.com/yawning/utls.git v0.0.12-1 - golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 + golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be golang.org/x/net v0.0.0-20220906165146-f3363e06e74c - golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 + golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec ) require ( + github.com/google/btree v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/segmentio/fasthash v1.0.3 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect + golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect + gvisor.dev/gvisor v0.0.0-20220817001344-846276b3dbc5 // indirect ) require ( @@ -130,6 +135,8 @@ require ( golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/tools v0.1.12 // indirect + golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c google.golang.org/protobuf v1.28.1 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index aea655d2ce..f30ca7c585 100644 --- a/go.sum +++ b/go.sum @@ -297,6 +297,8 @@ github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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/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= @@ -833,6 +835,8 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9 github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= @@ -995,8 +999,8 @@ golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM= -golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A= +golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20181106170214-d68db9428509/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 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= @@ -1214,8 +1218,8 @@ golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY= -golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI= +golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= @@ -1235,6 +1239,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1309,6 +1314,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T 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-20211104114900-415007cec224 h1:Ug9qvr1myri/zFN6xL17LSCBGFDnphBBhzmILHsM5TY= +golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c h1:Okh6a1xpnJslG9Mn84pId1Mn+Q8cvpo4HCeeFWHo0cA= +golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c/go.mod h1:enML0deDxY1ux+B6ANGiwtg0yAJi1rctkTpcHNAVPyg= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= @@ -1389,8 +1398,8 @@ google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c h1:wtujag7C+4D6KMoulW9YauvK2lgdvCMS260jsqqBXr0= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f h1:YORWxaStkWBnWgELOHTmDrqNlFXuVGEbhwbB5iK94bQ= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -1418,8 +1427,8 @@ google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.42.0-dev.0.20211020220737-f00baa6c3c84 h1:hZAzgyItS2MPyqvdC8wQZI99ZLGP9Vwijyfr0dmYWc4= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1469,7 +1478,10 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +gvisor.dev/gvisor v0.0.0-20220817001344-846276b3dbc5 h1:cv/zaNV0nr1mJzaeo4S5mHIm5va1W0/9J3/5prlsuRM= +gvisor.dev/gvisor v0.0.0-20220817001344-846276b3dbc5/go.mod h1:TIvkJD0sxe8pIob3p6T8IzxXunlp6yfgktvTNp+DGNM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 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= diff --git a/internal/cmd/miniooni/main.go b/internal/cmd/miniooni/main.go index cf231d45bc..828ed8ade1 100644 --- a/internal/cmd/miniooni/main.go +++ b/internal/cmd/miniooni/main.go @@ -37,6 +37,7 @@ type Options struct { ProbeServicesURL string Proxy string Random bool + RemoteName string RepeatEvery int64 ReportFile string TorArgs []string @@ -110,6 +111,13 @@ func main() { "set proxy URL to communicate with the OONI backend (mutually exclusive with --tunnel)", ) + flags.StringVar( + &globalOptions.RemoteName, + "remote", + "", + "name of the remote to use to hijack all network traffic", + ) + flags.Int64Var( &globalOptions.RepeatEvery, "repeat-every", @@ -166,6 +174,8 @@ func main() { registerAllExperiments(rootCmd, &globalOptions) registerOONIRun(rootCmd, &globalOptions) + registerRemoteTCP(rootCmd) + registerRemoteSSH(rootCmd) if err := rootCmd.Execute(); err != nil { os.Exit(1) @@ -292,6 +302,7 @@ func MainWithConfiguration(experimentName string, currentOptions *Options) { currentOptions.ReportFile = "report.jsonl" } log.Log = logger + remoteMaybeHijack(currentOptions) for { mainSingleIteration(logger, experimentName, currentOptions) if currentOptions.RepeatEvery <= 0 { @@ -325,11 +336,7 @@ func mainSingleIteration(logger model.Logger, experimentName string, currentOpti //Mon Jan 2 15:04:05 -0700 MST 2006 log.Infof("Current time: %s", time.Now().Format("2006-01-02 15:04:05 MST")) - homeDir := gethomedir(currentOptions.HomeDir) - runtimex.Assert(homeDir != "", "home directory is empty") - miniooniDir := path.Join(homeDir, ".miniooni") - err := os.MkdirAll(miniooniDir, 0700) - runtimex.PanicOnError(err, "cannot create $HOME/.miniooni directory") + miniooniDir := createAndReturnMiniooniDir(currentOptions) // We cleanup the assets files used by versions of ooniprobe // older than v3.9.0, where we started embedding the assets diff --git a/internal/cmd/miniooni/remotecore.go b/internal/cmd/miniooni/remotecore.go new file mode 100644 index 0000000000..0305d6b9a1 --- /dev/null +++ b/internal/cmd/miniooni/remotecore.go @@ -0,0 +1,430 @@ +package main + +// +// Core remote implementation +// + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/netip" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/apex/log" + "github.com/google/shlex" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/runtimex" + "github.com/songgao/water" + "golang.org/x/sys/execabs" + "golang.zx2c4.com/wireguard/tun" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +const ( + // remoteTUNDeviceName is the name assigned to the TUN device by the server. + remoteTUNDeviceName = "miniooni0" + + // remoteServerAddr is the address assigned to the server. + remoteServerAddr = "10.14.17.1" +) + +var ( + // remoteClientAddr is the address assigned to the client. + remoteClientAddr = netip.MustParseAddr("10.14.17.4") + + // remoteResolvers are the IP addresses used to implement getaddrinfo on the remote. + remoteResolvers = []netip.Addr{ + netip.MustParseAddr("8.8.8.8"), + netip.MustParseAddr("8.8.4.4"), + } +) + +// remoteServerConfig contains server configuration for remote operations. +type remoteServerConfig struct { + // iface is the output interface to use. + iface string +} + +// remoteServerListenerFactory creates a remoteServerListener. +type remoteServerListenerFactory interface { + // Listen returns a new listener instance or an error. + Listen() (remoteServerListener, error) +} + +// remoteServerListener creates remoteConns. +type remoteServerListener interface { + // Accept should return a new remoteConn or an error. This function + // MUST return net.ErrClosed after Close has been called. + Accept() (remoteConn, error) + + // Close closes the listener. + Close() error +} + +// remoteConn is a connection between a server and a remote miniooni client. +type remoteConn interface { + io.Reader + io.Writer + io.Closer +} + +// remoteServerMain is the main of a remote subcommand. +func remoteServerMain(config *remoteServerConfig, factory remoteServerListenerFactory) error { + // create the listener + listener, err := factory.Listen() + if err != nil { + return err + } + defer listener.Close() + + // create the TUN device + tunConfig := water.Config{ + DeviceType: water.TUN, + PlatformSpecificParams: water.PlatformSpecificParams{ + Name: remoteTUNDeviceName, + }, + } + tun, err := water.New(tunConfig) + if err != nil { + log.Errorf("remote: water.New failed: %s", err.Error()) + return err + } + defer tun.Close() + + // assign the correct IP address to the TUN device + if err := remoteServerAssignAddress(config); err != nil { + log.Errorf("remote: cannot assign address to TUN device: %s", err.Error()) + return err + } + defer remoteServerCleanupIPTables(config) + + // listen for signals and cleanup when we receive them + sigch := make(chan os.Signal, 1) + signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigch + log.Infof("remote: interrupted by signal") + listener.Close() + }() + + // accept incoming connections + for { + conn, err := listener.Accept() + if err != nil && errors.Is(err, net.ErrClosed) { + return nil // this is how we terminate successfully + } + if err != nil { + log.Warnf("remote: listener.Accept failed: %s", err.Error()) + continue + } + + // route traffic + go remoteServerRoute(conn, tun) + } +} + +// remoteServerRoute routes traffic between the remote conn and the TUN device. +func remoteServerRoute(conn remoteConn, tun *water.Interface) { + // route from the remote conn to the TUN device + go func() { + for { + pkt, err := remoteReadPacket(conn) + if err != nil { + log.Warnf("remote: cannot read from conn: %s", err.Error()) + return + } + if _, err := tun.Write(pkt); err != nil { + log.Warnf("remote: cannot write to TUN device: %s", err.Error()) + return + } + } + }() + + // route from the TUN device to the remote conn + go func() { + buffer := make([]byte, remoteMaxPacketSize) + for { + count, err := tun.Read(buffer) + if err != nil { + log.Warnf("remote: cannot read from TUN device: %s", err.Error()) + return + } + pkt := buffer[:count] + if err := remoteWritePacket(conn, pkt); err != nil { + log.Warnf("remote: cannot write to conn: %s", err.Error()) + return + } + } + }() +} + +// remoteReadPacket reads a packet from conn. +func remoteReadPacket(conn io.Reader) ([]byte, error) { + header := make([]byte, 3) + if _, err := io.ReadFull(conn, header); err != nil { + return nil, err + } + var length int + length |= int(header[0]) << 16 + length |= int(header[1]) << 8 + length |= int(header[2]) << 0 + pkt := make([]byte, length) + if _, err := io.ReadFull(conn, pkt); err != nil { + return nil, err + } + return pkt, nil +} + +// remoteMaxPacketSize is the maximum packet size. +const remoteMaxPacketSize = (1 << 24) - 1 + +// errRemotePacketTooBig indicates that a packet is too big +var errRemotePacketTooBig = errors.New("packet too big") + +// remoteWritePacket writes a packet to the conn. +func remoteWritePacket(conn io.Writer, pkt []byte) error { + length := len(pkt) + if length > remoteMaxPacketSize { + return errRemotePacketTooBig + } + data := make([]byte, 3) + data[0] = byte((length >> 16) & 0xff) + data[1] = byte((length >> 8) & 0xff) + data[2] = byte((length >> 0) & 0xff) + data = append(data, pkt...) + _, err := conn.Write(data) + return err +} + +// remoteServerAssignAddress assigns an address to the TUN device. +func remoteServerAssignAddress(config *remoteServerConfig) error { + script := []string{ + fmt.Sprintf("ip addr add %s/24 dev %s", remoteServerAddr, remoteTUNDeviceName), + fmt.Sprintf("ip link set dev %s up", remoteTUNDeviceName), + fmt.Sprintf("iptables -t nat -I POSTROUTING -o %s -j MASQUERADE", config.iface), + "sysctl net.ipv4.ip_forward=1", + } + for _, cmd := range script { + if err := remoteServerExec(cmd); err != nil { + return err + } + } + return nil +} + +// remoteServerExec executes a command. +func remoteServerExec(cmdline string) error { + argv, err := shlex.Split(cmdline) + runtimex.PanicOnError(err, "shlex.Split failed") + runtimex.Assert(len(argv) >= 1, "expected at least one argv entry") + cmd := execabs.Command(argv[0], argv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + log.Infof("remote: exec: %s", cmd.String()) + return cmd.Run() +} + +// remoteServerCleanupIPTables removes iptables rules we have added. +func remoteServerCleanupIPTables(config *remoteServerConfig) { + remoteServerExec(fmt.Sprintf( + "iptables -t nat -D POSTROUTING -o %s -j MASQUERADE", + config.iface, + )) +} + +// remoteClientDialer creates connections. +type remoteClientDialer interface { + Dial() (remoteConn, error) +} + +// remoteClient is a client for the remote protocol +type remoteClient struct { + // closeOnce allows to call Close just once + closeOnce *sync.Once + + // conn is the transport connection. + conn remoteConn + + // net is the underlying userspace network stack + net *netstack.Net + + // tun is the TUN device in userspace. + tun tun.Device +} + +// newRemoteClient creates a new remote client. +func newRemoteClient(dialer remoteClientDialer) (*remoteClient, error) { + // establish a connection with the remote host + conn, err := dialer.Dial() + if err != nil { + return nil, err + } + + const mtu = 1300 // must be >= 1252, which is used by quic-go + + // create the TUN device in userspace + tun, net, err := netstack.CreateNetTUN( + []netip.Addr{remoteClientAddr}, + remoteResolvers, + mtu, + ) + if err != nil { + conn.Close() + return nil, err + } + + client := &remoteClient{ + closeOnce: &sync.Once{}, + net: net, + tun: tun, + conn: conn, + } + return client, nil +} + +// Close closes the connections used by a client. +func (c *remoteClient) Close() error { + var err error + c.closeOnce.Do(func() { + if e := c.tun.Close(); e != nil { + err = e + } + if e := c.conn.Close(); e != nil && err == nil { + err = e + } + }) + return err +} + +// route routes the traffic +func (c *remoteClient) route() { + // the following code has been adapted from ooni/minivpn + const zeroOffset = 0 + + go func() { + for { + pkt, err := remoteReadPacket(c.conn) + if err != nil { + log.Errorf("remote: cannot read from conn: %s", err.Error()) + return + } + if _, err = c.tun.Write(pkt, zeroOffset); err != nil { + log.Errorf("remote: cannot write to TUN device: %v", err) + break + } + } + }() + + go func() { + buf := make([]byte, remoteMaxPacketSize) + for { + count, err := c.tun.Read(buf, zeroOffset) + if err != nil { + log.Errorf("remote: cannot read from TUN device: %v", err) + break + } + pkt := buf[:count] + if err := remoteWritePacket(c.conn, pkt); err != nil { + log.Errorf("remote: cannot write to conn: %s", err.Error()) + return + } + } + }() +} + +// DialWithDialer dials a network connection using the given stdlib dialer. +func (c *remoteClient) DialWithDialer(ctx context.Context, d *net.Dialer, network string, address string) (net.Conn, error) { + if d.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, d.Timeout) + defer cancel() + } + if remoteIsIPv6(address) { + // TODO(bassosimone): extend this implementation to support IPv6 + return nil, syscall.EHOSTUNREACH + } + return c.net.DialContext(ctx, network, address) +} + +// remoteIsIPv6 returns whether the given endpoint contains an IPv6 address +func remoteIsIPv6(endpoint string) bool { + addr, _, err := net.SplitHostPort(endpoint) + if err != nil { + return false + } + v6, err := netxlite.IsIPv6(addr) + if err != nil { + return false + } + return v6 +} + +// ListenUDP creates a new listening UDP socket. +func (c *remoteClient) ListenUDP(network string, addr *net.UDPAddr) (model.UDPLikeConn, error) { + pconn, err := c.net.ListenUDP(addr) + if err != nil { + return nil, err + } + pwrap := &remoteClientUDPConn{pconn} + return pwrap, nil +} + +// remoteClientUDPConn adapts to model.UDPLikeConn. +type remoteClientUDPConn struct { + net.PacketConn +} + +// WriteTo implements net.PacketConn. +func (c *remoteClientUDPConn) WriteTo(pkt []byte, dest net.Addr) (int, error) { + if remoteIsIPv6(dest.String()) { + // TODO(bassosimone): extend this implementation to support IPv6 + return 0, syscall.EHOSTUNREACH + } + return c.PacketConn.WriteTo(pkt, dest) +} + +// SetReadBuffer allows setting the read buffer. +func (c *remoteClientUDPConn) SetReadBuffer(bytes int) error { + return nil +} + +// SyscallConn returns a conn suitable for calling syscalls, +// which is also instrumental to setting the read buffer. +// +// We need to mock SyscallConn and return a fake syscall.RawConn +// because otherwise lucas-clemente/quic-go would not work as intended. +func (c *remoteClientUDPConn) SyscallConn() (syscall.RawConn, error) { + return &remoteClientRawConnUDP{}, nil +} + +// remoteClientRawConnUDP implements syscall.RawConn +type remoteClientRawConnUDP struct{} + +// Control implements syscall.RawConn +func (*remoteClientRawConnUDP) Control(f func(fd uintptr)) error { + return nil +} + +// Read implements syscall.RawConn +func (*remoteClientRawConnUDP) Read(f func(fd uintptr) (done bool)) error { + return nil +} + +// Write implements syscall.RawConn +func (*remoteClientRawConnUDP) Write(f func(fd uintptr) (done bool)) error { + return nil +} + +// GetaddrinfoLookupANY performs a DNS lookup using getaddrinfo. +func (c *remoteClient) GetaddrinfoLookupANY(ctx context.Context, domain string) ([]string, string, error) { + addrs, err := c.net.LookupContextHost(ctx, domain) + return addrs, "", err +} diff --git a/internal/cmd/miniooni/remotedialer.go b/internal/cmd/miniooni/remotedialer.go new file mode 100644 index 0000000000..5973d9eebe --- /dev/null +++ b/internal/cmd/miniooni/remotedialer.go @@ -0,0 +1,31 @@ +package main + +// +// Common code for dialing TCP connections +// + +import "net" + +// remoteDialer implements remoteClientDialer. +type remoteDialer struct { + // remoteAddr is the remote address to use. + remoteAddr string + + // wrapConn wraps the established conn. + wrapConn remoteConnWrapper +} + +var _ remoteClientDialer = &remoteDialer{} + +// Dial implements remoteClientDialer. +func (rcd *remoteDialer) Dial() (remoteConn, error) { + conn, err := net.Dial("tcp", rcd.remoteAddr) + if err != nil { + return nil, err + } + cw, err := rcd.wrapConn(conn) + if err != nil { + return nil, err + } + return cw, nil +} diff --git a/internal/cmd/miniooni/remotehijack.go b/internal/cmd/miniooni/remotehijack.go new file mode 100644 index 0000000000..5d339a7618 --- /dev/null +++ b/internal/cmd/miniooni/remotehijack.go @@ -0,0 +1,98 @@ +package main + +// +// Client-side connection hijacking implementation +// + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "gopkg.in/yaml.v3" +) + +// remoteMaybeHijack hijacks miniooni connections using the selected remote +// name unless the remote name is empty. +func remoteMaybeHijack(options *Options) error { + remoteName := options.RemoteName + if remoteName == "" { + return nil + } + + // obtain the remote configuration from the config file + cfg, err := remoteReadConfigFile(options) + if err != nil { + return fmt.Errorf("remote: cannot read config file: %w", err) + } + remote := cfg.Remotes[remoteName] + if remote == nil { + return fmt.Errorf("remote: %s: no such remote", remoteName) + } + + // establish the specified remote connection + var client *remoteClient + switch txp := remote.Transport; txp { + case "tcp": + client, err = newRemoteTCPClient(remote) + case "ssh": + client, err = newRemoteSSHClient(remote) + default: + return fmt.Errorf("remote: %s: no such transport", txp) + } + if err != nil { + return err + } + + // start routing traffic + go client.route() + + // hijack netxlite's fundamental network operations + netxlite.TProxyDialWithDialer = client.DialWithDialer + netxlite.TProxyGetaddrinfoLookupANY = client.GetaddrinfoLookupANY + netxlite.TProxyListenUDP = client.ListenUDP + log.Infof("remote: %s: hijacked netxlite network primitives", remoteName) + + return nil +} + +// remoteConfigFile contains the configuration file content. +type remoteConfigFile struct { + // Remotes maps a remote name to its settings. + Remotes map[string]*remoteConfig `yaml:"remotes"` +} + +// remoteConfig is the configuration of a specific remote. +type remoteConfig struct { + // Address is the remote endpoint to use. + Address string `yaml:"address"` + + // Transport is the transport to use. + Transport string `yaml:"transport"` + + // SSH contains optional SSH configuration. + SSH *remoteConfigSSH `yaml:"ssh"` +} + +// remoteConfigSSH contains SSH specific configuration. +type remoteConfigSSH struct { + // User is the user name to use + User string `yaml:"user"` +} + +// remoteReadConfigFile reads the remote config file. +func remoteReadConfigFile(options *Options) (*remoteConfigFile, error) { + miniooniDir := createAndReturnMiniooniDir(options) + filename := filepath.Join(miniooniDir, "remote", "config.yaml") + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + var cfg remoteConfigFile + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/internal/cmd/miniooni/remotelistener.go b/internal/cmd/miniooni/remotelistener.go new file mode 100644 index 0000000000..64b8b1ae9f --- /dev/null +++ b/internal/cmd/miniooni/remotelistener.go @@ -0,0 +1,143 @@ +package main + +// +// Common code for listening for TCP conns +// + +import ( + "errors" + "net" + "sync" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/netxlite" +) + +// remoteConnWrapper wraps a net.Conn to implement the specific +// protocol used by this remote transport. +type remoteConnWrapper func(conn net.Conn) (remoteConn, error) + +// remoteListenerFactory implements remoteServerListenerFactory. +type remoteListenerFactory struct { + iface string + port string + wrapconn remoteConnWrapper +} + +var _ remoteServerListenerFactory = &remoteListenerFactory{} + +// Listen implements remoteServerListenerFactory. +func (slf *remoteListenerFactory) Listen() (remoteServerListener, error) { + dev, err := net.InterfaceByName(slf.iface) + if err != nil { + return nil, err + } + cidrs, err := dev.Addrs() + if err != nil { + return nil, err + } + lst := []net.Listener{} + for _, cidr := range cidrs { + addr, _, err := net.ParseCIDR(cidr.String()) + if err != nil { + return nil, err + } + if netxlite.IsBogon(addr.String()) { + // We don't care about listening on link local IPv6 addresses + // and listening will fail anyway, so... + continue + } + endpoint := net.JoinHostPort(addr.String(), slf.port) + listener, err := net.Listen("tcp", endpoint) + if err != nil { + return nil, err + } + log.Infof("remotelistener: listening at %s", listener.Addr().String()) + lst = append(lst, listener) + } + wl := &remoteListener{ + closeOnce: &sync.Once{}, + wrapconn: slf.wrapconn, + isclosed: make(chan any), + listeners: lst, + newconnch: make(chan remoteConn), + startOnce: &sync.Once{}, + } + return wl, nil +} + +// remoteListener implements remoteServerListener. +type remoteListener struct { + closeOnce *sync.Once + isclosed chan any + listeners []net.Listener + newconnch chan remoteConn + startOnce *sync.Once + wrapconn remoteConnWrapper +} + +var _ remoteServerListener = &remoteListener{} + +// Accept implements remoteServerListener. +func (rsl *remoteListener) Accept() (remoteConn, error) { + rsl.startOnce.Do(rsl.startAccepting) + select { + case conn := <-rsl.newconnch: + return conn, nil + case <-rsl.isclosed: + return nil, net.ErrClosed + } +} + +// startAccepting starts accepting incoming connections. +func (rsl *remoteListener) startAccepting() { + for _, lst := range rsl.listeners { + go rsl.acceptloop(lst) + } +} + +// acceptloop is the accept loop. +func (rsl *remoteListener) acceptloop(listener net.Listener) { + for { + conn, err := listener.Accept() + if err != nil && errors.Is(err, net.ErrClosed) { + return + } + if err != nil { + log.Warnf("remotelistener: listener.Accept failed: %s", err.Error()) + continue + } + go rsl.wrapAndDispatchConn(conn) + } +} + +// wrapAndDispatchConn wraps the connection and then dispatches it to +// the code that will route incoming and outgoing packets. +func (rsl *remoteListener) wrapAndDispatchConn(conn net.Conn) { + wrapped, err := rsl.wrapconn(conn) + if err != nil { + log.Warnf("remotelistener: rsl.wrap failed: %s", err.Error()) + conn.Close() + return + } + select { + case rsl.newconnch <- wrapped: + case <-rsl.isclosed: + conn.Close() + return + } +} + +// Close implements remoteServerListener. +func (rsl *remoteListener) Close() error { + var err error + rsl.closeOnce.Do(func() { + for _, lst := range rsl.listeners { + if e := lst.Close(); e != nil && err == nil { + err = e + } + } + close(rsl.isclosed) + }) + return err +} diff --git a/internal/cmd/miniooni/remotessh.go b/internal/cmd/miniooni/remotessh.go new file mode 100644 index 0000000000..a43e7e985c --- /dev/null +++ b/internal/cmd/miniooni/remotessh.go @@ -0,0 +1,299 @@ +package main + +// +// SSH remote implementation +// + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sync" + + "github.com/ooni/probe-cli/v3/internal/runtimex" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +var ( + // remoteSSHPort is the port used by default by this remote. + remoteSSHPort string + + // remoteSSHInterface is the interface used by default by this remote. + remoteSSHInterface string +) + +// registerRemoteSSH registers the remotessh command. +func registerRemoteSSH(rootCmd *cobra.Command) { + subCmd := &cobra.Command{ + Use: "remotessh", + Short: "RemoteSSH protocol server", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + remoteSSHServerMain(remoteSSHPort, remoteSSHInterface) + }, + } + + flags := subCmd.Flags() + + flags.StringVar( + &remoteSSHPort, + "port", + "2222", + "selects the port to use", + ) + + flags.StringVar( + &remoteSSHInterface, + "interface", + "eth0", + "selects the interface to use", + ) + + rootCmd.AddCommand(subCmd) +} + +// remoteSSHServerMain is the main of the remotessh subcommand. +func remoteSSHServerMain(port, iface string) { + config := &remoteServerConfig{ + iface: iface, + } + sh, err := newRemoteSSHServerHandler() + runtimex.PanicOnError(err, "newRemoteSSHServerHandler failed") + factory := &remoteListenerFactory{ + iface: iface, + port: port, + wrapconn: sh.wrapConn, + } + err = remoteServerMain(config, factory) + runtimex.PanicOnError(err, "remoteServerMain failed") +} + +// remoteSSHServerHandler handles incoming SSH conns. +type remoteSSHServerHandler struct { + config *ssh.ServerConfig +} + +// remoteSSHReadAuthorizedKeys reads and parses the authorized_keys file. +func remoteSSHReadAuthorizedKeys() (map[string]bool, error) { + homeDir := gethomedir("") + filename := filepath.Join(homeDir, ".ssh", "authorized_keys") + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + akmap := map[string]bool{} + for len(data) > 0 { + pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(data) + if err != nil { + return nil, err + } + akmap[string(pubKey.Marshal())] = true + data = rest + } + return akmap, nil +} + +// remoteSSHReadSSHHostRSAKey reads the host's private key. +func remoteSSHReadSSHHostRSAKey() (ssh.Signer, error) { + data, err := os.ReadFile("/etc/ssh/ssh_host_rsa_key") + if err != nil { + return nil, err + } + return ssh.ParsePrivateKey(data) +} + +// newRemoteSSHServerHandler creates a new remoteSSHServerHandler instance. +func newRemoteSSHServerHandler() (*remoteSSHServerHandler, error) { + akmap, err := remoteSSHReadAuthorizedKeys() + if err != nil { + return nil, err + } + signer, err := remoteSSHReadSSHHostRSAKey() + if err != nil { + return nil, err + } + config := &ssh.ServerConfig{ + PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + if akmap[string(pubKey.Marshal())] { + return &ssh.Permissions{ + // Record the public key used for authentication. + Extensions: map[string]string{ + "pubkey-fp": ssh.FingerprintSHA256(pubKey), + }, + }, nil + } + return nil, fmt.Errorf("unknown public key for %q", c.User()) + }, + } + config.AddHostKey(signer) + handler := &remoteSSHServerHandler{ + config: config, + } + return handler, nil +} + +// errRemoteSSHInvalidChannelType indicates that the channel type is invalid +var errRemoteSSHInvalidChannelType = errors.New("invalid SSH channel type") + +// wrapConn wraps a server-side net.Conn to be an SSH conn. +func (h *remoteSSHServerHandler) wrapConn(conn net.Conn) (remoteConn, error) { + sshConn, chans, sshReqs, err := ssh.NewServerConn(conn, h.config) + if err != nil { + return nil, err + } + go ssh.DiscardRequests(sshReqs) + candidate := <-chans + if candidate.ChannelType() != "miniooni-remote" { + return nil, errRemoteSSHInvalidChannelType + } + channel, chanReqs, err := candidate.Accept() + if err != nil { + return nil, err + } + go ssh.DiscardRequests(chanReqs) + rc := &remoteSSHServerRemoteConn{ + channel: channel, + closeOnce: &sync.Once{}, + conn: sshConn, + } + return rc, nil +} + +// remoteSSHServerRemoteConn implements remoteConn +type remoteSSHServerRemoteConn struct { + channel ssh.Channel + closeOnce *sync.Once + conn *ssh.ServerConn +} + +var _ remoteConn = &remoteSSHServerRemoteConn{} + +func (c *remoteSSHServerRemoteConn) Read(data []byte) (int, error) { + return c.channel.Read(data) +} + +func (c *remoteSSHServerRemoteConn) Write(data []byte) (int, error) { + return c.channel.Write(data) +} + +func (c *remoteSSHServerRemoteConn) Close() error { + var err error + c.closeOnce.Do(func() { + if e := c.conn.Close(); e != nil { + err = e + } + }) + return err +} + +// newRemoteSSHClient creates a new remoteClient using SSH. +func newRemoteSSHClient(remote *remoteConfig) (*remoteClient, error) { + hx, err := newRemoteSSHClientHandshaker(remote) + if err != nil { + return nil, err + } + dialer := &remoteDialer{ + remoteAddr: remote.Address, + wrapConn: hx.wrapConn, + } + return newRemoteClient(dialer) +} + +// remoteSSHClientHandshaker performs the SSH handshake and returns +// a suitable connection for forwarding traffic. +type remoteSSHClientHandshaker struct { + config *ssh.ClientConfig +} + +// errRemoteSSHMissingConfig indicates SSH specific config is missing. +var errRemoteSSHMissingConfig = errors.New("SSH specific config is missing") + +// newRemoteSSHClientHandshaker creates a new remoteSSHClientHandshaker. +func newRemoteSSHClientHandshaker(remote *remoteConfig) (*remoteSSHClientHandshaker, error) { + if remote.SSH == nil { + return nil, errRemoteSSHMissingConfig + } + agentClient, err := remoteSSHClientCreateSSHAgent() + if err != nil { + return nil, err + } + config := &ssh.ClientConfig{ + User: remote.SSH.User, + Auth: []ssh.AuthMethod{ + ssh.PublicKeysCallback(agentClient.Signers), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + hx := &remoteSSHClientHandshaker{ + config: config, + } + return hx, nil +} + +// wrapConn wraps a server-side net.Conn to be an SSH conn. +func (hx *remoteSSHClientHandshaker) wrapConn(conn net.Conn) (remoteConn, error) { + sshConn, _, sshReqs, err := ssh.NewClientConn(conn, conn.RemoteAddr().String(), hx.config) + if err != nil { + return nil, err + } + go ssh.DiscardRequests(sshReqs) + channel, chanReqs, err := sshConn.OpenChannel("miniooni-remote", nil) + if err != nil { + return nil, err + } + go ssh.DiscardRequests(chanReqs) + rc := &remoteSSHClientRemoteConn{ + channel: channel, + closeOnce: &sync.Once{}, + conn: sshConn, + } + return rc, nil +} + +// remoteSSHClientRemoteConn implements remoteConn +type remoteSSHClientRemoteConn struct { + channel ssh.Channel + closeOnce *sync.Once + conn ssh.Conn +} + +var _ remoteConn = &remoteSSHClientRemoteConn{} + +func (c *remoteSSHClientRemoteConn) Read(data []byte) (int, error) { + return c.channel.Read(data) +} + +func (c *remoteSSHClientRemoteConn) Write(data []byte) (int, error) { + return c.channel.Write(data) +} + +func (c *remoteSSHClientRemoteConn) Close() error { + var err error + c.closeOnce.Do(func() { + if e := c.conn.Close(); e != nil { + err = e + } + }) + return err +} + +// errRemoteSSHNoAuthSock indicates that there is no SSH_AUTH_SOCK variable +var errRemoteSSHNoAuthSock = errors.New("no SSH_AUTH_SOCK environment variable") + +// remoteSSHClientCreateSSHAgent creates a SSH agent instance. +func remoteSSHClientCreateSSHAgent() (agent.ExtendedAgent, error) { + socket, found := os.LookupEnv("SSH_AUTH_SOCK") + if !found { + return nil, errRemoteSSHNoAuthSock + } + conn, err := net.Dial("unix", socket) + if err != nil { + return nil, err + } + agentClient := agent.NewClient(conn) + return agentClient, nil +} diff --git a/internal/cmd/miniooni/remotetcp.go b/internal/cmd/miniooni/remotetcp.go new file mode 100644 index 0000000000..10ceebb713 --- /dev/null +++ b/internal/cmd/miniooni/remotetcp.go @@ -0,0 +1,77 @@ +package main + +// +// TCP remote implementation +// + +import ( + "net" + + "github.com/ooni/probe-cli/v3/internal/runtimex" + "github.com/spf13/cobra" +) + +var ( + // remoteTCPPort is the port used by default by this remote. + remoteTCPPort string + + // remoteTCPInterface is the interface used by default by this remote. + remoteTCPInterface string +) + +// registerRemoteTCP registers the remotetcp command. +func registerRemoteTCP(rootCmd *cobra.Command) { + subCmd := &cobra.Command{ + Use: "remotetcp", + Short: "RemoteTCP protocol server", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + remoteTCPServerMain(remoteTCPPort, remoteTCPInterface) + }, + } + + flags := subCmd.Flags() + + flags.StringVar( + &remoteTCPPort, + "port", + "5555", + "selects the port to use", + ) + + flags.StringVar( + &remoteTCPInterface, + "interface", + "eth0", + "selects the interface to use", + ) + + rootCmd.AddCommand(subCmd) +} + +// remoteTCPServerMain is the main of the remotetcp subcommand. +func remoteTCPServerMain(port, iface string) { + config := &remoteServerConfig{ + iface: iface, + } + factory := &remoteListenerFactory{ + iface: iface, + port: port, + wrapconn: func(conn net.Conn) (remoteConn, error) { + return conn, nil + }, + } + err := remoteServerMain(config, factory) + runtimex.PanicOnError(err, "remoteServerMain failed") +} + +// newRemoteTCPClient creates a new remoteClient using TCP. +func newRemoteTCPClient(remote *remoteConfig) (*remoteClient, error) { + dialer := &remoteDialer{ + remoteAddr: remote.Address, + wrapConn: func(conn net.Conn) (remoteConn, error) { + return conn, nil + }, + } + return newRemoteClient(dialer) +} diff --git a/internal/cmd/miniooni/utils.go b/internal/cmd/miniooni/utils.go index cb51e13398..84e265146c 100644 --- a/internal/cmd/miniooni/utils.go +++ b/internal/cmd/miniooni/utils.go @@ -8,6 +8,7 @@ import ( "errors" "net/url" "os" + "path" "runtime" "strings" @@ -86,3 +87,14 @@ func gethomedir(optionsHome string) string { } return os.Getenv("HOME") } + +// createAndReturnMiniooniDir creates the $HOME/.miniooni directory +// and returns its full path to the caller. +func createAndReturnMiniooniDir(options *Options) string { + homeDir := gethomedir(options.HomeDir) + runtimex.Assert(homeDir != "", "home directory is empty") + miniooniDir := path.Join(homeDir, ".miniooni") + err := os.MkdirAll(miniooniDir, 0700) + runtimex.PanicOnError(err, "cannot create $HOME/.miniooni directory") + return miniooniDir +} diff --git a/internal/engine/geolocate/cloudflare.go b/internal/engine/geolocate/cloudflare.go index b98c1e6cc2..3de61a538b 100644 --- a/internal/engine/geolocate/cloudflare.go +++ b/internal/engine/geolocate/cloudflare.go @@ -15,6 +15,7 @@ func cloudflareIPLookup( httpClient *http.Client, logger model.Logger, userAgent string, + resolver model.Resolver, ) (string, error) { data, err := (&httpx.APIClientTemplate{ BaseURL: "https://www.cloudflare.com", diff --git a/internal/engine/geolocate/cloudflare_test.go b/internal/engine/geolocate/cloudflare_test.go index 49c50b7c15..4f0b44da41 100644 --- a/internal/engine/geolocate/cloudflare_test.go +++ b/internal/engine/geolocate/cloudflare_test.go @@ -8,6 +8,7 @@ import ( "github.com/apex/log" "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" ) func TestIPLookupWorksUsingcloudlflare(t *testing.T) { @@ -16,6 +17,7 @@ func TestIPLookupWorksUsingcloudlflare(t *testing.T) { http.DefaultClient, log.Log, model.HTTPHeaderUserAgent, + netxlite.NewStdlibResolver(model.DiscardLogger), ) if err != nil { t.Fatal(err) diff --git a/internal/engine/geolocate/geolocate.go b/internal/engine/geolocate/geolocate.go index 175c7db033..0b88bbaa04 100644 --- a/internal/engine/geolocate/geolocate.go +++ b/internal/engine/geolocate/geolocate.go @@ -90,7 +90,9 @@ func NewTask(config Config) *Task { probeIPLookupper: ipLookupClient(config), probeASNLookupper: mmdbLookupper{}, resolverASNLookupper: mmdbLookupper{}, - resolverIPLookupper: resolverLookupClient{}, + resolverIPLookupper: resolverLookupClient{ + Resolver: config.Resolver, + }, } } diff --git a/internal/engine/geolocate/invalid_test.go b/internal/engine/geolocate/invalid_test.go index aade7c71de..bb62404328 100644 --- a/internal/engine/geolocate/invalid_test.go +++ b/internal/engine/geolocate/invalid_test.go @@ -12,6 +12,7 @@ func invalidIPLookup( httpClient *http.Client, logger model.Logger, userAgent string, + resolver model.Resolver, ) (string, error) { return "invalid IP", nil } diff --git a/internal/engine/geolocate/iplookup.go b/internal/engine/geolocate/iplookup.go index b5b40b069b..3d9711f323 100644 --- a/internal/engine/geolocate/iplookup.go +++ b/internal/engine/geolocate/iplookup.go @@ -27,6 +27,7 @@ var ( type lookupFunc func( ctx context.Context, client *http.Client, logger model.Logger, userAgent string, + resolver model.Resolver, ) (string, error) type method struct { @@ -89,7 +90,7 @@ func (c ipLookupClient) doWithCustomFunc( txp := netxlite.NewHTTPTransportWithResolver(c.Logger, c.Resolver) clnt := &http.Client{Transport: txp} defer clnt.CloseIdleConnections() - ip, err := fn(ctx, clnt, c.Logger, c.UserAgent) + ip, err := fn(ctx, clnt, c.Logger, c.UserAgent, c.Resolver) if err != nil { return model.DefaultProbeIP, err } diff --git a/internal/engine/geolocate/resolverlookup.go b/internal/engine/geolocate/resolverlookup.go index fb25eb1a97..ef6835b225 100644 --- a/internal/engine/geolocate/resolverlookup.go +++ b/internal/engine/geolocate/resolverlookup.go @@ -3,7 +3,8 @@ package geolocate import ( "context" "errors" - "net" + + "github.com/ooni/probe-cli/v3/internal/model" ) var ( @@ -16,7 +17,9 @@ type dnsResolver interface { LookupHost(ctx context.Context, host string) (addrs []string, err error) } -type resolverLookupClient struct{} +type resolverLookupClient struct { + Resolver model.Resolver +} func (rlc resolverLookupClient) do(ctx context.Context, r dnsResolver) (string, error) { var ips []string @@ -31,5 +34,5 @@ func (rlc resolverLookupClient) do(ctx context.Context, r dnsResolver) (string, } func (rlc resolverLookupClient) LookupResolverIP(ctx context.Context) (ip string, err error) { - return rlc.do(ctx, &net.Resolver{}) + return rlc.do(ctx, rlc.Resolver) } diff --git a/internal/engine/geolocate/resolverlookup_test.go b/internal/engine/geolocate/resolverlookup_test.go index 03331610ee..3d34c3fb74 100644 --- a/internal/engine/geolocate/resolverlookup_test.go +++ b/internal/engine/geolocate/resolverlookup_test.go @@ -4,10 +4,16 @@ import ( "context" "errors" "testing" + + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" ) func TestLookupResolverIP(t *testing.T) { - addr, err := (resolverLookupClient{}).LookupResolverIP(context.Background()) + rlc := resolverLookupClient{ + Resolver: netxlite.NewStdlibResolver(model.DiscardLogger), + } + addr, err := rlc.LookupResolverIP(context.Background()) if err != nil { t.Fatal(err) } @@ -26,7 +32,9 @@ func (bhl brokenHostLookupper) LookupHost(ctx context.Context, host string) ([]s func TestLookupResolverIPFailure(t *testing.T) { expected := errors.New("mocked error") - rlc := resolverLookupClient{} + rlc := resolverLookupClient{ + Resolver: netxlite.NewStdlibResolver(model.DiscardLogger), + } addr, err := rlc.do(context.Background(), brokenHostLookupper{ err: expected, }) @@ -39,7 +47,9 @@ func TestLookupResolverIPFailure(t *testing.T) { } func TestLookupResolverIPNoAddressReturned(t *testing.T) { - rlc := resolverLookupClient{} + rlc := resolverLookupClient{ + Resolver: netxlite.NewStdlibResolver(model.DiscardLogger), + } addr, err := rlc.do(context.Background(), brokenHostLookupper{}) if !errors.Is(err, ErrNoIPAddressReturned) { t.Fatalf("not the error we expected: %+v", err) diff --git a/internal/engine/geolocate/stun.go b/internal/engine/geolocate/stun.go index cb3746d80c..d6133a1096 100644 --- a/internal/engine/geolocate/stun.go +++ b/internal/engine/geolocate/stun.go @@ -2,41 +2,49 @@ package geolocate import ( "context" + "net" "net/http" "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" "github.com/pion/stun" ) -// TODO(bassosimone): we should modify the stun code to use -// the session resolver rather than using its own. -// -// See https://github.com/ooni/probe/issues/1383. - type stunClient interface { Close() error Start(m *stun.Message, h stun.Handler) error } type stunConfig struct { - Dial func(network string, address string) (stunClient, error) - Endpoint string - Logger model.Logger + Dialer model.Dialer // optional + Endpoint string + Logger model.Logger + NewClient func(conn net.Conn) (stunClient, error) // optional + Resolver model.Resolver } -func stunDialer(network string, address string) (stunClient, error) { - return stun.Dial(network, address) +func stunNewClient(conn net.Conn) (stunClient, error) { + return stun.NewClient(conn) } func stunIPLookup(ctx context.Context, config stunConfig) (string, error) { config.Logger.Debugf("STUNIPLookup: start using %s", config.Endpoint) ip, err := func() (string, error) { - dial := config.Dial - if dial == nil { - dial = stunDialer + dialer := config.Dialer + if dialer == nil { + dialer = netxlite.NewDialerWithResolver(config.Logger, config.Resolver) + } + conn, err := dialer.DialContext(ctx, "udp", config.Endpoint) + if err != nil { + return model.DefaultProbeIP, err + } + newClient := config.NewClient + if newClient == nil { + newClient = stunNewClient } - clnt, err := dial("udp", config.Endpoint) + clnt, err := newClient(conn) if err != nil { + conn.Close() return model.DefaultProbeIP, err } defer clnt.Close() @@ -78,10 +86,12 @@ func stunEkigaIPLookup( httpClient *http.Client, logger model.Logger, userAgent string, + resolver model.Resolver, ) (string, error) { return stunIPLookup(ctx, stunConfig{ Endpoint: "stun.ekiga.net:3478", Logger: logger, + Resolver: resolver, }) } @@ -90,9 +100,11 @@ func stunGoogleIPLookup( httpClient *http.Client, logger model.Logger, userAgent string, + resolver model.Resolver, ) (string, error) { return stunIPLookup(ctx, stunConfig{ Endpoint: "stun.l.google.com:19302", Logger: logger, + Resolver: resolver, }) } diff --git a/internal/engine/geolocate/stun_test.go b/internal/engine/geolocate/stun_test.go index d89a5bfbaf..c161c069f0 100644 --- a/internal/engine/geolocate/stun_test.go +++ b/internal/engine/geolocate/stun_test.go @@ -10,6 +10,8 @@ import ( "github.com/apex/log" "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/model/mocks" + "github.com/ooni/probe-cli/v3/internal/netxlite" "github.com/pion/stun" ) @@ -19,6 +21,7 @@ func TestSTUNIPLookupCanceledContext(t *testing.T) { ip, err := stunIPLookup(ctx, stunConfig{ Endpoint: "stun.ekiga.net:3478", Logger: log.Log, + Resolver: netxlite.NewStdlibResolver(model.DiscardLogger), }) if !errors.Is(err, context.Canceled) { t.Fatalf("not the error we expected: %+v", err) @@ -32,8 +35,10 @@ func TestSTUNIPLookupDialFailure(t *testing.T) { expected := errors.New("mocked error") ctx := context.Background() ip, err := stunIPLookup(ctx, stunConfig{ - Dial: func(network, address string) (stunClient, error) { - return nil, expected + Dialer: &mocks.Dialer{ + MockDialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return nil, expected + }, }, Endpoint: "stun.ekiga.net:3478", Logger: log.Log, @@ -70,11 +75,17 @@ func TestSTUNIPLookupStartReturnsError(t *testing.T) { expected := errors.New("mocked error") ctx := context.Background() ip, err := stunIPLookup(ctx, stunConfig{ - Dial: func(network, address string) (stunClient, error) { - return MockableSTUNClient{StartErr: expected}, nil + Dialer: &mocks.Dialer{ + MockDialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + conn := &mocks.Conn{} + return conn, nil + }, }, Endpoint: "stun.ekiga.net:3478", Logger: log.Log, + NewClient: func(conn net.Conn) (stunClient, error) { + return MockableSTUNClient{StartErr: expected}, nil + }, }) if !errors.Is(err, expected) { t.Fatalf("not the error we expected: %+v", err) @@ -88,13 +99,19 @@ func TestSTUNIPLookupStunEventContainsError(t *testing.T) { expected := errors.New("mocked error") ctx := context.Background() ip, err := stunIPLookup(ctx, stunConfig{ - Dial: func(network, address string) (stunClient, error) { + Dialer: &mocks.Dialer{ + MockDialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + conn := &mocks.Conn{} + return conn, nil + }, + }, + Endpoint: "stun.ekiga.net:3478", + Logger: log.Log, + NewClient: func(conn net.Conn) (stunClient, error) { return MockableSTUNClient{Event: stun.Event{ Error: expected, }}, nil }, - Endpoint: "stun.ekiga.net:3478", - Logger: log.Log, }) if !errors.Is(err, expected) { t.Fatalf("not the error we expected: %+v", err) @@ -107,13 +124,19 @@ func TestSTUNIPLookupStunEventContainsError(t *testing.T) { func TestSTUNIPLookupCannotDecodeMessage(t *testing.T) { ctx := context.Background() ip, err := stunIPLookup(ctx, stunConfig{ - Dial: func(network, address string) (stunClient, error) { + Dialer: &mocks.Dialer{ + MockDialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + conn := &mocks.Conn{} + return conn, nil + }, + }, + Endpoint: "stun.ekiga.net:3478", + Logger: log.Log, + NewClient: func(conn net.Conn) (stunClient, error) { return MockableSTUNClient{Event: stun.Event{ Message: &stun.Message{}, }}, nil }, - Endpoint: "stun.ekiga.net:3478", - Logger: log.Log, }) if !errors.Is(err, stun.ErrAttributeNotFound) { t.Fatalf("not the error we expected: %+v", err) @@ -129,6 +152,7 @@ func TestIPLookupWorksUsingSTUNEkiga(t *testing.T) { http.DefaultClient, log.Log, model.HTTPHeaderUserAgent, + netxlite.NewStdlibResolver(model.DiscardLogger), ) if err != nil { t.Fatal(err) @@ -144,6 +168,7 @@ func TestIPLookupWorksUsingSTUNGoogle(t *testing.T) { http.DefaultClient, log.Log, model.HTTPHeaderUserAgent, + netxlite.NewStdlibResolver(model.DiscardLogger), ) if err != nil { t.Fatal(err) diff --git a/internal/engine/geolocate/ubuntu.go b/internal/engine/geolocate/ubuntu.go index c37e8c6079..0f75afb4e0 100644 --- a/internal/engine/geolocate/ubuntu.go +++ b/internal/engine/geolocate/ubuntu.go @@ -19,6 +19,7 @@ func ubuntuIPLookup( httpClient *http.Client, logger model.Logger, userAgent string, + resolver model.Resolver, ) (string, error) { data, err := (&httpx.APIClientTemplate{ BaseURL: "https://geoip.ubuntu.com/", diff --git a/internal/engine/geolocate/ubuntu_test.go b/internal/engine/geolocate/ubuntu_test.go index 22e1d287f6..d6edf7c015 100644 --- a/internal/engine/geolocate/ubuntu_test.go +++ b/internal/engine/geolocate/ubuntu_test.go @@ -10,6 +10,7 @@ import ( "github.com/apex/log" "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" ) func TestUbuntuParseError(t *testing.T) { @@ -23,6 +24,7 @@ func TestUbuntuParseError(t *testing.T) { }}, log.Log, model.HTTPHeaderUserAgent, + netxlite.NewStdlibResolver(model.DiscardLogger), ) if err == nil || !strings.HasPrefix(err.Error(), "XML syntax error") { t.Fatalf("not the error we expected: %+v", err) @@ -38,6 +40,7 @@ func TestIPLookupWorksUsingUbuntu(t *testing.T) { http.DefaultClient, log.Log, model.HTTPHeaderUserAgent, + netxlite.NewStdlibResolver(model.DiscardLogger), ) if err != nil { t.Fatal(err) diff --git a/internal/netxlite/dialer.go b/internal/netxlite/dialer.go index 3ec0cf9e96..f76b774742 100644 --- a/internal/netxlite/dialer.go +++ b/internal/netxlite/dialer.go @@ -34,7 +34,7 @@ func NewDialerWithResolver(dl model.DebugLogger, r model.Resolver, w ...model.Di // When possible use NewDialerWithResolver or NewDialerWithoutResolver // instead of using this rather low-level function. // -// Arguments +// # Arguments // // 1. logger is used to emit debug messages (MUST NOT be nil); // @@ -47,58 +47,57 @@ func NewDialerWithResolver(dl model.DebugLogger, r model.Resolver, w ...model.Di // modify the behavior of the returned dialer (see below). Please note // that this function will just ignore any nil wrapper. // -// Return value +// # Return value // // The returned dialer is an opaque type consisting of the composition of // several simple dialers. The following pseudo code illustrates the general // behavior of the returned composed dialer: // -// addrs, err := dnslookup() -// if err != nil { -// return nil, err -// } -// errors := []error{} -// for _, a := range addrs { -// conn, err := tcpconnect(a) -// if err != nil { -// errors = append(errors, err) -// continue -// } -// return conn, nil -// } -// return nil, errors[0] -// +// addrs, err := dnslookup() +// if err != nil { +// return nil, err +// } +// errors := []error{} +// for _, a := range addrs { +// conn, err := tcpconnect(a) +// if err != nil { +// errors = append(errors, err) +// continue +// } +// return conn, nil +// } +// return nil, errors[0] // // The following table describes the structure of the returned dialer: // -// +-------+-----------------+------------------------------------------+ -// | Index | Name | Description | -// +-------+-----------------+------------------------------------------+ -// | 0 | base | the baseDialer argument | -// +-------+-----------------+------------------------------------------+ -// | 1 | errWrapper | wraps Go errors to be consistent with | -// | | | OONI df-007-errors spec | -// +-------+-----------------+------------------------------------------+ -// | 2 | ??? | if there are wrappers, result of calling | -// | | | the first one on the errWrapper dialer | -// +-------+-----------------+------------------------------------------+ -// | ... | ... | ... | -// +-------+-----------------+------------------------------------------+ -// | N | ??? | if there are wrappers, result of calling | -// | | | the last one on the N-1 dialer | -// +-------+-----------------+------------------------------------------+ -// | N+1 | logger (inner) | logs TCP connect operations | -// +-------+-----------------+------------------------------------------+ -// | N+2 | resolver | DNS lookup and try connect each IP in | -// | | | sequence until one of them succeeds | -// +-------+-----------------+------------------------------------------+ -// | N+3 | logger (outer) | logs the overall dial operation | -// +-------+-----------------+------------------------------------------+ +// +-------+-----------------+------------------------------------------+ +// | Index | Name | Description | +// +-------+-----------------+------------------------------------------+ +// | 0 | base | the baseDialer argument | +// +-------+-----------------+------------------------------------------+ +// | 1 | errWrapper | wraps Go errors to be consistent with | +// | | | OONI df-007-errors spec | +// +-------+-----------------+------------------------------------------+ +// | 2 | ??? | if there are wrappers, result of calling | +// | | | the first one on the errWrapper dialer | +// +-------+-----------------+------------------------------------------+ +// | ... | ... | ... | +// +-------+-----------------+------------------------------------------+ +// | N | ??? | if there are wrappers, result of calling | +// | | | the last one on the N-1 dialer | +// +-------+-----------------+------------------------------------------+ +// | N+1 | logger (inner) | logs TCP connect operations | +// +-------+-----------------+------------------------------------------+ +// | N+2 | resolver | DNS lookup and try connect each IP in | +// | | | sequence until one of them succeeds | +// +-------+-----------------+------------------------------------------+ +// | N+3 | logger (outer) | logs the overall dial operation | +// +-------+-----------------+------------------------------------------+ // // The list of wrappers allows to insert modified dialers in the correct // place for observing and saving I/O events (connect, read, etc.). // -// Remarks +// # Remarks // // When the resolver is &NullResolver{} any attempt to perform DNS resolutions // in the dialer at index N+2 will fail with ErrNoResolver. @@ -155,7 +154,7 @@ var _ model.Dialer = &DialerSystem{} const dialerDefaultTimeout = 15 * time.Second -func (d *DialerSystem) newUnderlyingDialer() model.SimpleDialer { +func (d *DialerSystem) newUnderlyingDialer() *net.Dialer { t := d.timeout if t <= 0 { t = dialerDefaultTimeout @@ -164,7 +163,7 @@ func (d *DialerSystem) newUnderlyingDialer() model.SimpleDialer { } func (d *DialerSystem) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - return d.newUnderlyingDialer().DialContext(ctx, network, address) + return TProxyDialWithDialer(ctx, d.newUnderlyingDialer(), network, address) } func (d *DialerSystem) CloseIdleConnections() { diff --git a/internal/netxlite/dialer_test.go b/internal/netxlite/dialer_test.go index 6e394bd297..61de33a94c 100644 --- a/internal/netxlite/dialer_test.go +++ b/internal/netxlite/dialer_test.go @@ -84,7 +84,7 @@ func TestDialerSystem(t *testing.T) { t.Run("has a default timeout", func(t *testing.T) { d := &DialerSystem{} ud := d.newUnderlyingDialer() - if ud.(*net.Dialer).Timeout != dialerDefaultTimeout { + if ud.Timeout != dialerDefaultTimeout { t.Fatal("unexpected default timeout") } }) @@ -93,7 +93,7 @@ func TestDialerSystem(t *testing.T) { const smaller = 1 * time.Second d := &DialerSystem{timeout: smaller} ud := d.newUnderlyingDialer() - if ud.(*net.Dialer).Timeout != smaller { + if ud.Timeout != smaller { t.Fatal("unexpected timeout") } }) diff --git a/internal/netxlite/dnsovergetaddrinfo.go b/internal/netxlite/dnsovergetaddrinfo.go index 8fd5bc6bcf..920c134163 100644 --- a/internal/netxlite/dnsovergetaddrinfo.go +++ b/internal/netxlite/dnsovergetaddrinfo.go @@ -104,7 +104,7 @@ func (txp *dnsOverGetaddrinfoTransport) lookupfn() func(ctx context.Context, dom if txp.testableLookupANY != nil { return txp.testableLookupANY } - return getaddrinfoLookupANY + return TProxyGetaddrinfoLookupANY } func (txp *dnsOverGetaddrinfoTransport) RequiresPadding() bool { diff --git a/internal/netxlite/doc.go b/internal/netxlite/doc.go index 9bb2774c5b..206ee7617e 100644 --- a/internal/netxlite/doc.go +++ b/internal/netxlite/doc.go @@ -41,6 +41,18 @@ // See also the design document at docs/design/dd-003-step-by-step.md, // which provides an overview of netxlite's main concerns. // +// To implement integration testing, we support hijacking the core network +// primitives used by this package, that is: +// +// 1. connecting a new TCP/UDP connection; +// +// 2. creating listening UDP sockets; +// +// 3. resolving domain names with getaddrinfo. +// +// By overriding the TProxyXXX variables, you can control these operations and +// route traffic to, e.g., a wireguard peer where you implement censorship. +// // Operations // // This package implements the following operations: diff --git a/internal/netxlite/quic.go b/internal/netxlite/quic.go index fef805e081..5ad8f60799 100644 --- a/internal/netxlite/quic.go +++ b/internal/netxlite/quic.go @@ -29,7 +29,7 @@ var _ model.QUICListener = &quicListenerStdlib{} // Listen implements QUICListener.Listen. func (qls *quicListenerStdlib) Listen(addr *net.UDPAddr) (model.UDPLikeConn, error) { - return net.ListenUDP("udp", addr) + return TProxyListenUDP("udp", addr) } // NewQUICDialerWithResolver is the WrapDialer equivalent for QUIC where diff --git a/internal/netxlite/tproxy.go b/internal/netxlite/tproxy.go new file mode 100644 index 0000000000..17111fb6ad --- /dev/null +++ b/internal/netxlite/tproxy.go @@ -0,0 +1,27 @@ +package netxlite + +import ( + "context" + "net" + + "github.com/ooni/probe-cli/v3/internal/model" +) + +// TProxyDialWithDialer is the top-level function used for dialing. By default we use +// the given dialer, but you can override it. Should you choose to override this function, +// please ensure you're honouring dialer.Timeout, if nonzero, when dialing. +var TProxyDialWithDialer = func(ctx context.Context, d *net.Dialer, network, address string) (net.Conn, error) { + return d.DialContext(ctx, network, address) +} + +// TProxyListenUDP is the top-level function used to create listening UDP connections. By default +// this function calls net.ListenUDP, but you can override it. +var TProxyListenUDP = func(network string, addr *net.UDPAddr) (model.UDPLikeConn, error) { + return net.ListenUDP(network, addr) +} + +// TProxyGetaddrinfoLookupANY is the toplevel function used to invoke getaddrinfo. By default +// this function calls getaddrinfoLookupANY, but you can override it. +var TProxyGetaddrinfoLookupANY = func(ctx context.Context, domain string) ([]string, string, error) { + return getaddrinfoLookupANY(ctx, domain) +} From 1a8363e1a673098464702f654e31100825aa45bd Mon Sep 17 00:00:00 2001 From: Simone Basso Date: Wed, 12 Oct 2022 16:58:00 +0200 Subject: [PATCH 2/2] refactor(tproxy): more OO solution --- internal/cmd/miniooni/remotecore.go | 18 +++-- internal/cmd/miniooni/remotehijack.go | 4 +- internal/model/mocks/underlyingnetwork.go | 38 +++++++++ .../model/mocks/underlyingnetwork_test.go | 79 +++++++++++++++++++ internal/model/netx.go | 18 +++++ internal/netxlite/dialer.go | 6 +- internal/netxlite/dialer_test.go | 8 +- internal/netxlite/dnsovergetaddrinfo.go | 4 +- internal/netxlite/doc.go | 12 +-- internal/netxlite/quic.go | 2 +- internal/netxlite/tproxy.go | 32 +++++--- 11 files changed, 186 insertions(+), 35 deletions(-) create mode 100644 internal/model/mocks/underlyingnetwork.go create mode 100644 internal/model/mocks/underlyingnetwork_test.go diff --git a/internal/cmd/miniooni/remotecore.go b/internal/cmd/miniooni/remotecore.go index 0305d6b9a1..60a2341348 100644 --- a/internal/cmd/miniooni/remotecore.go +++ b/internal/cmd/miniooni/remotecore.go @@ -15,6 +15,7 @@ import ( "os/signal" "sync" "syscall" + "time" "github.com/apex/log" "github.com/google/shlex" @@ -340,11 +341,11 @@ func (c *remoteClient) route() { }() } -// DialWithDialer dials a network connection using the given stdlib dialer. -func (c *remoteClient) DialWithDialer(ctx context.Context, d *net.Dialer, network string, address string) (net.Conn, error) { - if d.Timeout > 0 { +// DialContext implements UnderlyingNetwork.DialContext. +func (c *remoteClient) DialContext(ctx context.Context, timeout time.Duration, network string, address string) (net.Conn, error) { + if timeout > 0 { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, d.Timeout) + ctx, cancel = context.WithTimeout(ctx, timeout) defer cancel() } if remoteIsIPv6(address) { @@ -367,7 +368,7 @@ func remoteIsIPv6(endpoint string) bool { return v6 } -// ListenUDP creates a new listening UDP socket. +// ListenUDP implements UnderlyingNetwork. func (c *remoteClient) ListenUDP(network string, addr *net.UDPAddr) (model.UDPLikeConn, error) { pconn, err := c.net.ListenUDP(addr) if err != nil { @@ -423,8 +424,13 @@ func (*remoteClientRawConnUDP) Write(f func(fd uintptr) (done bool)) error { return nil } -// GetaddrinfoLookupANY performs a DNS lookup using getaddrinfo. +// GetaddrinfoLookupANY implements UnderlyingNetwork. func (c *remoteClient) GetaddrinfoLookupANY(ctx context.Context, domain string) ([]string, string, error) { addrs, err := c.net.LookupContextHost(ctx, domain) return addrs, "", err } + +// GetaddrinfoResolverNetwork implements UnderlyingNetwork. +func (c *remoteClient) GetaddrinfoResolverNetwork() string { + return netxlite.StdlibResolverGetaddrinfo +} diff --git a/internal/cmd/miniooni/remotehijack.go b/internal/cmd/miniooni/remotehijack.go index 5d339a7618..d4cef95069 100644 --- a/internal/cmd/miniooni/remotehijack.go +++ b/internal/cmd/miniooni/remotehijack.go @@ -50,9 +50,7 @@ func remoteMaybeHijack(options *Options) error { go client.route() // hijack netxlite's fundamental network operations - netxlite.TProxyDialWithDialer = client.DialWithDialer - netxlite.TProxyGetaddrinfoLookupANY = client.GetaddrinfoLookupANY - netxlite.TProxyListenUDP = client.ListenUDP + netxlite.TProxy = client log.Infof("remote: %s: hijacked netxlite network primitives", remoteName) return nil diff --git a/internal/model/mocks/underlyingnetwork.go b/internal/model/mocks/underlyingnetwork.go new file mode 100644 index 0000000000..830322acdf --- /dev/null +++ b/internal/model/mocks/underlyingnetwork.go @@ -0,0 +1,38 @@ +package mocks + +import ( + "context" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/model" +) + +// UnderlyingNetwork allows mocking model.UnderlyingNetwork. +type UnderlyingNetwork struct { + MockDialContext func(ctx context.Context, timeout time.Duration, network, address string) (net.Conn, error) + + MockListenUDP func(network string, addr *net.UDPAddr) (model.UDPLikeConn, error) + + MockGetaddrinfoLookupANY func(ctx context.Context, domain string) ([]string, string, error) + + MockGetaddrinfoResolverNetwork func() string +} + +var _ model.UnderlyingNetwork = &UnderlyingNetwork{} + +func (un *UnderlyingNetwork) DialContext(ctx context.Context, timeout time.Duration, network, address string) (net.Conn, error) { + return un.MockDialContext(ctx, timeout, network, address) +} + +func (un *UnderlyingNetwork) ListenUDP(network string, addr *net.UDPAddr) (model.UDPLikeConn, error) { + return un.MockListenUDP(network, addr) +} + +func (un *UnderlyingNetwork) GetaddrinfoLookupANY(ctx context.Context, domain string) ([]string, string, error) { + return un.MockGetaddrinfoLookupANY(ctx, domain) +} + +func (un *UnderlyingNetwork) GetaddrinfoResolverNetwork() string { + return un.MockGetaddrinfoResolverNetwork() +} diff --git a/internal/model/mocks/underlyingnetwork_test.go b/internal/model/mocks/underlyingnetwork_test.go new file mode 100644 index 0000000000..9afc7a7186 --- /dev/null +++ b/internal/model/mocks/underlyingnetwork_test.go @@ -0,0 +1,79 @@ +package mocks + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/model" +) + +func TestUnderlyingNetwork(t *testing.T) { + t.Run("DialContext", func(t *testing.T) { + expect := errors.New("mocked error") + un := &UnderlyingNetwork{ + MockDialContext: func(ctx context.Context, timeout time.Duration, network, address string) (net.Conn, error) { + return nil, expect + }, + } + ctx := context.Background() + conn, err := un.DialContext(ctx, time.Second, "tcp", "1.1.1.1:443") + if !errors.Is(err, expect) { + t.Fatal("unexpected err", err) + } + if conn != nil { + t.Fatal("expected nil conn") + } + }) + + t.Run("ListenUDP", func(t *testing.T) { + expect := errors.New("mocked error") + un := &UnderlyingNetwork{ + MockListenUDP: func(network string, addr *net.UDPAddr) (model.UDPLikeConn, error) { + return nil, expect + }, + } + pconn, err := un.ListenUDP("udp", &net.UDPAddr{}) + if !errors.Is(err, expect) { + t.Fatal("unexpected err", err) + } + if pconn != nil { + t.Fatal("expected nil conn") + } + }) + + t.Run("GetaddrinfoLookupANY", func(t *testing.T) { + expect := errors.New("mocked error") + un := &UnderlyingNetwork{ + MockGetaddrinfoLookupANY: func(ctx context.Context, domain string) ([]string, string, error) { + return nil, "", expect + }, + } + ctx := context.Background() + addrs, cname, err := un.GetaddrinfoLookupANY(ctx, "dns.google") + if !errors.Is(err, expect) { + t.Fatal("unexpected err", err) + } + if len(addrs) != 0 { + t.Fatal("expected zero length addrs") + } + if cname != "" { + t.Fatal("expected empty name") + } + }) + + t.Run("GetaddrinfoResolverNetwork", func(t *testing.T) { + expect := "antani" + un := &UnderlyingNetwork{ + MockGetaddrinfoResolverNetwork: func() string { + return expect + }, + } + got := un.GetaddrinfoResolverNetwork() + if got != expect { + t.Fatal("unexpected resolver network") + } + }) +} diff --git a/internal/model/netx.go b/internal/model/netx.go index 0251fcbb48..a56c18b37f 100644 --- a/internal/model/netx.go +++ b/internal/model/netx.go @@ -480,3 +480,21 @@ type UDPLikeConn interface { // which is also instrumental to setting the read buffer. SyscallConn() (syscall.RawConn, error) } + +// UnderlyingNetwork implements the underlying network APIs on +// top of which we implement network extensions. +type UnderlyingNetwork interface { + // DialContext is equivalent to net.Dialer.DialContext except that + // there is also an explicit timeout for dialing. + DialContext(ctx context.Context, timeout time.Duration, network, address string) (net.Conn, error) + + // ListenUDP is equivalent to net.ListenUDP. + ListenUDP(network string, addr *net.UDPAddr) (UDPLikeConn, error) + + // GetaddrinfoLookupANY is like net.Resolver.LookupHost except that it + // also returns to the caller the CNAME when it is available. + GetaddrinfoLookupANY(ctx context.Context, domain string) ([]string, string, error) + + // GetaddrinfoResolverNetwork returns the resolver network. + GetaddrinfoResolverNetwork() string +} diff --git a/internal/netxlite/dialer.go b/internal/netxlite/dialer.go index f76b774742..79ab6c8adf 100644 --- a/internal/netxlite/dialer.go +++ b/internal/netxlite/dialer.go @@ -154,16 +154,16 @@ var _ model.Dialer = &DialerSystem{} const dialerDefaultTimeout = 15 * time.Second -func (d *DialerSystem) newUnderlyingDialer() *net.Dialer { +func (d *DialerSystem) configuredTimeout() time.Duration { t := d.timeout if t <= 0 { t = dialerDefaultTimeout } - return &net.Dialer{Timeout: t} + return t } func (d *DialerSystem) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - return TProxyDialWithDialer(ctx, d.newUnderlyingDialer(), network, address) + return TProxy.DialContext(ctx, d.configuredTimeout(), network, address) } func (d *DialerSystem) CloseIdleConnections() { diff --git a/internal/netxlite/dialer_test.go b/internal/netxlite/dialer_test.go index 61de33a94c..e28d38d321 100644 --- a/internal/netxlite/dialer_test.go +++ b/internal/netxlite/dialer_test.go @@ -83,8 +83,8 @@ func TestNewDialer(t *testing.T) { func TestDialerSystem(t *testing.T) { t.Run("has a default timeout", func(t *testing.T) { d := &DialerSystem{} - ud := d.newUnderlyingDialer() - if ud.Timeout != dialerDefaultTimeout { + timeout := d.configuredTimeout() + if timeout != dialerDefaultTimeout { t.Fatal("unexpected default timeout") } }) @@ -92,8 +92,8 @@ func TestDialerSystem(t *testing.T) { t.Run("we can change the timeout for testing", func(t *testing.T) { const smaller = 1 * time.Second d := &DialerSystem{timeout: smaller} - ud := d.newUnderlyingDialer() - if ud.Timeout != smaller { + timeout := d.configuredTimeout() + if timeout != smaller { t.Fatal("unexpected timeout") } }) diff --git a/internal/netxlite/dnsovergetaddrinfo.go b/internal/netxlite/dnsovergetaddrinfo.go index 920c134163..34fec553e6 100644 --- a/internal/netxlite/dnsovergetaddrinfo.go +++ b/internal/netxlite/dnsovergetaddrinfo.go @@ -104,7 +104,7 @@ func (txp *dnsOverGetaddrinfoTransport) lookupfn() func(ctx context.Context, dom if txp.testableLookupANY != nil { return txp.testableLookupANY } - return TProxyGetaddrinfoLookupANY + return TProxy.GetaddrinfoLookupANY } func (txp *dnsOverGetaddrinfoTransport) RequiresPadding() bool { @@ -112,7 +112,7 @@ func (txp *dnsOverGetaddrinfoTransport) RequiresPadding() bool { } func (txp *dnsOverGetaddrinfoTransport) Network() string { - return getaddrinfoResolverNetwork() + return TProxy.GetaddrinfoResolverNetwork() } func (txp *dnsOverGetaddrinfoTransport) Address() string { diff --git a/internal/netxlite/doc.go b/internal/netxlite/doc.go index 206ee7617e..285870de37 100644 --- a/internal/netxlite/doc.go +++ b/internal/netxlite/doc.go @@ -8,13 +8,13 @@ // You should consider checking the tutorial explaining how to use this package // for network measurements: https://github.com/ooni/probe-cli/tree/master/internal/tutorial/netxlite. // -// Naming and history +// # Naming and history // // Previous versions of this package were called netx. Compared to such // versions this package is lightweight because it does not contain code // to perform the measurements, hence its name. // -// Design +// # Design // // We want to potentially be able to observe each low-level operation // separately, even though this is not done by this package. This is @@ -50,10 +50,10 @@ // // 3. resolving domain names with getaddrinfo. // -// By overriding the TProxyXXX variables, you can control these operations and -// route traffic to, e.g., a wireguard peer where you implement censorship. +// By overriding the TProxy variable, you can control these operations and route +// traffic to, e.g., a wireguard peer where you implement censorship. // -// Operations +// # Operations // // This package implements the following operations: // @@ -74,7 +74,7 @@ // Operations 1, 2, 3, and 4 are used when we perform measurements, // while 5 and 6 are mostly used when speaking with our backend. // -// Getaddrinfo usage +// # Getaddrinfo usage // // When compiled with CGO_ENABLED=1, this package will link with libc // and call getaddrinfo directly. While this design choice means we will diff --git a/internal/netxlite/quic.go b/internal/netxlite/quic.go index 5ad8f60799..682c976e04 100644 --- a/internal/netxlite/quic.go +++ b/internal/netxlite/quic.go @@ -29,7 +29,7 @@ var _ model.QUICListener = &quicListenerStdlib{} // Listen implements QUICListener.Listen. func (qls *quicListenerStdlib) Listen(addr *net.UDPAddr) (model.UDPLikeConn, error) { - return TProxyListenUDP("udp", addr) + return TProxy.ListenUDP("udp", addr) } // NewQUICDialerWithResolver is the WrapDialer equivalent for QUIC where diff --git a/internal/netxlite/tproxy.go b/internal/netxlite/tproxy.go index 17111fb6ad..c56b853ec9 100644 --- a/internal/netxlite/tproxy.go +++ b/internal/netxlite/tproxy.go @@ -3,25 +3,37 @@ package netxlite import ( "context" "net" + "time" "github.com/ooni/probe-cli/v3/internal/model" ) -// TProxyDialWithDialer is the top-level function used for dialing. By default we use -// the given dialer, but you can override it. Should you choose to override this function, -// please ensure you're honouring dialer.Timeout, if nonzero, when dialing. -var TProxyDialWithDialer = func(ctx context.Context, d *net.Dialer, network, address string) (net.Conn, error) { +// TProxy refers to the UnderlyingNetwork implementation. By overriding this +// variable you can force netxlite to use alternative network primitives. +var TProxy model.UnderlyingNetwork = &DefaultTProxy{} + +// defaultTProxy is the default UnderlyingNetwork implementation. +type DefaultTProxy struct{} + +// DialContext implements UnderlyingNetwork. +func (tp *DefaultTProxy) DialContext(ctx context.Context, timeout time.Duration, network, address string) (net.Conn, error) { + d := &net.Dialer{ + Timeout: timeout, + } return d.DialContext(ctx, network, address) } -// TProxyListenUDP is the top-level function used to create listening UDP connections. By default -// this function calls net.ListenUDP, but you can override it. -var TProxyListenUDP = func(network string, addr *net.UDPAddr) (model.UDPLikeConn, error) { +// ListenUDP implements UnderlyingNetwork. +func (tp *DefaultTProxy) ListenUDP(network string, addr *net.UDPAddr) (model.UDPLikeConn, error) { return net.ListenUDP(network, addr) } -// TProxyGetaddrinfoLookupANY is the toplevel function used to invoke getaddrinfo. By default -// this function calls getaddrinfoLookupANY, but you can override it. -var TProxyGetaddrinfoLookupANY = func(ctx context.Context, domain string) ([]string, string, error) { +// GetaddrinfoLookupANY implements UnderlyingNetwork. +func (tp *DefaultTProxy) GetaddrinfoLookupANY(ctx context.Context, domain string) ([]string, string, error) { return getaddrinfoLookupANY(ctx, domain) } + +// GetaddrinfoResolverNetwork implements UnderlyingNetwork. +func (tp *DefaultTProxy) GetaddrinfoResolverNetwork() string { + return getaddrinfoResolverNetwork() +}