remote

package
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 4, 2026 License: GPL-3.0 Imports: 30 Imported by: 0

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

View Source
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).

View Source
const (
	TunnelPath = "/ether/plugins/tunnel"
	HostPath   = "/ether/plugin-host/"
)

TunnelPath is where sidecars connect. HostPath is where remote plugins call Ether's host services.

View Source
const (
	DefaultMaxInFlight  = 32
	DefaultChunkBytes   = 64 << 10
	DefaultPingInterval = 20 * time.Second
)

Default tunnel limits.

View Source
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.

View Source
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).

View Source
const PairExchangePath = "/ether/plugins/pair"
View Source
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

View Source
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 reports a missing or rejected plugin credential.
	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 is returned when a service is not wired yet.
	ErrUnavailable = errors.New("remote: unavailable")
	// ErrClosed reports use of a closed runtime.
	ErrClosed = errors.New("remote: closed")
)
View Source
var ErrStreamNotHandled = errors.New("remote: stream handler declined")

ErrStreamNotHandled asks the tunnel client to use its buffered handler for a request.

Functions

func GrantsLAN

func GrantsLAN(grantsOf func(ctx context.Context, pluginID string) (plugins.Grants, error)) func(string) bool

GrantsLAN adapts a Manager-like grants lookup to Factory.TrustedLAN, bounding the lookup so opening a runtime never hangs on storage.

func IsBlocked

func IsBlocked(err error) bool

IsBlocked reports whether err is a safefetch refusal, so callers can show the owner a plain "address not allowed" reason.

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

type CredentialResolverFunc func(ctx context.Context, credential string) (string, error)

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

type HandlerFunc func(ctx context.Context, req Request) (Response, error)

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) KVGet

func (a HostAdapter) KVGet(ctx context.Context, pluginID, key string) ([]byte, bool, error)

func (HostAdapter) KVList

func (a HostAdapter) KVList(ctx context.Context, pluginID, prefix string) ([]string, error)

func (HostAdapter) KVPut

func (a HostAdapter) KVPut(ctx context.Context, pluginID, key string, value []byte) error

func (HostAdapter) Log

func (a HostAdapter) Log(ctx context.Context, pluginID, level, msg string, attrs map[string]any) error

func (HostAdapter) NotifyOwner

func (a HostAdapter) NotifyOwner(ctx context.Context, pluginID, text string) error

func (HostAdapter) SetWatchState

func (a HostAdapter) SetWatchState(ctx context.Context, pluginID string, viewerID int64, titleID string, watched bool) error

func (HostAdapter) Settings

func (a HostAdapter) Settings(ctx context.Context, pluginID string) (map[string]any, error)

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

func NewPairing(db *sql.DB, grants *identity.Grants, owner func(context.Context) (int64, error), enabled func(context.Context, string) bool, tunnel *TunnelServer) *Pairing

func (*Pairing) Exchange

func (p *Pairing) Exchange(w http.ResponseWriter, r *http.Request)

Exchange accepts a code once. A process-wide attempt budget protects even unknown codes, and the high-entropy code prevents distributed guessing.

func (*Pairing) WrapOwner

func (p *Pairing) WrapOwner(next http.Handler) http.Handler

WrapOwner is mounted under the already authenticated owner API.

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 Response

type Response struct {
	Status  int
	Headers map[string][]string
	Body    []byte
}

Response is one buffered plugin reply.

func (Response) Header

func (r Response) Header(name string) string

Header returns the first value of name, or "".

type Runtime

type Runtime interface {
	Call(ctx context.Context, req Request) (Response, error)
	Close() error
}

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

type StaticCredentials map[string]string

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).

func (*TunnelClient) Serve

func (c *TunnelClient) Serve(ctx context.Context) error

Serve connects and serves until ctx ends, reconnecting with capped exponential backoff after a drop. In-flight requests fail when a connection drops; Ether decides whether to retry them.

func (*TunnelClient) ServeOnce

func (c *TunnelClient) ServeOnce(ctx context.Context) error

ServeOnce serves one tunnel connection until it ends.

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) Base

func (c *URLClient) Base() string

Base returns the client's plugin base URL.

func (*URLClient) Call

func (c *URLClient) Call(ctx context.Context, req Request) (Response, error)

Call sends one request and buffers the reply up to the size cap.

func (*URLClient) CallStream

func (c *URLClient) CallStream(ctx context.Context, req Request) (*StreamResponse, error)

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.

func (*URLClient) Close

func (c *URLClient) Close() error

Close releases idle connections. Calls after Close fail.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL