Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
module github.com/zcsizmadia/hawser

go 1.23

require (
github.com/Microsoft/go-winio v0.6.2 // indirect
golang.org/x/sys v0.10.0 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104 changes: 104 additions & 0 deletions internal/pipeproxy/dial_wsl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package pipeproxy

import (
"context"
"fmt"
"io"
"os/exec"
)

// WSLDialer reaches the engine socket by running socat inside the distro and
// relaying its stdio — the v0.1 transport Spike A validated.
//
// One process per connection is the known cost: ~165 ms of wsl.exe startup,
// measured in #2. It is accepted deliberately because it needs no guest agent,
// no vsock plumbing, and no open port; v0.2 replaces it with a persistent agent
// once the correctness this buys is locked in by tests.
type WSLDialer struct {
// Distro is the WSL distribution name, e.g. "hawser-engine".
Distro string

// SocketPath is the engine socket inside the distro.
// Defaults to DefaultSocketPath.
SocketPath string

// Exe overrides the wsl.exe path. Empty uses PATH.
Exe string
}

// DefaultSocketPath is where dockerd listens inside the Hawser distro.
const DefaultSocketPath = "/var/run/docker.sock"

// Dial starts the relay process and returns its stdio as a connection.
func (d *WSLDialer) Dial(ctx context.Context) (io.ReadWriteCloser, error) {
if d.Distro == "" {
return nil, fmt.Errorf("pipeproxy: WSLDialer.Distro is required")
}
socket := d.SocketPath
if socket == "" {
socket = DefaultSocketPath
}
exe := d.Exe
if exe == "" {
exe = "wsl.exe"
}

// --exec skips the shell, so nothing re-interprets the socket path and
// there is no shell process between us and socat.
cmd := exec.CommandContext(ctx, exe,
"-d", d.Distro,
"-u", "root",
"--exec", "socat", "STDIO", "UNIX-CONNECT:"+socket,
)

stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("pipeproxy: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return nil, fmt.Errorf("pipeproxy: stdout pipe: %w", err)
}

if err := cmd.Start(); err != nil {
stdin.Close()
stdout.Close()
return nil, fmt.Errorf("pipeproxy: start %s: %w", exe, err)
}

return &procConn{cmd: cmd, in: stdin, out: stdout}, nil
}

// procConn presents a child process's stdio as a connection. Closing stdin is a
// genuine half-close — socat sees EOF and shuts down its write side to the
// engine socket, which is what makes request-body-then-read work.
type procConn struct {
cmd *exec.Cmd
in io.WriteCloser
out io.ReadCloser
done bool
}

func (p *procConn) Read(b []byte) (int, error) { return p.out.Read(b) }
func (p *procConn) Write(b []byte) (int, error) { return p.in.Write(b) }

// CloseWrite propagates end-of-request without ending the response.
func (p *procConn) CloseWrite() error { return p.in.Close() }

// Close tears down the process and reaps it, so a busy client cannot leave a
// trail of zombie wsl.exe processes behind.
func (p *procConn) Close() error {
if p.done {
return nil
}
p.done = true

p.in.Close()
p.out.Close()
if p.cmd.Process != nil {
p.cmd.Process.Kill()
}
p.cmd.Wait()
return nil
}
56 changes: 56 additions & 0 deletions internal/pipeproxy/listen_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//go:build windows

package pipeproxy

import (
"fmt"
"net"

"github.com/Microsoft/go-winio"
)

// DefaultPipeName is what stock docker.exe connects to when DOCKER_HOST is
// unset. Hawser claims it only when Docker Desktop has not; the installer
// falls back to FallbackPipeName so the two coexist (PLAN §02).
const DefaultPipeName = `\\.\pipe\docker_engine`

// FallbackPipeName is Hawser's own pipe, used when the default is taken.
const FallbackPipeName = `\\.\pipe\hawser_engine`

// DefaultSDDL restricts the pipe to SYSTEM, local administrators, and
// interactive users.
//
// Pipe access is equivalent to root inside the engine VM, which in turn can
// read and write anything the automounted drives expose — the same trust
// boundary Docker Desktop has, and the reason this is not left at the default
// descriptor. A service running as SYSTEM would otherwise own the pipe and shut
// the logged-in user out.
//
// TODO(#8): once `hawser install` creates the local "Hawser Users" group, its
// SID replaces IU here, matching Docker's docker-users pattern so non-admin
// developer accounts can be granted access deliberately rather than by virtue
// of being interactive.
const DefaultSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;IU)"

// Listen creates the named pipe. An empty sddl applies DefaultSDDL; pass a
// custom descriptor only with a reason, since this is the security boundary.
func Listen(pipeName, sddl string) (net.Listener, error) {
if pipeName == "" {
return nil, fmt.Errorf("pipeproxy: empty pipe name")
}
if sddl == "" {
sddl = DefaultSDDL
}

l, err := winio.ListenPipe(pipeName, &winio.PipeConfig{
SecurityDescriptor: sddl,
// The engine API carries large payloads (image push/pull, build
// contexts); message mode would frame them wrongly. Byte mode is what
// dockerd's own Windows listener uses.
MessageMode: false,
})
if err != nil {
return nil, fmt.Errorf("pipeproxy: listen %s: %w", pipeName, err)
}
return l, nil
}
135 changes: 135 additions & 0 deletions internal/pipeproxy/listen_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//go:build windows

package pipeproxy_test

import (
"context"
"fmt"
"io"
"net"
"os"
"testing"
"time"

"github.com/Microsoft/go-winio"
"github.com/zcsizmadia/hawser/internal/pipeproxy"
)

// testPipeName keeps concurrent runs (and a developer's real Hawser install)
// from colliding.
func testPipeName(t *testing.T) string {
t.Helper()
return fmt.Sprintf(`\\.\pipe\hawser-test-%d-%s`, os.Getpid(), t.Name())
}

// TestListenServesOverRealPipe exercises the actual named pipe rather than a
// TCP stand-in: this is the transport stock docker.exe uses, so the SDDL, byte
// mode, and go-winio half-close behavior all get covered here.
func TestListenServesOverRealPipe(t *testing.T) {
engine := newServer(t, func(c net.Conn) { io.Copy(c, c) })

name := testPipeName(t)
l, err := pipeproxy.Listen(name, "")
if err != nil {
t.Fatalf("Listen(%s): %v", name, err)
}
defer l.Close()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
srv := &pipeproxy.Server{Dialer: engine.dialer()}
done := make(chan error, 1)
go func() { done <- srv.Serve(ctx, l) }()

conn, err := winio.DialPipe(name, nil)
if err != nil {
t.Fatalf("DialPipe: %v", err)
}
defer conn.Close()

want := "GET /_ping HTTP/1.1\r\n\r\n"
if _, err := io.WriteString(conn, want); err != nil {
t.Fatalf("write: %v", err)
}
buf := make([]byte, len(want))
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("deadline: %v", err)
}
if _, err := io.ReadFull(conn, buf); err != nil {
t.Fatalf("read: %v", err)
}
if string(buf) != want {
t.Errorf("got %q, want %q", buf, want)
}

cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("Serve: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("Serve did not return")
}
}

// TestListenBinaryOverRealPipe guards the byte-mode choice: in message mode a
// large payload would be framed and truncated, which is how image pulls break.
func TestListenBinaryOverRealPipe(t *testing.T) {
engine := newServer(t, func(c net.Conn) { io.Copy(c, c) })

name := testPipeName(t)
l, err := pipeproxy.Listen(name, "")
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer l.Close()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go (&pipeproxy.Server{Dialer: engine.dialer()}).Serve(ctx, l)

conn, err := winio.DialPipe(name, nil)
if err != nil {
t.Fatalf("DialPipe: %v", err)
}
defer conn.Close()

payload := make([]byte, 128*1024)
for i := range payload {
payload[i] = byte(i)
}
go func() {
conn.Write(payload)
if hc, ok := conn.(interface{ CloseWrite() error }); ok {
hc.CloseWrite()
}
}()

if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
t.Fatalf("deadline: %v", err)
}
got := make([]byte, len(payload))
if _, err := io.ReadFull(conn, got); err != nil {
t.Fatalf("read %d bytes: %v", len(payload), err)
}
for i := range payload {
if got[i] != payload[i] {
t.Fatalf("byte %d differs: got %#x want %#x", i, got[i], payload[i])
}
}
}

func TestListenRejectsEmptyName(t *testing.T) {
if _, err := pipeproxy.Listen("", ""); err == nil {
t.Error("Listen with empty name succeeded, want error")
}
}

func TestListenRejectsBadSDDL(t *testing.T) {
// A malformed descriptor must fail loudly at startup, not silently widen
// access — this is the pipe's security boundary.
if _, err := pipeproxy.Listen(testPipeName(t), "not-a-descriptor"); err == nil {
t.Error("Listen with invalid SDDL succeeded, want error")
}
}
Loading
Loading