Service APIs and internal APIs
The SDK serves APIs on the service's own listeners and describes them in
Manifest.apis. Descriptions feed the console API viewer. External publication,
proxy configuration and protocol conversion belong to application code and deployment.
Registering an API never publishes it through a gateway.
Managed listeners
import (
"github.com/backplane-labs/backplane/pkg/backplane/api"
"github.com/backplane-labs/backplane/pkg/backplane/wsproto"
)
svc.GRPC(st.Greeter.Register, api.Reflection())
wsproto.Serve(svc, "/ws/", api.Origins{}, st.Greeter.Register)
svc.HTTP("/hello/", handler, api.OpenAPIFS(apiFiles, "openapi.yaml"))
svc.GraphQL("/graphql/", graphqlHandler, introspection)
These methods serve native gRPC, ws-proto, HTTP and the author's GraphQL handler. They do not create Connect, gRPC-Web or REST/JSON conversion around gRPC. If those protocols are needed, the author implements them or configures them in deployment.
api.Listen("admin", cfg.AdminListen) selects a separate managed listener: its name is
fixed in code, its address usually comes from configuration. The manifest records the
route's listener by name; the instance state records the port of every listener
(public_port for the default one, listeners by name), so the manifest of a version
does not depend on configuration. A name has one address and an address one name, or
Run fails. api.Host("api.example.com")
restricts managed HTTP/GraphQL/ws-proto handler selection by Host. Empty Host accepts
any host; exact names and wildcard suffixes are supported. HTTP prefixes are normalized
to end in /; /api redirects to /api/. Duplicate host/prefix pairs on one listener
are rejected before startup.
api.Middleware(...), api.Interceptors(...) and api.StreamInterceptors(...)
run in the service, scoped to the registration. api.Reflection() enables native
gRPC reflection on that listener. Shared listener limits are configured with
BACKPLANE_SERVER_*, including HTTP header/idle limits and the gRPC receive limit.
Request timeouts, body bounds, browser CORS and other application behavior belong to
handlers, middleware or the external proxy. There are no declarative proxy policy options.
ws-proto endpoints
wsproto.Serve registers either gRPC services, with the same function as svc.GRPC,
or handlers generated for ws-proto itself (Register<Service>Handler) on the endpoint's
*wsrpc.Server. In both cases the endpoint announces the registered services. It is
connection-oriented, and the options follow the connection:
wsproto.Serve(svc, "/ws/", origins,
func(s *wsrpc.Server) { gatewayv1.RegisterGatewayHandler(s, gw) },
api.Middleware(gw.Connection), // once per connection
api.CallMiddleware(gw.Call), // every call on it
api.Keepalive(20*time.Second, 10*time.Second))
api.Middlewarewraps the upgrade and lasts for the whole connection:- it rejects the connection by answering without calling
next; - the request context it passes to
nextbecomes the connection's context. Every call's context derives from it, so identity attached once is visible to every call, and canceling it closes the socket (for example, on session revocation); nextreturns when the connection closes.
- it rejects the connection by answering without calling
api.CallMiddleware(wsrpc.Middleware) wraps every call, whichever way the services were registered. It runs inside the SDK's panic recovery and tracing.api.Interceptorsandapi.StreamInterceptorswrap only gRPC-registered services.api.Keepalivetunes pings. The default is every 20s with a 10s timeout.
Calls between services: mutual TLS
An API only other services call (a domain's admin API, a sync service)
should not answer whoever reaches its port. api.MTLS() serves the route's
port to services of the mesh only, and CallerOf says which one called:
// The server: only services may call, and the call knows who.
svc.GRPC(func(r grpc.ServiceRegistrar) { syncv1.RegisterSyncAdminServiceServer(r, admin) },
api.Listen("admin", cfg.AdminListen), api.MTLS())
func (a admin) Purge(ctx context.Context, in *syncv1.PurgeRequest) (*syncv1.PurgeResponse, error) {
caller, _ := backplane.CallerOf(ctx) // caller.Service == "feed"
...
}
// A client, in its newState: its own certificate, the server verified as "sync".
conn, err := root.DialService("sync", "sync:9090")
// A call that may wait while the channel reconnects, bounded by its deadline:
// client.Purge(ctx, req, grpc.WaitForReady(true))
// HTTP: &http.Transport{TLSClientConfig: root.ServiceTLS("sync")}
DialService (and DialPlatform for a platform port) builds the channel
with ServiceCredentials and recovers when the peer's address moves: the
channel resolves the host itself, looks it up again at most every 500 ms
when a connection is lost or refused (also when the address now answers
as another service: the handshake refuses it), retries a failing lookup
within 5 s and reconnects with a 250 ms–5 s backoff. gRPC's own DNS
resolver waits 30 s between lookups; nothing process-wide is changed.
Calls are not retried: a call made while the channel reconnects fails
with Unavailable, unless it waits for readiness with
grpc.WaitForReady(true) and a deadline. Backplane and the SDK dial their
own channels (hooks, activities, the console relay) the same way
(pkg/backplane/meshdial).
Nothing is configured for it beyond Consul, which the Consul driver already uses:
-
Certificates come from Consul's CA: each service gets a certificate naming it (
spiffe://<trust domain>/ns/default/dc/<dc>/svc/<service>), issued and renewed by the local agent before it expires and when the CA's root rotates. The SDK follows them with blocking queries and reads them on every TLS handshake: a renewed certificate applies to the next connection without a restart; open connections keep theirs. Private keys never pass through the KV store. A development Consul (consul agent -dev) is a CA already. -
Who may call is Consul's intentions with the service as destination, followed live and decided by the SDK (an exact source before
*):consul config write - <<'EOF'Kind = "service-intentions"Name = "sync"Sources = [{ Name = "feed", Action = "allow" },{ Name = "*", Action = "deny" },]EOFA caller no intention names gets
BACKPLANE_MESH_DEFAULT:allow(Consul's own default without ACLs) ordeny. A refused call isPermissionDenied(gRPC) or 403 (HTTP); a client without a certificate of the mesh does not get past the TLS handshake. -
A port is mTLS or not as a whole: every route on it says the same, or Run fails; so does
api.MTLS()without the Consul driver and Consul. The SDK follows the CA only once something uses it, so services without mTLS ask Consul for nothing. Readiness waits for the certificate.
The mesh spans one trust domain, namespace and partition: those of the service's own certificate. A caller presenting a certificate of the same CA for another namespace or partition (Consul Enterprise) does not get past the handshake, and intentions naming sources elsewhere do not apply; datacenters of the trust domain are one mesh, as Consul's intentions are.
In production protect the identity with Consul ACLs: whoever can get a
certificate for feed (a token with service:write on it) is feed.
The platform port
The platform port (BACKPLANE_INTERNAL_PORT, 9400) carries backplane's calls
of the service's activities, the services' hook calls to backplane and the
console's relay to internal APIs. Without TLS, the internal secret every
call presents travels in clear text. BACKPLANE_MESH_PLATFORM puts the port
under the same mutual TLS:
plain(default): no TLS, as before.accept: the port serves mutual TLS next to plain connections; the service still calls the others plain.mtls: the service calls the platform ports of others (and backplane's) over mutual TLS; a plain connection reaches only the probes (/healthz/*andgrpc.health.v1.Health, which kubelet and Consul checks use). Anything else plain is 401 (HTTP) orUnauthenticated(gRPC).
Who may call is the same intentions as above (backplane calls every service;
every service calling hooks calls backplane). The internal secret stays
required on top. A setting other than plain needs the Consul driver and
Consul with Connect enabled, or Open fails.
Roll it out in two steps, since a caller in mtls needs a callee in at least
accept: set accept on every service and on backplane, then mtls
everywhere. Every service must run an SDK that knows the setting.
Backplane dials with the same credentials a service can use itself:
root.PlatformCredentials("sync") and root.PlatformTLS("sync") are mutual
TLS in mtls and plain otherwise.
Independently served APIs
Describe an API that your own server already serves:
svc.API(api.HTTP("/legacy/",
api.Port("legacy", legacy.Port()),
api.OpenAPI(document)))
This only adds catalog metadata and optional schema files. It does not start a
listener, check reachability, create routes, or choose a public URL. api.Port
names the author's listener and gives its port, which goes to the instance state
like a managed listener's; without it the route is on the default public port. The
name is not also an api.Listen name, and has one port. api.GRPC, api.GraphQL
and api.WSProto provide the corresponding declarations.
Internal module API
svc.Internal(func(r grpc.ServiceRegistrar) {
helloconsolev1.RegisterAdminServiceServer(r, st.Admin)
})
svc.UI(bundle)
Internal RPCs live on the platform port, in any protobuf package except grpc.*
and the platform's own backplane.*. The console's authenticated WebSocket
relay sends each method to the service whose manifest declares it (a method two
services declare is refused) and calls it directly; UI bundles and API files are fetched directly from that port.
The public API is excluded from this relay.
api.Interceptors and api.StreamInterceptors give the internal API its own
middleware, as on svc.GRPC: they wrap only the services of that
Internal call, the first outermost, and run after the internal secret is
checked, so backplane.OperatorOf already answers in them.
svc.Internal(func(r grpc.ServiceRegistrar) {
helloconsolev1.RegisterAdminServiceServer(r, st.Admin)
}, api.Interceptors(audit, translateErrors))
The console operator
A relayed call names the operator's console session, and
backplane.OperatorOf(ctx) returns it as Operator{Session};
Operator.Actor() is console:<session>, the actor backplane's own audit
uses (see application audit). The
console signs in with the installation's admin token, so the session is all
there is to know of the person; the console's session menu lists each one's
address and user agent. Calls the console did not relay for a session
(backplane's own activity calls) have no operator.
The session is backplane's word, not the browser's: the relay drops every
bp-* key the browser sends and sets the session itself, and the platform port
admits only calls presenting the internal secret. That holds while
BACKPLANE_INTERNAL_SECRET is set and the platform port is unreachable to
anyone but backplane. Without a secret the port is open, and the session is
whatever the caller claims.
API documents
HTTP APIs accept OpenAPI, gRPC/ws-proto use descriptor sets, and GraphQL uses introspection JSON. Multi-file documents can be embedded as a filesystem. Publishing service API documents covers packaging, limits and reference resolution.
The declared path and port describe the service listener. External routing may rewrite paths or select another hostname; the platform does not infer that configuration.