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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
kind: Added
body: Freeze, realize, recover, and verify lease-private controlled-session networks and granted workload endpoints, with protocol-v2 session-local coordinates, fixed participant addresses, exact peer firewall grants, and ordinary public or local access preserved only when explicitly granted.
body: Freeze, realize, recover, and verify lease-private controlled-session networks and granted workload endpoints, with protocol-v1 session-local coordinates, fixed participant addresses, exact peer firewall grants, and ordinary public or local access preserved only when explicitly granted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new readiness and client API

This fragment still describes only controlled-session networking, so the newly exposed Linux session client and the protocol's mandatory ready event/request gate will be absent from the release notes. Extend this entry or add a separate Changie fragment covering those user-facing changes.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

20 changes: 14 additions & 6 deletions docs/CONTROLLED_SESSION_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ summary: Capability-scoped execution sessions that inherit Reploy's global conta
implemented. The host resolves a sorted requested subset of the exact
workload generation's declared endpoints and freezes their schemes,
container ports, lease-local aliases, and internal network name into both
container-plan digests and the protocol-v2 `opened` coordinates. It creates
container-plan digests and the protocol-v1 `opened` coordinates. It creates
one exact engine-internal network, records it before startup, derives fixed
controller and workload addresses from the verified engine-assigned
prefixes, attaches only the two exact inert containers, and removes the
Expand Down Expand Up @@ -545,9 +545,8 @@ use, but secrecy is not the sole security boundary. Isolation relies on:
## Session Protocol

The protocol is versioned, typed, length-framed, and binary-safe. The current
wire version is 2; version 1 remains reserved for the earlier strict `opened`
shape that did not contain endpoint coordinates. Terminal bytes are never
parsed as protocol messages.
and initial wire version is 1. Terminal bytes are never parsed as protocol
messages.

### Controller Requests

Expand Down Expand Up @@ -578,6 +577,13 @@ cleanup and no canceled request is replayed.
- `opened`: reports the effective dimensions, both runtime identities and
generations, fixed session capabilities, the structured coordinates of
each granted workload endpoint, and the workload-output-finalization timeout.
It means that the controller has claimed the authenticated channel; the
workload may not have started yet.
- `ready`: reports that Host Reploy has started the workload, verified its
lease-private network when present, and activated the lifecycle. Ordinary
controller requests are rejected before this payload-free event. A
startup failure never emits `ready`; the controller can still acknowledge the
resulting `terminated` event.
- `output(bytes)`: ordered PTY output bytes.
- `workload_exit(status, reason)`: reports host-observed workload-shell
exit.
Expand Down Expand Up @@ -630,7 +636,7 @@ Host Reploy owns workload-output finalization; it never waits indefinitely for
workload cooperation. Once termination begins, it rejects new output surfaces,
performs bounded graceful shutdown followed by forced container stop, and
continues draining the PTY. The immutable session plan carries a finite
output-finalization deadline. Protocol v2 defines an initial host-owned default
output-finalization deadline. Protocol v1 defines an initial host-owned default
of 30 seconds; the effective value is reported by `opened` and applies to
workload shutdown, final buffered-byte delivery, and controller backpressure.

Expand All @@ -651,7 +657,7 @@ completion by `complete` or terminal acknowledgement.

The barrier initially covers the PTY. A future workload output-file or
output-directory contract joins the same barrier after its files are closed,
validated, and published or have recorded an explicit failure; protocol v2
validated, and published or have recorded an explicit failure; protocol v1
does not otherwise speculate about file payloads. Native network traffic is not
session output and does not pass through this barrier.

Expand Down Expand Up @@ -1122,6 +1128,8 @@ asciinema
The proxy forwards input bytes, output bytes, resize operations, and terminal
completion. This keeps asciinema and recording dependencies out of workload
images while preserving the existing cast format and controller ownership.
It consumes `opened` as channel metadata and does not forward controller
requests until `ready`.

The prototype must test:

Expand Down
8 changes: 4 additions & 4 deletions internal/controlledsession/authorization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ func testAuthorizationV1() AuthorizationV1 {
}
}

func testEndpointsV1() []EndpointV2 {
return []EndpointV2{
{ID: "browser", Scheme: "http", Host: WorkloadEndpointHostV2, Port: 8080},
{ID: "terminal", Scheme: "https", Host: WorkloadEndpointHostV2, Port: 8443},
func testEndpointsV1() []EndpointV1 {
return []EndpointV1{
{ID: "browser", Scheme: "http", Host: WorkloadEndpointHostV1, Port: 8080},
{ID: "terminal", Scheme: "https", Host: WorkloadEndpointHostV1, Port: 8443},
}
}

Expand Down
12 changes: 6 additions & 6 deletions internal/controlledsession/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const (
// lease-private controller channel. HostDirectory must not already exist.
type PrivateChannelConfigV1 struct {
HostDirectory string
Opened OpenedV2
Opened OpenedV1
}

// ChannelClaimErrorV1 distinguishes failures before an authorized controller
Expand Down Expand Up @@ -95,12 +95,12 @@ func PreparePrivateChannelV1(config PrivateChannelConfigV1) (*PrivateChannelV1,
}
openedPayload := config.Opened
openedPayload.Authorization = cloneAuthorizationV1(config.Opened.Authorization)
openedPayload.Endpoints = append([]EndpointV2{}, config.Opened.Endpoints...)
openedPayload.Endpoints = append([]EndpointV1{}, config.Opened.Endpoints...)
opened := EventV1{Kind: EventOpenedV1, Opened: &openedPayload}
if err := ValidateEventV1(opened); err != nil {
return nil, fmt.Errorf("prepare controlled-session channel opened event: %w", err)
}
if err := WriteEventV2(io.Discard, opened); err != nil {
if err := WriteEventV1(io.Discard, opened); err != nil {
return nil, fmt.Errorf("prepare controlled-session channel opened frame: %w", err)
}
controllerIdentity := openedPayload.Authorization.Controller.RuntimeIdentity
Expand Down Expand Up @@ -131,7 +131,7 @@ func (channel *PrivateChannelV1) SocketPath() string {

// Claim accepts the only controller connection, verifies its kernel-reported
// identity, removes the listener pathname, and sends opened as the first event.
// A failed claim is terminal; protocol v2 does not reconnect or transfer
// A failed claim is terminal; protocol v1 does not reconnect or transfer
// ownership.
func (channel *PrivateChannelV1) Claim(ctx context.Context) (*ControllerConnectionV1, error) {
if ctx == nil || ctx.Done() == nil {
Expand Down Expand Up @@ -212,7 +212,7 @@ func (connection *ControllerConnectionV1) ReadRequest(ctx context.Context) (Requ
var request RequestV1
err := withConnectionDeadlineV1(ctx, connection.connection.SetReadDeadline, func() error {
var readErr error
request, readErr = ReadRequestV2(connection.connection)
request, readErr = ReadRequestV1(connection.connection)
return readErr
})
if err != nil {
Expand All @@ -234,7 +234,7 @@ func (connection *ControllerConnectionV1) WriteEvent(ctx context.Context, event
connection.writeMu.Lock()
defer connection.writeMu.Unlock()
err := withConnectionDeadlineV1(ctx, connection.connection.SetWriteDeadline, func() error {
return WriteEventV2(connection.connection, event)
return WriteEventV1(connection.connection, event)
})
if err != nil {
closeErr := connection.Close()
Expand Down
16 changes: 8 additions & 8 deletions internal/controlledsession/channel_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestPrivateChannelV1CreatesOneControllerOwnedClaim(t *testing.T) {
}{connection: connection, err: err}
}()
client := dialPrivateChannelV1(t, socket)
opened, err := ReadEventV2(client)
opened, err := ReadEventV1(client)
if err != nil {
t.Fatal(err)
}
Expand All @@ -60,7 +60,7 @@ func TestPrivateChannelV1CreatesOneControllerOwnedClaim(t *testing.T) {
}

wantRequest := RequestV1{Kind: RequestInputV1, Bytes: []byte{0, 3, 0xff}}
if err := WriteRequestV2(client, wantRequest); err != nil {
if err := WriteRequestV1(client, wantRequest); err != nil {
t.Fatal(err)
}
request, err := result.connection.ReadRequest(ctx)
Expand All @@ -74,7 +74,7 @@ func TestPrivateChannelV1CreatesOneControllerOwnedClaim(t *testing.T) {
if err := result.connection.WriteEvent(ctx, wantEvent); err != nil {
t.Fatal(err)
}
event, err := ReadEventV2(client)
event, err := ReadEventV1(client)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -210,7 +210,7 @@ func TestPrivateChannelV1RejectsMalformedAndOversizedRequests(t *testing.T) {
{name: "oversized", data: func() []byte {
header := make([]byte, frameHeaderSizeV1)
copy(header, frameMagicV1[:])
header[4] = ProtocolVersionV2
header[4] = ProtocolVersionV1
header[5] = byte(wireRequestInputV1)
binary.BigEndian.PutUint32(header[6:], MaxFramePayloadV1+1)
return header
Expand All @@ -220,7 +220,7 @@ func TestPrivateChannelV1RejectsMalformedAndOversizedRequests(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
channel, _ := prepareCurrentIdentityChannelV1(t)
server, client := claimPrivateChannelV1(t, channel)
if _, err := ReadEventV2(client); err != nil {
if _, err := ReadEventV1(client); err != nil {
t.Fatal(err)
}
if _, err := client.Write(test.data); err != nil {
Expand Down Expand Up @@ -292,7 +292,7 @@ func TestControllerConnectionV1BoundsAndSerializesFlow(t *testing.T) {
}
got := make([]EventV1, 0, len(events))
for range events {
event, err := ReadEventV2(client)
event, err := ReadEventV1(client)
if err != nil {
t.Fatal(err)
}
Expand All @@ -315,7 +315,7 @@ func TestPreparePrivateChannelV1FreezesOpenedAuthorization(t *testing.T) {
config.Opened.Endpoints[0].Port = 9999
server, client := claimPrivateChannelV1(t, channel)
defer server.Close()
event, err := ReadEventV2(client)
event, err := ReadEventV1(client)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -352,7 +352,7 @@ func currentIdentityChannelConfigV1(t *testing.T) PrivateChannelConfigV1 {
authorization.Controller.RuntimeIdentity = identity
config := PrivateChannelConfigV1{
HostDirectory: filepath.Join(shortChannelTestDirectoryV1(t), "session"),
Opened: OpenedV2{
Opened: OpenedV1{
Authorization: authorization, Endpoints: testEndpointsV1(), Columns: 80, Rows: 24,
OutputFinalizationTimeoutMilliseconds: DefaultOutputFinalizationTimeoutMillisecondsV1,
},
Expand Down
141 changes: 141 additions & 0 deletions internal/controlledsession/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package controlledsession

import (
"context"
"errors"
"fmt"
"net"
"sync"
)

// SessionClientV1 is the controller-side owner of one claimed private session
// connection. It consumes the mandatory opened event during construction and
// then admits one event read and one request write concurrently.
type SessionClientV1 struct {
connection net.Conn
opened OpenedV1
readMu sync.Mutex
writeMu sync.Mutex
stateMu sync.RWMutex
ready bool
terminated bool
closeOnce sync.Once
closeErr error
}

func newSessionClientV1(ctx context.Context, connection net.Conn) (*SessionClientV1, error) {
if ctx == nil || ctx.Done() == nil {
return nil, fmt.Errorf("open controlled-session client: cancelable context is required")
}
if connection == nil {
return nil, fmt.Errorf("open controlled-session client: connection is required")
}
var event EventV1
err := withConnectionDeadlineV1(ctx, connection.SetReadDeadline, func() error {
var readErr error
event, readErr = ReadEventV1(connection)
return readErr
})
if err != nil {
return nil, errors.Join(fmt.Errorf("read controlled-session opened event: %w", err), connection.Close())
}
if event.Kind != EventOpenedV1 || event.Opened == nil {
return nil, errors.Join(fmt.Errorf("controlled-session first event must be opened"), connection.Close())
}
opened := *event.Opened
opened.Authorization = cloneAuthorizationV1(event.Opened.Authorization)
opened.Endpoints = append([]EndpointV1(nil), event.Opened.Endpoints...)
return &SessionClientV1{connection: connection, opened: opened}, nil
}

// Opened returns an independent copy of the immutable session authorization
// and coordinates received during the connection claim.
func (client *SessionClientV1) Opened() OpenedV1 {
opened := client.opened
opened.Authorization = cloneAuthorizationV1(client.opened.Authorization)
opened.Endpoints = append([]EndpointV1(nil), client.opened.Endpoints...)
return opened
}

// Ready reports whether the host has verified workload startup and activated
// the lifecycle. It becomes true only after ReadEvent returns the one ready
// event and never becomes false.
func (client *SessionClientV1) Ready() bool {
client.stateMu.RLock()
defer client.stateMu.RUnlock()
return client.ready
}

func (client *SessionClientV1) ReadEvent(ctx context.Context) (EventV1, error) {
if ctx == nil || ctx.Done() == nil {
return EventV1{}, fmt.Errorf("read controlled-session client event: cancelable context is required")
}
client.readMu.Lock()
defer client.readMu.Unlock()
var event EventV1
err := withConnectionDeadlineV1(ctx, client.connection.SetReadDeadline, func() error {
var readErr error
event, readErr = ReadEventV1(client.connection)
return readErr
})
if err != nil {
return EventV1{}, errors.Join(fmt.Errorf("read controlled-session client event: %w", err), client.Close())
}
if event.Kind == EventOpenedV1 {
return EventV1{}, errors.Join(fmt.Errorf("read controlled-session client event: opened may appear only once"), client.Close())
}
if event.Kind == EventReadyV1 {
client.stateMu.Lock()
if client.ready {
client.stateMu.Unlock()
return EventV1{}, errors.Join(fmt.Errorf("read controlled-session client event: ready may appear only once"), client.Close())
}
client.ready = true
client.stateMu.Unlock()
}
if event.Kind == EventTerminatedV1 {
client.stateMu.Lock()
client.terminated = true
client.stateMu.Unlock()
}
return event, nil
}

func (client *SessionClientV1) WriteRequest(ctx context.Context, request RequestV1) error {
if ctx == nil || ctx.Done() == nil {
return fmt.Errorf("write controlled-session client request: cancelable context is required")
}
if err := ValidateRequestV1(request); err != nil {
return err
}
client.writeMu.Lock()
defer client.writeMu.Unlock()
client.stateMu.RLock()
ready := client.ready
terminated := client.terminated
client.stateMu.RUnlock()
if request.Kind == RequestAcknowledgeTerminatedV1 {
if !terminated {
return fmt.Errorf("write controlled-session client request: terminal result has not been received")
}
} else if terminated {
return fmt.Errorf("write controlled-session client request: session is terminated")
}
if request.Kind != RequestAcknowledgeTerminatedV1 && !ready {
return fmt.Errorf("write controlled-session client request: session is not ready")
}
err := withConnectionDeadlineV1(ctx, client.connection.SetWriteDeadline, func() error {
return WriteRequestV1(client.connection, request)
})
if err != nil {
return errors.Join(fmt.Errorf("write controlled-session client request: %w", err), client.Close())
}
return nil
}

func (client *SessionClientV1) Close() error {
client.closeOnce.Do(func() {
client.closeErr = client.connection.Close()
})
return client.closeErr
}
26 changes: 26 additions & 0 deletions internal/controlledsession/client_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//go:build linux

package controlledsession

import (
"context"
"fmt"
"net"
"path/filepath"
)

// DialSessionClientV1 claims the Linux lease-private socket exposed only to
// the controller and consumes its mandatory opened event.
func DialSessionClientV1(ctx context.Context, socketPath string) (*SessionClientV1, error) {
if ctx == nil || ctx.Done() == nil {
return nil, fmt.Errorf("dial controlled-session client: cancelable context is required")
}
if !filepath.IsAbs(socketPath) || filepath.Clean(socketPath) != socketPath {
return nil, fmt.Errorf("dial controlled-session client requires an absolute clean socket path")
}
connection, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
if err != nil {
return nil, fmt.Errorf("dial controlled-session socket: %w", err)
}
return newSessionClientV1(ctx, connection)
}
Loading
Loading