Files

263 lines
10 KiB
TypeScript

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<string | null> {
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)'}`,
)
})