From f12e2e2ac56d13c1d94f2fda2634800db8136444 Mon Sep 17 00:00:00 2001 From: Akihiro Suda Date: Thu, 24 Sep 2020 21:27:24 +0900 Subject: [PATCH] initial commit Signed-off-by: Akihiro Suda --- .gitignore | 1 + LICENSE | 202 +++++++++++++++++++++++ Makefile | 16 ++ README.md | 180 ++++++++++++++++++++ cmd/norouter/internal.go | 14 ++ cmd/norouter/internal_agent.go | 58 +++++++ cmd/norouter/main.go | 66 ++++++++ cmd/norouter/router.go | 55 +++++++ docs/image.png | Bin 0 -> 63598 bytes example.yaml | 13 ++ go.mod | 12 ++ go.sum | 33 ++++ integration/test-internal-agent.sh | 66 ++++++++ integration/test-router.sh | 63 +++++++ integration/test-router.yaml | 16 ++ pkg/agent/agent.go | 255 +++++++++++++++++++++++++++++ pkg/agent/config/config.go | 100 +++++++++++ pkg/agent/conn/conn.go | 130 +++++++++++++++ pkg/bicopy/bicopy.go | 62 +++++++ pkg/debugutil/debugutil.go | 18 ++ pkg/router/cmdclient.go | 84 ++++++++++ pkg/router/config/config.go | 11 ++ pkg/router/router.go | 103 ++++++++++++ pkg/stream/hash.go | 35 ++++ pkg/stream/receiver.go | 75 +++++++++ pkg/stream/sender.go | 66 ++++++++ pkg/stream/stream.go | 50 ++++++ 27 files changed, 1784 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/norouter/internal.go create mode 100644 cmd/norouter/internal_agent.go create mode 100644 cmd/norouter/main.go create mode 100644 cmd/norouter/router.go create mode 100644 docs/image.png create mode 100644 example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100755 integration/test-internal-agent.sh create mode 100755 integration/test-router.sh create mode 100644 integration/test-router.yaml create mode 100644 pkg/agent/agent.go create mode 100644 pkg/agent/config/config.go create mode 100644 pkg/agent/conn/conn.go create mode 100644 pkg/bicopy/bicopy.go create mode 100644 pkg/debugutil/debugutil.go create mode 100644 pkg/router/cmdclient.go create mode 100644 pkg/router/config/config.go create mode 100644 pkg/router/router.go create mode 100644 pkg/stream/hash.go create mode 100644 pkg/stream/receiver.go create mode 100644 pkg/stream/sender.go create mode 100644 pkg/stream/stream.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e56e04 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/bin diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0f56f1f --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.DEFAULT_GOAL := binaries + +binaries: bin/norouter + +bin/norouter: + CGO_ENABLED=0 go build -o $@ ./cmd/norouter + LANG=C LC_ALL=C file $@ | grep -qw "statically linked" + +clean: + rm -rf bin + +integration: + ./integration/test-internal-agent.sh + ./integration/test-router.sh + +.PHONY: bin/norouter clean integration diff --git a/README.md b/README.md new file mode 100644 index 0000000..54450b0 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# NoRouter: the easiest multi-host & multi-cloud networking ever. No root privilege required. + +NoRouter is the easiest multi-host & multi-cloud networking ever. And yet, NoRouter does not require any privilege such as `sudo` or `docker run --privileged`. + +NoRouter implements unprivileged networking by using multiple loopback addresseses such as 127.0.42.101 and 127.0.42.102. +The hosts in the network are connected by forwarding packets over stdio streams like `ssh`, `docker exec`, `podman exec`, `kubectl exec`, and whatever. + +![./docs/image.png](./docs/image.png) + + +NoRouter is mostly expected to be used in dev environments. + +## Example using `docker exec` and `podman exec` + +This example creates a virtual 127.0.42.0/24 network across a Docker container, a Podman container, and the localhost, using `docker exec` and `podman exec`. + +**Step 0: build `bin/norouter` binary** (on Linux) + +```console +make +``` + +**Step 1: create `host1` (nginx) as a Docker container** +```console +docker run -d --name host1 nginx:alpine +docker cp $(pwd)/bin/norouter host1:/usr/local/bin +``` + +**Step 2: create `host2` (Apache httpd) as a Podman container** +```console +podman run -d --name host2 httpd:alpine +podman cp $(pwd)/bin/norouter host2:/usr/local/bin +``` + +**Step 3: create [`example.yaml`](./example.yaml)** + +```yaml +hosts: + # host0 is the localhost + host0: + vip: "127.0.42.100" + host1: + cmd: ["docker", "exec", "-i", "host1", "norouter"] + vip: "127.0.42.101" + ports: ["8080:127.0.0.1:80"] + host2: + cmd: ["podman", "exec", "-i", "host2", "norouter"] + vip: "127.0.42.102" + ports: ["8080:127.0.0.1:80"] +``` + +**Step 4: start the NoRouter "router" process** + +```console +./bin/norouter example.yaml +``` + +**Step 5: connect to `host1` (127.0.42.101, nginx)** + +```console +wget -O - http://127.0.42.101:8080 +docker exec host1 wget -O - http://127.0.42.101:8080 +podman exec host2 wget -O - http://127.0.42.101:8080 +``` + +Make sure nginx's `index.html` ("Welcome to nginx!") is shown. + +**Step 6: connect to `host2` (127.0.42.102, Apache httpd)** + +```console +wget -O - http://127.0.42.102:8080 +docker exec host1 wget -O - http://127.0.42.102:8080 +podman exec host2 wget -O - http://127.0.42.102:8080 +``` + +Make sure Apache httpd's `index.html` ("It works!") is shown. + +### How it works under the hood + +The "router" process of NoRouter launches the following commands and transfer the packets using their stdio streams. + +``` +/proc/self/exe internal agent \ + --me 127.0.42.100 \ + --other 127.0.42.101:8080 \ + --other 127.0.42.102:8080 +``` + +``` +docker exec -i host1 norouter internal agent \ + --me 127.0.42.101 \ + --forward 8080:127.0.0.1:80 \ + --other 127.0.42.102:8080 +``` + +``` +podman exec -i host2 norouter internal agent \ + --me 127.0.42.102 \ + --other 127.0.42.101:8080 \ + --forward 8080:127.0.0.1:80 +``` + +`me` is used as a virtual src IP for connecting to `--other :`. + +#### stdio protocol + +The protocol is still subject to change. + + +``` +uint32le Len (includes header fields and Payload but does not include Len itself) +[4]byte SrcIP +uint16le SrcPort +[4]byte DstIP +uint16le DstPort +uint16le Proto +uint16le Flags +[]byte Payload (without L2/L3/L4 headers at all) +``` + +## More examples + +### Kubernetes + +Install `norouter` binary using `kubectl cp` + +e.g. +``` +kubectl run --image=nginx:alpine --restart=Never nginx +kubectl cp bin/norouter nginx:/usr/local/bin +``` + +In the NoRouter yaml, specify `cmd` as `["kubectl", "exec", "-i", "some-kubernetes-pod", "--", "norouter"]`. +To connect multiple Kubernetes clusters, pass `--context` arguments to `kubectl`. + +e.g. To connect GKE, AKS, and your laptop: + +```yaml +hosts: + laptop: + vip: "127.0.42.100" + nginx-on-gke: + cmd: ["kubectl", "--context=gke_myproject-12345_asia-northeast1-c_my-gke", "exec", "-i", "nginx", "--", "norouter"] + vip: "127.0.42.101" + ports: ["8080:127.0.0.1:80"] + httpd-on-aks: + cmd: ["kubectl", "--context=my-aks", "exec", "-i", "httpd", "--", "norouter"] + vip: "127.0.42.102" + ports: ["8080:127.0.0.1:80"] +``` + +### SSH + +Install `norouter` binary using `scp cp ./bin/norouter some-user@some-ssh-host.example.com:/usr/local/bin` . + +In the NoRouter yaml, specify `cmd` as `["ssh", "some-user@some-ssh-host.example.com", "--", "norouter"]`. + +If your key has a passphrase, make sure to configure `ssh-agent` so that NoRouter can login to the host automatically. + +### Azure Container Instances (`az container exec`) + +`az container exec` can't be supported currently because: +- No support for stdin without tty: https://github.com/Azure/azure-cli/issues/15225 +- No support for appending command arguments: https://docs.microsoft.com/en-us/azure/container-instances/container-instances-exec#restrictions +- Extra TTY escape sequence on busybox: https://github.com/Azure/azure-cli/issues/6537 + +A workaround is to inject an SSH sidecar into an Azure container group, and use `ssh` instead of `az container exec`. + +## TODOs + +- Install `norouter` binary to remote hosts automatically? +- Assist generating mTLS certs? +- Add DNS fields to `/etc/resolv.conf` when the file is writable? (writable by default in Docker and Kubernetes) +- Detect port numbers automatically by watching `/proc/net/tcp`, and propagate the information across the cluster automatically? + +## Similar projects + +- [vdeplug4](https://github.com/rd235/vdeplug4): vdeplug4 can create ad-hoc L2 networks over stdio. + vdeplug4 is similar to NoRouter in the sense that it uses stdio, but vdeplug4 requires privileges (at least in userNS) for creating TAP devices. +- [telepresence](https://www.telepresence.io/): kube-only and needs privileges diff --git a/cmd/norouter/internal.go b/cmd/norouter/internal.go new file mode 100644 index 0000000..17c2f86 --- /dev/null +++ b/cmd/norouter/internal.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/urfave/cli/v2" +) + +var internalCommand = &cli.Command{ + Name: "internal", + Usage: "Internal commands", + Hidden: true, + Subcommands: []*cli.Command{ + internalAgentCommand, + }, +} diff --git a/cmd/norouter/internal_agent.go b/cmd/norouter/internal_agent.go new file mode 100644 index 0000000..6c2466d --- /dev/null +++ b/cmd/norouter/internal_agent.go @@ -0,0 +1,58 @@ +package main + +import ( + "os" + + "github.com/norouter/norouter/pkg/agent" + "github.com/norouter/norouter/pkg/agent/config" + "github.com/urfave/cli/v2" +) + +var internalAgentCommand = &cli.Command{ + Name: "agent", + Usage: "agent", + Action: internalAgentAction, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "me", + Usage: "my virtual IP without port and proto, e.g. \"127.0.42.101\"", + Required: true, + }, + &cli.StringSliceFlag{ + Name: "other", + Usage: "other virtual IP, port, and optionally proto, e.g. \"127.0.42.102:8080/tcp\"", + }, + &cli.StringSliceFlag{ + Name: "forward", + Usage: "local forward, e.g. \"8080:127.0.0.1:80/tcp\"", + }, + }, +} + +func internalAgentAction(clicontext *cli.Context) error { + me, err := config.ParseMe(clicontext.String("me")) + if err != nil { + return err + } + var others []*config.Other + for _, s := range clicontext.StringSlice("other") { + o, err := config.ParseOther(s) + if err != nil { + return err + } + others = append(others, o) + } + var forwards []*config.Forward + for _, s := range clicontext.StringSlice("forward") { + f, err := config.ParseForward(s) + if err != nil { + return err + } + forwards = append(forwards, f) + } + a, err := agent.New(me, others, forwards, os.Stdout, os.Stdin) + if err != nil { + return err + } + return a.Run() +} diff --git a/cmd/norouter/main.go b/cmd/norouter/main.go new file mode 100644 index 0000000..6f27081 --- /dev/null +++ b/cmd/norouter/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +func main() { + logrus.SetFormatter(newLogrusFormatter()) + if err := newApp().Run(os.Args); err != nil { + logrus.Fatal(err) + } +} + +func newApp() *cli.App { + debug := false + app := cli.NewApp() + app.Name = "norouter" + app.Usage = "NoRouter: the easiest multi-host & multi-cloud networking ever. No root privilege required." + + app.Flags = []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + Usage: "debug mode", + Destination: &debug, + }, + } + app.Before = func(context *cli.Context) error { + if debug { + logrus.SetLevel(logrus.DebugLevel) + } + return nil + } + app.Commands = []*cli.Command{ + routerCommand, + internalCommand, + } + app.Action = routerAction + return app +} + +func newLogrusFormatter() logrus.Formatter { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "" + } + return &logrusFormatter{ + prefix: hostname + ": ", + Formatter: &logrus.TextFormatter{}, + } +} + +type logrusFormatter struct { + prefix string + logrus.Formatter +} + +func (lf *logrusFormatter) Format(e *logrus.Entry) ([]byte, error) { + b, err := lf.Formatter.Format(e) + if err != nil { + return b, err + } + return append([]byte(lf.prefix), b...), nil +} diff --git a/cmd/norouter/router.go b/cmd/norouter/router.go new file mode 100644 index 0000000..8265f97 --- /dev/null +++ b/cmd/norouter/router.go @@ -0,0 +1,55 @@ +package main + +import ( + "io/ioutil" + + "github.com/norouter/norouter/pkg/router" + "github.com/norouter/norouter/pkg/router/config" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" + "gopkg.in/yaml.v2" +) + +var routerCommand = &cli.Command{ + Name: "router", + Aliases: []string{"r"}, + Usage: "router (default subcommand)", + Action: routerAction, +} + +func routerAction(clicontext *cli.Context) error { + configPath := clicontext.Args().First() + if configPath == "" { + return errors.New("no config file path was specified") + } + cfg, err := loadConfig(configPath) + if err != nil { + return err + } + logrus.Debugf("config: %+v", cfg) + ccSet, err := router.NewCmdClientSet(cfg) + if err != nil { + return err + } + for vip, client := range ccSet.ByVIP { + logrus.Debugf("client for %q: %q", vip, client.String()) + } + r, err := router.New(ccSet) + if err != nil { + return err + } + return r.Run() +} + +func loadConfig(configPath string) (*config.Config, error) { + configB, err := ioutil.ReadFile(configPath) + if err != nil { + return nil, err + } + var cfg config.Config + if err := yaml.Unmarshal(configB, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/docs/image.png b/docs/image.png new file mode 100644 index 0000000000000000000000000000000000000000..18c7b1db7a98b2ebbe214cced75d486f1ba38008 GIT binary patch literal 63598 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 literal 0 HcmV?d00001 diff --git a/example.yaml b/example.yaml new file mode 100644 index 0000000..2a2b5b9 --- /dev/null +++ b/example.yaml @@ -0,0 +1,13 @@ +# see README.md for the usage +hosts: + # host0 is the localhost + host0: + vip: "127.0.42.100" + host1: + cmd: ["docker", "exec", "-i", "host1", "norouter"] + vip: "127.0.42.101" + ports: ["8080:127.0.0.1:80"] + host2: + cmd: ["podman", "exec", "-i", "host2", "norouter"] + vip: "127.0.42.102" + ports: ["8080:127.0.0.1:80"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..29777f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/norouter/norouter + +go 1.15 + +require ( + github.com/hashicorp/go-multierror v1.1.0 + github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.6.0 + github.com/urfave/cli/v2 v2.2.0 + golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 + gopkg.in/yaml.v2 v2.2.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5fc1069 --- /dev/null +++ b/go.sum @@ -0,0 +1,33 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= +github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/integration/test-internal-agent.sh b/integration/test-internal-agent.sh new file mode 100755 index 0000000..c97cb81 --- /dev/null +++ b/integration/test-internal-agent.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# A script for testing `norouter internal agent` without `norouter router` +# +set -eu -o pipefail + +cd "$(dirname $0)/.." + +pid="" +cleanup() { + echo "Cleaning up..." + set +e + if [[ -n "$pid" && -d "/proc/$pid" ]]; then kill $pid; fi + docker rm -f host1 host2 + make clean + set -e +} +cleanup +trap "cleanup" EXIT + +make +docker run -d --name host1 -v "$(pwd)/bin:/mnt:ro" nginx:1.19.2-alpine +docker run -d --name host2 -v "$(pwd)/bin:/mnt:ro" httpd:2.4.46-alpine + +: ${DEBUG=} +flags="" +if [[ -n "$DEBUG" ]]; then + flags="--debug" +fi + +dpipe \ + docker exec -i host1 /mnt/norouter ${flags} internal agent \ + --me 127.0.42.101 \ + --forward 8080:127.0.0.1:80 \ + --other 127.0.42.102:8080 \ + = \ + docker exec -i host2 /mnt/norouter ${flags} internal agent \ + --me 127.0.42.102 \ + --other 127.0.42.101:8080 \ + --forward 8080:127.0.0.1:80 & +pid=$! + +sleep 2 + +: ${N=10} +succeeds=0 +fails=0 +# Connect to host1 (127.0.42.101, nginx) from host2 +for ((i = 0; i < $N; i++)); do + if docker exec host2 wget -q -O- http://127.0.42.101:8080 | grep -q "Welcome to nginx"; then + succeeds=$((succeeds + 1)) + else + fails=$((fails + 1)) + fi +done + +# Connect to host2 (127.0.42.102, Apache httpd) from host1 +for ((i = 0; i < $N; i++)); do + if docker exec host1 wget -q -O- http://127.0.42.102:8080 | grep -q "It works"; then + succeeds=$((succeeds + 1)) + else + fails=$((fails + 1)) + fi +done + +echo "tests: $((N * 2)), succceeds: ${succeeds}, fails: ${fails}" +exit ${fails} diff --git a/integration/test-router.sh b/integration/test-router.sh new file mode 100755 index 0000000..094a130 --- /dev/null +++ b/integration/test-router.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -eu -o pipefail + +cd "$(dirname $0)/.." + +pid="" +cleanup() { + echo "Cleaning up..." + set +e + if [[ -n "$pid" && -d "/proc/$pid" ]]; then kill $pid; fi + docker rm -f host1 host2 host3 + make clean + set -e +} +cleanup +trap "cleanup" EXIT + +make +docker run -d --name host1 -v "$(pwd)/bin:/mnt:ro" nginx:1.19.2-alpine +docker run -d --name host2 -v "$(pwd)/bin:/mnt:ro" httpd:2.4.46-alpine +docker run -d --name host3 -v "$(pwd)/bin:/mnt:ro" caddy:2.1.1-alpine + +: ${DEBUG=} +flags="" +if [[ -n "$DEBUG" ]]; then + flags="--debug" +fi + +./bin/norouter ${flags} ./integration/test-router.yaml & +pid=$! + +sleep 3 + +: ${N=3} +succeeds=0 +fails=0 + +test_wget() { + for ((i = 0; i < $N; i++)); do + if wget -q -O- $1 | grep -q "$2"; then + succeeds=$((succeeds + 1)) + else + fails=$((fails + 1)) + fi + for ((j = 1; j <= 3; j++)); do + if docker exec host${j} wget -q -O- $1 | grep -q "$2"; then + succeeds=$((succeeds + 1)) + else + fails=$((fails + 1)) + fi + done + done +} + +# Connect to host1 (nginx) +test_wget http://127.0.42.101:8080 "Welcome to nginx" +# Connect to host2 (Apache httpd) +test_wget http://127.0.42.102:8080 "It works" +# Connect to host3 (Caddy) +test_wget http://127.0.42.103:8080 "Caddy" + +echo "tests: $((N * 4 * 3)), succceeds: ${succeeds}, fails: ${fails}" +exit ${fails} diff --git a/integration/test-router.yaml b/integration/test-router.yaml new file mode 100644 index 0000000..199746d --- /dev/null +++ b/integration/test-router.yaml @@ -0,0 +1,16 @@ +hosts: + # host0 is the localhost + host0: + vip: "127.0.42.100" + host1: + cmd: ["docker", "exec", "-i", "host1", "/mnt/norouter"] + vip: "127.0.42.101" + ports: ["8080:127.0.0.1:80"] + host2: + cmd: ["docker", "exec", "-i", "host2", "/mnt/norouter"] + vip: "127.0.42.102" + ports: ["8080:127.0.0.1:80"] + host3: + cmd: ["docker", "exec", "-i", "host3", "/mnt/norouter"] + vip: "127.0.42.103" + ports: ["8080:127.0.0.1:80"] diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 0000000..6e86e2d --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,255 @@ +package agent + +import ( + "encoding/binary" + "fmt" + "hash/fnv" + "io" + "math/rand" + "net" + "runtime" + "sync" + "time" + + "github.com/norouter/norouter/pkg/agent/config" + "github.com/norouter/norouter/pkg/agent/conn" + "github.com/norouter/norouter/pkg/bicopy" + "github.com/norouter/norouter/pkg/debugutil" + "github.com/norouter/norouter/pkg/stream" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +func runForward(me net.IP, f *config.Forward) error { + lh := fmt.Sprintf("%s:%d", me.String(), f.ListenPort) + l, err := net.Listen(f.Proto, lh) + if err != nil { + return errors.Wrapf(err, "failed to listen on %q", lh) + } + go func() { + for { + lconn, err := l.Accept() + if err != nil { + logrus.WithError(err).Error("failed to accept") + continue + } + go func() { + dh := fmt.Sprintf("%s:%d", f.ConnectIP.String(), f.ConnectPort) + dconn, err := net.Dial("tcp", dh) + if err != nil { + logrus.WithError(err).Errorf("failed to dial to %q", dh) + return + } + defer dconn.Close() + defer lconn.Close() + bicopy.Bicopy(lconn, dconn, nil) + }() + } + }() + return nil +} + +func newRandSource(me net.IP, now time.Time) rand.Source { + h := fnv.New64() + me4 := me.To4() + if me4 == nil { + panic(errors.Errorf("unsupported IP address %q", me)) + + } + binary.Write(h, binary.LittleEndian, me) + binary.Write(h, binary.LittleEndian, now.UnixNano) + seed := h.Sum64() + return rand.NewSource(int64(seed)) +} + +func New(me net.IP, others []*config.Other, forwards []*config.Forward, w io.Writer, r io.Reader) (*Agent, error) { + debugDump := false + sender := &stream.Sender{ + Writer: w, + DebugDump: debugDump, + } + receiver := &stream.Receiver{ + Reader: r, + DebugDump: debugDump, + } + a := &Agent{ + me: me, + others: others, + tcpForwards: make(map[uint16]*config.Forward), + sender: sender, + receiver: receiver, + rand: rand.New(newRandSource(me, time.Now())), + pwHM: make(map[uint64]*io.PipeWriter), + } + for _, f := range forwards { + if f.Proto != "tcp" { + return nil, errors.Errorf("unexpected proto %q", f.Proto) + } + a.tcpForwards[f.ListenPort] = f + } + return a, nil +} + +type Agent struct { + me net.IP + others []*config.Other + tcpForwards map[uint16]*config.Forward // key: listenPort + sender *stream.Sender + receiver *stream.Receiver + rand *rand.Rand + pwHM map[uint64]*io.PipeWriter + pwHMMu sync.RWMutex +} + +func (a *Agent) generateVSrcPort() uint16 { + var vSrcPort uint16 + for { + vSrcPort = uint16(a.rand.Int()) + if vSrcPort == 0 { + continue + } + if _, conflict := a.tcpForwards[vSrcPort]; conflict { + continue + } + // FIXME: detect more colisions + break + } + return vSrcPort +} + +func (a *Agent) runOther(o *config.Other) error { + lh := fmt.Sprintf("%s:%d", o.IP, o.Port) + l, err := net.Listen(o.Proto, lh) + if err != nil { + return err + } + go func() { + for { + lconn, err := l.Accept() + if err != nil { + logrus.WithError(err).Error("failed to accept") + continue + } + vSrcPort := a.generateVSrcPort() + logrus.Debugf("generated vSrcPort=%d", vSrcPort) + conn, pw, err := conn.New(a.me, vSrcPort, o.IP, o.Port, a.sender) + if err != nil { + logrus.WithError(err).Warn("failed to create conn") + lconn.Close() + return + } + hdrHash := stream.HashFields(o.IP, o.Port, a.me, vSrcPort, stream.TCP) + a.pwHMMu.Lock() + logrus.Debugf("registering pw for %s:%d->%s:%d", o.IP, o.Port, a.me, vSrcPort) + a.pwHM[hdrHash] = pw + a.pwHMMu.Unlock() + go func() { + defer lconn.Close() + defer conn.Close() + bicopy.Bicopy(conn, lconn, nil) + }() + } + }() + return nil +} + +func (a *Agent) getPW(hdrHash uint64, pkt *stream.Packet) (*io.PipeWriter, bool, error) { + a.pwHMMu.RLock() + pw, pwOk := a.pwHM[hdrHash] + a.pwHMMu.RUnlock() + if pwOk { + return pw, pwOk, nil + } + if f, fOk := a.tcpForwards[pkt.DstPort]; fOk { + // connect to forward ports, e.g. 8080 (->127.0.0.1:80) + dh := fmt.Sprintf("%s:%d", f.ConnectIP.String(), f.ConnectPort) + dconn, err := net.Dial(f.Proto, dh) + if err != nil { + logrus.WithError(err).Warnf("failed to dial %q", dh) + return nil, false, err + } + logrus.Debugf("dialed to %q, creating replyConn", dh) + var replyConn *conn.Conn + replyConn, pw, err = conn.New(pkt.DstIP, pkt.DstPort, pkt.SrcIP, pkt.SrcPort, a.sender) + if err != nil { + dconn.Close() + return nil, false, errors.Wrap(err, "failed to create replyConn") + } + a.pwHMMu.Lock() + logrus.Debugf("registering pw for %s:%d->%s:%d", pkt.SrcIP, pkt.SrcPort, pkt.DstIP, pkt.DstPort) + a.pwHM[hdrHash] = pw + a.pwHMMu.Unlock() + go func() { + defer dconn.Close() + defer replyConn.Close() + bicopy.Bicopy(dconn, replyConn, nil) + }() + return pw, true, nil + } + return nil, false, nil +} + +func (a *Agent) Run() error { + for _, f := range a.tcpForwards { + if err := runForward(a.me, f); err != nil { + return err + } + } + for _, o := range a.others { + if err := a.runOther(o); err != nil { + return err + } + } + for { + pkt, err := a.receiver.Recv() + if err != nil { + return errors.Wrap(err, "failed to recv from receiver") + } + if pkt.Proto != stream.TCP { + logrus.Warnf("received unknown proto %d, ignoring", pkt.Proto) + continue + } + if !pkt.DstIP.Equal(a.me) { + logrus.Warnf("received dstIP=%s is not me (%s), ignoring", pkt.DstIP.String(), a.me.String()) + continue + } + hdrHash := stream.HashFields(pkt.SrcIP, pkt.SrcPort, pkt.DstIP, pkt.DstPort, pkt.Proto) + pw, pwOk, err := a.getPW(hdrHash, pkt) + if err != nil { + logrus.WithError(err).Warnf("failed to call getPW (%s:%d->%s:%d)", pkt.SrcIP, pkt.SrcPort, pkt.DstIP, pkt.DstPort) + } + if pwOk { + logrus.Debugf("Calling pw.Write %s:%d->%s:%d", pkt.SrcIP, pkt.SrcPort, pkt.DstIP, pkt.DstPort) + if _, err := pw.Write(pkt.Payload); err != nil { + logrus.WithError(err).Warn("pw.Write failed") + } + } else { + logrus.Debugf("NOT calling pw.Write %s:%d->%s:%d", pkt.SrcIP, pkt.SrcPort, pkt.DstIP, pkt.DstPort) + } + a.gc(pkt, hdrHash, pw) + a.debugPrintStat() + } +} + +func (a *Agent) gc(pkt *stream.Packet, hdrHash uint64, pw *io.PipeWriter) { + // FIXME: support half-closing properly + if pkt.Flags&stream.FlagCloseRead != 0 || pkt.Flags&stream.FlagCloseWrite != 0 { + if pw != nil { + if err := pw.Close(); err != nil { + logrus.WithError(err).Debugf("failed to close pw") + } + } + a.pwHMMu.Lock() + delete(a.pwHM, hdrHash) + a.pwHMMu.Unlock() + } +} + +func (a *Agent) debugPrintStat() { + if logrus.GetLevel() >= logrus.DebugLevel { + a.pwHMMu.RLock() + l := len(a.pwHM) + a.pwHMMu.RUnlock() + logrus.Debugf("STAT: len(a.pwHM)=%d,GoRoutines=%d, FDs=%d", l, runtime.NumGoroutine(), debugutil.NumFDs()) + } +} diff --git a/pkg/agent/config/config.go b/pkg/agent/config/config.go new file mode 100644 index 0000000..a2bb39f --- /dev/null +++ b/pkg/agent/config/config.go @@ -0,0 +1,100 @@ +package config + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/pkg/errors" +) + +type Other struct { + IP net.IP + Port uint16 + Proto string +} + +func (o *Other) String() string { + return fmt.Sprintf("%s:%d/%s", o.IP.String(), o.Port, o.Proto) +} + +type Forward struct { + // listenIP is "me" + ListenPort uint16 + ConnectIP net.IP + ConnectPort uint16 + Proto string +} + +// parseMe parses --me=127.0.42.101 flag +func ParseMe(me string) (net.IP, error) { + ip := net.ParseIP(me) + if ip == nil { + return nil, errors.Errorf("invalid \"me\" IP %q", me) + } + ip = ip.To4() + if ip == nil { + return nil, errors.Errorf("invalid \"me\" IP %q, must be IPv4", me) + } + return ip, nil +} + +// ParseOther parses --other=127.0.42.102:8080[/tcp] flag +func ParseOther(other string) (*Other, error) { + s := strings.TrimSuffix(other, "/tcp") + if strings.Contains(s, "/") { + // TODO: support "/udp" suffix + return nil, errors.Errorf("cannot parse \"other\" address %q", other) + } + h, p, err := net.SplitHostPort(s) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse \"other\" address %q", other) + } + ip := net.ParseIP(h) + if ip == nil { + return nil, errors.Errorf("cannot parse \"other\" address %q", other) + } + port, err := strconv.Atoi(p) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse \"other\" address %q", other) + } + o := &Other{ + IP: ip, + Port: uint16(port), + Proto: "tcp", + } + return o, nil +} + +// ParseForward parses --forward=8080:127.0.0.1:80[/tcp] flag +func ParseForward(forward string) (*Forward, error) { + s := strings.TrimSuffix(forward, "/tcp") + if strings.Contains(s, "/") { + // TODO: support "/udp" suffix + return nil, errors.Errorf("cannot parse \"forward\" address %q", forward) + } + split := strings.Split(s, ":") + if len(split) != 3 { + return nil, errors.Errorf("cannot parse \"forward\" address %q", forward) + } + listenPort, err := strconv.Atoi(split[0]) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse \"forward\" address %q", forward) + } + connectIP := net.ParseIP(split[1]) + if connectIP == nil { + return nil, errors.Errorf("cannot parse \"forward\" address %q", forward) + } + connectPort, err := strconv.Atoi(split[2]) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse \"forward\" address %q", forward) + } + f := &Forward{ + ListenPort: uint16(listenPort), + ConnectIP: connectIP, + ConnectPort: uint16(connectPort), + Proto: "tcp", + } + return f, nil +} diff --git a/pkg/agent/conn/conn.go b/pkg/agent/conn/conn.go new file mode 100644 index 0000000..b13fbfb --- /dev/null +++ b/pkg/agent/conn/conn.go @@ -0,0 +1,130 @@ +package conn + +import ( + "io" + "net" + + "github.com/hashicorp/go-multierror" + "github.com/norouter/norouter/pkg/stream" +) + +func New(srcIP net.IP, srcPort uint16, dstIP net.IP, dstPort uint16, sender *stream.Sender) (*Conn, *io.PipeWriter, error) { + pr, pw := io.Pipe() + c := &Conn{ + SrcIP: srcIP, + SrcPort: srcPort, + DstIP: dstIP, + DstPort: dstPort, + sender: sender, + pr: pr, + } + return c, pw, nil +} + +type Conn struct { + SrcIP net.IP + SrcPort uint16 + DstIP net.IP + DstPort uint16 + + sender *stream.Sender + pr *io.PipeReader // pkt.Payload, passed from receiver + readClosed bool + writeClosed bool +} + +func (c *Conn) Write(p []byte) (int, error) { + pkt := &stream.Packet{ + SrcIP: c.SrcIP, + SrcPort: c.SrcPort, + DstIP: c.DstIP, + DstPort: c.DstPort, + Proto: stream.TCP, + Flags: 0, + Payload: p, + } + if err := c.sender.Send(pkt); err != nil { + return 0, err + } + return len(p), nil +} + +func (c *Conn) Read(p []byte) (int, error) { + return c.pr.Read(p) +} + +func (c *Conn) Close() error { + if c.readClosed && c.writeClosed { + return nil + } + if c.readClosed { + return c.CloseWrite() + } + if c.writeClosed { + return c.CloseRead() + } + var merr *multierror.Error + pkt := &stream.Packet{ + SrcIP: c.SrcIP, + SrcPort: c.SrcPort, + DstIP: c.DstIP, + DstPort: c.DstPort, + Proto: stream.TCP, + Flags: stream.FlagCloseRead | stream.FlagCloseWrite, + Payload: nil, + } + if err := c.sender.Send(pkt); err != nil { + merr = multierror.Append(merr, err) + } + if err := c.pr.Close(); err != nil { + merr = multierror.Append(merr, err) + } + c.readClosed = true + c.writeClosed = true + return merr.ErrorOrNil() +} + +func (c *Conn) CloseRead() error { + if c.readClosed { + return nil + } + var merr *multierror.Error + pkt := &stream.Packet{ + SrcIP: c.SrcIP, + SrcPort: c.SrcPort, + DstIP: c.DstIP, + DstPort: c.DstPort, + Proto: stream.TCP, + Flags: stream.FlagCloseRead, + Payload: nil, + } + if err := c.sender.Send(pkt); err != nil { + merr = multierror.Append(merr, err) + } + if err := c.pr.Close(); err != nil { + merr = multierror.Append(merr, err) + } + c.readClosed = true + return merr.ErrorOrNil() +} + +func (c *Conn) CloseWrite() error { + if c.writeClosed { + return nil + } + var merr *multierror.Error + pkt := &stream.Packet{ + SrcIP: c.SrcIP, + SrcPort: c.SrcPort, + DstIP: c.DstIP, + DstPort: c.DstPort, + Proto: stream.TCP, + Flags: stream.FlagCloseWrite, + Payload: nil, + } + if err := c.sender.Send(pkt); err != nil { + merr = multierror.Append(merr, err) + } + c.writeClosed = true + return merr.ErrorOrNil() +} diff --git a/pkg/bicopy/bicopy.go b/pkg/bicopy/bicopy.go new file mode 100644 index 0000000..0ed50a5 --- /dev/null +++ b/pkg/bicopy/bicopy.go @@ -0,0 +1,62 @@ +package bicopy + +import ( + "io" + "sync" + + "github.com/sirupsen/logrus" +) + +// Bicopy is from https://github.com/rootless-containers/rootlesskit/blob/v0.10.1/pkg/port/builtin/parent/tcp/tcp.go#L73-L104 +// (originally from libnetwork, Apache License 2.0) +func Bicopy(x, y io.ReadWriter, quit <-chan struct{}) { + type closeReader interface { + CloseRead() error + } + type closeWriter interface { + CloseWrite() error + } + var wg sync.WaitGroup + var broker = func(to, from io.ReadWriter) { + if _, err := io.Copy(to, from); err != nil { + logrus.WithError(err).Debug("failed to call io.Copy") + } + if fromCR, ok := from.(closeReader); ok { + if err := fromCR.CloseRead(); err != nil { + logrus.WithError(err).Debug("failed to call CloseRead") + } + } + if toCW, ok := to.(closeWriter); ok { + if err := toCW.CloseWrite(); err != nil { + logrus.WithError(err).Debug("failed to call CloseWrite") + } + } + wg.Done() + } + + wg.Add(2) + go broker(x, y) + go broker(y, x) + finish := make(chan struct{}) + go func() { + wg.Wait() + close(finish) + }() + + select { + case <-quit: + case <-finish: + } + if xCloser, ok := x.(io.Closer); ok { + if err := xCloser.Close(); err != nil { + logrus.WithError(err).Debug("failed to call xCloser.Close") + } + } + if yCloser, ok := y.(io.Closer); ok { + if err := yCloser.Close(); err != nil { + logrus.WithError(err).Debug("failed to call yCloser.Close") + } + } + <-finish + // TODO: return copied bytes +} diff --git a/pkg/debugutil/debugutil.go b/pkg/debugutil/debugutil.go new file mode 100644 index 0000000..0febbf1 --- /dev/null +++ b/pkg/debugutil/debugutil.go @@ -0,0 +1,18 @@ +package debugutil + +import ( + "io/ioutil" + "runtime" +) + +func NumFDs() int { + if runtime.GOOS != "linux" { + // unimplemented + return -1 + } + ents, err := ioutil.ReadDir("/proc/self/fd") + if err != nil { + return -1 + } + return len(ents) +} diff --git a/pkg/router/cmdclient.go b/pkg/router/cmdclient.go new file mode 100644 index 0000000..fd6f65a --- /dev/null +++ b/pkg/router/cmdclient.go @@ -0,0 +1,84 @@ +package router + +import ( + "fmt" + "os" + "os/exec" + "runtime" + + agentconfig "github.com/norouter/norouter/pkg/agent/config" + "github.com/norouter/norouter/pkg/router/config" +) + +type CmdClientSet struct { + ByVIP map[string]*CmdClient +} + +func NewCmdClientSet(cfg *config.Config) (*CmdClientSet, error) { + var publicHostPorts []string + for _, h := range cfg.Hosts { + for _, p := range h.Ports { + f, err := agentconfig.ParseForward(p) + if err != nil { + return nil, err + } + publicHostPorts = append(publicHostPorts, fmt.Sprintf("%s:%d/%s", h.VIP, f.ListenPort, f.Proto)) + } + } + ccSet := &CmdClientSet{ + ByVIP: make(map[string]*CmdClient), + } + for hostname, h := range cfg.Hosts { + client, err := NewCmdClient(hostname, h, publicHostPorts) + if err != nil { + return nil, err + } + ccSet.ByVIP[h.VIP] = client + } + return ccSet, nil +} + +// NewCmdClient. +func NewCmdClient(hostname string, h config.Host, publicHostPorts []string) (*CmdClient, error) { + var cmd *exec.Cmd + if len(h.Cmd) != 0 { + // e.g. ["docker", "exec", "-i", "host1", "--", "norouter"] + cmd = exec.Command(h.Cmd[0], h.Cmd[1:]...) + } else { + if runtime.GOOS == "linux" { + cmd = exec.Command("/proc/self/exe") + } else { + cmd = exec.Command(os.Args[0]) + } + } + cmd.Args = append(cmd.Args, "internal", "agent", "--me", h.VIP) + for _, port := range h.Ports { + cmd.Args = append(cmd.Args, "--forward", port) + } + for _, pub := range publicHostPorts { + o, err := agentconfig.ParseOther(pub) + if err != nil { + return nil, err + } + if o.IP.String() == h.VIP { + continue + } + cmd.Args = append(cmd.Args, "--other", pub) + } + c := &CmdClient{ + Hostname: hostname, + VIP: h.VIP, + cmd: cmd, + } + return c, nil +} + +type CmdClient struct { + Hostname string + VIP string + cmd *exec.Cmd +} + +func (c *CmdClient) String() string { + return fmt.Sprintf("<%s (%s)> %s", c.Hostname, c.VIP, c.cmd.String()) +} diff --git a/pkg/router/config/config.go b/pkg/router/config/config.go new file mode 100644 index 0000000..ec04ff3 --- /dev/null +++ b/pkg/router/config/config.go @@ -0,0 +1,11 @@ +package config + +type Config struct { + Hosts map[string]Host `yaml:"hosts"` +} + +type Host struct { + Cmd []string `yaml:"cmd"` // e.g. ["docker", "exec", "-i", "host1", "norouter"] + VIP string `yaml:"vip"` // e.g. "127.0.42.101" + Ports []string `yaml:"ports"` // e.g. ["8080:127.0.0.1:80"], or ["8080:127.0.0.1:80/tcp"] +} diff --git a/pkg/router/router.go b/pkg/router/router.go new file mode 100644 index 0000000..d5acb86 --- /dev/null +++ b/pkg/router/router.go @@ -0,0 +1,103 @@ +package router + +import ( + "os" + + "github.com/norouter/norouter/pkg/stream" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" +) + +func New(ccSet *CmdClientSet) (*Router, error) { + r := &Router{ + ccSet: ccSet, + senders: make(map[string]*stream.Sender), + receivers: make(map[string]*stream.Receiver), + } + return r, nil +} + +type Router struct { + ccSet *CmdClientSet + senders map[string]*stream.Sender // key: vip (TODO: don't use string) + receivers map[string]*stream.Receiver +} + +func (r *Router) Run() error { + debugDump := false + // Step 1: fill up senders + for vip, cc := range r.ccSet.ByVIP { + cc.cmd.Stderr = &stderrWriter{ + vip: cc.VIP, + hostname: cc.Hostname, + } + stdin, err := cc.cmd.StdinPipe() + if err != nil { + return err + } + sender := &stream.Sender{ + Writer: stdin, + DebugDump: debugDump, + } + r.senders[vip] = sender + stdout, err := cc.cmd.StdoutPipe() + if err != nil { + return err + } + receiver := &stream.Receiver{ + Reader: stdout, + DebugDump: debugDump, + } + r.receivers[vip] = receiver + logrus.Infof("starting client for %s(%s): %q", cc.Hostname, vip, cc.cmd.String()) + if err := cc.cmd.Start(); err != nil { + return err + } + // TODO: notify if a client exits + defer func() { + logrus.Warnf("exiting client: %q", cc.String()) + if err := cc.cmd.Process.Signal(os.Interrupt); err != nil { + logrus.WithError(err).Errorf("error while sending os.Interrupt to %s(%s)", cc.Hostname, vip) + cc.cmd.Process.Kill() + } + }() + } + + var eg errgroup.Group + // Step 2: start goroutines after filling up all r.senders + for vipx, receiverx := range r.receivers { + vip := vipx + receiver := receiverx + eg.Go(func() error { + for { + pkt, err := receiver.Recv() + if err != nil { + return errors.Errorf("failed to receive from %s", vip) + } + dstIPStr := pkt.DstIP.String() + sender, ok := r.senders[dstIPStr] + if !ok { + logrus.WithError(err).Warnf("unexpected dstIP %s in a packet from %s", dstIPStr, vip) + continue + } + logrus.Debugf("routing packet from %s:%d to %s:%d", pkt.SrcIP, pkt.SrcPort, pkt.DstIP, pkt.DstPort) + if err := sender.Send(pkt); err != nil { + logrus.WithError(err).Warnf("routing packet from %s:%d to %s:%d", pkt.SrcIP, pkt.SrcPort, pkt.DstIP, pkt.DstPort) + continue + } + } + }) + } + return eg.Wait() +} + +type stderrWriter struct { + hostname string + vip string +} + +func (w *stderrWriter) Write(p []byte) (int, error) { + logrus.Warnf("stderr[%s(%s)]: %s", w.hostname, w.vip, string(p)) + return len(p), nil +} diff --git a/pkg/stream/hash.go b/pkg/stream/hash.go new file mode 100644 index 0000000..d774601 --- /dev/null +++ b/pkg/stream/hash.go @@ -0,0 +1,35 @@ +package stream + +import ( + "encoding/binary" + "hash/fnv" + "net" + + "github.com/pkg/errors" +) + +func HashFields(srcIP net.IP, srcPort uint16, dstIP net.IP, dstPort uint16, proto Proto) uint64 { + h := fnv.New64() + if len(srcIP) != 0 { + srcIP4 := srcIP.To4() + if srcIP4 == nil { + panic(errors.Errorf("unsupported IP address %q", srcIP)) + } + binary.Write(h, binary.LittleEndian, srcIP4) + } else { + binary.Write(h, binary.LittleEndian, net.ParseIP("0.0.0.0")) + } + binary.Write(h, binary.LittleEndian, srcPort) + if len(dstIP) != 0 { + dstIP4 := dstIP.To4() + if dstIP4 == nil { + panic(errors.Errorf("unsupported IP address %q", dstIP)) + } + binary.Write(h, binary.LittleEndian, dstIP4) + } else { + binary.Write(h, binary.LittleEndian, net.ParseIP("0.0.0.0")) + } + binary.Write(h, binary.LittleEndian, dstPort) + binary.Write(h, binary.LittleEndian, proto) + return h.Sum64() +} diff --git a/pkg/stream/receiver.go b/pkg/stream/receiver.go new file mode 100644 index 0000000..3edfc5a --- /dev/null +++ b/pkg/stream/receiver.go @@ -0,0 +1,75 @@ +package stream + +import ( + "bytes" + "encoding/binary" + "io" + "net" + "sync" + + "github.com/sirupsen/logrus" +) + +// Receiver +type Receiver struct { + io.Reader + sync.Mutex + DebugDump bool +} + +func (receiver *Receiver) Recv() (*Packet, error) { + var length uint32 // HeaderLength + len(p) + receiver.Lock() + if err := binary.Read(receiver.Reader, binary.LittleEndian, &length); err != nil { + return nil, err + } + b := make([]byte, length) + if err := binary.Read(receiver.Reader, binary.LittleEndian, &b); err != nil { + return nil, err + } + receiver.Unlock() + var ( + srcIP4 [4]byte + srcPort uint16 + dstIP4 [4]byte + dstPort uint16 + proto uint16 + flags uint16 + ) + br := bytes.NewReader(b) + if err := binary.Read(br, binary.LittleEndian, &srcIP4); err != nil { + return nil, err + } + if err := binary.Read(br, binary.LittleEndian, &srcPort); err != nil { + return nil, err + } + if err := binary.Read(br, binary.LittleEndian, &dstIP4); err != nil { + return nil, err + } + if err := binary.Read(br, binary.LittleEndian, &dstPort); err != nil { + return nil, err + } + if err := binary.Read(br, binary.LittleEndian, &proto); err != nil { + return nil, err + } + if err := binary.Read(br, binary.LittleEndian, &flags); err != nil { + return nil, err + } + pkt := &Packet{ + SrcIP: net.IP(srcIP4[:]), + SrcPort: srcPort, + DstIP: net.IP(dstIP4[:]), + DstPort: dstPort, + Proto: Proto(proto), + Flags: flags, + Payload: b[HeaderLength:], + } + + if receiver.DebugDump && logrus.GetLevel() >= logrus.DebugLevel { + logrus.Debugf("receiver: Received %s:%d->%s:%d (%v) 0b%b: %q", + pkt.SrcIP.String(), pkt.SrcPort, + pkt.DstIP.String(), pkt.DstPort, + pkt.Proto, pkt.Flags, string(pkt.Payload)) + } + return pkt, nil +} diff --git a/pkg/stream/sender.go b/pkg/stream/sender.go new file mode 100644 index 0000000..f3c37cf --- /dev/null +++ b/pkg/stream/sender.go @@ -0,0 +1,66 @@ +package stream + +import ( + "bytes" + "encoding/binary" + "io" + "sync" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +// Sender +type Sender struct { + io.Writer + sync.Mutex + DebugDump bool +} + +func (sender *Sender) Send(p *Packet) error { + if sender.DebugDump && logrus.GetLevel() >= logrus.DebugLevel { + logrus.Debugf("sender: Sending %s:%d %s:%d (%v) 0b%b: %q", + p.SrcIP.String(), p.SrcPort, + p.DstIP.String(), p.DstPort, + p.Proto, p.Flags, string(p.Payload)) + } + var buf bytes.Buffer + if err := binary.Write(&buf, binary.LittleEndian, uint32(HeaderLength+len(p.Payload))); err != nil { + return err + } + sip := p.SrcIP.To4() + if sip == nil { + return errors.Errorf("unexpected ip %+v", sip) + } + if err := binary.Write(&buf, binary.LittleEndian, []byte(sip)); err != nil { + return err + } + if err := binary.Write(&buf, binary.LittleEndian, p.SrcPort); err != nil { + return err + } + dip := p.DstIP.To4() + if dip == nil { + return errors.Errorf("unexpected ip %+v", dip) + } + if err := binary.Write(&buf, binary.LittleEndian, []byte(dip)); err != nil { + return err + } + if err := binary.Write(&buf, binary.LittleEndian, p.DstPort); err != nil { + return err + } + if err := binary.Write(&buf, binary.LittleEndian, uint16(p.Proto)); err != nil { + return err + } + if err := binary.Write(&buf, binary.LittleEndian, p.Flags); err != nil { + return err + } + if p.Payload != nil { + if err := binary.Write(&buf, binary.LittleEndian, p.Payload); err != nil { + return err + } + } + sender.Lock() + _, err := io.Copy(sender.Writer, &buf) + sender.Unlock() + return err +} diff --git a/pkg/stream/stream.go b/pkg/stream/stream.go new file mode 100644 index 0000000..73cd008 --- /dev/null +++ b/pkg/stream/stream.go @@ -0,0 +1,50 @@ +package stream + +import ( + "net" +) + +type Proto = uint16 + +const ( + TCP Proto = 0 +) + +// HeaderLength: +// +// + uint64 srcIP (4 bytes) +// +// + uint16 srcPort (2 bytes) +// +// + uint32 dstIP (4 bytes) +// +// + uint16 dstPort (2 bytes) +// +// + uint16 proto (2 bytes) +// +// + uint32 flags (2 bytes) +const HeaderLength = 4 + 2 + 4 + 2 + 2 + 2 + +// Packet requires uint32le length to be prepended. +// The protocol is highly likely to be changed. +type Packet struct { + // SrcIP is the src IP. Must be [4]byte. + SrcIP net.IP + // SrcPort is the dest port. + SrcPort uint16 + // DstIP is the dest IP. Must be [4]byte. + DstIP net.IP + // DstPort is the dest port. + DstPort uint16 + // Proto must be TCP. + Proto Proto + // Flags, such as FlagCloseRead and FlagCloseWrite + Flags uint16 + // Payload does not contain any L2/L3/L4 headers. + Payload []byte +} + +const ( + FlagCloseRead = 0b01 + FlagCloseWrite = 0b10 +)