From 18d3f55534bc8c98a34749da7d07e20f326ffd36 Mon Sep 17 00:00:00 2001 From: Akihiro Suda Date: Mon, 1 Mar 2021 23:47:36 +0900 Subject: [PATCH] Port API: support specifying IP version explicitly ("tcp4", "tcp6") Fix #231 Signed-off-by: Akihiro Suda --- .github/workflows/main.yaml | 3 ++ docs/port.md | 14 +++++ hack/integration-port.sh | 80 +++++++++++++++++++++++++++++ pkg/api/openapi.yaml | 8 ++- pkg/port/builtin/child/child.go | 9 +++- pkg/port/builtin/msg/msg.go | 2 +- pkg/port/builtin/parent/parent.go | 6 +-- pkg/port/builtin/parent/tcp/tcp.go | 2 +- pkg/port/builtin/parent/udp/udp.go | 4 +- pkg/port/port.go | 6 ++- pkg/port/portutil/portutil.go | 5 +- pkg/port/portutil/portutil_test.go | 9 ++++ pkg/port/slirp4netns/slirp4netns.go | 10 +++- pkg/port/socat/socat.go | 16 +++--- pkg/port/testsuite/testsuite.go | 23 ++++++--- 15 files changed, 171 insertions(+), 26 deletions(-) create mode 100755 hack/integration-port.sh diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 0e49a044..5d7954da 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -27,6 +27,9 @@ jobs: run: docker run --rm --privileged rootlesskit:test-integration sh -exc "sudo mount --make-rshared / && ./integration-propagation.sh" - name: "Integration test: restart" run: docker run --rm --privileged rootlesskit:test-integration ./integration-restart.sh + - name: "Integration test: port" + # NOTE: "--net=host" is a bad hack to enable IPv6 + run: docker run --rm --net=host --privileged rootlesskit:test-integration ./integration-port.sh # ===== Benchmark: Network (MTU=1500) ===== - name: "Benchmark: Network (MTU=1500, network driver=slirp4netns)" run: | diff --git a/docs/port.md b/docs/port.md index 98ea47f1..c46ee241 100644 --- a/docs/port.md +++ b/docs/port.md @@ -42,3 +42,17 @@ If you are using `builtin` driver, you can expose the privileged ports without c ```console $ sudo setcap cap_net_bind_service=ep $(pwd rootlesskit) ``` + +### Note about IPv6 + +Specifying `0.0.0.0:8080:80/tcp` may cause listening on IPv6 as well as on IPv4. +Same applies to `[::]:8080:80/tcp`. + +This behavior may sound weird but corresponds to [Go's behavior](https://github.com/golang/go/commit/071908f3d809245eda42bf6eab071c323c67b7d2), +so this is not a bug. + +To specify IPv4 explicitly, use `tcp4` instead of `tcp`, e.g., `0.0.0.0:8080:80/tcp4`. +To specify IPv6 explicitly, use `tcp6`, e.g., `[::]:8080:80/tcp6`. + +The `tcp4` and `tcp6` forms were introduced in RootlessKit v0.14.0. +The `tcp6` is currently supported only for `builtin` port driver. diff --git a/hack/integration-port.sh b/hack/integration-port.sh new file mode 100755 index 00000000..811c0725 --- /dev/null +++ b/hack/integration-port.sh @@ -0,0 +1,80 @@ +#!/bin/bash +source $(realpath $(dirname $0))/common.inc.sh + +# test_port PORT_DRIVER CURL_URL EXPECTATION [ROOTLESSKIT ARGS...] +function test_port() { + args="$@" + port_driver="$1" + curl_url="$2" + expectation="$3" + shift + shift + shift + rootlesskit_args="$@" + INFO "Testing port_driver=\"${port_driver}\" curl_url=\"${curl_url}\" expectation=\"${expectation}\" rootlesskit_args=\"${rootlesskit_args}\"" + tmp=$(mktemp -d) + state_dir=${tmp}/state + html_dir=${tmp}/html + mkdir -p ${html_dir} + echo "test_port ($args)" >${html_dir}/index.html + $ROOTLESSKIT \ + --state-dir=${state_dir} \ + --net=slirp4netns \ + --disable-host-loopback \ + --copy-up=/etc \ + --port-driver=${port_driver} \ + ${rootlesskit_args} \ + busybox httpd -f -v -p 80 -h ${html_dir} \ + 2>&1 & + pid=$! + sleep 1 + + set +e + curl -fsSL ${curl_url} + code=$? + set -e + if [ "${expectation}" = "should success" ]; then + if [ ${code} != 0 ]; then + ERROR "curl exited with ${code}" + exit ${code} + fi + elif [ "${expectation}" = "should fail" ]; then + if [ ${code} = 0 ]; then + ERROR "curl should not success" + exit 1 + fi + else + ERROR "internal error" + exit 1 + fi + + INFO "Test pasing, stopping httpd (\"exit status 255\" is negligible here)" + kill -SIGTERM $(cat ${state_dir}/child_pid) + wait $pid >/dev/null 2>&1 || true + rm -rf $tmp +} + +INFO "===== Port driver: builtin =====" +INFO "=== protocol \"tcp\" listens on both v4 and v6 ===" +test_port builtin http://127.0.0.1:8080 "should success" -p 0.0.0.0:8080:80/tcp +test_port builtin http://[::1]:8080 "should success" -p 0.0.0.0:8080:80/tcp + +INFO "=== protocol \"tcp4\" is strictly v4-only ===" +test_port builtin http://127.0.0.1:8080 "should success" -p 0.0.0.0:8080:80/tcp4 +test_port builtin http://[::1]:8080 "should fail" -p 0.0.0.0:8080:80/tcp4 + +INFO "=== protocol \"tcp6\" is strictly v4-only ===" +test_port builtin http://127.0.0.1:8080 "should fail" -p [::]:8080:80/tcp6 +test_port builtin http://[::1]:8080 "should success" -p [::]:8080:80/tcp6 + +INFO "=== \"tcp4\" and \"tcp6\" do not conflict ===" +test_port builtin http://127.0.0.1:8080 "should success" -p 0.0.0.0:8080:80/tcp4 -p [::]:8080:80/tcp6 + +INFO "===== Port driver: slirp4netns (IPv4 only)=====" +INFO "=== protocol \"tcp\" listens on v4 ===" +test_port slirp4netns http://127.0.0.1:8080 "should success" -p 0.0.0.0:8080:80/tcp + +INFO "=== protocol \"tcp4\" is strictly v4-only ===" +test_port slirp4netns http://[::1]:8080 "should fail" -p 0.0.0.0:8080:80/tcp4 + +INFO "===== PASSING =====" diff --git a/pkg/api/openapi.yaml b/pkg/api/openapi.yaml index 00fdb96b..6a6550c3 100644 --- a/pkg/api/openapi.yaml +++ b/pkg/api/openapi.yaml @@ -56,11 +56,17 @@ components: schemas: Proto: type: string - description: "protocol for listening" + description: "protocol for listening. Corresponds to Go's net.Listen. The strings with \"4\" and \"6\" suffixes were introduced in API 1.1.0." enum: - tcp + - tcp4 + - tcp6 - udp + - udp4 + - udp6 - sctp + - sctp4 + - sctp6 PortSpec: required: - proto diff --git a/pkg/port/builtin/child/child.go b/pkg/port/builtin/child/child.go index fc249c2d..57ee040c 100644 --- a/pkg/port/builtin/child/child.go +++ b/pkg/port/builtin/child/child.go @@ -5,6 +5,7 @@ import ( "io" "net" "os" + "strings" "github.com/pkg/errors" "golang.org/x/sys/unix" @@ -101,10 +102,16 @@ func (d *childDriver) handleConnectInit(c *net.UnixConn, req *msg.Request) error func (d *childDriver) handleConnectRequest(c *net.UnixConn, req *msg.Request) error { switch req.Proto { case "tcp": + case "tcp4": + case "tcp6": case "udp": + case "udp4": + case "udp6": default: return errors.Errorf("unknown proto: %q", req.Proto) } + // dialProto is always non-v6 + dialProto := strings.TrimSuffix(req.Proto, "6") var dialer net.Dialer ip := req.IP if ip == "" { @@ -120,7 +127,7 @@ func (d *childDriver) handleConnectRequest(c *net.UnixConn, req *msg.Request) er } ip = p.String() } - targetConn, err := dialer.Dial(req.Proto, fmt.Sprintf("%s:%d", ip, req.Port)) + targetConn, err := dialer.Dial(dialProto, fmt.Sprintf("%s:%d", ip, req.Port)) if err != nil { return err } diff --git a/pkg/port/builtin/msg/msg.go b/pkg/port/builtin/msg/msg.go index a8c8e038..a60d99bd 100644 --- a/pkg/port/builtin/msg/msg.go +++ b/pkg/port/builtin/msg/msg.go @@ -19,7 +19,7 @@ const ( // Request and Response are encoded as JSON with uint32le length header. type Request struct { Type string // "init" or "connect" - Proto string // "tcp" or "udp" + Proto string // "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6" IP string Port int } diff --git a/pkg/port/builtin/parent/parent.go b/pkg/port/builtin/parent/parent.go index 1566d516..e7ce641e 100644 --- a/pkg/port/builtin/parent/parent.go +++ b/pkg/port/builtin/parent/parent.go @@ -60,7 +60,7 @@ type driver struct { func (d *driver) Info(ctx context.Context) (*api.PortDriverInfo, error) { info := &api.PortDriverInfo{ Driver: "builtin", - Protos: []string{"tcp", "udp"}, + Protos: []string{"tcp", "tcp4", "tcp6", "udp", "udp4", "udp6"}, } return info, nil } @@ -143,9 +143,9 @@ func (d *driver) AddPort(ctx context.Context, spec port.Spec) (*port.Status, err return nil // FIXME } switch spec.Proto { - case "tcp": + case "tcp", "tcp4", "tcp6": err = tcp.Run(d.socketPath, spec, routineStopCh, d.logWriter) - case "udp": + case "udp", "udp4", "udp6": err = udp.Run(d.socketPath, spec, routineStopCh, d.logWriter) default: // NOTREACHED diff --git a/pkg/port/builtin/parent/tcp/tcp.go b/pkg/port/builtin/parent/tcp/tcp.go index 9fb80116..7a7a167f 100644 --- a/pkg/port/builtin/parent/tcp/tcp.go +++ b/pkg/port/builtin/parent/tcp/tcp.go @@ -13,7 +13,7 @@ import ( ) func Run(socketPath string, spec port.Spec, stopCh <-chan struct{}, logWriter io.Writer) error { - ln, err := net.Listen("tcp", net.JoinHostPort(spec.ParentIP, strconv.Itoa(spec.ParentPort))) + ln, err := net.Listen(spec.Proto, net.JoinHostPort(spec.ParentIP, strconv.Itoa(spec.ParentPort))) if err != nil { fmt.Fprintf(logWriter, "listen: %v\n", err) return err diff --git a/pkg/port/builtin/parent/udp/udp.go b/pkg/port/builtin/parent/udp/udp.go index fbff2b08..0080dd22 100644 --- a/pkg/port/builtin/parent/udp/udp.go +++ b/pkg/port/builtin/parent/udp/udp.go @@ -14,11 +14,11 @@ import ( ) func Run(socketPath string, spec port.Spec, stopCh <-chan struct{}, logWriter io.Writer) error { - addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(spec.ParentIP, strconv.Itoa(spec.ParentPort))) + addr, err := net.ResolveUDPAddr(spec.Proto, net.JoinHostPort(spec.ParentIP, strconv.Itoa(spec.ParentPort))) if err != nil { return err } - c, err := net.ListenUDP("udp", addr) + c, err := net.ListenUDP(spec.Proto, addr) if err != nil { return err } diff --git a/pkg/port/port.go b/pkg/port/port.go index cc875904..b260fcba 100644 --- a/pkg/port/port.go +++ b/pkg/port/port.go @@ -8,8 +8,10 @@ import ( ) type Spec struct { - Proto string `json:"proto,omitempty"` // either "tcp" or "udp". in future "sctp" will be supported as well. - ParentIP string `json:"parentIP,omitempty"` // IPv4 address. can be empty (0.0.0.0). + // Proto is one of ["tcp", "tcp4", "tcp6", "udp", "udp4", "udp6"]. + // "tcp" may cause listening on both IPv4 and IPv6. (Corresponds to Go's net.Listen .) + Proto string `json:"proto,omitempty"` + ParentIP string `json:"parentIP,omitempty"` // IPv4 or IPv6 address. can be empty (0.0.0.0). ParentPort int `json:"parentPort,omitempty"` ChildPort int `json:"childPort,omitempty"` // ChildIP is an IPv4 address. diff --git a/pkg/port/portutil/portutil.go b/pkg/port/portutil/portutil.go index a885a76c..93793264 100644 --- a/pkg/port/portutil/portutil.go +++ b/pkg/port/portutil/portutil.go @@ -152,7 +152,10 @@ func ValidatePortSpec(spec port.Spec, existingPorts map[int]*port.Status) error func validateProto(proto string) error { switch proto { - case "tcp", "udp", "sctp": + case + "tcp", "tcp4", "tcp6", + "udp", "udp4", "udp6", + "sctp", "sctp4", "sctp6": return nil default: return errors.Errorf("unknown proto: %q", proto) diff --git a/pkg/port/portutil/portutil_test.go b/pkg/port/portutil/portutil_test.go index 8afc9acc..3d84909c 100644 --- a/pkg/port/portutil/portutil_test.go +++ b/pkg/port/portutil/portutil_test.go @@ -24,6 +24,15 @@ func TestParsePortSpec(t *testing.T) { ChildPort: 80, }, }, + { + s: "127.0.0.1:8080:80/tcp4", + expected: &port.Spec{ + Proto: "tcp4", + ParentIP: "127.0.0.1", + ParentPort: 8080, + ChildPort: 80, + }, + }, { s: "127.0.0.1:8080:10.0.2.100:80/tcp", expected: &port.Spec{ diff --git a/pkg/port/slirp4netns/slirp4netns.go b/pkg/port/slirp4netns/slirp4netns.go index a8f8f492..85f146cb 100644 --- a/pkg/port/slirp4netns/slirp4netns.go +++ b/pkg/port/slirp4netns/slirp4netns.go @@ -6,6 +6,7 @@ import ( "io" "io/ioutil" "net" + "strings" "sync" "github.com/pkg/errors" @@ -38,7 +39,8 @@ type driver struct { func (d *driver) Info(ctx context.Context) (*api.PortDriverInfo, error) { info := &api.PortDriverInfo{ Driver: "slirp4netns", - Protos: []string{"tcp", "udp"}, + // No IPv6 support yet + Protos: []string{"tcp", "tcp4", "udp", "udp4"}, } return info, nil } @@ -64,6 +66,10 @@ func (d *driver) AddPort(ctx context.Context, spec port.Spec) (*port.Status, err if err != nil { return nil, err } + if strings.HasSuffix(spec.Proto, "6") { + return nil, errors.Errorf("unsupported protocol %q", spec.Proto) + } + proto := strings.TrimSuffix(spec.Proto, "4") ip := spec.ChildIP if ip == "" { ip = d.childIP @@ -81,7 +87,7 @@ func (d *driver) AddPort(ctx context.Context, spec port.Spec) (*port.Status, err req := request{ Execute: "add_hostfwd", Arguments: addHostFwdArguments{ - Proto: spec.Proto, + Proto: proto, HostAddr: spec.ParentIP, HostPort: spec.ParentPort, GuestAddr: ip, diff --git a/pkg/port/socat/socat.go b/pkg/port/socat/socat.go index d1b2591c..f4cf8b96 100644 --- a/pkg/port/socat/socat.go +++ b/pkg/port/socat/socat.go @@ -47,7 +47,7 @@ type driver struct { func (d *driver) Info(ctx context.Context) (*api.PortDriverInfo, error) { info := &api.PortDriverInfo{ Driver: "socat", - Protos: []string{"tcp", "udp"}, + Protos: []string{"tcp", "tcp4", "udp", "udp4"}, } return info, nil } @@ -77,6 +77,11 @@ func (d *driver) AddPort(ctx context.Context, spec port.Spec) (*port.Status, err if err != nil { return nil, err } + switch spec.Proto { + case "tcp", "tcp4", "udp", "udp4": + default: + return nil, errors.Errorf("unsupported proto: %s", spec.Proto) + } cf := func() (*exec.Cmd, error) { return createSocatCmd(ctx, spec, d.logWriter, d.childPID) } @@ -124,9 +129,6 @@ func (d *driver) RemovePort(ctx context.Context, id int) error { } func createSocatCmd(ctx context.Context, spec port.Spec, logWriter io.Writer, childPID int) (*exec.Cmd, error) { - if spec.Proto != "tcp" && spec.Proto != "udp" { - return nil, errors.Errorf("unsupported proto: %s", spec.Proto) - } ipStr := "0.0.0.0" if spec.ParentIP != "" { ip := net.ParseIP(spec.ParentIP) @@ -165,10 +167,12 @@ func createSocatCmd(ctx context.Context, spec port.Spec, logWriter io.Writer, ch hp = net.JoinHostPort(ip, strconv.Itoa(spec.ChildPort)) ) switch spec.Proto { - case "tcp": + case "tcp", "tcp4": proto = "TCP" - case "udp": + case "udp", "udp4": proto = "UDP" + default: + panic("should not reach here") } cmd = exec.CommandContext(ctx, "socat", diff --git a/pkg/port/testsuite/testsuite.go b/pkg/port/testsuite/testsuite.go index 5f8c52fc..6a613a38 100644 --- a/pkg/port/testsuite/testsuite.go +++ b/pkg/port/testsuite/testsuite.go @@ -64,17 +64,27 @@ func Main(m *testing.M, cf func() port.ChildDriver) { func Run(t *testing.T, pf func() port.ParentDriver) { RunTCP(t, pf) + RunTCP4(t, pf) RunUDP(t, pf) + RunUDP4(t, pf) } func RunTCP(t *testing.T, pf func() port.ParentDriver) { t.Run("TestTCP", func(t *testing.T) { TestProto(t, "tcp", pf()) }) } +func RunTCP4(t *testing.T, pf func() port.ParentDriver) { + t.Run("TestTCP4", func(t *testing.T) { TestProto(t, "tcp4", pf()) }) +} + func RunUDP(t *testing.T, pf func() port.ParentDriver) { t.Run("TestUDP", func(t *testing.T) { TestProto(t, "udp", pf()) }) } +func RunUDP4(t *testing.T, pf func() port.ParentDriver) { + t.Run("TestUDP4", func(t *testing.T) { TestProto(t, "udp4", pf()) }) +} + func TestProto(t *testing.T, proto string, d port.ParentDriver) { ensureDeps(t, "nsenter") t.Logf("creating USER+NET namespace") @@ -189,13 +199,14 @@ func nsenterExec(pid int, cmdss ...string) ([]byte, error) { return cmd.CombinedOutput() } +// FIXME: support IPv6 func testProtoRoutine(t *testing.T, proto string, d port.ParentDriver, childPID, childP, parentP int) { stdoutR, stdoutW := io.Pipe() var ncFlags []string switch proto { - case "tcp": + case "tcp", "tcp4": // NOP - case "udp": + case "udp", "udp4": ncFlags = append(ncFlags, "-u") default: panic("invalid proto") @@ -224,7 +235,7 @@ func testProtoRoutine(t *testing.T, proto string, d port.ParentDriver, childPID, panic(err) } t.Logf("opened port: %+v", portStatus) - if proto == "udp" { + if proto == "udp" || proto == "udp4" { // Dial does not return an error for UDP even if the port is not exposed yet time.Sleep(1 * time.Second) } @@ -245,11 +256,11 @@ func testProtoRoutine(t *testing.T, proto string, d port.ParentDriver, childPID, panic(err) } switch proto { - case "tcp": + case "tcp", "tcp4": if err := conn.(*net.TCPConn).CloseWrite(); err != nil { panic(err) } - case "udp": + case "udp", "udp4": if err := conn.(*net.UDPConn).Close(); err != nil { panic(err) } @@ -261,7 +272,7 @@ func testProtoRoutine(t *testing.T, proto string, d port.ParentDriver, childPID, if bytes.Compare(wBytes, rBytes) != 0 { panic(fmt.Errorf("expected %q, got %q", string(wBytes), string(rBytes))) } - if proto == "tcp" { + if proto == "tcp" || proto == "tcp4" { if err := conn.Close(); err != nil { panic(err) }