Skip to content

Commit 720bbe5

Browse files
JAORMXclaude
andcommitted
Add hosted network service API and shared topology constants
Introduce net/hosted.Service and Provider.AddService() to let callers expose HTTP handlers inside the virtual network on the gateway IP (192.168.127.1) without opening real host sockets. Services are started before the guest boots and shut down gracefully on Stop(). Extract duplicated network topology constants (subnet, gateway IP, MAC, guest IP, MTU) into net/topology/ and use them from both the hosted provider and the runner's in-process networking setup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ed276b4 commit 720bbe5

8 files changed

Lines changed: 764 additions & 13 deletions

File tree

net/hosted/doc.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package hosted implements a [net.Provider] that runs the gvisor-tap-vsock
5+
// VirtualNetwork in the caller's process rather than inside propolis-runner.
6+
//
7+
// This enables callers to access the VirtualNetwork directly — for example,
8+
// to create in-process TCP listeners via gonet that are reachable from the
9+
// guest VM without opening real host sockets.
10+
//
11+
// # Usage
12+
//
13+
// p := hosted.NewProvider()
14+
// vm, err := propolis.Run(ctx, image,
15+
// propolis.WithNetProvider(p),
16+
// propolis.WithPorts(propolis.PortForward{Host: sshPort, Guest: 22}),
17+
// )
18+
// // p.VirtualNetwork() is now available for gonet listeners.
19+
//
20+
// # HTTP Services
21+
//
22+
// Use [Provider.AddService] to register HTTP handlers that listen inside the
23+
// virtual network on the gateway IP (192.168.127.1). Services are started
24+
// before the guest boots and are reachable from inside the VM.
25+
//
26+
// p := hosted.NewProvider()
27+
// p.AddService(hosted.Service{Port: 4483, Handler: myHandler})
28+
// vm, err := propolis.Run(ctx, image, propolis.WithNetProvider(p))
29+
// // Guest can reach http://192.168.127.1:4483/
30+
//
31+
// The provider exposes a Unix socket that propolis-runner connects to. Frames
32+
// are bridged between the runner connection and the VirtualNetwork's QEMU
33+
// transport. When firewall rules are configured, a [firewall.Relay] is
34+
// inserted to filter traffic.
35+
package hosted

net/hosted/provider.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package hosted
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"log/slog"
10+
"net"
11+
"os"
12+
"path/filepath"
13+
"sync"
14+
15+
"github.com/containers/gvisor-tap-vsock/pkg/types"
16+
"github.com/containers/gvisor-tap-vsock/pkg/virtualnetwork"
17+
18+
propnet "github.com/stacklok/propolis/net"
19+
"github.com/stacklok/propolis/net/firewall"
20+
"github.com/stacklok/propolis/net/topology"
21+
)
22+
23+
const socketName = "hosted-net.sock"
24+
25+
// Provider runs a gvisor-tap-vsock VirtualNetwork in the caller's process
26+
// and exposes a Unix socket for propolis-runner to connect to.
27+
type Provider struct {
28+
mu sync.Mutex
29+
vn *virtualnetwork.VirtualNetwork
30+
listener net.Listener
31+
sockPath string
32+
relay *firewall.Relay
33+
cancel context.CancelFunc
34+
wg sync.WaitGroup
35+
pendingServices []Service
36+
runningServices []runningService
37+
}
38+
39+
// NewProvider creates a new hosted network provider.
40+
func NewProvider() *Provider {
41+
return &Provider{}
42+
}
43+
44+
// Start launches the virtual network and begins listening on a Unix socket.
45+
// It satisfies the [net.Provider] interface.
46+
func (p *Provider) Start(ctx context.Context, cfg propnet.Config) error {
47+
p.mu.Lock()
48+
defer p.mu.Unlock()
49+
50+
if p.vn != nil {
51+
return fmt.Errorf("provider already started")
52+
}
53+
54+
// Build port forward map: "127.0.0.1:<host>" -> "<guestIP>:<guest>"
55+
forwards := make(map[string]string, len(cfg.Forwards))
56+
for _, pf := range cfg.Forwards {
57+
hostAddr := fmt.Sprintf("127.0.0.1:%d", pf.Host)
58+
guestAddr := fmt.Sprintf("%s:%d", topology.GuestIP, pf.Guest)
59+
forwards[hostAddr] = guestAddr
60+
}
61+
62+
// Create the virtual network stack.
63+
vn, err := virtualnetwork.New(&types.Configuration{
64+
Subnet: topology.Subnet,
65+
GatewayIP: topology.GatewayIP,
66+
GatewayMacAddress: topology.GatewayMAC,
67+
MTU: topology.MTU,
68+
Forwards: forwards,
69+
})
70+
if err != nil {
71+
return fmt.Errorf("create virtual network: %w", err)
72+
}
73+
p.vn = vn
74+
75+
// Start any registered services on the virtual network.
76+
if err := p.startServices(); err != nil {
77+
return fmt.Errorf("start services: %w", err)
78+
}
79+
80+
// Prepare the Unix socket path.
81+
p.sockPath = filepath.Join(cfg.LogDir, socketName)
82+
83+
// Remove stale socket if present.
84+
if err := os.Remove(p.sockPath); err != nil && !os.IsNotExist(err) {
85+
return fmt.Errorf("remove stale socket: %w", err)
86+
}
87+
88+
listener, err := net.Listen("unix", p.sockPath)
89+
if err != nil {
90+
return fmt.Errorf("listen on unix socket: %w", err)
91+
}
92+
p.listener = listener
93+
94+
// Set up optional firewall relay.
95+
if len(cfg.FirewallRules) > 0 {
96+
filter := firewall.NewFilter(cfg.FirewallRules, cfg.FirewallDefaultAction)
97+
p.relay = firewall.NewRelay(filter)
98+
99+
bgCtx, cancel := context.WithCancel(ctx)
100+
p.cancel = cancel
101+
filter.StartExpiry(bgCtx)
102+
} else {
103+
_, cancel := context.WithCancel(ctx)
104+
p.cancel = cancel
105+
}
106+
107+
// Accept connections in the background.
108+
p.wg.Add(1)
109+
go p.acceptLoop()
110+
111+
return nil
112+
}
113+
114+
// SocketPath returns the path to the Unix socket for propolis-runner.
115+
func (p *Provider) SocketPath() string {
116+
p.mu.Lock()
117+
defer p.mu.Unlock()
118+
return p.sockPath
119+
}
120+
121+
// Stop terminates the provider and cleans up resources.
122+
func (p *Provider) Stop() {
123+
p.mu.Lock()
124+
services := p.snapshotAndClearServices()
125+
cancel := p.cancel
126+
listener := p.listener
127+
sockPath := p.sockPath
128+
p.mu.Unlock()
129+
130+
// Shut down HTTP services outside the lock so Shutdown's blocking
131+
// does not prevent other callers from acquiring the mutex.
132+
p.shutdownServices(services)
133+
134+
if cancel != nil {
135+
cancel()
136+
}
137+
138+
if listener != nil {
139+
_ = listener.Close()
140+
}
141+
142+
// Wait for the accept loop to finish.
143+
p.wg.Wait()
144+
145+
// Clean up the socket file.
146+
if sockPath != "" {
147+
_ = os.Remove(sockPath)
148+
}
149+
}
150+
151+
// VirtualNetwork returns the underlying gvisor-tap-vsock VirtualNetwork.
152+
// Returns nil before Start is called.
153+
func (p *Provider) VirtualNetwork() *virtualnetwork.VirtualNetwork {
154+
p.mu.Lock()
155+
defer p.mu.Unlock()
156+
return p.vn
157+
}
158+
159+
// Relay returns the firewall relay, or nil if no firewall rules are configured.
160+
func (p *Provider) Relay() *firewall.Relay {
161+
p.mu.Lock()
162+
defer p.mu.Unlock()
163+
return p.relay
164+
}
165+
166+
// acceptLoop accepts connections from propolis-runner and bridges them
167+
// to the VirtualNetwork.
168+
func (p *Provider) acceptLoop() {
169+
defer p.wg.Done()
170+
171+
for {
172+
conn, err := p.listener.Accept()
173+
if err != nil {
174+
// Listener closed during Stop — expected.
175+
return
176+
}
177+
178+
p.wg.Add(1)
179+
go p.handleConn(conn)
180+
}
181+
}
182+
183+
// handleConn bridges a single runner connection to the VirtualNetwork.
184+
func (p *Provider) handleConn(runnerConn net.Conn) {
185+
defer p.wg.Done()
186+
187+
p.mu.Lock()
188+
vn := p.vn
189+
relay := p.relay
190+
p.mu.Unlock()
191+
192+
if relay != nil {
193+
// With firewall: create an in-memory pipe. The relay sits between
194+
// the runner connection and one end of the pipe; the other end
195+
// is passed to AcceptQemu.
196+
vnConn, relayConn := net.Pipe()
197+
198+
// Start the relay between runner and the pipe end.
199+
go func() {
200+
if err := relay.Run(context.Background(), runnerConn, relayConn); err != nil {
201+
slog.Debug("relay ended", "error", err)
202+
}
203+
}()
204+
205+
// Bridge pipe's other end to the VirtualNetwork.
206+
if err := vn.AcceptQemu(context.Background(), vnConn); err != nil {
207+
slog.Debug("AcceptQemu ended", "error", err)
208+
}
209+
} else {
210+
// Without firewall: direct bridge.
211+
if err := vn.AcceptQemu(context.Background(), runnerConn); err != nil {
212+
slog.Debug("AcceptQemu ended", "error", err)
213+
}
214+
}
215+
}

0 commit comments

Comments
 (0)