Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Interfaces

setupInterfaces() defines the network interfaces your service exposes and how they are made available to the user. This function runs on service install, update, and config save.

Network Reachability

Your package declares what it exposes. The user decides where it is reachable. An interface is bound to the server’s gateways, and the user enables or disables each resulting address individually from the service’s Interfaces tab. LAN addresses (the .local hostname, the LAN IP) are enabled by default; public IPv4 addresses are off by default.

Two consequences worth internalizing before you write any interface code:

  • type is a label, not a control. 'ui', 'api', and 'p2p' tell the user what an interface is for. They do not select a transport, grant public access, or imply anything about how the interface is reached.
  • Tor is opt-in and per-interface. Tor is not part of StartOS. The user installs the Tor service from the marketplace, and then explicitly adds an onion address to each interface they want on Tor — see Tor. Nothing your package does provisions one.

Warning

Never state — in README.md, instructions.md, a comment, or a plan — that a service “is exposed on Tor” or “is published to the internet.” Your package cannot know: no binding type, and no value of type, causes an onion or a clearnet address to exist. Describe what the interface serves and let the user decide how to reach it.

Single Interface

For a service with one web interface:

import { i18n } from './i18n'
import { sdk } from './sdk'

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const multi = sdk.MultiHost.of(effects, 'ui')
  const origin = await multi.bindPort(80, {
    protocol: 'http',
    preferredExternalPort: 80,
  })

  const ui = sdk.createInterface(effects, {
    name: i18n('Web Interface'),
    id: 'ui',
    description: i18n('The main web interface'),
    type: 'ui',
    masked: false,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })

  return [await origin.export([ui])]
})

Multiple Interfaces

Expose multiple paths (e.g., web UI and admin panel) from the same port:

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const multi = sdk.MultiHost.of(effects, 'web')
  const origin = await multi.bindPort(80, {
    protocol: 'http',
    preferredExternalPort: 80,
  })

  const ui = sdk.createInterface(effects, {
    name: i18n('Web UI'),
    id: 'ui',
    description: i18n('The web interface'),
    type: 'ui',
    masked: false,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })

  const admin = sdk.createInterface(effects, {
    name: i18n('Admin Panel'),
    id: 'admin',
    description: i18n('Admin interface'),
    type: 'ui',
    masked: false,
    schemeOverride: null,
    username: null,
    path: '/admin/',
    query: {},
  })

  return [await origin.export([ui, admin])]
})

Expose interfaces on separate ports:

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const receipts = []

  // Web UI — HTTP
  const uiMulti = sdk.MultiHost.of(effects, 'ui')
  const uiOrigin = await uiMulti.bindPort(80, {
    protocol: 'http',
    preferredExternalPort: 80,
  })
  const ui = sdk.createInterface(effects, {
    name: i18n('Web Interface'),
    id: 'ui',
    description: i18n('The main browser interface'),
    type: 'ui',
    masked: false,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })
  receipts.push(await uiOrigin.export([ui]))

  // API — HTTPS with SSL termination
  const apiMulti = sdk.MultiHost.of(effects, 'api')
  const apiOrigin = await apiMulti.bindPort(8080, {
    protocol: 'https',
    preferredExternalPort: 8080,
    addSsl: {
      alpn: null,
      preferredExternalPort: 8080,
      addXForwardedHeaders: false,
    },
  })
  const api = sdk.createInterface(effects, {
    name: i18n('REST API'),
    id: 'api',
    description: i18n('Programmatic access'),
    type: 'api',
    masked: true,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })
  receipts.push(await apiOrigin.export([api]))

  // Peer — raw TCP (not HTTP)
  const peerMulti = sdk.MultiHost.of(effects, 'peer')
  const peerOrigin = await peerMulti.bindPort(9735, {
    protocol: null,
    addSsl: null,
    preferredExternalPort: 9735,
    secure: { ssl: false },
  })
  const peer = sdk.createInterface(effects, {
    name: i18n('Peer Interface'),
    id: 'peer',
    description: i18n('Peer-to-peer network connections'),
    type: 'p2p',
    masked: true,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })
  receipts.push(await peerOrigin.export([peer]))

  return receipts
})

The key steps are:

  1. Create a MultiHost and bind a port with protocol and options
  2. Create one or more interfaces using sdk.createInterface()
  3. Export the interfaces from the origin and return the receipt(s)

bindPort Options

OptionTypeDescription
protocol'http' | 'https' | nullThe protocol. Use null for raw TCP (non-HTTP).
preferredExternalPortnumberThe port users will see in their URLs.
addSslobject | nullSSL termination options for HTTPS. Set to null for no SSL.
addSsl.alpnstring | nullALPN protocol negotiation (e.g., 'h2'). Usually null.
addSsl.preferredExternalPortnumberExternal port for SSL connections.
addSsl.addXForwardedHeadersbooleanWhether to add X-Forwarded-* headers.
addSsl.authProxyAuth | nullOptional auth gate enforced by the OS reverse proxy. See Authenticating at the Proxy.
addSsl.upstreamCertValidation'disable' | { certificate: string } | omittedHow the OS validates your container’s TLS cert when it rewraps SSL. Omit to validate against the StartOS root CA (default). See Rewrapping SSL.
secure{ ssl: boolean } | nullFor non-HTTP protocols, whether the connection is secure. { ssl: true } with addSsl: null serves your container’s own TLS end to end — see Serving Your Own TLS.

Interface Options

sdk.createInterface(effects, {
  name: i18n('Display Name'), // Shown in UI (wrap with i18n)
  id: 'unique-id', // How you find this interface under its host
  description: i18n('Description'), // Shown in UI (wrap with i18n)
  type: 'ui', // 'ui', 'api', or 'p2p'
  masked: false, // Hide URLs with sensitive credentials?
  schemeOverride: null, // Override URL scheme (see below)
  username: null, // Auth username embedded in URL
  path: '/some/path/', // URL path
  query: {}, // URL query params
})
OptionTypeDescription
namestringDisplay name shown to the user. Wrap with i18n().
idstringUnique identifier. How you find this interface at runtime, by walking the host from sdk.host.getOwn() (see main.ts).
descriptionstringDescription shown to the user. Wrap with i18n().
type'ui', 'api', or 'p2p''ui' for browser interfaces, 'api' for programmatic endpoints, 'p2p' for peer-to-peer connections.
maskedbooleanIf true, the interface URL is shown as a copyable secret. Use for URLs containing credentials or tokens.
schemeOverride{ ssl: string | null; noSsl: string | null } | nullOverride the URL scheme for custom protocols. For example, { ssl: 'lndconnect', noSsl: 'lndconnect' } produces lndconnect:// URLs. Use null for standard http/https.
usernamestring | nullUsername embedded in the URL (e.g., for smp://fingerprint:password@host).
pathstringURL path appended to the base address (e.g., '/admin/').
queryobjectURL query parameters as key-value pairs (e.g., { macaroon: 'abc123' }).

Tip

The id you assign to an interface is what you use in main.ts to retrieve hostnames for it. Interfaces are reached through their host: sdk.host.getOwn(effects, hostId) returns the host, and the interface lives at host.bindings[internalPort].interfaces[id]. See Main for details.

Port Ranges

Some services need a contiguous block of ports rather than a single one — coturn / RTP media relays, bitcoin’s ZMQ notification endpoints, passive-FTP data ports. Use bindPortRange instead of one bindPort per port:

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const turn = sdk.MultiHost.of(effects, 'turn')
  const range = await turn.bindPortRange({
    internalStartPort: 49152,
    externalStartPort: 49152, // may differ; the forward maps by offset
    numberOfPorts: 100, // 2–500 contiguous ports
  })

  await range.export(
    sdk.createRangeInterface(effects, {
      id: 'turn-relay',
      name: i18n('TURN Relay'),
      description: i18n('WebRTC media relay ports'),
    }),
  )
  return []
})

A range binds TCP + UDP together and exposes exactly one api service interface spanning the whole range. The interface is deliberately restricted compared to createInterface: it is always type: 'api' and has no masked, username, path, query, or schemeOverride. The one extra option is an optional scheme — a transport prefix for protocols addressed as scheme://host:port, e.g. tcp for bitcoin ZMQ:

const zmq = sdk.MultiHost.of(effects, 'zmq')
const zmqRange = await zmq.bindPortRange({
  internalStartPort: 28332,
  externalStartPort: 28332,
  numberOfPorts: 2,
})
await zmqRange.export(
  sdk.createRangeInterface(effects, {
    id: 'zmq',
    name: i18n('ZMQ'),
    description: i18n('Bitcoin ZMQ notification endpoints'),
    scheme: 'tcp', // omit for raw UDP/TCP ranges (coturn, RTP, FTP data)
  }),
)

Two distinct endpoints are two bindPortRange calls — a range is a homogeneous pool of ports, so it maps to one named interface. Range interfaces show up in the service’s Interfaces page using the same per-gateway address cards as single-port interfaces (non-SSL, IPv4-only). The public/WAN address is disabled by default; enabling it surfaces the exact port range to forward on the router.

createRangeInterface optionTypeDescription
idstringUnique identifier for the range interface.
namestringDisplay name shown to the user. Wrap with i18n().
descriptionstringDescription shown to the user. Wrap with i18n().
schemestring | nullOptional transport prefix (e.g. 'tcp'). Omit for raw UDP/TCP ranges.

TLS Termination

StartOS terminates TLS at the platform edge and proxies plain HTTP to your container. This has two important consequences any time your service generates URLs or makes scheme decisions:

1. Inside the container, every request arrives over HTTP. A reverse proxy like nginx will see $scheme == "http", the X-Forwarded-Proto header is not authoritative by default, and there is no TLS certificate to terminate. Do not configure in-container HTTPS — StartOS is already doing it.

2. The browser loaded the page over https://. Any URL your service emits for the browser to consume (login redirects, API endpoints in a config.json, OAuth callbacks, absolute links in HTML) must use https://. If you emit http:// or derive the scheme from $scheme, the browser will block the request as mixed active content.

Hardcode https:// for browser-facing URLs rather than interpolating $scheme or reading the protocol from the incoming request:

# BAD — $scheme is always "http" inside the container
return 200 '{"api_url":"$scheme://$host/api"}';

# GOOD — match what the browser actually sees
return 200 '{"api_url":"https://$host/api"}';

This applies to any configuration file generated in setupMain or any runtime response that includes absolute URLs — not just nginx. When in doubt, hardcode https://.

Rewrapping SSL to a TLS container

The guidance above (“do not configure in-container HTTPS”) applies when StartOS terminates TLS and forwards plain HTTP — the http/ws protocols. The https/wss protocols are different: the container serves its own TLS, StartOS terminates the client’s TLS at the edge, and then opens a fresh TLS connection to your container (a “rewrap”). This happens whenever addSsl is set and the protocol’s secure.ssl is true.

On that inner OS→container leg, StartOS validates your container’s certificate. By default it requires a certificate signed by the StartOS root CA. A container serving a self-signed certificate on the internal bridge will fail that check, so use addSsl.upstreamCertValidation to control it:

ValueBehavior
omittedValidate against the StartOS root CA (default).
'disable'Skip certificate validation entirely. Appropriate for a self-signed cert on the trusted internal bridge.
{ certificate: '<pem>' }Validate against the supplied PEM certificate/chain instead of the root CA.
const origin = await multi.bindPort(443, {
  protocol: 'https',
  addSsl: {
    upstreamCertValidation: 'disable', // container serves its own self-signed cert
  },
})

Note

For { certificate }, StartOS connects to the container by IP, so the pinned certificate must be valid for that internal IP (present in its SANs). If it isn’t, use 'disable' instead.

Serving Your Own TLS (Passthrough)

There is a third arrangement, distinct from both plain termination and the rewrap: passthrough, where your container’s certificate reaches the client unmodified. Set secure: { ssl: true } with no addSsl:

const origin = await multi.bindPort(10009, {
  protocol: null,
  addSsl: null,
  preferredExternalPort: 10009,
  secure: { ssl: true },
})

StartOS still fronts the port with one of its TLS listeners, but that listener pipes the raw TLS stream through instead of terminating it, so nothing about the handshake is rewritten. The container sees the client’s real source address rather than the proxy’s — except for a client on the box itself, which appears as the bridge IP.

When to use it

Reach for passthrough only when the rewrap genuinely cannot serve, which is one of two cases:

  1. The client must verify your container’s own certificate. A wallet that pins a certificate carried in a connection URI can only do so if the certificate it pins is the one actually served.
  2. The handshake carries something a rewrap does not. ALPN is the concrete case: StartOS negotiates no ALPN with the client across an addSsl rewrap, and gRPC-go rejects a connection with no selected ALPN (missing selected ALPN property). LND binds its gRPC interface this way for exactly that reason.

Otherwise prefer addSsl. Passthrough gives up everything the proxy does on your behalf:

CapabilityaddSslPassthrough
Certificate the client seesThe device certificateYour container’s
Proxy auth (addSsl.auth)AvailableNot available — auth lives under addSsl
X-Forwarded-* headersAvailableNot applicable
ACME on a custom domainStartOS obtains and renews itSkipped — your container is the ACME client
UDP on the same portNot applicableNo; the port accepts TLS only
Certificate issuance and renewalHandled by the platformYours to handle

Minting the certificate

sdk.getSslCertificate returns a PEM fullchain — leaf, intermediate, StartOS root CA — for the hostnames you name, and sdk.getSslKey returns the matching key. Because the chain terminates at the StartOS root CA, a client that already trusts the box validates your certificate without pinning anything.

The SANs are the whole contract. Nothing rewrites the handshake, so the certificate must be valid for every address a client actually dials — there is no proxy to paper over a mismatch:

  • sdk.getOsIp (10.0.3.1) — the bridge, where other services reach you
  • 127.0.0.1 — your own subcontainers, which share the service’s network namespace
  • sdk.getContainerIp — the container itself
  • any other address you expect a client to use
export const setupCerts = sdk.setupOnInit(async effects => {
  const hostnames = [await sdk.getContainerIp(effects).const(), '127.0.0.1', await sdk.getOsIp(effects)]
  const cert = (await sdk.getSslCertificate(effects, hostnames).const()).join('')
  const key = await sdk.getSslKey(effects, { hostnames })
  await writeFile('/media/startos/volumes/main/tls.cert', cert)
  await writeFile('/media/startos/volumes/main/tls.key', key)
})

Read the container IP with .const() rather than .once(): a container that comes back on a new IP must reissue the certificate, or every client dialing the old one fails verification.

Warning

Do not add a <package-id>.startos DNS name to the SANs. That overlay DNS is deprecated and slated for removal, and it resolves to the container IP rather than the bridge — so it bypasses the platform entirely. Dependents reach you through the bridge; see Service-to-Service Networking.

A passthrough port carries its external port in net.assignedSslPort, the same as an addSsl port — which of the two fields is populated says whether the port speaks TLS, not who terminates it. Dependents should read neither field directly; sdk.host.getBridgeAddress resolves the binding’s derived address and is correct under every arrangement on this page.

Authenticating at the Proxy

For protocols that StartOS fronts with its reverse proxy (http, https, ws, wss), you can gate an interface with HTTP authentication by setting addSsl.auth. The OS reverse proxy validates the Authorization header on every incoming request before forwarding it to your container. Requests that fail get 401 Unauthorized with a WWW-Authenticate challenge and never reach your service. You do not need to build auth into the service or run a sidecar proxy — the platform enforces it at the edge.

auth takes a ProxyAuth, which is one of two shapes:

// Basic — one or more username/password pairs; any match passes
const uiOrigin = await uiMulti.bindPort(uiPort, {
  protocol: 'http',
  addSsl: {
    auth: {
      type: 'basic',
      credentials: [{ username: 'admin', password }],
      realm: null, // advertised in the WWW-Authenticate challenge; defaults to "StartOS"
    },
  },
})

// Bearer — any of the listed tokens is accepted as `Authorization: Bearer <token>`
const apiOrigin = await apiMulti.bindPort(apiPort, {
  protocol: 'https',
  addSsl: {
    auth: { type: 'bearer', tokens: [apiToken], realm: null },
  },
})
ProxyAuth fieldTypeDescription
type'basic' | 'bearer'The auth scheme the proxy enforces.
credentials (basic)Array<{ username, password }>Accepted pairs. Any match passes. The matched username is forwarded upstream as X-Forwarded-User.
tokens (bearer)Array<string>Accepted bearer tokens. Any match passes.
realmstring | nullRealm advertised in the 401 WWW-Authenticate challenge. Defaults to "StartOS". Use a stable realm across bindings that share credentials so browsers reuse them.

Setting auth implies HTTP-aware proxying, so it is only valid on the SSL-variant protocols above — not on raw TCP (protocol: null).

Note

The username field on createInterface is unrelated to this gate — it only embeds a username in the displayed URL (e.g. https://user@host/). The enforced credential check is addSsl.auth.

Generating and rotating credentials

Don’t hard-code the password. Generate it at install time and let the user rotate it through an action. Store the credential in a file model such as store.json and read it reactively in setupInterfaces — when the action rewrites the stored value, setupInterfaces re-runs and the proxy picks up the new credential automatically:

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const password = await storeJson.read(s => s.uiPassword).const(effects)

  const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
  const uiOrigin = await uiMulti.bindPort(uiPort, {
    protocol: 'http',
    addSsl: {
      auth: { type: 'basic', credentials: [{ username: 'admin', password }], realm: null },
    },
  })

  const ui = sdk.createInterface(effects, {
    name: i18n('Web UI'),
    id: 'ui',
    description: i18n('The web interface'),
    type: 'ui',
    masked: false,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })

  return [await uiOrigin.export([ui])]
})

Seed uiPassword with a generated value during install init so the gate is active from first start, and pair it with a reset-password action that rewrites the stored value and surfaces it to the user once. See Reset Password.