Documentation
¶
Overview ¶
Package remote is the remote plugin runtime (SPEC §5.3, WS8b).
A remote plugin is an HTTP-shaped JSON service. Ether reaches it two ways, with identical request and response shapes:
- URL mode: Ether fetches <base><path> over HTTP(S) through internal/safefetch (public addresses only, or exactly one owner-confirmed LAN origin) and internal/egress. Any Stremio add-on is a remote plugin in this mode.
- Tunnel mode: a sidecar opens one outbound websocket to Ether at GET /ether/plugins/tunnel, authenticated with a plugin credential. Ether multiplexes HTTP-shaped requests over that socket, so the sidecar needs no inbound ports and works behind NAT. Byte ranges for files the sidecar serves are relayed over the tunnel in chunks.
The transport types below are the wire shapes (multi-value headers, byte bodies). factory.go is the one place they meet the plugin host's contract in internal/plugins (plugins.Request, plugins.Runtime, plugins.Host), converting both ways so the rest of this package stays transport-only.
Index ¶
- Constants
- Variables
- func GrantsLAN(grantsOf func(ctx context.Context, pluginID string) (plugins.Grants, error)) func(string) bool
- func IsBlocked(err error) bool
- type CredentialResolver
- type CredentialResolverFunc
- type Factory
- type HandlerFunc
- type Host
- type HostAdapter
- func (a HostAdapter) CatalogGet(ctx context.Context, pluginID, id string) (json.RawMessage, error)
- func (a HostAdapter) CatalogSearch(ctx context.Context, pluginID, query string, limit, offset int) (json.RawMessage, error)
- func (a HostAdapter) CollectionsUpsert(ctx context.Context, pluginID string, collection json.RawMessage) error
- func (a HostAdapter) KVDelete(ctx context.Context, pluginID, key string) error
- func (a HostAdapter) KVGet(ctx context.Context, pluginID, key string) ([]byte, bool, error)
- func (a HostAdapter) KVList(ctx context.Context, pluginID, prefix string) ([]string, error)
- func (a HostAdapter) KVPut(ctx context.Context, pluginID, key string, value []byte) error
- func (a HostAdapter) Log(ctx context.Context, pluginID, level, msg string, attrs map[string]any) error
- func (a HostAdapter) NotifyOwner(ctx context.Context, pluginID, text string) error
- func (a HostAdapter) SetWatchState(ctx context.Context, pluginID string, viewerID int64, titleID string, ...) error
- func (a HostAdapter) Settings(ctx context.Context, pluginID string) (map[string]any, error)
- func (a HostAdapter) ViewerGranted(ctx context.Context, pluginID string) (json.RawMessage, error)
- func (a HostAdapter) ViewerHistory(ctx context.Context, pluginID string, viewerID int64, limit, offset int) (json.RawMessage, error)
- type HostHandler
- type HostOptions
- type Pairing
- type Request
- type Response
- type Runtime
- type StaticCredentials
- type StreamCall
- type StreamHandlerFunc
- type StreamResponse
- type Streamer
- type TunnelClient
- type TunnelClientOptions
- type TunnelOptions
- type TunnelServer
- type URLClient
- type URLOptions
Constants ¶
const ( HeaderRequestID = "X-Ether-Request-Id" HeaderDeadline = "X-Ether-Deadline" HeaderLocale = "X-Ether-Locale" HeaderCredential = "X-Ether-Plugin-Credential" // HeaderPick carries a subtitle pick token (SPEC §5.1.2). Callers // set it through Request.Headers; the constant keeps the spelling // in one place. HeaderPick = "X-Ether-Pick" )
Header names Ether sends to every remote plugin (SPEC §5.1.2).
const ( TunnelPath = "/ether/plugins/tunnel" HostPath = "/ether/plugin-host/" )
TunnelPath is where sidecars connect. HostPath is where remote plugins call Ether's host services.
const ( DefaultMaxInFlight = 32 DefaultChunkBytes = 64 << 10 DefaultPingInterval = 20 * time.Second )
Default tunnel limits.
const DefaultCallTimeout = 30 * time.Second
DefaultCallTimeout bounds a call whose request and context carry no deadline. Route deadlines in SPEC §5.1.2 are shorter; callers pass them per request.
const DefaultMaxResponseBytes = 10 << 20
DefaultMaxResponseBytes caps a buffered Call reply (SPEC §5.1.5 sets 10 MiB for host fetches; plugin replies get the same default).
const PairExchangePath = "/ether/plugins/pair"
const TunnelProtocol = "ether-tunnel-v1"
TunnelProtocol is the tunnel wire protocol version. It is sent as the websocket subprotocol and in the hello/welcome frames.
Variables ¶
var ( // ErrTooLarge reports a response body over the configured cap. ErrTooLarge = errors.New("remote: response body too large") // ErrDisconnected reports a tunnel connection lost while a call // was in flight, or a call made with no sidecar connected. ErrDisconnected = errors.New("remote: tunnel disconnected") ErrUnauthorized = errors.New("remote: unauthorized") // ErrPermissionDenied is returned by Host implementations when a // plugin lacks the grant a host service needs (SPEC §5.4). ErrPermissionDenied = errors.New("remote: permission denied") // ErrNotFound is returned by Host implementations for missing data. ErrNotFound = errors.New("remote: not found") // ErrInvalid is returned by Host implementations for bad input. ErrInvalid = errors.New("remote: invalid request") ErrUnavailable = errors.New("remote: unavailable") // ErrClosed reports use of a closed runtime. ErrClosed = errors.New("remote: closed") )
var ErrStreamNotHandled = errors.New("remote: stream handler declined")
ErrStreamNotHandled asks the tunnel client to use its buffered handler for a request.
Functions ¶
Types ¶
type CredentialResolver ¶
type CredentialResolver interface {
ResolvePluginCredential(ctx context.Context, credential string) (string, error)
}
CredentialResolver authenticates a plugin credential and returns the plugin id it belongs to. WS7 mints credentials at pairing time as identity_grants rows on surface "plugin"; this package only needs the lookup, so the host supplies it.
type CredentialResolverFunc ¶
CredentialResolverFunc adapts a function to CredentialResolver.
func (CredentialResolverFunc) ResolvePluginCredential ¶
func (f CredentialResolverFunc) ResolvePluginCredential(ctx context.Context, credential string) (string, error)
ResolvePluginCredential calls f.
type Factory ¶
type Factory struct {
// Tunnel serves tunnel-mode plugins. Required for plugins with
// no URL endpoint.
Tunnel *TunnelServer
// TrustedLAN reports whether the owner confirmed the plugin's
// URL as a LAN origin at install (Manager grants carry it).
TrustedLAN func(pluginID string) bool
// Credential returns the credential Ether presents to the plugin
// on URL calls, or "" for none (plain Stremio add-ons).
Credential func(pluginID string) string
// Guard guards URL fetches; nil uses a zero safefetch.Guard.
Guard *safefetch.Guard
// Locale is the fallback X-Ether-Locale for calls that carry none.
Locale string
Logger *slog.Logger
}
Factory builds remote runtimes for the plugin host.
func (*Factory) RuntimeFactory ¶
func (f *Factory) RuntimeFactory() plugins.RuntimeFactory
RuntimeFactory returns the plugins.RuntimeFactory for kind "remote". Endpoint selection, from Installed.SourceURL:
- "" or a tunnel: URL ("tunnel://", "ether+tunnel://"): the sidecar connects out; calls go over Factory.Tunnel.
- an http(s) manifest URL, or any other http(s) URL that is not a github.com repo page: URL mode against that base.
- a github.com repo URL: a repo is not an endpoint, so the plugin is served by a sidecar over the tunnel.
type HandlerFunc ¶
HandlerFunc serves one tunneled plugin request on the sidecar. It mirrors the hosted runtime ABI (SPEC §5.2): request in, response out. A handler that needs to stream a large body (a file range) implements StreamHandlerFunc instead; the client picks it when set.
type Host ¶
type Host interface {
KVGet(ctx context.Context, pluginID, key string) ([]byte, bool, error)
KVPut(ctx context.Context, pluginID, key string, value []byte) error
KVDelete(ctx context.Context, pluginID, key string) error
KVList(ctx context.Context, pluginID, prefix string) ([]string, error)
CatalogSearch(ctx context.Context, pluginID, query string, limit, offset int) (json.RawMessage, error)
CatalogGet(ctx context.Context, pluginID, id string) (json.RawMessage, error)
ViewerGranted(ctx context.Context, pluginID string) (json.RawMessage, error)
ViewerHistory(ctx context.Context, pluginID string, viewerID int64, limit, offset int) (json.RawMessage, error)
SetWatchState(ctx context.Context, pluginID string, viewerID int64, titleID string, watched bool) error
CollectionsUpsert(ctx context.Context, pluginID string, collection json.RawMessage) error
Settings(ctx context.Context, pluginID string) (map[string]any, error)
NotifyOwner(ctx context.Context, pluginID, text string) error
Log(ctx context.Context, pluginID, level, msg string, attrs map[string]any) error
}
Host is the set of Ether services a remote plugin may call back into (SPEC §5.1.5). It mirrors plugins.Host method for method for the services remote plugins may use; secret.get is hosted-only and deliberately absent (remote plugins receive secrets in /ether/configure). Every method is expected to re-check the plugin's live grants and return ErrPermissionDenied when a grant is missing or revoked.
type HostAdapter ¶
type HostAdapter struct {
HostFor func(pluginID string) plugins.Host
// SettingsWithSecrets, when set, returns the plugin's settings with its own
// secrets (Manager.PluginSettings); a remote sidecar cannot call
// secret.get, so this is how it recovers configuration after a
// restart or reconnect. Without it only non-secret settings are served.
SettingsWithSecrets func(ctx context.Context, pluginID string) (map[string]any, error)
}
HostAdapter exposes one plugin's plugins.Host through the Host interface the plugin-host HTTP API delegates to. HostFor is one adapter for every plugin: it binds the plugin id the credential resolved to, then calls Manager.HostFor. Errors are translated to this package's sentinels so the HTTP layer maps them precisely.
func (HostAdapter) CatalogGet ¶
func (a HostAdapter) CatalogGet(ctx context.Context, pluginID, id string) (json.RawMessage, error)
func (HostAdapter) CatalogSearch ¶
func (a HostAdapter) CatalogSearch(ctx context.Context, pluginID, query string, limit, offset int) (json.RawMessage, error)
func (HostAdapter) CollectionsUpsert ¶
func (a HostAdapter) CollectionsUpsert(ctx context.Context, pluginID string, collection json.RawMessage) error
func (HostAdapter) KVDelete ¶
func (a HostAdapter) KVDelete(ctx context.Context, pluginID, key string) error
func (HostAdapter) NotifyOwner ¶
func (a HostAdapter) NotifyOwner(ctx context.Context, pluginID, text string) error
func (HostAdapter) SetWatchState ¶
func (HostAdapter) ViewerGranted ¶
func (a HostAdapter) ViewerGranted(ctx context.Context, pluginID string) (json.RawMessage, error)
func (HostAdapter) ViewerHistory ¶
func (a HostAdapter) ViewerHistory(ctx context.Context, pluginID string, viewerID int64, limit, offset int) (json.RawMessage, error)
type HostHandler ¶
type HostHandler struct {
// contains filtered or unexported fields
}
HostHandler serves /ether/plugin-host/* for remote plugins. Every request is authenticated with a plugin credential; the resolved plugin id is the only identity a call can act as, so a plugin can never name another plugin in a request body and be believed.
func NewHostHandler ¶
func NewHostHandler(opts HostOptions) *HostHandler
NewHostHandler builds the plugin-host API. Mount the result at HostPath (or use Mount to register it on a mux).
func (*HostHandler) Mount ¶
func (h *HostHandler) Mount(mux *http.ServeMux)
Mount registers the handler's routes on mux.
func (*HostHandler) ServeHTTP ¶
func (h *HostHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
type HostOptions ¶
type HostOptions struct {
Host Host
Auth CredentialResolver
Logger *slog.Logger
}
HostOptions configures a HostHandler.
type Pairing ¶
type Pairing struct {
// TunnelURL derives the externally reachable URL from the owner request.
TunnelURL func(*http.Request) string
// Exists allows owners to inspect and revoke pairings of disabled plugins.
Exists func(context.Context, string) bool
// contains filtered or unexported fields
}
Pairing implements owner-initiated, one-use sidecar pairing. Codes are intentionally ephemeral; restarting the server invalidates pending codes.
func NewPairing ¶
type Request ¶
type Request struct {
Method string
Path string
Query map[string][]string
Headers map[string][]string
Body []byte
// Locale, when set, overrides the client's configured locale for
// this call.
Locale string
// RequestID correlates the call in logs; generated when empty.
RequestID string
// Timeout bounds this call. When zero, the context deadline (or
// the client default) applies. The resulting absolute deadline is
// sent to the plugin as X-Ether-Deadline.
Timeout time.Duration
}
Request is one HTTP-shaped plugin call. Path is a plugin route such as "/manifest.json", "/stream/movie/tt123.json" or "/ether/configure"; it must be a path, never an absolute URL.
type Runtime ¶
Runtime is the transport WS7's host talks through: one HTTP-shaped JSON call, plus Close. URL clients and tunnel runtimes both implement it.
type StaticCredentials ¶
StaticCredentials resolves credentials from a fixed map of credential to plugin id. It exists for tests, fixtures and the conformance kit; production wiring uses identity_grants through WS7. Lookups hash the presented credential and compare in constant time so map iteration order does not leak through timing.
func (StaticCredentials) ResolvePluginCredential ¶
func (s StaticCredentials) ResolvePluginCredential(_ context.Context, credential string) (string, error)
ResolvePluginCredential implements CredentialResolver.
type StreamCall ¶
type StreamCall interface {
CallStream(ctx context.Context, req plugins.Request) (plugins.Response, io.ReadCloser, error)
}
StreamCall is the optional streaming surface WS9 adapters look for on a plugins.Runtime.
type StreamHandlerFunc ¶
type StreamHandlerFunc func(ctx context.Context, req Request) (*StreamResponse, error)
StreamHandlerFunc serves a request with a streaming body. Return Handled=false (via a nil StreamResponse and ErrStreamNotHandled) to fall back to the buffered handler.
func FileHandler ¶
func FileHandler(root string) StreamHandlerFunc
FileHandler serves files under root for requests to /ether/file/<path>, honouring one Range header (bytes=start-end, bytes=start-, bytes=-suffix). It is how a sidecar on another machine serves `file` streams (SPEC §5.1.4): Ether relays the range over the tunnel in chunks and the sidecar never needs an inbound port. Paths cannot escape root, and only regular files are served. Use it as a TunnelClient StreamHandler.
type StreamResponse ¶
type StreamResponse struct {
Status int
Headers map[string][]string
Body io.ReadCloser
}
StreamResponse is a plugin reply whose body streams. The caller must close Body; closing it cancels the underlying call.
func (*StreamResponse) Header ¶
func (s *StreamResponse) Header(name string) string
Header returns the first value of name, or "".
type Streamer ¶
type Streamer interface {
CallStream(ctx context.Context, req Request) (*StreamResponse, error)
}
Streamer is implemented by runtimes that can relay a response body without buffering it (byte-range file relay in both modes).
type TunnelClient ¶
type TunnelClient struct {
// contains filtered or unexported fields
}
TunnelClient is the Go reference sidecar: it connects out to Ether over one websocket, authenticates with a plugin credential, and dispatches multiplexed requests to a local handler. It exists for the conformance kit, for Go sidecars, and as the executable reference the Python and TypeScript fixtures mirror.
func NewTunnelClient ¶
func NewTunnelClient(opts TunnelClientOptions) (*TunnelClient, error)
NewTunnelClient builds a sidecar client. Serve it with ServeOnce or Serve (reconnecting).
type TunnelClientOptions ¶
type TunnelClientOptions struct {
// URL is Ether's tunnel endpoint, ws:// or wss:// (http/https
// are accepted and rewritten).
URL string
// Credential authenticates the connection (sent as
// X-Ether-Plugin-Credential on the upgrade request).
Credential string
// Plugin, when set, declares the plugin id in the hello frame.
// It must match the credential's plugin or Ether refuses the
// connection, which catches a sidecar started with the wrong
// credential early.
Plugin string
// Handler serves buffered replies. Required.
Handler HandlerFunc
// StreamHandler, when set, gets first refusal on every request
// (file ranges use it through FileHandler).
StreamHandler StreamHandlerFunc
Logger *slog.Logger
}
TunnelClientOptions configures a TunnelClient.
type TunnelOptions ¶
type TunnelOptions struct {
// Auth resolves tunnel credentials to plugin ids. Required: a
// server without it refuses every connection.
Auth CredentialResolver
// Locale is sent to plugins unless a request overrides it.
Locale string
// MaxResponseBytes caps buffered replies; zero uses the default.
MaxResponseBytes int64
// MaxInFlight bounds concurrent calls per connection
// (backpressure); zero uses DefaultMaxInFlight.
MaxInFlight int
// ChunkBytes is the streamed-body chunk size advertised to
// sidecars; zero uses DefaultChunkBytes.
ChunkBytes int
// PingInterval is the websocket keepalive cadence; zero uses
// DefaultPingInterval, negative disables server pings.
PingInterval time.Duration
// Timeout is the default per-call deadline; zero uses
// DefaultCallTimeout.
Timeout time.Duration
Logger *slog.Logger
}
TunnelOptions configures a TunnelServer.
type TunnelServer ¶
type TunnelServer struct {
// contains filtered or unexported fields
}
TunnelServer accepts sidecar tunnel connections and hands out a Runtime per connected plugin. One live connection per plugin: a reconnect replaces the old socket, whose in-flight calls fail with ErrDisconnected. Runtimes obtained from RuntimeFor resolve the current connection at call time, so they survive reconnects.
func NewTunnelServer ¶
func NewTunnelServer(opts TunnelOptions) *TunnelServer
NewTunnelServer builds a tunnel endpoint. Mount Handler at TunnelPath.
func (*TunnelServer) Close ¶
func (s *TunnelServer) Close() error
Close drops every tunnel connection. In-flight calls fail.
func (*TunnelServer) Connected ¶
func (s *TunnelServer) Connected() []string
Connected lists the plugin ids with a live tunnel.
func (*TunnelServer) Disconnect ¶
func (s *TunnelServer) Disconnect(pluginID string)
Disconnect drops a plugin's current socket, including in-flight requests.
func (*TunnelServer) Handler ¶
func (s *TunnelServer) Handler() http.Handler
Handler serves GET TunnelPath: authenticate, upgrade, then serve the connection until it ends. Authentication happens before the upgrade so a bad credential gets a plain 401, never a socket.
func (*TunnelServer) IsConnected ¶
func (s *TunnelServer) IsConnected(pluginID string) bool
IsConnected reports whether a plugin's sidecar is connected.
func (*TunnelServer) RuntimeFor ¶
func (s *TunnelServer) RuntimeFor(pluginID string) Runtime
RuntimeFor returns the runtime for a plugin. The runtime resolves the plugin's current connection per call, so it keeps working across sidecar reconnects; calls with no sidecar connected fail with ErrDisconnected.
type URLClient ¶
type URLClient struct {
// contains filtered or unexported fields
}
URLClient talks to a remote plugin at a base URL (SPEC §5.3 URL mode). Every fetch goes through safefetch: public addresses only, unless TrustedOrigin names exactly this client's origin as owner-confirmed (LAN URL mode). Explicit proxy routing follows egress CapPlugins; process environment proxies are not used.
func NewURLClient ¶
func NewURLClient(opts URLOptions) (*URLClient, error)
NewURLClient builds a URL-mode runtime for one plugin.
func (*URLClient) CallStream ¶
CallStream sends one request and streams the reply. Closing the returned body cancels the call. Range requests relay byte for byte because the body is never buffered or re-encoded.
type URLOptions ¶
type URLOptions struct {
// Plain Stremio resources may redirect to public CDNs. Credentials never follow.
AllowPublicRedirects bool
// Base is the plugin base URL, usually the manifest URL's
// directory (for example https://addons.example/usercfg). Request
// paths are appended to it. A trailing "/manifest.json" is
// stripped so a manifest URL may be passed directly.
Base string
// Credential is sent as X-Ether-Plugin-Credential on every call.
// Plain Stremio add-ons have none.
Credential string
// Plain marks a third-party Stremio add-on: it is sent only standard
// HTTP headers (no X-Ether-* request id, deadline or locale), as the
// Stremio app would, and its base keeps its configuration query.
Plain bool
// Locale is sent as X-Ether-Locale unless a request overrides it.
Locale string
// TrustedOrigin allows this exact origin to resolve to private
// addresses (owner-confirmed LAN URL mode). It must name the same
// origin as Base; anything else is refused at construction so a
// client can never be built trusted for one host and aimed at
// another.
TrustedOrigin string
// Guard guards fetches; nil uses a zero safefetch.Guard. Tests
// inject one with fake DNS.
Guard *safefetch.Guard
// BaseTransport is the transport the guard clones. It must be a
// *http.Transport (the guard refuses anything else). Nil uses
// egress.Transport(egress.CapPlugins).
BaseTransport http.RoundTripper
// MaxResponseBytes caps buffered replies; zero uses the default,
// negative means the default too (a cap is always enforced).
MaxResponseBytes int64
// MaxStreamBytes caps streamed replies; zero means no cap beyond
// the caller's context (range-relayed media files are large).
MaxStreamBytes int64
// Timeout is the default per-call deadline; zero uses
// DefaultCallTimeout.
Timeout time.Duration
Logger *slog.Logger
}
URLOptions configures a URLClient.