import { sdk } from '../sdk' import { FileHelper } from '@start9labs/start-sdk' import { Volume } from '@start9labs/start-sdk/package/lib/util/Volume' import { z } from 'zod' const mainVolume = new Volume('main') // File the relay container reads to learn the URLs of co-installed // services. Written here at install/update time via the StartOS SDK's // service-interface lookup; the container exposes it via // /admin/btcpay/discover so the dashboard can offer one-click setup. const discoveryFile = FileHelper.json( { base: mainVolume, subpath: 'discovered-services.json', }, z.object({ btcpay: z .object({ // LAN URL the operator's browser uses (with port). Used as // the base for the /api-keys/authorize redirect page. browser_url: z.string().nullable(), // Public clearnet URL buyers use to pay. Used by the // relay's rewriteCheckoutUrl to swap the host on BTCPay's // returned checkout link before handing it to a buyer. public_url: z.string().nullable().optional(), // Internal Start9 hostname for container-to-container calls. // Faster than the LAN URL since it stays inside docker. internal_url: z.string().nullable(), // ISO timestamp of last discovery — useful for debugging // stale entries if BTCPay has since been uninstalled. discovered_at: z.string().nullable(), }) .nullable(), // The relay's own clearnet URL — used as the webhook URL we // register with BTCPay. BTCPay's container needs to be able to // resolve this to post InvoiceSettled events; mDNS .local // doesn't resolve inside docker. Clearnet does. self: z .object({ public_url: z.string().nullable().optional(), }) .nullable() .optional(), }), ) // Look up BTCPay's service interfaces. Returns three URLs (one may // be null) used for different audiences: // - browser_url: what the OPERATOR opens in their browser for the // authorize flow (mDNS / LAN preferred — same-LAN // flow keeps everything local) // - public_url: what BUYERS open to pay (clearnet preferred — // buyers may be anywhere on the internet, not on // the operator's LAN). Used by rewriteCheckoutUrl // on the BTCPay-returned checkout link. // - internal_url: what the RELAY container uses for daemon-to- // daemon API calls (Start9 docker internal hostname). // // Returns null when BTCPay isn't installed or has no 'ui' interface. // // Picking the right URL is the whole trick. addressInfo.format() // returns EVERY hostname mapped to this binding — clearnet domain, // mDNS `.local`, onion, private IPs, link-local v6, and the LXC // bridge gateway (`10.0.3.1` shape) the relay container sees from // inside docker. Almost all of these are wrong for a browser link // the operator clicks in StartOS dashboard: // // - bridge IP (10.0.3.x) — only resolvable from inside the host // - link-local v6 (fe80::/10) — same problem // - localhost (127.0.0.1, ::1) — useless // - public domain (clearnet) — best, if configured // - mDNS .local — what operators actually use day-to-day // - private DNS — also fine // // Order of preference for the operator-facing browser URL: // 1. mDNS .local — same-LAN browser hop, keeps the auth + callback // flow entirely local. Most operators run BTCPay // and the relay on the same Start9, so mDNS Just // Works without involving any clearnet hop. // 2. private domain (LAN DNS, if the operator runs one) // 3. public clearnet domain — fallback for remote operators // 4. anything non-local that isn't bridge/link-local/localhost async function discoverBtcpay(effects: any): Promise<{ browser_url: string | null public_url: string | null internal_url: string | null } | null> { try { const ifaces = await sdk.serviceInterface .getAll(effects, { packageId: 'btcpayserver' }) .const() if (!ifaces || ifaces.length === 0) return null const ui = ifaces.find((i: any) => i?.type === 'ui') if (!ui?.addressInfo) return null const addr = ui.addressInfo let browserUrl: string | null = null // Each .filter() narrows the address set; .format() returns URL // strings. Walk preference tiers and stop at the first that // yields any URL. Wrapped in try/catch each tier because filter // calls can throw on empty matches in some SDK versions. // Walk preference tiers, stop at the first that yields a URL. const firstUrl = (urls: any): string | null => { if (Array.isArray(urls) && urls.length > 0 && urls[0]) return String(urls[0]) return null } const browserTries: Array<() => string | null> = [ // 1. mDNS .local — same-LAN flow () => firstUrl(addr.filter({ kind: 'mdns' }).format?.()), // 2. Private LAN domain — operator's own LAN DNS, if any () => firstUrl(addr.filter({ visibility: 'private', kind: 'domain' }).format?.()), // 3. Public clearnet domain — fallback for remote operators () => firstUrl(addr.public?.filter({ kind: 'domain' }).format?.()), // 4. Any non-local address (excludes localhost, link-local, bridge) () => firstUrl( addr.nonLocal ?.filter({ exclude: { kind: ['bridge', 'link-local', 'localhost'] }, }) .format?.(), ), ] for (const attempt of browserTries) { try { const u = attempt() if (u) { browserUrl = u break } } catch { // try next tier } } // Public buyer-facing URL — used to rewrite BTCPay's returned // checkout link (`btcpayserver.startos:23000/i/...`) before // handing it to the buyer's browser. Buyer may be on clearnet // (e.g. accessing Recap via a StartTunnel public domain), so we // strongly prefer a clearnet domain here. Falls through to mDNS // / private DNS only when BTCPay isn't exposed on clearnet — // those operators can only sell to LAN buyers anyway. let publicUrl: string | null = null const publicTries: Array<() => string | null> = [ // 1. Public clearnet domain — works for any buyer on the internet () => firstUrl(addr.public?.filter({ kind: 'domain' }).format?.()), // 2. Private LAN domain () => firstUrl(addr.filter({ visibility: 'private', kind: 'domain' }).format?.()), // 3. mDNS .local — same-LAN buyers only () => firstUrl(addr.filter({ kind: 'mdns' }).format?.()), ] for (const attempt of publicTries) { try { const u = attempt() if (u) { publicUrl = u break } } catch { // try next tier } } // Server-to-server URL is the Start9 internal docker hostname. // This works because we declare btcpayserver as a required // `running` dependency in dependencies.ts — Start9 wires up the // DNS link inside our container as part of that declaration. // Port 23000 is BTCPay's standard internal container port (every // Start9 BTCPay install uses this); hardcoded rather than // discovered via the SDK because it's stable across BTCPay // versions and saves us a fragile lookup path. // // This mirrors how Keysat's licensing-service reaches BTCPay on // a co-installed Start9 — same hostname, same port. const internalUrl = 'http://btcpayserver.startos:23000' return { browser_url: browserUrl, public_url: publicUrl, internal_url: internalUrl, } } catch (err: any) { console.warn( `[setup] BTCPay discovery skipped: ${err?.message || err}`, ) return null } } // Discover the relay's OWN clearnet URL by walking our own service // interfaces. Used as the webhook URL we register with BTCPay — // BTCPay's container needs a hostname it can resolve internally // (clearnet via standard DNS works, mDNS doesn't). async function discoverSelfPublicUrl(effects: any): Promise { try { const ifaces = await sdk.serviceInterface.getAllOwn(effects).const() if (!ifaces || ifaces.length === 0) return null const ui = ifaces.find((i: any) => i?.type === 'ui') || ifaces[0] if (!ui?.addressInfo) return null const addr = ui.addressInfo const firstUrl = (urls: any): string | null => { if (Array.isArray(urls) && urls.length > 0 && urls[0]) return String(urls[0]) return null } // Priority: public clearnet (best for BTCPay container's DNS), // then private domain. mDNS won't resolve from BTCPay's // container so we explicitly skip it. const tries: Array<() => string | null> = [ () => firstUrl(addr.public?.filter({ kind: 'domain' }).format?.()), () => firstUrl(addr.filter({ visibility: 'private', kind: 'domain' }).format?.()), ] for (const attempt of tries) { try { const u = attempt() if (u) return u } catch { // try next } } return null } catch (err: any) { console.warn(`[setup] self-discovery skipped: ${err?.message || err}`) return null } } export const setup = sdk.setupOnInit(async (effects, kind) => { // Probe for BTCPay on every install / update. We write the result // every time (even on "no BTCPay found") so the container always // sees a fresh snapshot — never a leftover hit from a previous // version that has since been uninstalled. const btcpay = await discoverBtcpay(effects) const selfPublicUrl = await discoverSelfPublicUrl(effects) await discoveryFile.write(effects, { btcpay: btcpay ? { ...btcpay, discovered_at: new Date().toISOString(), } : null, self: selfPublicUrl ? { public_url: selfPublicUrl } : null, }) if (btcpay) { console.info( `[setup] discovered BTCPay at ${btcpay.browser_url || '(no browser URL)'}`, ) } else { console.info( `[setup] BTCPay not detected on this Start9 — credit-purchase flow will require a manual URL`, ) } console.info( `[setup] self clearnet URL: ${selfPublicUrl || '(none — webhooks will use the operator-facing host header at finalize time, may fail if mDNS)'}`, ) })