Add gated fabric session websocket endpoint

This commit is contained in:
2026-05-16 00:16:13 +03:00
parent 01f28693f5
commit 8a972ea68f
3 changed files with 193 additions and 2 deletions
@@ -19,6 +19,7 @@ import (
"time" "time"
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority"
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -26,6 +27,7 @@ type ProductionEnvelopeObserver func(context.Context, ProductionEnvelopeObservat
type ProductionEnvelopeDelivery func(context.Context, ProductionEnvelope) error type ProductionEnvelopeDelivery func(context.Context, ProductionEnvelope) error
type ProductionForwardLogger func(ProductionForwardLogEntry) type ProductionForwardLogger func(ProductionForwardLogEntry)
type FabricServiceChannelAccessLogger func(FabricServiceChannelAccessLogEntry) type FabricServiceChannelAccessLogger func(FabricServiceChannelAccessLogEntry)
type FabricSessionEventLogger func(FabricSessionEventLogEntry)
type RemoteWorkspaceFrameSink interface { type RemoteWorkspaceFrameSink interface {
AcceptRemoteWorkspaceFrameBatchProbe(context.Context, RemoteWorkspaceFrameBatchDelivery) (RemoteWorkspaceFrameBatchDeliveryReceipt, error) AcceptRemoteWorkspaceFrameBatchProbe(context.Context, RemoteWorkspaceFrameBatchDelivery) (RemoteWorkspaceFrameBatchDeliveryReceipt, error)
} }
@@ -81,6 +83,8 @@ type Server struct {
BackendProxyBaseURL string BackendProxyBaseURL string
ClusterAuthorityPublicKey string ClusterAuthorityPublicKey string
ServiceChannelIntrospection bool ServiceChannelIntrospection bool
FabricSessionEnabled bool
FabricSessionLogger FabricSessionEventLogger
} }
func (s Server) Handler() http.Handler { func (s Server) Handler() http.Handler {
@@ -88,6 +92,9 @@ func (s Server) Handler() http.Handler {
mux.HandleFunc("/mesh/v1/health", s.handleHealth) mux.HandleFunc("/mesh/v1/health", s.handleHealth)
mux.HandleFunc("/mesh/v1/forward", s.handleForward) mux.HandleFunc("/mesh/v1/forward", s.handleForward)
mux.HandleFunc("/mesh/v1/synthetic/probe", s.handleSyntheticProbe) mux.HandleFunc("/mesh/v1/synthetic/probe", s.handleSyntheticProbe)
if s.FabricSessionEnabled {
mux.HandleFunc("/mesh/v1/fabric/session/ws", s.handleFabricSessionWebSocket)
}
if s.RemoteWorkspaceFrameSink != nil { if s.RemoteWorkspaceFrameSink != nil {
mux.HandleFunc("/mesh/v1/remote-workspace/adapter-sessions/", s.handleRemoteWorkspaceAdapterSessionControl) mux.HandleFunc("/mesh/v1/remote-workspace/adapter-sessions/", s.handleRemoteWorkspaceAdapterSessionControl)
} }
@@ -187,6 +194,84 @@ func (s Server) handleRemoteWorkspaceAdapterSessionSnapshot(w http.ResponseWrite
_ = json.NewEncoder(w).Encode(snapshotter.SnapshotAdapterSessions(includeTerminal, limit, time.Now().UTC())) _ = json.NewEncoder(w).Encode(snapshotter.SnapshotAdapterSessions(includeTerminal, limit, time.Now().UTC()))
} }
type FabricSessionEventLogEntry struct {
Event string `json:"event"`
ClusterID string `json:"cluster_id,omitempty"`
NodeID string `json:"node_id,omitempty"`
SessionEvent fabricproto.SessionEventType `json:"session_event,omitempty"`
StreamID uint64 `json:"stream_id,omitempty"`
Sequence uint64 `json:"sequence,omitempty"`
TrafficClass fabricproto.TrafficClass `json:"traffic_class,omitempty"`
RemoteAddr string `json:"remote_addr,omitempty"`
Reason string `json:"reason,omitempty"`
ObservedAt time.Time `json:"observed_at"`
}
func (s Server) handleFabricSessionWebSocket(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
s.logFabricSession(FabricSessionEventLogEntry{
Event: "fabric_session_websocket_opened",
ClusterID: s.Local.ClusterID,
NodeID: s.Local.NodeID,
RemoteAddr: r.RemoteAddr,
ObservedAt: time.Now().UTC(),
})
loop := fabricproto.TransportLoop{
Session: fabricproto.NewSession(fabricproto.SessionConfig{}),
OnEvent: func(event fabricproto.SessionEvent) ([]fabricproto.Frame, error) {
s.logFabricSession(FabricSessionEventLogEntry{
Event: "fabric_session_event",
ClusterID: s.Local.ClusterID,
NodeID: s.Local.NodeID,
SessionEvent: event.Type,
StreamID: event.StreamID,
Sequence: event.Sequence,
TrafficClass: event.TrafficClass,
RemoteAddr: r.RemoteAddr,
ObservedAt: time.Now().UTC(),
})
return nil, nil
},
}
err = loop.RunWebSocket(r.Context(), conn, fabricproto.WebSocketTransportConfig{})
if err != nil && !errors.Is(err, context.Canceled) {
s.logFabricSession(FabricSessionEventLogEntry{
Event: "fabric_session_websocket_closed",
ClusterID: s.Local.ClusterID,
NodeID: s.Local.NodeID,
RemoteAddr: r.RemoteAddr,
Reason: err.Error(),
ObservedAt: time.Now().UTC(),
})
return
}
s.logFabricSession(FabricSessionEventLogEntry{
Event: "fabric_session_websocket_closed",
ClusterID: s.Local.ClusterID,
NodeID: s.Local.NodeID,
RemoteAddr: r.RemoteAddr,
ObservedAt: time.Now().UTC(),
})
}
func (s Server) logFabricSession(entry FabricSessionEventLogEntry) {
if s.FabricSessionLogger != nil {
s.FabricSessionLogger(entry)
}
}
func (s Server) handleRemoteWorkspaceAdapterSessionMailbox(w http.ResponseWriter, r *http.Request) { func (s Server) handleRemoteWorkspaceAdapterSessionMailbox(w http.ResponseWriter, r *http.Request) {
reader, ok := s.RemoteWorkspaceFrameSink.(RemoteWorkspaceFrameSinkSessionMailbox) reader, ok := s.RemoteWorkspaceFrameSink.(RemoteWorkspaceFrameSinkSessionMailbox)
if !ok { if !ok {
@@ -19,6 +19,7 @@ import (
"time" "time"
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority"
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -74,6 +75,80 @@ func TestMeshForwardingDisabled(t *testing.T) {
} }
} }
func TestFabricSessionWebSocketDisabledByDefault(t *testing.T) {
server := httptest.NewServer(Server{Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}}.Handler())
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws"
_, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err == nil {
t.Fatal("dial fabric session unexpectedly succeeded")
}
if resp == nil || resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %v err=%v, want 404", resp, err)
}
}
func TestFabricSessionWebSocketPingPongAndEvents(t *testing.T) {
var events []FabricSessionEventLogEntry
server := httptest.NewServer(Server{
Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"},
FabricSessionEnabled: true,
FabricSessionLogger: func(entry FabricSessionEventLogEntry) {
events = append(events, entry)
},
}.Handler())
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial fabric session websocket: %v", err)
}
defer conn.Close()
writeMeshFabricFrame(t, conn, fabricproto.Frame{Type: fabricproto.FramePing, Sequence: 17, Payload: []byte("probe")})
pong := readMeshFabricFrame(t, conn)
if pong.Type != fabricproto.FramePong || pong.Sequence != 17 || string(pong.Payload) != "probe" {
t.Fatalf("pong = %+v", pong)
}
if len(events) < 2 || events[0].Event != "fabric_session_websocket_opened" || events[1].SessionEvent != fabricproto.SessionEventPing {
t.Fatalf("events = %+v", events)
}
}
func TestFabricSessionWebSocketOpenStreamDataAck(t *testing.T) {
server := httptest.NewServer(Server{
Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"},
FabricSessionEnabled: true,
}.Handler())
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial fabric session websocket: %v", err)
}
defer conn.Close()
writeMeshFabricFrame(t, conn, fabricproto.Frame{
Type: fabricproto.FrameOpenStream,
TrafficClass: fabricproto.TrafficClassInteractive,
StreamID: 9,
})
writeMeshFabricFrame(t, conn, fabricproto.Frame{
Type: fabricproto.FrameData,
TrafficClass: fabricproto.TrafficClassInteractive,
StreamID: 9,
Sequence: 3,
Payload: []byte("input"),
})
ack := readMeshFabricFrame(t, conn)
if ack.Type != fabricproto.FrameAck || ack.StreamID != 9 || ack.Sequence != 3 {
t.Fatalf("ack = %+v", ack)
}
}
func TestMeshForwardingGateEnabledStillHasNoProductionRuntime(t *testing.T) { func TestMeshForwardingGateEnabledStillHasNoProductionRuntime(t *testing.T) {
local := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"} local := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"}
server := httptest.NewServer(Server{ server := httptest.NewServer(Server{
@@ -96,6 +171,36 @@ func TestMeshForwardingGateEnabledStillHasNoProductionRuntime(t *testing.T) {
} }
} }
func writeMeshFabricFrame(t *testing.T, conn *websocket.Conn, frame fabricproto.Frame) {
t.Helper()
encoded, err := fabricproto.MarshalFrame(frame)
if err != nil {
t.Fatalf("marshal fabric frame: %v", err)
}
if err := conn.WriteMessage(websocket.BinaryMessage, encoded); err != nil {
t.Fatalf("write fabric websocket frame: %v", err)
}
}
func readMeshFabricFrame(t *testing.T, conn *websocket.Conn) fabricproto.Frame {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatalf("set websocket read deadline: %v", err)
}
messageType, payload, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read fabric websocket frame: %v", err)
}
if messageType != websocket.BinaryMessage {
t.Fatalf("message type = %d, want binary", messageType)
}
frame, err := fabricproto.UnmarshalFrame(payload, fabricproto.DefaultMaxPayload)
if err != nil {
t.Fatalf("unmarshal fabric websocket frame: %v", err)
}
return frame
}
func TestMeshForwardingGateDeliversFabricControlAtDestination(t *testing.T) { func TestMeshForwardingGateDeliversFabricControlAtDestination(t *testing.T) {
local := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-c"} local := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-c"}
var events []ProductionForwardLogEntry var events []ProductionForwardLogEntry
@@ -256,8 +256,9 @@ Deliverables:
### Stage FNP-3: WebSocket/TCP Compatibility Transport ### Stage FNP-3: WebSocket/TCP Compatibility Transport
Status: started with a transport-neutral `io.Reader`/`io.Writer` frame loop and Status: started with a transport-neutral `io.Reader`/`io.Writer` frame loop,
WebSocket frame adapter in `agents/rap-node-agent/internal/fabricproto`. WebSocket frame adapter in `agents/rap-node-agent/internal/fabricproto`, and a
gated mesh smoke endpoint at `/mesh/v1/fabric/session/ws`.
Deliverables: Deliverables: