diff --git a/docs/superpowers/plans/2026-07-29-agent-console-proxy.md b/docs/superpowers/plans/2026-07-29-agent-console-proxy.md new file mode 100644 index 0000000..e7b8497 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-agent-console-proxy.md @@ -0,0 +1,2262 @@ +# Agent-relayed console proxy — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Carry browser-console TCP traffic over the agent's existing outbound gRPC connection, so a cloud-hosted control plane can open SSH, RDP and VNC sessions to servers on private networks. + +**Architecture:** The server allocates a single-use `proxy_id` and an ephemeral TCP listener, tells the agent over the existing `CommandStream` to open a new `ProxyStream` RPC, and pipes bytes between the guacd connection that arrives on the listener and that stream. The agent dials only `127.0.0.1` on a port the server names. Direct dialling by guacd is removed; every console session on every deployment uses the relay. + +**Tech Stack:** Go 1.x (multi-module `go.work`), gRPC with a **JSON codec**, gin, `github.com/wwt/guac`, MongoDB. No new third-party dependencies. + +Spec: `docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md` + +## Global Constraints + +- **The `pb` packages are hand-written, not generated.** `proto/vantage/v1/vantage.proto` is documentation only. Every message added must be hand-written **twice**, identically: `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`. Do not run `protoc`. +- **The codec is JSON** (`server/internal/grpc/codec.go`). Struct tags must be present and must match the `.proto` field names in snake_case. `[]byte` marshals as base64 — that is expected. +- **Relay chunk size is 32768 bytes.** Declared once as `const chunkSize = 32 * 1024`. +- **The agent's dial host is the literal string `"127.0.0.1"`, hardcoded agent-side.** No code path may take a hostname from the server. This is the feature's core security property. +- **Rendezvous deadline is 10 seconds**, declared once per side as `const rendezvousTimeout = 10 * time.Second`. +- Go module roots: run `go build`/`go test` from `server/` and `agent/` respectively, not the repo root. +- Commit style follows the repo: `feat:`, `fix:`, `docs:`, `test:`. + +--- + +### Task 1: Wire types for `ProxyStream` + +Adds the messages, the service descriptor entry, and the agent's client method. Nothing uses them yet; the deliverable is that both modules compile and a message round-trips through the JSON codec. + +**Files:** +- Modify: `proto/vantage/v1/vantage.proto` +- Modify: `server/internal/grpc/pb/vantage.pb.go` +- Modify: `agent/internal/grpc/pb/vantage.pb.go` +- Modify: `agent/internal/grpc/client.go` +- Test: `server/internal/grpc/pb/vantage_pb_test.go` (create) + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `pb.OpenProxyCmd{ProxyId string; Port uint32}`, reachable as `ServerCommand.OpenProxy *OpenProxyCmd` + - `pb.ProxyOpen{ServerId, AgentToken, ProxyId string}` + - `pb.ProxyClose{Reason string}` + - `pb.ProxyClientMsg{Open *ProxyOpen; Data []byte; Close *ProxyClose}` + - `pb.ProxyServerMsg{Data []byte; Close *ProxyClose}` + - `pb.Vantage_ProxyStreamServer` interface — `Send(*ProxyServerMsg) error`, `Recv() (*ProxyClientMsg, error)` + - `pb.Vantage_ProxyStreamClient` interface — `Send(*ProxyClientMsg) error`, `Recv() (*ProxyServerMsg, error)`, `CloseSend() error` + - `VantageServer` interface gains `ProxyStream(Vantage_ProxyStreamServer) error` + - `(*grpcclient.Client).ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error)` + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/grpc/pb/vantage_pb_test.go`: + +```go +package pb + +import ( + "encoding/json" + "testing" +) + +func TestProxyClientMsgRoundTrip(t *testing.T) { + in := &ProxyClientMsg{Data: []byte{0x00, 0xff, 0x10}} + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out ProxyClientMsg + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if string(out.Data) != string(in.Data) { + t.Fatalf("data mismatch: got %v want %v", out.Data, in.Data) + } + if out.Open != nil || out.Close != nil { + t.Fatalf("empty oneof fields should stay nil, got open=%v close=%v", out.Open, out.Close) + } +} + +func TestOpenProxyCmdOnServerCommand(t *testing.T) { + cmd := &ServerCommand{CommandId: "c1", OpenProxy: &OpenProxyCmd{ProxyId: "p1", Port: 22}} + raw, err := json.Marshal(cmd) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out ServerCommand + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.OpenProxy == nil || out.OpenProxy.ProxyId != "p1" || out.OpenProxy.Port != 22 { + t.Fatalf("open_proxy did not round-trip: %+v", out.OpenProxy) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/grpc/pb/ -run TestProxy -v` +Expected: FAIL — compile error, `ProxyClientMsg` and `OpenProxyCmd` undefined. + +- [ ] **Step 3: Add the message types to the server pb** + +In `server/internal/grpc/pb/vantage.pb.go`, next to the other `ServerCommand`-related types, add: + +```go +type OpenProxyCmd struct { + ProxyId string `json:"proxy_id"` + Port uint32 `json:"port"` +} + +type ProxyOpen struct { + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` + ProxyId string `json:"proxy_id"` +} + +type ProxyClose struct { + Reason string `json:"reason,omitempty"` +} + +type ProxyClientMsg struct { + Open *ProxyOpen `json:"open,omitempty"` + Data []byte `json:"data,omitempty"` + Close *ProxyClose `json:"close,omitempty"` +} + +type ProxyServerMsg struct { + Data []byte `json:"data,omitempty"` + Close *ProxyClose `json:"close,omitempty"` +} +``` + +Then add the field to the existing `ServerCommand` struct, after `CleanupWorkspace`: + +```go + OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"` +``` + +- [ ] **Step 4: Add the stream stubs to the server pb** + +In the same file, below the `CommandStream` stubs, following exactly the same shape: + +```go +type Vantage_ProxyStreamServer interface { + Send(*ProxyServerMsg) error + Recv() (*ProxyClientMsg, error) + grpc.ServerStream +} + +type vantageProxyStreamServer struct { + grpc.ServerStream +} + +func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error { + return s.ServerStream.SendMsg(m) +} + +func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) { + m := new(ProxyClientMsg) + if err := s.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +type Vantage_ProxyStreamClient interface { + Send(*ProxyClientMsg) error + Recv() (*ProxyServerMsg, error) + CloseSend() error + grpc.ClientStream +} + +type vantageProxyStreamClient struct { + grpc.ClientStream +} + +func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error { + return c.ClientStream.SendMsg(m) +} + +func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) { + m := new(ProxyServerMsg) + if err := c.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +func _Vantage_ProxyStream_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(VantageServer).ProxyStream(&vantageProxyStreamServer{stream}) +} +``` + +Add to the `VantageServer` interface, next to `CommandStream`: + +```go + ProxyStream(Vantage_ProxyStreamServer) error +``` + +Add to `UnimplementedVantageServer`: + +```go +func (UnimplementedVantageServer) ProxyStream(Vantage_ProxyStreamServer) error { + return status.Errorf(codes.Unimplemented, "method ProxyStream not implemented") +} +``` + +Add to the `VantageClient` interface: + +```go + ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) +``` + +Add the client method next to the existing `CommandStream` one (note `Streams[1]` — the new stream is the second entry): + +```go +func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) { + stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[1], "/vantage.v1.Vantage/ProxyStream", opts...) + if err != nil { + return nil, err + } + return &vantageProxyStreamClient{stream}, nil +} +``` + +And append to `Vantage_ServiceDesc.Streams`, **after** the `CommandStream` entry so the index above stays correct: + +```go + { + StreamName: "ProxyStream", + Handler: _Vantage_ProxyStream_Handler, + ServerStreams: true, + ClientStreams: true, + }, +``` + +- [ ] **Step 5: Run the pb test** + +Run: `cd server && go test ./internal/grpc/pb/ -run TestProxy -v` +Expected: PASS (both tests). + +- [ ] **Step 6: Mirror every change into the agent pb** + +Copy the identical type definitions, the `ServerCommand.OpenProxy` field, the stream stubs, the `VantageServer`/`VantageClient` interface methods, the `Unimplemented` method, the handler, the client method and the `Streams` entry into `agent/internal/grpc/pb/vantage.pb.go`. The two files must not diverge. + +- [ ] **Step 7: Add the agent client wrapper** + +In `agent/internal/grpc/client.go`, below the existing `CommandStream` method: + +```go +func (c *Client) ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error) { + return c.client.ProxyStream(ctx) +} +``` + +- [ ] **Step 8: Update the proto documentation** + +In `proto/vantage/v1/vantage.proto`, add to the service block: + +```protobuf + rpc ProxyStream(stream ProxyClientMsg) returns (stream ProxyServerMsg); +``` + +Add `OpenProxyCmd open_proxy = 8;` to the `ServerCommand` oneof, and append the message definitions: + +```protobuf +// OpenProxyCmd tells the agent to dial 127.0.0.1:port locally and relay that +// connection back over a fresh ProxyStream identified by proxy_id. +message OpenProxyCmd { + string proxy_id = 1; + uint32 port = 2; +} + +message ProxyOpen { + string server_id = 1; + string agent_token = 2; + string proxy_id = 3; +} + +message ProxyClose { string reason = 1; } + +message ProxyClientMsg { + oneof payload { + ProxyOpen open = 1; // first message only + bytes data = 2; + ProxyClose close = 3; + } +} + +message ProxyServerMsg { + oneof payload { + bytes data = 1; + ProxyClose close = 2; + } +} +``` + +- [ ] **Step 9: Verify both modules build** + +Run: `cd server && go build ./... && cd ../agent && go build ./...` +Expected: no output, exit 0. + +- [ ] **Step 10: Commit** + +```bash +git add proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go \ + server/internal/grpc/pb/vantage_pb_test.go agent/internal/grpc/pb/vantage.pb.go \ + agent/internal/grpc/client.go +git commit -m "feat: add ProxyStream wire types for agent-relayed console" +``` + +--- + +### Task 2: Proxy session registry + +Pure in-memory state: no network, no gRPC. Owns `proxy_id` generation, scoping and the single-use rule. + +**Files:** +- Create: `server/internal/proxy/registry.go` +- Test: `server/internal/proxy/registry_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `proxy.NewID() (string, error)` — 32 random bytes, hex + - `type Registry struct{...}`, `proxy.NewRegistry() *Registry`, `var proxy.Default *Registry` + - `(*Registry).Add(entry *Entry)` + - `(*Registry).Claim(instanceID, serverID, proxyID string) (*Entry, error)` — removes the entry; single-use + - `(*Registry).Remove(proxyID string)` + - `(*Registry).Len() int` + - `type Entry struct { ProxyID, InstanceID, ServerID string; Session *Session }` — `Session` is filled in Task 3; until then the field type is defined in Task 3's file, so **Task 2 declares `Entry` without the `Session` field and Task 3 adds it.** + - `proxy.ErrNotFound`, `proxy.ErrForbidden` + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/proxy/registry_test.go`: + +```go +package proxy + +import ( + "errors" + "testing" +) + +func newEntry(id string) *Entry { + return &Entry{ProxyID: id, InstanceID: "inst-1", ServerID: "srv-1"} +} + +func TestClaimReturnsEntry(t *testing.T) { + r := NewRegistry() + r.Add(newEntry("p1")) + + got, err := r.Claim("inst-1", "srv-1", "p1") + if err != nil { + t.Fatalf("claim: %v", err) + } + if got.ProxyID != "p1" { + t.Fatalf("got proxy %q, want p1", got.ProxyID) + } +} + +func TestClaimIsSingleUse(t *testing.T) { + r := NewRegistry() + r.Add(newEntry("p1")) + + if _, err := r.Claim("inst-1", "srv-1", "p1"); err != nil { + t.Fatalf("first claim: %v", err) + } + _, err := r.Claim("inst-1", "srv-1", "p1") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("second claim err = %v, want ErrNotFound", err) + } + if r.Len() != 0 { + t.Fatalf("registry should be empty after claim, len=%d", r.Len()) + } +} + +func TestClaimUnknownID(t *testing.T) { + r := NewRegistry() + _, err := r.Claim("inst-1", "srv-1", "nope") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestClaimRejectsForeignInstance(t *testing.T) { + r := NewRegistry() + r.Add(newEntry("p1")) + + _, err := r.Claim("inst-2", "srv-1", "p1") + if !errors.Is(err, ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden", err) + } + if r.Len() != 1 { + t.Fatalf("a rejected claim must not consume the entry, len=%d", r.Len()) + } +} + +func TestClaimRejectsForeignServer(t *testing.T) { + r := NewRegistry() + r.Add(newEntry("p1")) + + _, err := r.Claim("inst-1", "srv-2", "p1") + if !errors.Is(err, ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden", err) + } +} + +func TestRemove(t *testing.T) { + r := NewRegistry() + r.Add(newEntry("p1")) + r.Remove("p1") + if r.Len() != 0 { + t.Fatalf("len=%d, want 0", r.Len()) + } + r.Remove("p1") // must not panic +} + +func TestNewIDIsUniqueAndLong(t *testing.T) { + a, err := NewID() + if err != nil { + t.Fatalf("NewID: %v", err) + } + b, _ := NewID() + if a == b { + t.Fatal("NewID returned the same value twice") + } + if len(a) != 64 { + t.Fatalf("len(id) = %d, want 64 hex chars", len(a)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/proxy/ -v` +Expected: FAIL — no such package / undefined `NewRegistry`. + +- [ ] **Step 3: Write the implementation** + +Create `server/internal/proxy/registry.go`: + +```go +// Package proxy relays console TCP traffic between guacd and a managed server's +// agent. The agent dials only its own loopback address; the port is the single +// value it takes from the control plane. +package proxy + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "sync" +) + +var ( + ErrNotFound = errors.New("proxy session not found") + ErrForbidden = errors.New("proxy session belongs to another server") +) + +// NewID returns a 32-byte random identifier as hex. It is the only credential +// tying an incoming ProxyStream to a pending console session. +func NewID() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +type Entry struct { + ProxyID string + InstanceID string + ServerID string +} + +type Registry struct { + mu sync.Mutex + entries map[string]*Entry +} + +func NewRegistry() *Registry { + return &Registry{entries: make(map[string]*Entry)} +} + +var Default = NewRegistry() + +func (r *Registry) Add(e *Entry) { + r.mu.Lock() + defer r.mu.Unlock() + r.entries[e.ProxyID] = e +} + +// Claim removes and returns the entry. It is single-use: a second claim on the +// same proxy_id gets ErrNotFound. A claim whose instance or server does not +// match leaves the entry in place and gets ErrForbidden. +func (r *Registry) Claim(instanceID, serverID, proxyID string) (*Entry, error) { + r.mu.Lock() + defer r.mu.Unlock() + + e, ok := r.entries[proxyID] + if !ok { + return nil, ErrNotFound + } + if e.InstanceID != instanceID || e.ServerID != serverID { + return nil, ErrForbidden + } + delete(r.entries, proxyID) + return e, nil +} + +func (r *Registry) Remove(proxyID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.entries, proxyID) +} + +func (r *Registry) Len() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.entries) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/proxy/ -v` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/proxy/registry.go server/internal/proxy/registry_test.go +git commit -m "feat: add console proxy session registry" +``` + +--- + +### Task 3: Proxy session — listener, rendezvous and byte relay + +The networking half. Owns the ephemeral listener, the guacd source check, the 10s deadline and the two `io.Copy` directions. + +**Files:** +- Create: `server/internal/proxy/session.go` +- Test: `server/internal/proxy/session_test.go` +- Modify: `server/internal/proxy/registry.go` (add `Session` field to `Entry`) + +**Interfaces:** +- Consumes: `Entry`, `NewID`, `Registry` from Task 2. +- Produces: + - `type AgentStream interface { Send(*pb.ProxyServerMsg) error; Recv() (*pb.ProxyClientMsg, error) }` + - `proxy.NewSession(listenHost string, allowed []string) (*Session, error)` + - `(*Session).Port() int` + - `(*Session).Serve(stream AgentStream) error` + - `(*Session).Close(reason string)` + - `(*Session).Reason() string` + - `proxy.allowedRemote(remote string, allowed []string) bool` + - `Entry.Session *Session` + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/proxy/session_test.go`: + +```go +package proxy + +import ( + "io" + "net" + "strconv" + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb" +) + +// fakeStream stands in for the agent side of a ProxyStream. +type fakeStream struct { + toServer chan *pb.ProxyClientMsg + fromServer chan *pb.ProxyServerMsg +} + +func newFakeStream() *fakeStream { + return &fakeStream{ + toServer: make(chan *pb.ProxyClientMsg, 64), + fromServer: make(chan *pb.ProxyServerMsg, 64), + } +} + +func (f *fakeStream) Send(m *pb.ProxyServerMsg) error { + f.fromServer <- m + return nil +} + +func (f *fakeStream) Recv() (*pb.ProxyClientMsg, error) { + m, ok := <-f.toServer + if !ok { + return nil, io.EOF + } + return m, nil +} + +func TestAllowedRemote(t *testing.T) { + cases := []struct { + remote string + allowed []string + want bool + }{ + {"172.18.0.5:41234", []string{"172.18.0.5"}, true}, + {"172.18.0.9:41234", []string{"172.18.0.5"}, false}, + {"172.18.0.9:41234", nil, true}, // unresolvable guacd host: fail open, logged + {"garbage", []string{"172.18.0.5"}, false}, + } + for _, c := range cases { + if got := allowedRemote(c.remote, c.allowed); got != c.want { + t.Errorf("allowedRemote(%q, %v) = %v, want %v", c.remote, c.allowed, got, c.want) + } + } +} + +func TestSessionRelaysBothDirections(t *testing.T) { + s, err := NewSession("127.0.0.1", nil) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer s.Close("") + + stream := newFakeStream() + go func() { _ = s.Serve(stream) }() + + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(s.Port()))) + if err != nil { + t.Fatalf("dial relay: %v", err) + } + defer conn.Close() + + // guacd -> agent + if _, err := conn.Write([]byte("hello")); err != nil { + t.Fatalf("write: %v", err) + } + select { + case m := <-stream.fromServer: + if string(m.Data) != "hello" { + t.Fatalf("agent got %q, want hello", m.Data) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for data at the agent") + } + + // agent -> guacd + stream.toServer <- &pb.ProxyClientMsg{Data: []byte("world")} + buf := make([]byte, 5) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read: %v", err) + } + if string(buf) != "world" { + t.Fatalf("guacd got %q, want world", buf) + } +} + +func TestSessionRejectsForeignSource(t *testing.T) { + s, err := NewSession("127.0.0.1", []string{"10.99.99.99"}) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer s.Close("") + + stream := newFakeStream() + done := make(chan error, 1) + go func() { done <- s.Serve(stream) }() + + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(s.Port()))) + if err != nil { + t.Fatalf("dial relay: %v", err) + } + defer conn.Close() + + select { + case err := <-done: + if err == nil { + t.Fatal("Serve returned nil for a connection from a disallowed source") + } + case <-time.After(2 * time.Second): + t.Fatal("Serve did not reject the foreign source") + } +} + +func TestSessionTimesOutWaitingForGuacd(t *testing.T) { + s, err := NewSession("127.0.0.1", nil) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer s.Close("") + s.timeout = 100 * time.Millisecond + + start := time.Now() + if err := s.Serve(newFakeStream()); err == nil { + t.Fatal("Serve returned nil, want a timeout error") + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("Serve took %s, expected the short timeout to apply", elapsed) + } + if s.Reason() == "" { + t.Fatal("Reason() empty after a timeout") + } +} + +func TestCloseIsIdempotent(t *testing.T) { + s, err := NewSession("127.0.0.1", nil) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + s.Close("first") + s.Close("second") // must not panic + if s.Reason() != "first" { + t.Fatalf("Reason() = %q, want the first reason", s.Reason()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/proxy/ -run TestSession -v` +Expected: FAIL — `NewSession` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `server/internal/proxy/session.go`: + +```go +package proxy + +import ( + "errors" + "fmt" + "io" + "log" + "net" + "sync" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb" +) + +const ( + chunkSize = 32 * 1024 + rendezvousTimeout = 10 * time.Second +) + +// AgentStream is the server's half of a ProxyStream. It is an interface so the +// relay can be tested without gRPC. +type AgentStream interface { + Send(*pb.ProxyServerMsg) error + Recv() (*pb.ProxyClientMsg, error) +} + +// Session owns one ephemeral listener and relays the single connection that +// arrives on it to an agent stream. +type Session struct { + listener net.Listener + allowed []string + timeout time.Duration + + once sync.Once + mu sync.Mutex + reason string +} + +// NewSession binds an ephemeral port on listenHost. allowed is the set of IPs +// permitted to connect; an empty set allows any, which is the degraded case +// when guacd's host could not be resolved. +func NewSession(listenHost string, allowed []string) (*Session, error) { + ln, err := net.Listen("tcp", net.JoinHostPort(listenHost, "0")) + if err != nil { + return nil, fmt.Errorf("bind relay listener: %w", err) + } + return &Session{listener: ln, allowed: allowed, timeout: rendezvousTimeout}, nil +} + +func (s *Session) Port() int { + return s.listener.Addr().(*net.TCPAddr).Port +} + +// Reason reports why the session ended, empty if it ended cleanly. +func (s *Session) Reason() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.reason +} + +func (s *Session) setReason(r string) { + s.mu.Lock() + if s.reason == "" { + s.reason = r + } + s.mu.Unlock() +} + +// Close tears the session down once. A non-empty reason is recorded only if no +// reason has been recorded already. +func (s *Session) Close(reason string) { + if reason != "" { + s.setReason(reason) + } + s.once.Do(func() { + _ = s.listener.Close() + }) +} + +// Serve accepts exactly one connection, verifies its source, and relays until +// either side ends. It always closes the listener before returning. +func (s *Session) Serve(stream AgentStream) error { + defer s.Close("") + + if l, ok := s.listener.(*net.TCPListener); ok { + _ = l.SetDeadline(time.Now().Add(s.timeout)) + } + + conn, err := s.listener.Accept() + if err != nil { + s.setReason("guacd_timeout") + return fmt.Errorf("waiting for guacd: %w", err) + } + // One connection only: nothing else may claim this port. + s.Close("") + + if !allowedRemote(conn.RemoteAddr().String(), s.allowed) { + _ = conn.Close() + s.setReason("foreign_source") + return fmt.Errorf("relay connection from disallowed source %s", conn.RemoteAddr()) + } + defer conn.Close() + + return s.relay(conn, stream) +} + +func (s *Session) relay(conn net.Conn, stream AgentStream) error { + errCh := make(chan error, 2) + + // guacd -> agent + go func() { + buf := make([]byte, chunkSize) + for { + n, err := conn.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + if sendErr := stream.Send(&pb.ProxyServerMsg{Data: chunk}); sendErr != nil { + errCh <- sendErr + return + } + } + if err != nil { + if !errors.Is(err, io.EOF) { + s.setReason("guacd_read_error") + } + errCh <- err + return + } + } + }() + + // agent -> guacd + go func() { + for { + msg, err := stream.Recv() + if err != nil { + errCh <- err + return + } + if msg.Close != nil { + s.setReason(msg.Close.Reason) + errCh <- fmt.Errorf("agent closed relay: %s", msg.Close.Reason) + return + } + if len(msg.Data) > 0 { + if _, err := conn.Write(msg.Data); err != nil { + errCh <- err + return + } + } + } + }() + + err := <-errCh + _ = conn.Close() + if errors.Is(err, io.EOF) { + return nil + } + return err +} + +// allowedRemote reports whether remote (a host:port string) is in allowed. An +// empty allowed list permits anything. +func allowedRemote(remote string, allowed []string) bool { + host, _, err := net.SplitHostPort(remote) + if err != nil { + log.Printf("proxy: unparseable remote address %q", remote) + return false + } + if len(allowed) == 0 { + return true + } + for _, a := range allowed { + if a == host { + return true + } + } + return false +} +``` + +- [ ] **Step 4: Add the `Session` field to `Entry`** + +In `server/internal/proxy/registry.go`, change `Entry` to: + +```go +type Entry struct { + ProxyID string + InstanceID string + ServerID string + Session *Session +} +``` + +- [ ] **Step 5: Run the whole package** + +Run: `cd server && go test ./internal/proxy/ -v` +Expected: PASS, all registry and session tests. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/proxy/session.go server/internal/proxy/session_test.go server/internal/proxy/registry.go +git commit -m "feat: add console proxy session relay" +``` + +--- + +### Task 4: `ProxyStream` gRPC handler + +Authenticates the agent, claims the entry, hands the stream to the session. No relay logic of its own. + +**Files:** +- Create: `server/internal/grpc/proxystream.go` +- Test: `server/internal/grpc/proxystream_test.go` + +**Interfaces:** +- Consumes: `proxy.Registry`, `proxy.Entry`, `proxy.ErrNotFound`, `proxy.ErrForbidden`, `(*proxy.Session).Serve` from Tasks 2–3; `services.ValidateAgentToken(serverID, token) (*models.Server, error)` which already exists at `server/internal/services/servers.go:166` and returns a server whose `InstanceID` is non-empty. +- Produces: `func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error`, and the testable core `func serveProxy(reg *proxy.Registry, open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error`. + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/grpc/proxystream_test.go`: + +```go +package grpcserver + +import ( + "errors" + "io" + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/proxy" +) + +type nullStream struct{} + +func (nullStream) Send(*pb.ProxyServerMsg) error { return nil } +func (nullStream) Recv() (*pb.ProxyClientMsg, error) { return nil, io.EOF } + +func TestServeProxyUnknownID(t *testing.T) { + reg := proxy.NewRegistry() + err := serveProxy(reg, &pb.ProxyOpen{ServerId: "srv-1", ProxyId: "nope"}, "inst-1", nullStream{}) + if !errors.Is(err, proxy.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestServeProxyForeignInstance(t *testing.T) { + reg := proxy.NewRegistry() + sess, err := proxy.NewSession("127.0.0.1", nil) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer sess.Close("") + reg.Add(&proxy.Entry{ProxyID: "p1", InstanceID: "inst-1", ServerID: "srv-1", Session: sess}) + + err = serveProxy(reg, &pb.ProxyOpen{ServerId: "srv-1", ProxyId: "p1"}, "inst-2", nullStream{}) + if !errors.Is(err, proxy.ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden", err) + } + if reg.Len() != 1 { + t.Fatal("a rejected claim must leave the entry in place") + } +} + +func TestServeProxyForeignServer(t *testing.T) { + reg := proxy.NewRegistry() + sess, _ := proxy.NewSession("127.0.0.1", nil) + defer sess.Close("") + reg.Add(&proxy.Entry{ProxyID: "p1", InstanceID: "inst-1", ServerID: "srv-1", Session: sess}) + + err := serveProxy(reg, &pb.ProxyOpen{ServerId: "srv-2", ProxyId: "p1"}, "inst-1", nullStream{}) + if !errors.Is(err, proxy.ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden", err) + } +} + +func TestServeProxyClaimIsSingleUse(t *testing.T) { + reg := proxy.NewRegistry() + sess, _ := proxy.NewSession("127.0.0.1", nil) + sess.Close("") // listener shut, so Serve returns immediately + reg.Add(&proxy.Entry{ProxyID: "p1", InstanceID: "inst-1", ServerID: "srv-1", Session: sess}) + + _ = serveProxy(reg, &pb.ProxyOpen{ServerId: "srv-1", ProxyId: "p1"}, "inst-1", nullStream{}) + + err := serveProxy(reg, &pb.ProxyOpen{ServerId: "srv-1", ProxyId: "p1"}, "inst-1", nullStream{}) + if !errors.Is(err, proxy.ErrNotFound) { + t.Fatalf("second use err = %v, want ErrNotFound", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/grpc/ -run TestServeProxy -v` +Expected: FAIL — `serveProxy` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `server/internal/grpc/proxystream.go`: + +```go +package grpcserver + +import ( + "log" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/proxy" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ProxyStream carries one console TCP connection. The agent opens it after +// dialling its own loopback address, and authenticates with the same agent +// token as the command stream plus the single-use proxy_id it was handed. +func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error { + msg, err := stream.Recv() + if err != nil { + return status.Errorf(codes.InvalidArgument, "expected initial open message: %v", err) + } + if msg.Open == nil { + return status.Error(codes.InvalidArgument, "first message must be open") + } + + srv, err := services.ValidateAgentToken(msg.Open.ServerId, msg.Open.AgentToken) + if err != nil { + return status.Error(codes.Unauthenticated, "invalid agent token") + } + + if err := serveProxy(proxy.Default, msg.Open, srv.InstanceID, stream); err != nil { + // The reason is deliberately not returned to the agent: an unknown and a + // foreign proxy_id must be indistinguishable. + log.Printf("proxy %s (server %s): %v", msg.Open.ProxyId, msg.Open.ServerId, err) + return status.Error(codes.PermissionDenied, "proxy session unavailable") + } + return nil +} + +// serveProxy claims the pending session and relays it. Split out from the gRPC +// method so the authorisation matrix is testable without a real stream. +func serveProxy(reg *proxy.Registry, open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error { + entry, err := reg.Claim(instanceID, open.ServerId, open.ProxyId) + if err != nil { + return err + } + return entry.Session.Serve(stream) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/grpc/ -run TestServeProxy -v` +Expected: PASS, 4 tests. + +- [ ] **Step 5: Verify the server still builds** + +Run: `cd server && go build ./...` +Expected: exit 0. (`vantageServer` now satisfies the extended `VantageServer` interface.) + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/grpc/proxystream.go server/internal/grpc/proxystream_test.go +git commit -m "feat: add ProxyStream handler with scoped single-use auth" +``` + +--- + +### Task 5: `OpenConsoleProxy` service facade + +Ties registry, session, dispatcher and configuration together behind one call for the API layer. + +**Files:** +- Create: `server/internal/services/consoleproxy.go` +- Test: `server/internal/services/consoleproxy_test.go` + +**Interfaces:** +- Consumes: `proxy.NewID`, `proxy.NewSession`, `proxy.Default`, `proxy.Entry`; `Dispatcher.IsConnected(serverID)` and the private `Dispatcher.dispatch` pattern from `server/internal/services/dispatch.go`. +- Produces: + - `services.ErrAgentOffline` (sentinel) + - `services.OpenConsoleProxy(instanceID, serverID string, targetPort int) (*ConsoleProxy, error)` + - `type ConsoleProxy struct { ProxyID string; Host string; Port int; session *proxy.Session }` + - `(*ConsoleProxy).Close()` and `(*ConsoleProxy).Reason() string` + - `services.guacdHosts(addr string) []string` + - `services.DispatchOpenProxy(serverID, proxyID string, port uint32) error` + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/services/consoleproxy_test.go`: + +```go +package services + +import ( + "errors" + "net" + "strconv" + "testing" +) + +func TestOpenConsoleProxyRefusesOfflineAgent(t *testing.T) { + // No agent has connected, so the dispatcher has no channel for this server. + _, err := OpenConsoleProxy("inst-1", "srv-offline", 22) + if !errors.Is(err, ErrAgentOffline) { + t.Fatalf("err = %v, want ErrAgentOffline", err) + } +} + +func TestOpenConsoleProxyBindsAndRegisters(t *testing.T) { + ch := Dispatcher.Connect("srv-1") + defer Dispatcher.Disconnect("srv-1") + + cp, err := OpenConsoleProxy("inst-1", "srv-1", 3389) + if err != nil { + t.Fatalf("OpenConsoleProxy: %v", err) + } + defer cp.Close() + + if cp.Port == 0 { + t.Fatal("relay port is 0, expected an ephemeral bound port") + } + if len(cp.ProxyID) != 64 { + t.Fatalf("ProxyID length = %d, want 64", len(cp.ProxyID)) + } + + // The listener must actually be accepting. + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(cp.Port))) + if err != nil { + t.Fatalf("relay listener not accepting: %v", err) + } + _ = conn.Close() + + select { + case cmd := <-ch: + if cmd.OpenProxy == nil { + t.Fatalf("dispatched command was not OpenProxy: %+v", cmd) + } + if cmd.OpenProxy.ProxyId != cp.ProxyID { + t.Fatalf("proxy id mismatch: %q vs %q", cmd.OpenProxy.ProxyId, cp.ProxyID) + } + if cmd.OpenProxy.Port != 3389 { + t.Fatalf("target port = %d, want 3389", cmd.OpenProxy.Port) + } + default: + t.Fatal("no OpenProxyCmd was dispatched to the agent") + } +} + +func TestGuacdHosts(t *testing.T) { + // A literal IP needs no DNS and must pass straight through. + got := guacdHosts("127.0.0.1:4822") + if len(got) != 1 || got[0] != "127.0.0.1" { + t.Fatalf("guacdHosts = %v, want [127.0.0.1]", got) + } + // An unresolvable host degrades to the empty set rather than blocking. + if h := guacdHosts("no-such-host.invalid:4822"); len(h) != 0 { + t.Fatalf("guacdHosts for an unresolvable host = %v, want empty", h) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/services/ -run 'TestOpenConsoleProxy|TestGuacdHosts' -v` +Expected: FAIL — `OpenConsoleProxy` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `server/internal/services/consoleproxy.go`: + +```go +package services + +import ( + "errors" + "fmt" + "log" + "net" + "os" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/proxy" +) + +// ErrAgentOffline means the console cannot be opened because the target's agent +// is not on the command stream. Every console session is relayed by the agent, +// so this is fatal rather than a degraded mode. +var ErrAgentOffline = errors.New("agent is not connected") + +// ConsoleProxy is a pending relay: a bound listener guacd can dial and a +// dispatched command telling the agent to meet it. +type ConsoleProxy struct { + ProxyID string + Host string + Port int + + session *proxy.Session +} + +func (c *ConsoleProxy) Close() { + proxy.Default.Remove(c.ProxyID) + c.session.Close("") +} + +func (c *ConsoleProxy) Reason() string { return c.session.Reason() } + +func proxyListenHost() string { + if v := os.Getenv("PROXY_LISTEN_HOST"); v != "" { + return v + } + return "0.0.0.0" +} + +func proxyAdvertiseHost() string { + if v := os.Getenv("PROXY_ADVERTISE_HOST"); v != "" { + return v + } + return "server" +} + +func guacdAddr() string { + if v := os.Getenv("GUACD_ADDR"); v != "" { + return v + } + return "guacd:4822" +} + +// guacdHosts resolves guacd's address to the IPs allowed to claim a relay +// listener. An unresolvable host yields an empty set, which allows any source: +// refusing everything would take the console down entirely, so the narrower +// protections (ephemeral port, 10s window, single accept) carry it instead. +func guacdHosts(addr string) []string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + if ip := net.ParseIP(host); ip != nil { + return []string{ip.String()} + } + ips, err := net.LookupHost(host) + if err != nil { + log.Printf("proxy: cannot resolve guacd host %q, allowing any relay source: %v", host, err) + return nil + } + return ips +} + +// DispatchOpenProxy tells the agent to dial its own loopback on port and relay +// it back under proxyID. +func DispatchOpenProxy(serverID, proxyID string, port uint32) error { + return Dispatcher.dispatch(serverID, &pb.ServerCommand{ + CommandId: proxyID, + OpenProxy: &pb.OpenProxyCmd{ProxyId: proxyID, Port: port}, + }) +} + +// OpenConsoleProxy binds a relay listener, registers it, and asks the agent to +// connect. The caller must Close the result. +func OpenConsoleProxy(instanceID, serverID string, targetPort int) (*ConsoleProxy, error) { + if !Dispatcher.IsConnected(serverID) { + return nil, ErrAgentOffline + } + + proxyID, err := proxy.NewID() + if err != nil { + return nil, fmt.Errorf("generate proxy id: %w", err) + } + + sess, err := proxy.NewSession(proxyListenHost(), guacdHosts(guacdAddr())) + if err != nil { + return nil, err + } + + proxy.Default.Add(&proxy.Entry{ + ProxyID: proxyID, + InstanceID: instanceID, + ServerID: serverID, + Session: sess, + }) + + if err := DispatchOpenProxy(serverID, proxyID, uint32(targetPort)); err != nil { + proxy.Default.Remove(proxyID) + sess.Close("dispatch_failed") + return nil, err + } + + return &ConsoleProxy{ + ProxyID: proxyID, + Host: proxyAdvertiseHost(), + Port: sess.Port(), + session: sess, + }, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/services/ -run 'TestOpenConsoleProxy|TestGuacdHosts' -v` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/consoleproxy.go server/internal/services/consoleproxy_test.go +git commit -m "feat: add OpenConsoleProxy service facade" +``` + +--- + +### Task 6: Agent-side relay package + +The agent half. Dials loopback and pumps bytes. Testable with a fake stream and a real local echo server. + +**Files:** +- Create: `agent/internal/proxy/proxy.go` +- Test: `agent/internal/proxy/proxy_test.go` + +**Interfaces:** +- Consumes: `pb.ProxyClientMsg`, `pb.ProxyServerMsg`, `pb.ProxyOpen`, `pb.ProxyClose` from Task 1 (agent copy). +- Produces: + - `type Stream interface { Send(*pb.ProxyClientMsg) error; Recv() (*pb.ProxyServerMsg, error); CloseSend() error }` + - `agentproxy.Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error` + +- [ ] **Step 1: Write the failing test** + +Create `agent/internal/proxy/proxy_test.go`: + +```go +package agentproxy + +import ( + "io" + "net" + "strings" + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb" +) + +type fakeStream struct { + sent chan *pb.ProxyClientMsg + fromServer chan *pb.ProxyServerMsg + closed bool +} + +func newFakeStream() *fakeStream { + return &fakeStream{ + sent: make(chan *pb.ProxyClientMsg, 64), + fromServer: make(chan *pb.ProxyServerMsg, 64), + } +} + +func (f *fakeStream) Send(m *pb.ProxyClientMsg) error { + f.sent <- m + return nil +} + +func (f *fakeStream) Recv() (*pb.ProxyServerMsg, error) { + m, ok := <-f.fromServer + if !ok { + return nil, io.EOF + } + return m, nil +} + +func (f *fakeStream) CloseSend() error { + f.closed = true + return nil +} + +// freePort returns a port with nothing listening on it. +func freePort(t *testing.T) uint32 { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + p := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return uint32(p) +} + +func TestOpenSendsOpenThenCloseWhenDialRefused(t *testing.T) { + s := newFakeStream() + + err := Open(s, "srv-1", "tok", "p1", freePort(t)) + if err == nil { + t.Fatal("Open returned nil for a refused dial") + } + + first := <-s.sent + if first.Open == nil { + t.Fatalf("first message must be open, got %+v", first) + } + if first.Open.ServerId != "srv-1" || first.Open.AgentToken != "tok" || first.Open.ProxyId != "p1" { + t.Fatalf("open fields wrong: %+v", first.Open) + } + + second := <-s.sent + if second.Close == nil { + t.Fatalf("second message must be close, got %+v", second) + } + if !strings.HasPrefix(second.Close.Reason, "dial_refused") { + t.Fatalf("reason = %q, want a dial_refused prefix", second.Close.Reason) + } +} + +func TestOpenRelaysToLocalService(t *testing.T) { + // A local echo server standing in for sshd. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + _, _ = io.Copy(c, c) + }() + port := uint32(ln.Addr().(*net.TCPAddr).Port) + + s := newFakeStream() + done := make(chan error, 1) + go func() { done <- Open(s, "srv-1", "tok", "p1", port) }() + + if first := <-s.sent; first.Open == nil { + t.Fatalf("first message must be open, got %+v", first) + } + + s.fromServer <- &pb.ProxyServerMsg{Data: []byte("ping")} + + select { + case msg := <-s.sent: + if string(msg.Data) != "ping" { + t.Fatalf("echoed %q, want ping", msg.Data) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the echo") + } + + close(s.fromServer) + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Open did not return after the stream ended") + } +} + +func TestOpenNeverDialsANonLoopbackHost(t *testing.T) { + // Guard the core security property by inspecting the source: the dial target + // must be built from the hardcoded loopback constant. + if loopbackHost != "127.0.0.1" { + t.Fatalf("loopbackHost = %q, must be 127.0.0.1", loopbackHost) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd agent && go test ./internal/proxy/ -v` +Expected: FAIL — no such package / `Open` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `agent/internal/proxy/proxy.go`: + +```go +// Package agentproxy relays a single TCP connection between a local service and +// the control plane, so a control plane that cannot route to this host's network +// can still open a console session. +// +// The dial host is hardcoded to loopback. The control plane supplies only a +// port, and nothing in this package can be made to dial anywhere else. +package agentproxy + +import ( + "errors" + "fmt" + "io" + "net" + "strconv" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb" +) + +const ( + loopbackHost = "127.0.0.1" + chunkSize = 32 * 1024 + dialTimeout = 10 * time.Second +) + +// Stream is the agent's half of a ProxyStream. +type Stream interface { + Send(*pb.ProxyClientMsg) error + Recv() (*pb.ProxyServerMsg, error) + CloseSend() error +} + +// Open dials the local port, announces itself on the stream, and relays until +// either side ends. A refused dial is reported as an explicit close so the +// operator sees a reason rather than a hang. +func Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error { + conn, dialErr := net.DialTimeout("tcp", + net.JoinHostPort(loopbackHost, strconv.Itoa(int(port))), dialTimeout) + + if err := stream.Send(&pb.ProxyClientMsg{Open: &pb.ProxyOpen{ + ServerId: serverID, + AgentToken: agentToken, + ProxyId: proxyID, + }}); err != nil { + if conn != nil { + _ = conn.Close() + } + return fmt.Errorf("send open: %w", err) + } + + if dialErr != nil { + _ = stream.Send(&pb.ProxyClientMsg{Close: &pb.ProxyClose{ + Reason: "dial_refused: " + dialErr.Error(), + }}) + _ = stream.CloseSend() + return fmt.Errorf("dial 127.0.0.1:%d: %w", port, dialErr) + } + defer conn.Close() + + return relay(conn, stream) +} + +func relay(conn net.Conn, stream Stream) error { + errCh := make(chan error, 2) + + // local service -> control plane + go func() { + buf := make([]byte, chunkSize) + for { + n, err := conn.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + if sendErr := stream.Send(&pb.ProxyClientMsg{Data: chunk}); sendErr != nil { + errCh <- sendErr + return + } + } + if err != nil { + errCh <- err + return + } + } + }() + + // control plane -> local service + go func() { + for { + msg, err := stream.Recv() + if err != nil { + errCh <- err + return + } + if msg.Close != nil { + errCh <- fmt.Errorf("server closed relay: %s", msg.Close.Reason) + return + } + if len(msg.Data) > 0 { + if _, err := conn.Write(msg.Data); err != nil { + errCh <- err + return + } + } + } + }() + + err := <-errCh + _ = conn.Close() + _ = stream.CloseSend() + if errors.Is(err, io.EOF) { + return nil + } + return err +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd agent && go test ./internal/proxy/ -v` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add agent/internal/proxy/proxy.go agent/internal/proxy/proxy_test.go +git commit -m "feat: add agent-side console relay" +``` + +--- + +### Task 7: Agent handles `OpenProxyCmd` + +Wires the relay into the command stream loop. One fresh gRPC connection and one goroutine per proxy, so a console session cannot block command handling. + +**Files:** +- Modify: `agent/internal/sync/sync.go:197-199` (the command dispatch block) and the helper section below it + +**Interfaces:** +- Consumes: `agentproxy.Open` from Task 6; `grpcclient.New(cfg.ServerURL, cfg.TLS)` and `(*Client).ProxyStream(ctx)` from Task 1. +- Produces: `handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd)` in package `sync`. + +- [ ] **Step 1: Add the command case** + +In `agent/internal/sync/sync.go`, inside `connectAndHandleStream`'s receive loop, immediately after the `cmd.CleanupWorkspace` block: + +```go + if cmd.OpenProxy != nil { + go handleOpenProxy(ctx, cfg, cmd.OpenProxy) + } +``` + +- [ ] **Step 2: Add the handler** + +In the same file, next to `handleCleanupWorkspace`: + +```go +// handleOpenProxy relays one console connection. It uses its own gRPC +// connection so console traffic never shares a stream with commands, key sync +// or workflow output. +func handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd) { + client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) + if err != nil { + log.Printf("proxy %s: dial control plane: %v", cmd.ProxyId, err) + return + } + defer client.Close() + + stream, err := client.ProxyStream(ctx) + if err != nil { + log.Printf("proxy %s: open stream: %v", cmd.ProxyId, err) + return + } + + log.Printf("proxy %s: relaying 127.0.0.1:%d", cmd.ProxyId, cmd.Port) + if err := agentproxy.Open(stream, cfg.ServerID, cfg.AgentToken, cmd.ProxyId, cmd.Port); err != nil { + log.Printf("proxy %s: %v", cmd.ProxyId, err) + } +} +``` + +- [ ] **Step 3: Add the import** + +In the import block of `agent/internal/sync/sync.go`, alongside the other internal packages: + +```go + agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy" +``` + +- [ ] **Step 4: Verify the agent builds, on both platforms** + +Run: +```bash +cd agent && go build ./... && GOOS=windows GOARCH=amd64 go build ./... && go vet ./... +``` +Expected: exit 0. The relay uses only `net` and has no build tags, so the Windows build must succeed unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add agent/internal/sync/sync.go +git commit -m "feat: handle OpenProxyCmd in the agent command stream" +``` + +--- + +### Task 8: Route the console through the relay + +Removes direct dialling. `BuildGuacParams` stops seeing `srv.IPAddress`; target port selection becomes its own function. + +**Files:** +- Modify: `server/internal/services/console.go:80-124` (`portOr`, `BuildGuacParams`) +- Modify: `server/internal/api/console.go:16-69` (`consoleConnect`), `:103-167` (`consoleTunnel`) +- Test: `server/internal/services/console_test.go` (create) + +**Interfaces:** +- Consumes: `OpenConsoleProxy`, `ErrAgentOffline`, `ConsoleProxy` from Task 5; `models.Server` fields `IPAddress`, `SSHPort`, `RDPPort`, `Status`. +- Produces: + - `services.TargetPort(srv *models.Server, protocol string) (int, error)` + - `services.BuildGuacParams(protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass, relayHost string, relayPort int) (*GuacParams, error)` — **note the changed signature: `srv` is gone** + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/services/console_test.go`: + +```go +package services + +import ( + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +func TestTargetPortDefaults(t *testing.T) { + srv := &models.Server{} + cases := map[string]int{"ssh": 22, "rdp": 3389, "vnc": 5900} + for proto, want := range cases { + got, err := TargetPort(srv, proto) + if err != nil { + t.Fatalf("TargetPort(%s): %v", proto, err) + } + if got != want { + t.Errorf("TargetPort(%s) = %d, want %d", proto, got, want) + } + } +} + +func TestTargetPortHonoursOverrides(t *testing.T) { + srv := &models.Server{SSHPort: 2222, RDPPort: 33890} + if got, _ := TargetPort(srv, "ssh"); got != 2222 { + t.Errorf("ssh port = %d, want 2222", got) + } + if got, _ := TargetPort(srv, "rdp"); got != 33890 { + t.Errorf("rdp port = %d, want 33890", got) + } +} + +func TestTargetPortUnknownProtocol(t *testing.T) { + if _, err := TargetPort(&models.Server{}, "telnet"); err == nil { + t.Fatal("expected an error for an unsupported protocol") + } +} + +func TestBuildGuacParamsPointsAtTheRelayNotTheServer(t *testing.T) { + gp, err := BuildGuacParams("ssh", "root", "", "", "", "", "relay-host", 41000) + if err != nil { + t.Fatalf("BuildGuacParams: %v", err) + } + if gp.Params["hostname"] != "relay-host" { + t.Errorf("hostname = %q, want relay-host", gp.Params["hostname"]) + } + if gp.Params["port"] != "41000" { + t.Errorf("port = %q, want 41000", gp.Params["port"]) + } +} + +func TestBuildGuacParamsRDPCarriesCredentials(t *testing.T) { + gp, err := BuildGuacParams("rdp", "", "", "", "admin", "hunter2", "relay-host", 41001) + if err != nil { + t.Fatalf("BuildGuacParams: %v", err) + } + if gp.Protocol != "rdp" { + t.Errorf("protocol = %q, want rdp", gp.Protocol) + } + if gp.Params["username"] != "admin" || gp.Params["password"] != "hunter2" { + t.Errorf("credentials not carried through: %+v", gp.Params) + } + if gp.Params["hostname"] != "relay-host" || gp.Params["port"] != "41001" { + t.Errorf("rdp must target the relay: %+v", gp.Params) + } +} + +func TestBuildGuacParamsSSHDefaultsToRoot(t *testing.T) { + gp, _ := BuildGuacParams("ssh", "", "", "", "", "", "relay-host", 41002) + if gp.Params["username"] != "root" { + t.Errorf("username = %q, want root", gp.Params["username"]) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/services/ -run 'TestTargetPort|TestBuildGuacParams' -v` +Expected: FAIL — `TargetPort` undefined and `BuildGuacParams` has the wrong signature. + +- [ ] **Step 3: Rewrite the two functions** + +In `server/internal/services/console.go`, replace `portOr` and `BuildGuacParams` with: + +```go +func portOr(v, def int) int { + if v == 0 { + return def + } + return v +} + +// TargetPort is the port on the managed server that the agent will dial on its +// own loopback address. +func TargetPort(srv *models.Server, protocol string) (int, error) { + switch protocol { + case "ssh": + return portOr(srv.SSHPort, 22), nil + case "rdp": + return portOr(srv.RDPPort, 3389), nil + case "vnc": + return 5900, nil + default: + return 0, fmt.Errorf("unsupported protocol %q", protocol) + } +} + +// BuildGuacParams points guacd at the relay listener, never at the managed +// server: on a cloud deployment the server's address is not routable from here. +func BuildGuacParams(protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass, relayHost string, relayPort int) (*GuacParams, error) { + port := strconv.Itoa(relayPort) + switch protocol { + case "ssh": + p := map[string]string{ + "hostname": relayHost, + "port": port, + } + if sshUser == "" { + sshUser = "root" + } + p["username"] = sshUser + if privateKey != "" { + p["private-key"] = privateKey + } + if passphrase != "" { + p["passphrase"] = passphrase + } + return &GuacParams{Protocol: "ssh", Params: p}, nil + case "rdp": + return &GuacParams{Protocol: "rdp", Params: map[string]string{ + "hostname": relayHost, + "port": port, + "username": rdpUser, + "password": rdpPass, + "security": "any", + "ignore-cert": "true", + }}, nil + case "vnc": + return &GuacParams{Protocol: "vnc", Params: map[string]string{ + "hostname": relayHost, + "port": port, + "password": rdpPass, + }}, nil + default: + return nil, fmt.Errorf("unsupported protocol %q", protocol) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/services/ -run 'TestTargetPort|TestBuildGuacParams' -v` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Refuse an offline agent at connect time** + +In `server/internal/api/console.go`, in `consoleConnect`, immediately after the `services.GetServer` block (which ends at line 34) and before `CreateConsoleSession`: + +```go + if srv.Status != "active" { + c.JSON(http.StatusConflict, gin.H{ + "error": "agent_offline", + "message": "The agent on this server is not connected. " + + "Console sessions are relayed by the agent, so it must be online.", + }) + return + } +``` + +- [ ] **Step 6: Open the relay in `consoleTunnel`** + +In `server/internal/api/console.go`, replace everything from the `gp, err := services.BuildGuacParams(...)` call through the end of the `connect` closure's `guacdAddr` handling. The new body, starting where `rdpUser, rdpPass` have just been resolved: + +```go + targetPort, err := services.TargetPort(srv, sess.Protocol) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + relay, err := services.OpenConsoleProxy(instanceID, srv.ServerID, targetPort) + if err != nil { + if errors.Is(err, services.ErrAgentOffline) { + c.JSON(http.StatusConflict, gin.H{"error": "agent_offline"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open relay"}) + return + } + defer relay.Close() + + gp, err := services.BuildGuacParams(sess.Protocol, sess.SSHUsername, privKey, passphrase, + rdpUser, rdpPass, relay.Host, relay.Port) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + guacdAddr := os.Getenv("GUACD_ADDR") + if guacdAddr == "" { + guacdAddr = "guacd:4822" + } +``` + +The `connect` closure below is unchanged — it still dials guacd and hands over `gp`. Then replace the `OnDisconnect` assignment with: + +```go + wsServer := guac.NewWebsocketServer(connect) + wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) { + if reason := relay.Reason(); reason != "" { + services.LogEvent(instanceID, "console.proxy_failed", actorFromCtx(c), srv.ServerID, "", + "console relay failed: "+reason) + } + relay.Close() + _ = services.EndConsoleSession(instanceID, sessionID) + } + wsServer.ServeHTTP(c.Writer, c.Request) +``` + +- [ ] **Step 7: Record the relay in the opened event** + +In `consoleConnect`, change the existing `LogEvent` call to name the relay explicitly: + +```go + services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "", + "console session opened ("+body.Protocol+", agent-relayed)") +``` + +- [ ] **Step 8: Add the `errors` import** + +`server/internal/api/console.go` now needs `"errors"` in its import block. + +- [ ] **Step 9: Build and run the full server test suite** + +Run: `cd server && go build ./... && go test ./... 2>&1 | grep -v "^ok" | head -30` +Expected: build succeeds; no `FAIL` lines. Any remaining caller of the old `BuildGuacParams` signature is a compile error and must be updated. + +- [ ] **Step 10: Commit** + +```bash +git add server/internal/services/console.go server/internal/services/console_test.go server/internal/api/console.go +git commit -m "feat: route every console session through the agent relay" +``` + +--- + +### Task 9: End-to-end relay test + +The test that would have caught the original bug: bytes traverse listener → registry → stream → local service and back, with no real network between hosts. + +**Files:** +- Create: `server/internal/proxy/endtoend_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 2, 3 and 6. The agent package is in a different module, so this test implements the agent side inline rather than importing it. + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/proxy/endtoend_test.go`: + +```go +package proxy + +import ( + "io" + "net" + "strconv" + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb" +) + +// pipeStream is a bidirectional in-memory stand-in for a gRPC stream. The +// server holds one end, the fake agent the other. +type pipeStream struct { + in chan *pb.ProxyClientMsg // agent -> server + out chan *pb.ProxyServerMsg // server -> agent +} + +func (p *pipeStream) Send(m *pb.ProxyServerMsg) error { + p.out <- m + return nil +} + +func (p *pipeStream) Recv() (*pb.ProxyClientMsg, error) { + m, ok := <-p.in + if !ok { + return nil, io.EOF + } + return m, nil +} + +// fakeAgent relays between the stream and a local TCP service, exactly as the +// real agent does. +func fakeAgent(t *testing.T, ps *pipeStream, port int) { + t.Helper() + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err != nil { + t.Errorf("fake agent dial: %v", err) + return + } + defer conn.Close() + + go func() { + buf := make([]byte, 4096) + for { + n, err := conn.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + ps.in <- &pb.ProxyClientMsg{Data: chunk} + } + if err != nil { + close(ps.in) + return + } + } + }() + + for msg := range ps.out { + if len(msg.Data) > 0 { + if _, err := conn.Write(msg.Data); err != nil { + return + } + } + } +} + +func TestEndToEndConsoleRelay(t *testing.T) { + // Stand-in for sshd on the managed server: uppercases what it receives. + target, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen target: %v", err) + } + defer target.Close() + go func() { + c, err := target.Accept() + if err != nil { + return + } + defer c.Close() + buf := make([]byte, 512) + n, err := c.Read(buf) + if err != nil { + return + } + out := make([]byte, n) + for i := 0; i < n; i++ { + ch := buf[i] + if ch >= 'a' && ch <= 'z' { + ch -= 32 + } + out[i] = ch + } + _, _ = c.Write(out) + }() + targetPort := target.Addr().(*net.TCPAddr).Port + + // Server side: registry entry plus a bound relay listener. + reg := NewRegistry() + sess, err := NewSession("127.0.0.1", nil) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer sess.Close("") + reg.Add(&Entry{ProxyID: "p1", InstanceID: "inst-1", ServerID: "srv-1", Session: sess}) + + // The agent claims it and starts relaying to the local service. + entry, err := reg.Claim("inst-1", "srv-1", "p1") + if err != nil { + t.Fatalf("claim: %v", err) + } + ps := &pipeStream{ + in: make(chan *pb.ProxyClientMsg, 64), + out: make(chan *pb.ProxyServerMsg, 64), + } + go func() { _ = entry.Session.Serve(ps) }() + go fakeAgent(t, ps, targetPort) + + // guacd side. + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(sess.Port()))) + if err != nil { + t.Fatalf("dial relay: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("vantage")); err != nil { + t.Fatalf("write: %v", err) + } + + buf := make([]byte, 7) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read back through the relay: %v", err) + } + if string(buf) != "VANTAGE" { + t.Fatalf("got %q through the relay, want VANTAGE", buf) + } +} +``` + +- [ ] **Step 2: Run it** + +Run: `cd server && go test ./internal/proxy/ -run TestEndToEnd -v` +Expected: PASS. If it hangs, the rendezvous is wrong — check that `Serve` is running before guacd dials. + +- [ ] **Step 3: Run the full suite for race conditions** + +Run: `cd server && go test ./internal/proxy/ -race -count=3 -v` +Expected: PASS every time, no `DATA RACE` output. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/proxy/endtoend_test.go +git commit -m "test: end-to-end console relay through the proxy registry" +``` + +--- + +### Task 10: Deployment configuration and documentation + +The relay is inert unless guacd can resolve the control plane by the name the server advertises. + +**Files:** +- Modify: `deploy/docker-compose.yml` +- Modify: `deploy/docker-compose.site.yml` +- Modify: `docsite/docs/reference/environment-variables.md` +- Modify: `docsite/docs/reference/ports-and-networking.md` +- Modify: `docsite/docs/vantage/browser-console.md` +- Modify: `docsite/docs/reference/troubleshooting.md:89` +- Modify: `CLAUDE.md` + +**Interfaces:** +- Consumes: `PROXY_ADVERTISE_HOST`, `PROXY_LISTEN_HOST` from Task 5. +- Produces: no code. + +- [ ] **Step 1: Set the advertised host in both compose files** + +In `deploy/docker-compose.yml`, in the `server` service's `environment` block, add: + +```yaml + PROXY_ADVERTISE_HOST: server +``` + +Do the same in `deploy/docker-compose.site.yml` if that file overrides the `server` service's environment. The value must be the name **guacd** resolves the control plane by on the compose network — the service name, `server`. + +- [ ] **Step 2: Document the two variables** + +In `docsite/docs/reference/environment-variables.md`, in the server table: + +| Name | Required | Notes | +| --- | --- | --- | +| `PROXY_ADVERTISE_HOST` | no | default `server`. The hostname **guacd** uses to reach the control plane's console relay. Wrong here and every console session fails at connect with guacd unable to resolve the relay. | +| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`. Interface the ephemeral relay listeners bind. Narrow it only if guacd shares a known interface. | + +- [ ] **Step 3: Correct the reachability documentation** + +`docsite/docs/vantage/browser-console.md` currently states, at lines 8 and 18, that the control plane must be able to reach the target on the protocol port. That is now false. Replace those requirement lines with: + +```markdown +- The target's **agent must be online**. Console traffic is relayed over the + agent's existing outbound connection, so the control plane never needs a route + to the server's address — but it does need the agent. +- No inbound port on the target, beyond what the protocol already listens on + locally. A service bound only to `127.0.0.1` works, because the agent dials + loopback on the target itself. +``` + +Update the troubleshooting row in the same file (line 70) and the matching row in `docsite/docs/reference/troubleshooting.md:89` from "The control plane cannot reach the target on the protocol port" to: + +```markdown +| Hangs at connecting | The agent is offline, or nothing is listening on the protocol port on the target's own loopback address. Check the audit log for `console.proxy_failed` — its reason names which | +``` + +- [ ] **Step 4: Correct the networking documentation** + +In `docsite/docs/reference/ports-and-networking.md`, the "What to open" section must say that the console needs **no** control-plane-to-target route and no new inbound port: it rides the agent's outbound gRPC connection on 9090, the same one used for key sync. + +- [ ] **Step 5: Update the architecture map** + +In `CLAUDE.md`, replace the Browser console subsection's description with: + +```markdown +### Browser console + +`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel` +upgrades to a WebSocket and proxies to **guacd** using `github.com/wwt/guac`. + +guacd never dials the managed server. The server binds a single-use ephemeral +listener, pushes `OpenProxyCmd` down the agent's command stream, and the agent +opens a `ProxyStream` and relays the connection from its own **`127.0.0.1`** — +the host is hardcoded agent-side, so the control plane can name only a port. +This is what makes the console work on Vantage Cloud, where the customer's +server is behind NAT on a private address. It also means the console now +**requires a live agent** on every deployment: `consoleConnect` answers 409 +`agent_offline` rather than hanging. + +SSH connections authenticate with a stored private key; RDP/VNC credentials are +encrypted, single-use, and consumed when the tunnel opens. None of them reach +the agent — the session is negotiated end-to-end between guacd and the target +daemon, so the agent relays bytes it cannot read. +``` + +Add `PROXY_ADVERTISE_HOST` and `PROXY_LISTEN_HOST` to the server environment table in the same file. + +- [ ] **Step 6: Refresh the knowledge graph** + +Run: `graphify update .` +Expected: completes without error. + +- [ ] **Step 7: Commit** + +```bash +git add deploy/docker-compose.yml deploy/docker-compose.site.yml CLAUDE.md docsite/docs/ +git commit -m "docs: document the agent-relayed console proxy" +``` + +--- + +## Manual verification + +Run in this order after Task 10. Each one is a distinct failure mode. + +1. **Self-hosted SSH** to a server on the same network as the control plane. Proves the relay did not regress the case that already worked. +2. **Self-hosted RDP** to a Windows agent. Proves the relay is protocol-agnostic and that the Windows agent build relays. +3. **Cloud SSH** to a server on a private network behind NAT. This is the bug being fixed; it must now connect. +4. **Stop the agent, then open a console.** Expect an immediate 409 `agent_offline` in the UI, not a hang. +5. **Point a server's SSH port at a closed port and connect.** Expect a prompt failure and a `console.proxy_failed` audit entry with reason `dial_refused`. diff --git a/docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md b/docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md index 1321f13..568efe2 100644 --- a/docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md +++ b/docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md @@ -95,6 +95,14 @@ message ProxyServerMsg { oneof payload { bytes data = 1; ProxyClose close = 2; } message ProxyClose { string reason = 1; } ``` +Two implementation facts about this repo shape the above. The `pb` packages are +**hand-written Go, not protoc output** — `vantage.proto` is documentation, and +both `server/internal/grpc/pb` and `agent/internal/grpc/pb` are edited by hand +and kept in sync manually. And the registered codec is JSON, so a `bytes` field +travels as a base64 string: roughly 33% overhead on relayed traffic. That is +accepted rather than fixed here, because introducing a second codec for one RPC +is a larger change than this feature warrants. Relay chunks are 32 KiB. + ## Security **The agent only ever dials `127.0.0.1`.** The port is the only field it takes