# 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`.
