mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
877 lines
34 KiB
Go
877 lines
34 KiB
Go
package zip
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/zap-proto/http"
|
|
)
|
|
|
|
// Transport is decomplected: ONE fiber handler, served on any number of
|
|
// addresses, where the transport is a VALUE (the address scheme) — never a
|
|
// method name. There is ONE verb, Listen, and the scheme selects how the
|
|
// bytes are terminated/serialized: ZAP, HTTP, or any future protocol you
|
|
// RegisterTransport. Same handler, middleware, auth, and error handling over
|
|
// every transport — your routes ARE the surface, no per-endpoint wiring.
|
|
//
|
|
// app.Listen(":9653") // ZAP (the primary; bare addr = ZAP)
|
|
// app.Listen(":9653", "http://:8080") // ZAP + HTTP in one call
|
|
// app.Listen("http://:8080") // HTTP only
|
|
// app.Listen("/run/hanzo/app.sock") // ZAP on a unix socket
|
|
// app.Listen("quic://:443") // any RegisterTransport'd proto
|
|
//
|
|
// This mirrors net.Listen(network, addr): the network is a value, not a
|
|
// ListenTCP/ListenUDP method explosion. The scheme names the PROTOCOL and the
|
|
// address names where it is spoken — a path is a unix socket, a host:port is
|
|
// tcp — so there is never a second scheme for the same wire.
|
|
|
|
// Server is a running transport listener bound to one address. Both
|
|
// zap-proto/http.Server and the built-in HTTP server satisfy it, as does any
|
|
// custom transport.
|
|
type Server interface {
|
|
ListenAndServe() error
|
|
Close() error
|
|
}
|
|
|
|
// Client is the call side of a transport: anything that can complete a
|
|
// request. *http.Transport and *fasthttp.HostClient already satisfy it, so
|
|
// giving a scheme a Dial is a one-liner.
|
|
type Client interface {
|
|
Do(req *fasthttp.Request, resp *fasthttp.Response) error
|
|
}
|
|
|
|
// ContextClient is a Client that can be cancelled mid-flight and bounded by a
|
|
// deadline. *http.Transport implements it; a Client that does not is still
|
|
// usable, it just cannot be stopped once the request is on the wire.
|
|
type ContextClient interface {
|
|
DoContext(ctx context.Context, req *fasthttp.Request, resp *fasthttp.Response) error
|
|
}
|
|
|
|
var _ ContextClient = (*http.Transport)(nil)
|
|
|
|
// do sends req over client under ctx. A client without DoContext gets the
|
|
// ctx checked once, before the wire.
|
|
func do(ctx context.Context, client Client, req *fasthttp.Request, resp *fasthttp.Response) error {
|
|
if cc, ok := client.(ContextClient); ok {
|
|
return cc.DoContext(ctx, req, resp)
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return context.Cause(ctx)
|
|
}
|
|
return client.Do(req, resp)
|
|
}
|
|
|
|
// ended reports whether err is a ctx ending rather than a far-end failure. The
|
|
// conn's deadline can fire a moment before ctx's own timer, so ctx.Err() alone
|
|
// can still be nil here.
|
|
func ended(err error) bool {
|
|
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
|
}
|
|
|
|
// Transport is one address scheme in both directions: Serve terminates bytes
|
|
// arriving here, Dial originates bytes going there. They are the same concept
|
|
// — a wire — so they live in one value rather than two registries that drift.
|
|
// A scheme may leave either half nil; Listen and Proxy each say which they
|
|
// needed.
|
|
type Transport struct {
|
|
Serve func(addr string, handler fasthttp.RequestHandler) Server
|
|
Dial func(addr string) Client
|
|
}
|
|
|
|
// DefaultScheme is the transport a bare address (no "scheme://") uses. ZAP is
|
|
// the primary transport (TLS 1.3 + post-quantum, gRPC's replacement), so the
|
|
// path of least resistance is ZAP-native.
|
|
const DefaultScheme = "zap"
|
|
|
|
var (
|
|
transportsMu sync.RWMutex
|
|
transports = map[string]Transport{
|
|
"zap": {
|
|
Serve: func(addr string, h fasthttp.RequestHandler) Server {
|
|
return &zapServer{addr: addr, srv: &http.Server{Network: NetworkOf(addr), Addr: addr, Handler: overZAP(h)}}
|
|
},
|
|
Dial: func(addr string) Client { return http.Dial(NetworkOf(addr), addr) },
|
|
},
|
|
"http": {
|
|
Serve: func(addr string, h fasthttp.RequestHandler) Server {
|
|
return &httpServer{addr: addr, srv: &fasthttp.Server{Handler: h}}
|
|
},
|
|
Dial: func(addr string) Client {
|
|
return &fasthttp.HostClient{Addr: withPort(addr, "80"), DisablePathNormalizing: true}
|
|
},
|
|
},
|
|
// DisablePathNormalizing on both dialers: the address a caller BUILT is the
|
|
// address that goes out. fasthttp's default is to re-read the path —
|
|
// decoding %2F and resolving ".." — which turns a percent-encoded argument
|
|
// back into path structure and lets one operation's input address another
|
|
// (see Remote.do). HostClient.doNonNilReqResp assigns the request's flag
|
|
// FROM this field, so on these two transports this is the only setting that
|
|
// is read; the request-level one covers the rest.
|
|
//
|
|
// Dial-only: terminating TLS is the ingress's job, so nothing in the
|
|
// fleet serves https directly — but reaching something that does (an
|
|
// api.* host, a third party) is an ordinary Proxy, and a CLI talking to
|
|
// a published API is the same call. One registry entry, both directions
|
|
// covered by the half that exists.
|
|
"https": {
|
|
Dial: func(addr string) Client {
|
|
return &fasthttp.HostClient{Addr: withPort(addr, "443"), IsTLS: true, DisablePathNormalizing: true}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
// viaZAP marks a request the ZAP transport carried. ZAP frames a request and its
|
|
// reply and has no connection to hand over, so its server never runs a hijack:
|
|
// an upgrade relayed over it would answer an empty 200 and hold the plugin for
|
|
// good.
|
|
type viaZAP struct{}
|
|
|
|
func overZAP(h fasthttp.RequestHandler) fasthttp.RequestHandler {
|
|
return func(rc *fasthttp.RequestCtx) {
|
|
rc.SetUserValue(viaZAP{}, true)
|
|
h(rc)
|
|
}
|
|
}
|
|
|
|
func carriedByZAP(rc *fasthttp.RequestCtx) bool {
|
|
v, _ := rc.UserValue(viaZAP{}).(bool)
|
|
return v
|
|
}
|
|
|
|
// withPort defaults the port of a tcp address, because a URL usually omits it
|
|
// and a dialer never can. A unix path is returned untouched.
|
|
func withPort(addr, port string) string {
|
|
if NetworkOf(addr) == "unix" || strings.LastIndex(addr, ":") > strings.LastIndex(addr, "]") {
|
|
return addr
|
|
}
|
|
return addr + ":" + port
|
|
}
|
|
|
|
// RegisterTransport adds (or replaces) a transport keyed by address scheme, so
|
|
// any future protocol slots into both Listen and Proxy with ZERO change to
|
|
// either API. Call before Listen or Proxy.
|
|
//
|
|
// zip.RegisterTransport("quic", zip.Transport{
|
|
// Serve: func(addr string, h fasthttp.RequestHandler) zip.Server {
|
|
// return myquic.NewServer(addr, h)
|
|
// },
|
|
// Dial: func(addr string) zip.Client { return myquic.NewClient(addr) },
|
|
// })
|
|
func RegisterTransport(scheme string, t Transport) {
|
|
transportsMu.Lock()
|
|
defer transportsMu.Unlock()
|
|
transports[scheme] = t
|
|
}
|
|
|
|
// NetworkOf reads the plumbing off the address shape, the way the address
|
|
// already tells you: a filesystem path is a unix socket, anything else is
|
|
// host:port. The scheme names the PROTOCOL (zap) and the address names where
|
|
// it is spoken, so there is no second scheme for the same wire.
|
|
//
|
|
// zap://:9653 -> tcp
|
|
// zap://billing.hanzo.svc:9653 -> tcp
|
|
// zap:///run/hanzo/billing.sock -> unix
|
|
// /run/hanzo/billing.sock -> unix (bare address, DefaultScheme)
|
|
// @hanzo-billing -> unix (Linux abstract socket)
|
|
//
|
|
// It is exported because it is a RULE and not an implementation detail: anything
|
|
// that dials one of these addresses has to make the same reading, and a second
|
|
// copy of it is a place where a socket path becomes a hostname. [zapmcp.Dial]
|
|
// wants the network as a value, so a client reaching an app's MCP door asks
|
|
// here rather than re-deriving it.
|
|
func NetworkOf(addr string) string {
|
|
if strings.HasPrefix(addr, "/") || strings.HasPrefix(addr, "./") || strings.HasPrefix(addr, "@") {
|
|
return "unix"
|
|
}
|
|
return "tcp"
|
|
}
|
|
|
|
// transportFor resolves a raw address to its scheme, bare address, and wire.
|
|
func transportFor(raw string) (scheme, addr string, t Transport, err error) {
|
|
scheme, addr = splitScheme(raw)
|
|
transportsMu.RLock()
|
|
t, ok := transports[scheme]
|
|
transportsMu.RUnlock()
|
|
if !ok {
|
|
return scheme, addr, t, fmt.Errorf("zip: no transport registered for scheme %q (address %q)", scheme, raw)
|
|
}
|
|
return scheme, addr, t, nil
|
|
}
|
|
|
|
// prepare installs the deferred projections of the typed-op registry (the
|
|
// OpenAPI document, the GraphQL schema, the MCP tool surface, the op-call
|
|
// plane) before any listener starts, so every transport exposes the same
|
|
// routes. Runs exactly once even if Listen is called again.
|
|
func (a *App) prepare() {
|
|
a.prepareOnce.Do(func() {
|
|
a.installOpenAPIRoutes()
|
|
a.installGraph()
|
|
a.installMCP()
|
|
a.installCallPlane()
|
|
a.installPluginRoute()
|
|
a.prepared.Store(true)
|
|
})
|
|
}
|
|
|
|
// Listen is [Serve] followed by [Host.Wait]: it starts serving and blocks until
|
|
// the listeners stop. The address scheme selects the transport; a bare address
|
|
// uses ZAP (DefaultScheme).
|
|
//
|
|
// It is the TERMINAL spelling, and the difference from Serve is deliberate
|
|
// rather than an oversight: Listen hands back no host, so a program started this
|
|
// way cannot later be changed with [Host.Include] or [Host.Drop]. That is the
|
|
// right shape for a main whose program is fixed —
|
|
//
|
|
// log.Fatal(app.Listen(":8080"))
|
|
//
|
|
// — and the wrong one the moment anything loads at run time, which is why Serve
|
|
// exists and why this is one line of sugar over it rather than a second way to
|
|
// serve. There is one serving mechanism; this is its no-handle form.
|
|
func (a *App) Listen(addrs ...string) error {
|
|
h, err := Serve(a, addrs...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return h.Wait()
|
|
}
|
|
|
|
// listenOn serves an ALREADY BUILT program. [Serve] uses it after installing
|
|
// generation 0, so the build happens once and in one place.
|
|
func (a *App) listenOn(addrs []string) error {
|
|
// The handler resolves the generation PER REQUEST, so a later Include or
|
|
// Drop is picked up without re-binding a listener, and a request that has
|
|
// already arrived finishes on the generation it arrived under.
|
|
h := a.pinned()
|
|
|
|
// Asked BEFORE anything binds, so a door that cannot be honoured costs a
|
|
// boot and not a deployment: nothing is listening to take traffic that would
|
|
// silently miss half its tools.
|
|
if err := a.checkMCP(); err != nil {
|
|
return err
|
|
}
|
|
|
|
servers := make([]Server, 0, len(addrs))
|
|
unbind := make([]func(), 0, len(addrs))
|
|
for _, raw := range addrs {
|
|
scheme, addr, t, err := transportFor(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if t.Serve == nil {
|
|
return fmt.Errorf("zip: transport %q cannot serve (dial-only)", scheme)
|
|
}
|
|
if err := makeSocketDir(addr); err != nil {
|
|
return err
|
|
}
|
|
s := t.Serve(addr, h)
|
|
// Push the App's per-conn wire tuning (ReadBufferSize / WriteBufferSize /
|
|
// Concurrency) into the transport's fasthttp.Server. Without this the
|
|
// built-in HTTP transport constructs a bare fasthttp.Server whose
|
|
// ReadBufferSize defaults to 4 KiB — capping total request-header size and
|
|
// returning 431 (Request Header Fields Too Large) above it, which silently
|
|
// dropped every zip.Config buffer knob at the wire. The transport, not just
|
|
// the fiber handler, must honor Config. A custom transport opts in by
|
|
// implementing tunableServer.
|
|
if t, ok := s.(tunableServer); ok {
|
|
t.applyConfig(a.cfg)
|
|
}
|
|
servers = append(servers, s)
|
|
// The plain-HTTP listener that lets an upgrade reach this app. [plain]
|
|
// derives its address and the host dials it; this is the half that
|
|
// answers. Appended to servers so it shuts down with the rest, and NOT
|
|
// bound: it is the same app at a derived address, not a second place to
|
|
// find it.
|
|
// The sibling's address is DERIVED, so its length is not something the
|
|
// caller chose and not something it can see. Asked here, where an error can
|
|
// still be returned, because the alternative is what it used to be: the bind
|
|
// fails inside the transport as "invalid argument", which names neither the
|
|
// limit nor the length, and reads like a broken socket rather than a long
|
|
// path. load.go asks the same question of the same address before it execs a
|
|
// child; this is the other half of one rule.
|
|
if to := plain(addr); to != "" {
|
|
if err := socketFits(to); err != nil {
|
|
return fmt.Errorf("zip: the upgrade sibling cannot be bound: %w", err)
|
|
}
|
|
}
|
|
if p := a.plainSibling(addr, h); p != nil {
|
|
servers = append(servers, p)
|
|
}
|
|
// This app now answers at this address, and a caller in THIS process can
|
|
// reach its ops without the address (see [Serving] and [Here]). Recorded
|
|
// here rather than at Serve because binding is what makes it true.
|
|
unbind = append(unbind, bound(addr, a))
|
|
a.logger.Info("zip listening", "transport", scheme, "addr", addr)
|
|
}
|
|
|
|
a.srvMu.Lock()
|
|
a.servers = servers
|
|
a.unbind = unbind
|
|
a.srvMu.Unlock()
|
|
a.listening.Add(int32(len(servers)))
|
|
|
|
// The two listeners a plugin main does not have to write. Each appears only
|
|
// when the app actually has the thing it serves, so one verb — Listen —
|
|
// covers a plugin run standalone and the same plugin run as a child.
|
|
//
|
|
// HIP-0119 §1: the ops surface is a SECOND listener, up only when the
|
|
// deployment named an ops port (a host names none on a child).
|
|
a.serveOps()
|
|
// HIP-0106 §2(3): peer ops ride a SECOND app served only on the canonical
|
|
// socket, so an internal op is never reachable on the edge.
|
|
a.servePeer()
|
|
// MCP over ZAP, when the deployment named an address for it. It is not a
|
|
// scheme in the transport registry above, because that registry is about
|
|
// WIRES that carry fasthttp requests and this is a different protocol on the
|
|
// same wire — the sibling of zap-proto/http, not a second spelling of it.
|
|
a.serveMCP()
|
|
|
|
// Serve every transport concurrently; return the first error (Shutdown
|
|
// closes the rest via closeServers).
|
|
errc := make(chan error, len(servers))
|
|
for _, s := range servers {
|
|
go func(s Server) { errc <- s.ListenAndServe() }(s)
|
|
}
|
|
return <-errc
|
|
}
|
|
|
|
// mount registers prefix onto a dialled address. The prefix is already CLAIMED
|
|
// by the caller — [Proxy] claims its one, [App.load] claims a plugin's whole set
|
|
// all-or-nothing before spawning anything — so this does not claim again and
|
|
// there is exactly one claim per prefix however the composition came in.
|
|
func (a *App) mount(prefix, addr string) error {
|
|
scheme, hostport, t, err := transportFor(addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if t.Dial == nil {
|
|
return fmt.Errorf("zip: transport %q cannot dial (serve-only)", scheme)
|
|
}
|
|
client := t.Dial(hostport)
|
|
a.logger.Info("zip mounting", "prefix", prefix, "transport", scheme, "addr", hostport)
|
|
a.mountVia(prefix, func() (Client, string, hold) { return client, hostport, hold{} })
|
|
return nil
|
|
}
|
|
|
|
// mountVia registers prefix once, resolving the target per request. The
|
|
// indirection is what makes a plugin reloadable: a reload swaps what `to`
|
|
// returns, so the router is never touched twice. Re-registering routes on
|
|
// every reload would grow the route table without bound — the leak that makes
|
|
// naive hot-reload untenable.
|
|
//
|
|
// Registering the route is a separate concern from CLAIMING the prefix (see
|
|
// [App.claim]): a reload re-resolves the target without re-registering, and an
|
|
// ownership assertion happens once, at the door the composition came in.
|
|
//
|
|
// The target comes back HELD: the instance behind it counts the request until it
|
|
// is over, which is what keeps eviction off a process mid-answer.
|
|
func (a *App) mountVia(prefix string, to func() (Client, string, hold)) {
|
|
h := func(c *Ctx) error {
|
|
client, host, held := to()
|
|
if upgrading(c.fc.Request()) {
|
|
if carriedByZAP(c.fc.RequestCtx()) {
|
|
held.release()
|
|
return Errorf(501, "mount %s: a protocol upgrade does not cross ZAP", prefix)
|
|
}
|
|
return relay(c.fc.RequestCtx(), host, "mount "+prefix, held)
|
|
}
|
|
return forward(c.Context(), c.fc.Request(), c.fc.Response(), client, host, "", "mount "+prefix, held)
|
|
}
|
|
prefix = strings.TrimSuffix(normPath(prefix), "/")
|
|
site := here(1)
|
|
// runtimeEntry, because a plugin may be loaded AFTER Listen: a runtime mount
|
|
// is a deployment event, not a program edit, so it goes onto the live router
|
|
// without reopening a sealed program.
|
|
// A plugin may be mounted AFTER Listen, so this goes through a generation
|
|
// when one is live: build the new route set, validate it, swap or keep
|
|
// serving the old one.
|
|
if err := a.includeRoutes(site,
|
|
route{method: methodAll, path: prefix, chain: []Handler{h}},
|
|
route{method: methodAll, path: prefix + "/*", chain: []Handler{h}},
|
|
); err != nil {
|
|
a.logger.Error("zip mount refused", "prefix", prefix, "err", err)
|
|
}
|
|
}
|
|
|
|
// forward is THE proxy hop: the inbound fasthttp request object is handed to the
|
|
// client and the reply written straight back, so nothing is re-encoded in
|
|
// between. A non-empty path rewrites the target — which is the whole difference
|
|
// between a mounted prefix (same path) and the composed MCP door (the owner's own
|
|
// /mcp) — so there is one forwarder in the framework and not two.
|
|
//
|
|
// what names the caller in the error, because "no instance running" is worth
|
|
// nothing without knowing what did not run.
|
|
//
|
|
// h is released when the reply is over: here for a whole reply, when its body is
|
|
// closed for a streamed one. A zero hold — a remote, which no host may stop —
|
|
// takes the direct path.
|
|
func forward(ctx context.Context, req *fasthttp.Request, resp *fasthttp.Response, client Client, host, path, what string, h hold) error {
|
|
if client == nil {
|
|
h.release()
|
|
return Errorf(503, "%s: no instance running", what)
|
|
}
|
|
// The caller's Host survives the hop. A client dials its own address and never
|
|
// reads this header to find the far end, so overwriting it destroyed the one
|
|
// fact a callee needs to serve more than one brand. The dial address is the
|
|
// fallback for a request carrying none — HTTP/1.1 requires the header.
|
|
if inbound := req.Header.Host(); len(inbound) > 0 {
|
|
host = string(inbound)
|
|
}
|
|
req.SetHost(host)
|
|
if path != "" {
|
|
req.URI().SetPath(path)
|
|
}
|
|
if h.in == nil {
|
|
if err := do(ctx, client, req, resp); err != nil {
|
|
// The upstream, not this hop, is what failed.
|
|
return Errorf(502, "%s: %v", what, err)
|
|
}
|
|
return nil
|
|
}
|
|
return forwardHeld(ctx, req, resp, client, what, h)
|
|
}
|
|
|
|
// forwardHeld is forward for a reply whose instance is held. The reply is read
|
|
// into a response of its own, because a streamed body outlives this call and
|
|
// fasthttp offers no way to take a stream back out of a response without closing
|
|
// it: the hold has to ride the body, so the body has to be wrapped before it is
|
|
// handed over.
|
|
func forwardHeld(ctx context.Context, req *fasthttp.Request, resp *fasthttp.Response, client Client, what string, h hold) error {
|
|
got := fasthttp.AcquireResponse()
|
|
if err := do(ctx, client, req, got); err != nil {
|
|
fasthttp.ReleaseResponse(got)
|
|
h.release()
|
|
return Errorf(502, "%s: %v", what, err)
|
|
}
|
|
if !got.IsBodyStream() {
|
|
got.CopyTo(resp)
|
|
fasthttp.ReleaseResponse(got)
|
|
h.release()
|
|
return nil
|
|
}
|
|
got.Header.CopyTo(&resp.Header)
|
|
resp.SkipBody = got.SkipBody
|
|
resp.SetBodyStream(&heldBody{got: got, h: h}, got.Header.ContentLength())
|
|
return nil
|
|
}
|
|
|
|
// heldBody is a streamed reply that keeps its instance busy until the reply is
|
|
// read to its end or let go: a token stream is a request in flight for as long
|
|
// as tokens arrive. The writer closes it once, from the goroutine that reads it.
|
|
type heldBody struct {
|
|
got *fasthttp.Response
|
|
h hold
|
|
}
|
|
|
|
func (b *heldBody) Read(p []byte) (int, error) {
|
|
if b.got == nil {
|
|
return 0, io.EOF
|
|
}
|
|
return b.got.BodyStream().Read(p)
|
|
}
|
|
|
|
func (b *heldBody) Close() error {
|
|
if b.got == nil {
|
|
return nil
|
|
}
|
|
err := b.got.CloseBodyStream()
|
|
fasthttp.ReleaseResponse(b.got)
|
|
b.got = nil
|
|
b.h.release()
|
|
return err
|
|
}
|
|
|
|
// declineWait bounds reading the reply a plugin gave instead of switching
|
|
// protocols. It is an ordinary answer and arrives at once; one still streaming
|
|
// after this is not an answer to an upgrade.
|
|
const declineWait = 10 * time.Second
|
|
|
|
// upgradeWait bounds reaching a plugin for an upgrade. It is a dial on a socket
|
|
// in this pod, so a second is already generous; what it exists to stop is a
|
|
// caller hanging on a plugin that is not listening yet.
|
|
const upgradeWait = time.Second
|
|
|
|
// plain is where a mounted plugin speaks ordinary HTTP, derived from the address
|
|
// it speaks ZAP on.
|
|
//
|
|
// A plugin's socket carries ZAP frames: one length-prefixed request, one
|
|
// length-prefixed reply. That shape has no room for a connection that STOPS being
|
|
// request-and-reply, which is exactly what an upgrade is — so a websocket cannot
|
|
// cross it, and the frames a client sends after the handshake arrive at an HTTP
|
|
// parser as nonsense ("error when reading request headers", the first byte being
|
|
// 0x81). The plugin therefore listens a second time, in plain HTTP, beside the
|
|
// first. This DERIVES that address instead of configuring it, so a mount is still
|
|
// one address and there is no second value to keep in step with the first.
|
|
//
|
|
// Only over a unix socket, because that is the one place the sibling is known to
|
|
// be the same process. A tcp mount would need a port nobody has agreed on.
|
|
func plain(addr string) string {
|
|
if NetworkOf(addr) != "unix" {
|
|
return ""
|
|
}
|
|
return addr + ".http"
|
|
}
|
|
|
|
// plainSibling is the listener that answers at [plain] — the second, ordinary
|
|
// HTTP listener a mounted app needs because an upgrade cannot cross ZAP framing.
|
|
// Without it the host dials a socket nobody created and every websocket through
|
|
// a mount fails the handshake.
|
|
//
|
|
// nil when the address has no sibling. A tcp address has none (no port both ends
|
|
// have agreed on), and neither does an app that is ITSELF a sibling — ops and
|
|
// peer are reached by name from inside the fleet, never mounted, so a second
|
|
// socket beside them would be one nothing dials.
|
|
func (a *App) plainSibling(addr string, h fasthttp.RequestHandler) Server {
|
|
if a.sibling {
|
|
return nil
|
|
}
|
|
to := plain(addr)
|
|
if to == "" {
|
|
return nil
|
|
}
|
|
// Resolved through the registry rather than constructed here, so the sibling
|
|
// is the same HTTP transport a caller gets from an http:// address and there
|
|
// is one implementation of "serve plain HTTP" to keep correct.
|
|
_, at, t, err := transportFor("http://" + to)
|
|
if err != nil || t.Serve == nil {
|
|
a.logger.Error("zip: no http transport for the upgrade sibling", "addr", to, "err", err)
|
|
return nil
|
|
}
|
|
s := t.Serve(at, h)
|
|
if t, ok := s.(tunableServer); ok {
|
|
t.applyConfig(a.cfg)
|
|
}
|
|
a.logger.Info("zip listening", "transport", "http", "addr", at, "for", "upgrade")
|
|
return s
|
|
}
|
|
|
|
// upgrading reports whether a request is asking to stop being HTTP.
|
|
func upgrading(req *fasthttp.Request) bool {
|
|
return len(req.Header.Peek(fasthttp.HeaderUpgrade)) > 0 &&
|
|
bytes.Contains(bytes.ToLower(req.Header.Peek(fasthttp.HeaderConnection)),
|
|
[]byte("upgrade"))
|
|
}
|
|
|
|
// relay carries an upgraded connection between a caller and a mounted plugin.
|
|
//
|
|
// Everything up to the switch is ordinary — the request goes up as it arrived,
|
|
// and a plugin that declines (an unauthorized socket is refused BEFORE it opens)
|
|
// answers an ordinary reply that comes straight back. What is different is what
|
|
// happens after 101: neither end is speaking HTTP any more, so there is nothing
|
|
// left to parse and the only correct act is to move bytes until one side stops.
|
|
//
|
|
// The switch reply is written verbatim, from the bytes the plugin sent, rather
|
|
// than through the response object — fasthttp would frame a body onto a message
|
|
// that has none. Bytes the plugin has already sent past the header are held in
|
|
// the reader, so the copy starts there and not at the socket.
|
|
//
|
|
// h covers the whole exchange: a refused upgrade releases it on return, a
|
|
// switched one when the tunnel closes, because a WebSocket session is a request
|
|
// in flight for as long as it is open.
|
|
func relay(rc *fasthttp.RequestCtx, addr, what string, h hold) error {
|
|
to := plain(addr)
|
|
if to == "" {
|
|
h.release()
|
|
return Errorf(502, "%s: no upgrade path over %s", what, NetworkOf(addr))
|
|
}
|
|
tunnel := false
|
|
defer func() {
|
|
if !tunnel {
|
|
h.release()
|
|
}
|
|
}()
|
|
up, err := net.DialTimeout(NetworkOf(to), to, upgradeWait)
|
|
if err != nil {
|
|
return Errorf(502, "%s: upgrade: %v", what, err)
|
|
}
|
|
bw := bufio.NewWriter(up)
|
|
if err := rc.Request.Write(bw); err == nil {
|
|
err = bw.Flush()
|
|
}
|
|
if err != nil {
|
|
_ = up.Close()
|
|
return Errorf(502, "%s: upgrade: %v", what, err)
|
|
}
|
|
|
|
br := bufio.NewReader(up)
|
|
var head fasthttp.ResponseHeader
|
|
if err := head.Read(br); err != nil {
|
|
_ = up.Close()
|
|
return Errorf(502, "%s: upgrade: %v", what, err)
|
|
}
|
|
if head.StatusCode() != fasthttp.StatusSwitchingProtocols {
|
|
defer func() { _ = up.Close() }()
|
|
head.CopyTo(&rc.Response.Header)
|
|
// A declined upgrade is an ordinary reply, and it may be chunked or never
|
|
// end: read it as a reply, for as long as a reply takes and no longer, or
|
|
// the request holding the plugin waits on a body that is still streaming.
|
|
_ = up.SetReadDeadline(time.Now().Add(declineWait))
|
|
var got fasthttp.Response
|
|
head.CopyTo(&got.Header)
|
|
if err := got.ReadBody(br, 0); err != nil {
|
|
return Errorf(502, "%s: upgrade declined: %v", what, err)
|
|
}
|
|
rc.Response.SetBody(got.Body())
|
|
return nil
|
|
}
|
|
|
|
switched := head.Header()
|
|
rc.HijackSetNoResponse(true)
|
|
tunnel = true
|
|
rc.Hijack(func(down net.Conn) {
|
|
defer h.release()
|
|
defer func() { _ = up.Close() }()
|
|
defer func() { _ = down.Close() }()
|
|
if _, err := down.Write(switched); err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
_, _ = io.Copy(up, down)
|
|
// The caller is gone. Close write so the plugin's read sees EOF and
|
|
// ends its session rather than holding a shell nobody is watching.
|
|
if c, ok := up.(interface{ CloseWrite() error }); ok {
|
|
_ = c.CloseWrite()
|
|
}
|
|
}()
|
|
_, _ = io.Copy(down, br)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// makeSocketDir creates the directory a unix socket will be bound in, so
|
|
// serving at the canonical zip.SocketPath("name") works on a host where the
|
|
// runtime dir does not exist yet — which is every host, the first time.
|
|
//
|
|
// 0700, so the fleet's sockets are reachable only by the user running them.
|
|
// An existing directory keeps its own mode: a deployment that needs a socket
|
|
// shared across users creates the directory itself, with the mode it means,
|
|
// and points ZIP_RUNTIME_DIR at it. An abstract socket (@name) has no
|
|
// directory and a tcp address has no path.
|
|
func makeSocketDir(addr string) error {
|
|
if NetworkOf(addr) != "unix" || strings.HasPrefix(addr, "@") {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(addr), 0o700); err != nil {
|
|
return fmt.Errorf("zip: socket dir for %s: %w", addr, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// closeServers stops every running listener. Called from Shutdown.
|
|
func (a *App) closeServers(ctx context.Context) {
|
|
a.srvMu.Lock()
|
|
servers := a.servers
|
|
unbind := a.unbind
|
|
a.unbind = nil
|
|
a.srvMu.Unlock()
|
|
// Stop answering by name BEFORE the listeners go, so an in-process caller
|
|
// cannot be handed an App that is on its way down while a caller over the
|
|
// socket is already getting a refused connection. The two ends of the same
|
|
// app must not disagree about whether it is up.
|
|
for _, u := range unbind {
|
|
u()
|
|
}
|
|
// Every listener stops accepting first; then the servers that hold
|
|
// connections open drain them together, bounded by ctx. A stream a client
|
|
// keeps reading would otherwise hold the shutdown for as long as it liked.
|
|
var drains []drainer
|
|
for _, s := range servers {
|
|
if d, ok := s.(drainer); ok {
|
|
drains = append(drains, d)
|
|
continue
|
|
}
|
|
_ = s.Close()
|
|
}
|
|
var wg sync.WaitGroup
|
|
for _, d := range drains {
|
|
wg.Go(func() { _ = d.ShutdownWithContext(ctx) })
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// drainer is a server that stops accepting and then waits, bounded by ctx, for
|
|
// the connections it holds to finish.
|
|
type drainer interface {
|
|
ShutdownWithContext(ctx context.Context) error
|
|
}
|
|
|
|
// splitScheme splits "scheme://addr" into (scheme, addr); a bare address
|
|
// (no "://") yields (DefaultScheme, addr).
|
|
func splitScheme(raw string) (scheme, addr string) {
|
|
if i := strings.Index(raw, "://"); i >= 0 {
|
|
return raw[:i], raw[i+3:]
|
|
}
|
|
return DefaultScheme, raw
|
|
}
|
|
|
|
// httpServer adapts fasthttp.Server (whose ListenAndServe takes the addr) to
|
|
// the Server interface (whose ListenAndServe takes none) so plain HTTP is just
|
|
// another transport in the registry.
|
|
type httpServer struct {
|
|
addr string
|
|
mode os.FileMode
|
|
srv *fasthttp.Server
|
|
}
|
|
|
|
// ListenAndServe binds the address, over tcp or over a unix socket.
|
|
//
|
|
// A PATH IS A SOCKET on this transport for the same reason it is on the ZAP one:
|
|
// the address names where the bytes are spoken and the shape of the address says
|
|
// which kind of place that is. It earns its keep next to a plugin — an upgraded
|
|
// connection cannot cross ZAP framing, so a plugin listens HTTP beside its socket
|
|
// and the host relays to it — and a path there must not mean "a tcp host called
|
|
// /var/lib/…", which is what binding it as tcp amounts to.
|
|
func (h *httpServer) ListenAndServe() error {
|
|
if NetworkOf(h.addr) == "unix" {
|
|
ln, err := bindSocket(h.addr, h.mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return h.srv.Serve(ln)
|
|
}
|
|
return h.srv.ListenAndServe(h.addr)
|
|
}
|
|
func (h *httpServer) Close() error { return h.srv.Shutdown() }
|
|
|
|
// ShutdownWithContext stops accepting and waits for open connections, until ctx
|
|
// ends.
|
|
func (h *httpServer) ShutdownWithContext(ctx context.Context) error {
|
|
return h.srv.ShutdownWithContext(ctx)
|
|
}
|
|
|
|
// zapServer adapts zap-proto/http.Server the same way, and for the same
|
|
// reason the HTTP one exists: the mode a socket is bound with is the App's to
|
|
// decide and the upstream server takes an address, binds it, and never learns
|
|
// what [Config.SocketMode] said. Binding through [bindSocket] here is what
|
|
// makes the config true on the PRIMARY transport — a bare address is ZAP, so
|
|
// the canonical [SocketPath] socket every plugin and peer is reached on is
|
|
// this one, and left to net.Listen it carried 0777 masked by the process
|
|
// umask (0755 under the usual 022) whatever the deployment asked for.
|
|
type zapServer struct {
|
|
addr string
|
|
mode os.FileMode
|
|
srv *http.Server
|
|
}
|
|
|
|
func (z *zapServer) ListenAndServe() error {
|
|
if NetworkOf(z.addr) == "unix" {
|
|
ln, err := bindSocket(z.addr, z.mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return z.srv.Serve(ln)
|
|
}
|
|
return z.srv.ListenAndServe()
|
|
}
|
|
func (z *zapServer) Close() error { return z.srv.Close() }
|
|
func (z *zapServer) applyConfig(cfg Config) { z.mode = cfg.SocketMode }
|
|
|
|
// staleWait bounds the dial that tells a socket left by a dead process from
|
|
// one something is still answering on.
|
|
const staleWait = 200 * time.Millisecond
|
|
|
|
// bindSocket binds a unix socket and gives it the mode the deployment asked
|
|
// for, zero meaning 0600 — see [Config.SocketMode].
|
|
//
|
|
// A socket has a mode only because something chmods it: net.Listen creates it
|
|
// 0777 masked by the process umask on Linux and darwin alike, which is a value
|
|
// the deployment never chose. Doing it here, once, is what keeps "the socket
|
|
// carries the config's mode" one fact rather than one per scheme — the umask
|
|
// is process-wide and shared with every other file the program writes, so
|
|
// narrowing it around a bind would answer a question about one socket by
|
|
// changing the answer for everything. The window between bind and chmod is
|
|
// closed by the directory: [makeSocketDir] creates it 0700.
|
|
//
|
|
// A stale socket is cleared first, because it outlives the process that made
|
|
// it and bind refuses the leftover file; a socket something is still answering
|
|
// on is left alone and reported, because that is a second process and not a
|
|
// crash.
|
|
func bindSocket(addr string, mode os.FileMode) (net.Listener, error) {
|
|
if mode == 0 {
|
|
mode = 0o600
|
|
}
|
|
// An abstract socket (@name) is a name in the kernel, not a file: it has no
|
|
// directory, no leftover to clear, and no mode to carry.
|
|
if strings.HasPrefix(addr, "@") {
|
|
return net.Listen("unix", addr)
|
|
}
|
|
if c, err := net.DialTimeout("unix", addr, staleWait); err == nil {
|
|
_ = c.Close()
|
|
return nil, fmt.Errorf("zip: %s is already served by a live process", addr)
|
|
}
|
|
if err := os.Remove(addr); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return nil, fmt.Errorf("zip: clearing %s: %w", addr, err)
|
|
}
|
|
ln, err := net.Listen("unix", addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.Chmod(addr, mode); err != nil {
|
|
_ = ln.Close()
|
|
return nil, fmt.Errorf("zip: mode %#o on %s: %w", mode, addr, err)
|
|
}
|
|
return ln, nil
|
|
}
|
|
|
|
// tunableServer is a transport whose underlying server accepts the App's
|
|
// per-conn wire tuning. Listen applies it after construction so zip.Config's
|
|
// buffer/concurrency knobs actually reach the socket. The built-in HTTP
|
|
// transport implements it; a custom transport may too, or ignore the config.
|
|
type tunableServer interface{ applyConfig(cfg Config) }
|
|
|
|
// applyConfig copies the App's fasthttp tuning onto the HTTP transport's
|
|
// server. Only non-zero knobs are applied, so an unset field falls through to
|
|
// fasthttp's own default (4 KiB buffers, 256k concurrency) rather than zeroing
|
|
// it. This is the seam that makes zip.Config{ReadBufferSize: 32768} raise the
|
|
// 431 header ceiling on the wire.
|
|
func (h *httpServer) applyConfig(cfg Config) {
|
|
h.mode = cfg.SocketMode
|
|
if cfg.ReadBufferSize > 0 {
|
|
h.srv.ReadBufferSize = cfg.ReadBufferSize
|
|
}
|
|
// The body ceiling reaches the wire only here. fiber binds its own BodyLimit
|
|
// when fiber owns the listener, and on this transport it does not, so without
|
|
// this the socket keeps fasthttp's 4 MiB default. fasthttp refuses an
|
|
// oversized body before any handler runs, answering 400 "Error when parsing
|
|
// request".
|
|
if cfg.BodyLimit > 0 {
|
|
h.srv.MaxRequestBodySize = cfg.BodyLimit
|
|
}
|
|
if cfg.WriteBufferSize > 0 {
|
|
h.srv.WriteBufferSize = cfg.WriteBufferSize
|
|
}
|
|
if cfg.Concurrency > 0 {
|
|
h.srv.Concurrency = cfg.Concurrency
|
|
}
|
|
// The transport's OWN pre-routing responses — 431 (header overflow), 400
|
|
// (parse error), request timeouts — are written by fasthttp BEFORE the fiber
|
|
// handler runs, so no middleware (including ProductionHeaders) can brand
|
|
// them. Left unset, fasthttp stamps its framework default "Server: fasthttp"
|
|
// — a stack leak on exactly the malformed-request path an attacker probes.
|
|
// Propagate the App's ServerHeader onto the transport server so those
|
|
// responses carry the same value as the handled ones; "-" suppresses the
|
|
// header entirely (NoDefaultServerHeader), matching New()'s suppress semantic.
|
|
switch cfg.ServerHeader {
|
|
case "-":
|
|
h.srv.NoDefaultServerHeader = true
|
|
case "":
|
|
// Unreachable via New (which defaults to "zip"), but a direct transport
|
|
// caller with no ServerHeader still must not leak "fasthttp".
|
|
h.srv.NoDefaultServerHeader = true
|
|
default:
|
|
h.srv.Name = cfg.ServerHeader
|
|
}
|
|
}
|