Skip to content

Commit 4045dd9

Browse files
author
gluck
committed
plan: http gateway rev2 (peer mesh) + validated tunnel spike
- plans/http-gateway.md: rev 2, peer architecture. Control plane routes through the local daemon; data plane goes direct to the owning node. The CLI stays dumb and holds no credentials. - internal/gateway: the tunnel is a byte pump between a websocket and the angelus socket. Proven with an unmodified jkrpc client: calls, notifications, 512KiB frames, and a policy refusal that does not sever the connection. - api/transport: https/wss scheme dials the tunnel and presents it as a net.Conn, so jkrpc sees the stream it always saw. Caller is the DAEMON. Security review returned five CRITICALs, three of which are pre-existing holes the gateway would have exposed rather than gateway bugs. Recorded in the plan; C3 (agency methods unguarded on hub sockets) blocks the rest.
1 parent 6a650ef commit 4045dd9

8 files changed

Lines changed: 1188 additions & 1 deletion

File tree

‎api/transport/transport.go‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,51 @@
11
// Package transport defines connection abstractions between components.
2-
// Endpoints use URI syntax (unix:// or tcp://).
2+
// Endpoints use URI syntax (unix://, tcp://, or https:// for a remote
3+
// daemon reached through the HTTP gateway).
34
package transport
45

56
import (
7+
"context"
68
"fmt"
79
"net"
10+
"net/http"
11+
"strings"
12+
"time"
813

14+
"github.com/coder/websocket"
915
"github.com/jack-work/jkrpc"
1016
)
1117

1218
// Endpoint describes how to reach a figaro component (angelus or figaro agent).
1319
type Endpoint struct {
1420
Scheme string `json:"scheme"`
1521
Address string `json:"address"`
22+
// Bearer is the credential presented when Scheme is http(s). It is
23+
// carried on the Endpoint rather than looked up at dial time so that
24+
// exactly one place -- the CLI's origin resolver -- decides which secret
25+
// belongs to which host, and nothing below this line reads a keyring.
26+
//
27+
// It is never serialized: a credential has no business in the JSON an
28+
// endpoint travels as (attach responses hand endpoints back to clients).
29+
Bearer string `json:"-"`
30+
}
31+
32+
// Remote reports whether this endpoint leaves the machine. A remote daemon is
33+
// never auto-started, and pid bindings are meaningless against one.
34+
func (e Endpoint) Remote() bool {
35+
return e.Scheme == "http" || e.Scheme == "https"
36+
}
37+
38+
// HTTPEndpoint constructs a gateway endpoint from a base URL.
39+
func HTTPEndpoint(rawURL, bearer string) (Endpoint, error) {
40+
u := strings.TrimRight(rawURL, "/")
41+
switch {
42+
case strings.HasPrefix(u, "https://"):
43+
return Endpoint{Scheme: "https", Address: strings.TrimPrefix(u, "https://"), Bearer: bearer}, nil
44+
case strings.HasPrefix(u, "http://"):
45+
return Endpoint{Scheme: "http", Address: strings.TrimPrefix(u, "http://"), Bearer: bearer}, nil
46+
default:
47+
return Endpoint{}, fmt.Errorf("origin %q: want an http:// or https:// URL", rawURL)
48+
}
1649
}
1750

1851
// UnixEndpoint is a convenience constructor for unix socket endpoints.
@@ -47,11 +80,60 @@ func DialRaw(ep Endpoint) (net.Conn, error) {
4780
return net.Dial("unix", ep.Address)
4881
case "tcp":
4982
return net.Dial("tcp", ep.Address)
83+
case "http", "https":
84+
return dialGateway(ep)
5085
default:
5186
return nil, fmt.Errorf("unsupported transport scheme %q", ep.Scheme)
5287
}
5388
}
5489

90+
// gatewayHandshake bounds the upgrade, not the session. Once the tunnel is
91+
// open it lives as long as the caller wants: a `listen` may hold it for hours.
92+
const gatewayHandshake = 30 * time.Second
93+
94+
// dialGateway opens the tunnel: a WebSocket to /v1/socket, presented as a
95+
// net.Conn so jkrpc sees exactly the stream it always saw. The gateway on
96+
// the far side copies these bytes to the angelus socket without parsing
97+
// them, so every method -- including ones added after this code was written
98+
// -- works across it.
99+
func dialGateway(ep Endpoint) (net.Conn, error) {
100+
ws := "wss"
101+
if ep.Scheme == "http" {
102+
ws = "ws"
103+
}
104+
hdr := http.Header{}
105+
if ep.Bearer != "" {
106+
hdr.Set("Authorization", "Bearer "+ep.Bearer)
107+
}
108+
109+
// The handshake gets a deadline; the resulting conn does not inherit it,
110+
// which is why the context is detached below.
111+
ctx, cancel := context.WithTimeout(context.Background(), gatewayHandshake)
112+
defer cancel()
113+
114+
c, resp, err := websocket.Dial(ctx, ws+"://"+ep.Address+"/v1/socket",
115+
&websocket.DialOptions{HTTPHeader: hdr})
116+
if err != nil {
117+
if resp != nil {
118+
switch resp.StatusCode {
119+
case http.StatusUnauthorized, http.StatusForbidden:
120+
return nil, fmt.Errorf("gateway %s refused this credential (%s): check `figaro origin`",
121+
ep.Address, resp.Status)
122+
case http.StatusNotFound:
123+
return nil, fmt.Errorf("gateway %s has no tunnel at /v1/socket (%s): is that a figaro gateway?",
124+
ep.Address, resp.Status)
125+
}
126+
return nil, fmt.Errorf("dial gateway %s: %s: %w", ep.Address, resp.Status, err)
127+
}
128+
return nil, fmt.Errorf("dial gateway %s: %w", ep.Address, err)
129+
}
130+
// No read limit, for the same reason the gateway sets none: a page
131+
// carrying a large tool output is a legitimate frame.
132+
c.SetReadLimit(-1)
133+
// context.Background, deliberately: the conn outlives the handshake.
134+
return websocket.NetConn(context.Background(), c, websocket.MessageBinary), nil
135+
}
136+
55137
// Listen creates a net.Listener for an endpoint.
56138
func Listen(ep Endpoint) (net.Listener, error) {
57139
switch ep.Scheme {

‎go.mod‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ require (
3131
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
3232
github.com/charmbracelet/bubbletea v1.3.6
3333
github.com/charmbracelet/huh v1.0.0
34+
github.com/coder/websocket v1.8.15
3435
github.com/creack/pty v1.1.24
3536
github.com/zalando/go-keyring v0.2.8
3637
)

‎go.sum‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
7676
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
7777
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
7878
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
79+
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
80+
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
7981
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
8082
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
8183
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=

‎internal/gateway/filter.go‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package gateway
2+
3+
// THE FILTER: the one place the tunnel looks at what it carries.
4+
//
5+
// A pure pump cannot enforce a method policy, so when a policy is configured
6+
// the client->daemon direction runs through here instead. The rule that keeps
7+
// this honest: DECODE TO CHECK, FORWARD THE ORIGINAL. The bytes that reach
8+
// the daemon are the bytes the client sent, never a re-encoding, so no field
9+
// this gateway does not know about can be dropped on the way through.
10+
11+
import (
12+
"bufio"
13+
"encoding/json"
14+
"fmt"
15+
"io"
16+
)
17+
18+
// maxFrame bounds one NDJSON line the filter will buffer. A frame larger
19+
// than this is refused rather than truncated: truncation would change the
20+
// meaning of what we forward, which is the one thing this file promises not
21+
// to do.
22+
const maxFrame = 8 << 20 // 8 MiB
23+
24+
// Decision is a filter's verdict on one method.
25+
type Decision struct {
26+
Allow bool
27+
Reason string
28+
}
29+
30+
// Filter checks each request's method before forwarding it.
31+
type Filter struct {
32+
// Check is consulted once per request frame. Notifications and responses
33+
// from the client (there are none in practice) pass untouched.
34+
Check func(method string) Decision
35+
}
36+
37+
// frame is the sliver of JSON-RPC the filter needs. Everything else in the
38+
// message is deliberately not modelled: this struct exists to read a method
39+
// name and an id, not to represent the protocol.
40+
type frame struct {
41+
Method string `json:"method"`
42+
ID *json.RawMessage `json:"id"`
43+
}
44+
45+
// Pump copies src to dst, refusing frames the policy denies. A denied
46+
// request is answered on the CLIENT's stream with a JSON-RPC error carrying
47+
// its id, so the caller gets a verdict rather than a hang.
48+
//
49+
// src must be the client side (we write refusals back to it); dst the daemon.
50+
func (f *Filter) Pump(src io.ReadWriter, dst io.Writer) error {
51+
br := bufio.NewReaderSize(src, 64<<10)
52+
// A json.Decoder would be tidier, but it buffers past the value it
53+
// returns, and we must forward the EXACT bytes of each message. Reading
54+
// discrete NDJSON lines is what makes verbatim forwarding possible.
55+
for {
56+
line, err := readLine(br, maxFrame)
57+
if err != nil {
58+
return err
59+
}
60+
if len(line) == 0 {
61+
continue
62+
}
63+
64+
var fr frame
65+
if json.Unmarshal(line, &fr) != nil {
66+
// Unparseable: let the daemon reject it in its own words. We are
67+
// a policy check, not a validator, and inventing an error here
68+
// would put this gateway in the business of defining the wire.
69+
if _, werr := dst.Write(line); werr != nil {
70+
return werr
71+
}
72+
continue
73+
}
74+
75+
if fr.Method != "" && f.Check != nil {
76+
if d := f.Check(fr.Method); !d.Allow {
77+
if fr.ID != nil {
78+
if werr := writeRefusal(src, *fr.ID, fr.Method, d.Reason); werr != nil {
79+
return werr
80+
}
81+
}
82+
continue // never reaches the daemon
83+
}
84+
}
85+
86+
if _, werr := dst.Write(line); werr != nil {
87+
return werr
88+
}
89+
}
90+
}
91+
92+
// readLine returns one NDJSON line INCLUDING its terminating newline, so the
93+
// bytes forwarded are byte-for-byte what arrived.
94+
func readLine(br *bufio.Reader, limit int) ([]byte, error) {
95+
var buf []byte
96+
for {
97+
chunk, err := br.ReadSlice('\n')
98+
buf = append(buf, chunk...)
99+
if len(buf) > limit {
100+
return nil, fmt.Errorf("frame exceeds %d bytes", limit)
101+
}
102+
if err == bufio.ErrBufferFull {
103+
continue
104+
}
105+
if err != nil {
106+
if len(buf) > 0 && err == io.EOF {
107+
return buf, nil
108+
}
109+
return nil, err
110+
}
111+
return buf, nil
112+
}
113+
}
114+
115+
// refusalCode is the JSON-RPC error code for a method the policy refused.
116+
// It sits in the -32000..-32099 application range and is distinct from the
117+
// daemon's own codes so a client can tell "the gateway said no" from "the
118+
// daemon said no".
119+
const refusalCode = -32040
120+
121+
func writeRefusal(w io.Writer, id json.RawMessage, method, reason string) error {
122+
if reason == "" {
123+
reason = "refused by gateway policy"
124+
}
125+
msg := struct {
126+
JSONRPC string `json:"jsonrpc"`
127+
ID json.RawMessage `json:"id"`
128+
Error struct {
129+
Code int `json:"code"`
130+
Message string `json:"message"`
131+
} `json:"error"`
132+
}{JSONRPC: "2.0", ID: id}
133+
msg.Error.Code = refusalCode
134+
msg.Error.Message = fmt.Sprintf("%s: %s", method, reason)
135+
136+
b, err := json.Marshal(msg)
137+
if err != nil {
138+
return err
139+
}
140+
b = append(b, '\n')
141+
_, err = w.Write(b)
142+
return err
143+
}

0 commit comments

Comments
 (0)