v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
This commit is contained in:
+202
-15
@@ -1,41 +1,228 @@
|
||||
// Daemon definition — the thing that actually runs when the service is
|
||||
// started. Passes configuration into the Rust binary via environment
|
||||
// variables, same interface as `.env.example` in the upstream project.
|
||||
// variables (same interface as `.env.example` in the upstream project).
|
||||
//
|
||||
// SDK 0.4.0 shape:
|
||||
// - `setupMain(async ({ effects }) => Daemons)` — no `started` any more.
|
||||
// - Mounts are built via `sdk.Mounts.of().mountVolume(...)` (immutable
|
||||
// builder) and passed as a single object to `sdk.SubContainer.of`.
|
||||
// - Daemons are created via `sdk.Daemons.of(effects)` (effects directly).
|
||||
// - Store reads use the FileHelper reactive API: `.read().const(effects)`
|
||||
// so the daemon re-runs if the store changes at runtime.
|
||||
// - The public URL is read from our own `api` service interface via
|
||||
// `sdk.serviceInterface.getOwn(...).const()` + `.addressInfo.nonLocal.format()`.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
import { store } from './fileModels/store'
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects, started }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
/**
|
||||
* Pick a URL from a service interface's address list that's actually
|
||||
* reachable from the operator's normal-LAN browser.
|
||||
*
|
||||
* StartOS hands us a list of URLs the service is reachable on, but they
|
||||
* vary in who-can-reach-them:
|
||||
* - mDNS `.local` hostname → reachable on the operator's LAN
|
||||
* - LAN RFC1918 IP (192.168, etc) → reachable on the operator's LAN
|
||||
* - public clearnet URL → reachable from anywhere
|
||||
* - StartTunnel local IP (10.59) → only reachable inside StartOS
|
||||
* - .startos bridge hostname → only reachable inside containers
|
||||
* - localhost / 127.x → only reachable inside the container
|
||||
*
|
||||
* Naively picking `addressInfo.nonLocal.format()[0]` can land on the
|
||||
* StartTunnel-local IP, which breaks any flow where the operator's
|
||||
* browser actually has to follow the URL. This helper ranks URLs by
|
||||
* realistic browser reachability instead.
|
||||
*/
|
||||
// Shared URL filters used by both pickers below.
|
||||
function isLocalhost(u: string): boolean {
|
||||
return (
|
||||
u.startsWith('http://localhost') ||
|
||||
u.startsWith('https://localhost') ||
|
||||
u.startsWith('http://127.') ||
|
||||
u.startsWith('https://127.')
|
||||
)
|
||||
}
|
||||
function isBridge(u: string): boolean {
|
||||
return u.includes('.startos:')
|
||||
}
|
||||
function isMdns(u: string): boolean {
|
||||
return /\/\/[^/:]+\.local(:|\/)/.test(u)
|
||||
}
|
||||
function isRfc1918(u: string): boolean {
|
||||
return (
|
||||
/\/\/192\.168\.\d+\.\d+(:|\/)/.test(u) ||
|
||||
/\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:|\/)/.test(u) ||
|
||||
// Real RFC1918 10.0.0.0/8 — but exclude StartTunnel's 10.59.x.x range
|
||||
// which is StartOS-internal and not reachable from a normal browser.
|
||||
(/\/\/10\.\d+\.\d+\.\d+(:|\/)/.test(u) && !/\/\/10\.59\./.test(u))
|
||||
)
|
||||
}
|
||||
function isStarttunnelLocal(u: string): boolean {
|
||||
return /\/\/10\.59\./.test(u)
|
||||
}
|
||||
function isIpv4(u: string): boolean {
|
||||
return /\/\/\d+\.\d+\.\d+\.\d+(:|\/)/.test(u)
|
||||
}
|
||||
function isIpv6Bracketed(u: string): boolean {
|
||||
return /\/\/\[/.test(u)
|
||||
}
|
||||
|
||||
// Public URL the service advertises to buyers / referenced in webhooks.
|
||||
// We read our own primary interface address from StartOS at runtime so
|
||||
// this works whether the operator exposes on Tor, LAN, or clearnet.
|
||||
const publicUrl = await sdk.serviceInterface
|
||||
.getOwn(effects, 'api')
|
||||
/// Pick a URL the OPERATOR's browser can reach during one-time setup
|
||||
/// flows (OAuth authorize, etc.). Operator is typically on the same
|
||||
/// LAN as the Start9, so mDNS / RFC1918 LAN URLs are preferred —
|
||||
/// they're faster and don't depend on Cloudflare being up.
|
||||
function pickBrowserUrl(
|
||||
allUrls: string[],
|
||||
addrInfo?: { nonLocal: { format(): string[] } } | null | undefined,
|
||||
): string | undefined {
|
||||
const browserUsable = (u: string) =>
|
||||
!isLocalhost(u) && !isBridge(u) && !isStarttunnelLocal(u)
|
||||
|
||||
const mdnsUrls = allUrls.filter((u) => isMdns(u) && browserUsable(u))
|
||||
if (mdnsUrls.length > 0) return mdnsUrls[0]
|
||||
const lanUrls = allUrls.filter((u) => isRfc1918(u) && browserUsable(u))
|
||||
if (lanUrls.length > 0) return lanUrls[0]
|
||||
const nonLocalUrls = (addrInfo?.nonLocal.format() ?? []).filter(browserUsable)
|
||||
if (nonLocalUrls.length > 0) return nonLocalUrls[0]
|
||||
const anyUsable = allUrls.filter(browserUsable)
|
||||
if (anyUsable.length > 0) return anyUsable[0]
|
||||
return undefined
|
||||
}
|
||||
|
||||
/// Pick a URL that BUYERS on the public internet can reach. Used to
|
||||
/// rewrite checkout URLs so they're browser-reachable from anywhere.
|
||||
/// Prefers domain-named URLs (clearnet via StartTunnel) over IP/mDNS
|
||||
/// addresses. Falls back to LAN/mDNS only if no public domain is set
|
||||
/// up — useful for local testing but won't work for real customers.
|
||||
function pickPublicUrl(allUrls: string[]): string | undefined {
|
||||
const usable = allUrls.filter(
|
||||
(u) => !isLocalhost(u) && !isBridge(u) && !isStarttunnelLocal(u),
|
||||
)
|
||||
// Prefer URLs with a real domain name (no IP, no .local).
|
||||
const clearnet = usable.filter(
|
||||
(u) => !isIpv4(u) && !isIpv6Bracketed(u) && !isMdns(u),
|
||||
)
|
||||
if (clearnet.length > 0) return clearnet[0]
|
||||
// Fall back to LAN (still browser-reachable for testing on the same network).
|
||||
const mdns = usable.filter(isMdns)
|
||||
if (mdns.length > 0) return mdns[0]
|
||||
const lan = usable.filter(isRfc1918)
|
||||
if (lan.length > 0) return lan[0]
|
||||
return usable[0]
|
||||
}
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects }) => {
|
||||
const storeData = await store.read().const(effects)
|
||||
if (!storeData) {
|
||||
// Init should always run before main, so this is a real error.
|
||||
throw new Error(
|
||||
'Keysat store.json is missing — init did not run. Try restarting the service.',
|
||||
)
|
||||
}
|
||||
|
||||
// Public URL advertised to buyers / baked into webhook payloads. We read
|
||||
// our own `api` interface from StartOS at runtime so this works whether the
|
||||
// operator exposes on Tor, LAN, or clearnet. `.nonLocal` filters out
|
||||
// localhost/link-local; we pick the first resulting URL, falling back to
|
||||
// localhost only if StartOS hasn't filled in the interface yet.
|
||||
// Pick a browser-reachable URL for ourselves. This is what we hand to
|
||||
// BTCPay as the OAuth redirect_uri (the operator's browser follows it
|
||||
// after clicking Authorize), and it's the URL buyers later use to
|
||||
// poll purchase status. Same ranking logic as for BTCPay's URL —
|
||||
// prefer mDNS .local and RFC1918 LAN IPs, deprioritize StartTunnel
|
||||
// local addresses (10.59.x.x), avoid localhost / bridge.
|
||||
const iface = await sdk.serviceInterface.getOwn(effects, 'api').const()
|
||||
const ownAllUrls = iface?.addressInfo?.format() ?? []
|
||||
// Use the PUBLIC-preferred picker for our own URL — buyers redirected
|
||||
// back from BTCPay after payment hit this URL with their browser; it
|
||||
// needs to be clearnet-resolvable. Falls back to the operator-facing
|
||||
// mDNS/LAN URL if no clearnet domain is set up.
|
||||
const publicUrl =
|
||||
pickPublicUrl(ownAllUrls) ??
|
||||
pickBrowserUrl(ownAllUrls, iface?.addressInfo) ??
|
||||
'http://localhost:8080'
|
||||
|
||||
// BTCPay's PUBLIC web UI URL — distinct from the internal-network
|
||||
// hostname we use for daemon-to-daemon API calls. The operator's
|
||||
// browser is redirected here to authorize Keysat against BTCPay; that
|
||||
// means the URL must be resolvable from a normal browser.
|
||||
//
|
||||
// We can't hardcode BTCPay's interface ID because it's package-
|
||||
// specific (and the previous version of this code guessed wrong by
|
||||
// assuming `'ui'`). Instead, fetch ALL interfaces BTCPay exposes,
|
||||
// pick the one whose TYPE is `'ui'`, and read its address list.
|
||||
// Within that, prefer non-local URLs but accept LAN URLs as a
|
||||
// fallback (they're perfectly browser-reachable for the operator).
|
||||
const btcpayIfaces = await sdk.serviceInterface
|
||||
.getAll(effects, { packageId: 'btcpayserver' })
|
||||
.const()
|
||||
.then((i) => i?.addressInfo?.urls?.[0] ?? 'http://localhost:8080')
|
||||
const ifaceList = btcpayIfaces ?? []
|
||||
const uiIface = ifaceList.find((i) => i.type === 'ui') ?? null
|
||||
const btcpayAllUrls = uiIface?.addressInfo?.format() ?? []
|
||||
const btcpayBrowserUrl = pickBrowserUrl(btcpayAllUrls, uiIface?.addressInfo) ?? ''
|
||||
// PUBLIC URL preference is different — for buyer-facing checkout
|
||||
// URLs we want a clearnet domain that random internet customers
|
||||
// can resolve. Falls back to the operator-facing browser URL (mDNS/
|
||||
// LAN) if no clearnet domain is set up; that's only useful for
|
||||
// local testing but won't break production.
|
||||
const btcpayPublicUrl = pickPublicUrl(btcpayAllUrls) ?? btcpayBrowserUrl
|
||||
console.info(
|
||||
`Keysat BTCPay lookup: ${ifaceList.length} interface(s) declared by btcpayserver. ` +
|
||||
`Types found: [${ifaceList.map((i) => `${i.id}:${i.type}`).join(', ')}]. ` +
|
||||
`Selected ui interface id="${uiIface?.id ?? '(none)'}". ` +
|
||||
`Picked browser URL "${btcpayBrowserUrl || '(none)'}". ` +
|
||||
`Picked public URL "${btcpayPublicUrl || '(none — falling back to internal URL)'}".`,
|
||||
)
|
||||
|
||||
const mounts = sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
mountpoint: '/data',
|
||||
subpath: null,
|
||||
readonly: false,
|
||||
})
|
||||
|
||||
const sub = await sdk.SubContainer.of(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
[{ mountpoint: '/data', volumeId: 'main', subpath: null, readonly: false }],
|
||||
mounts,
|
||||
'keysat',
|
||||
)
|
||||
|
||||
return sdk.Daemons.of({ effects, started, healthReceipts: [] }).addDaemon('primary', {
|
||||
return sdk.Daemons.of(effects).addDaemon('primary', {
|
||||
subcontainer: sub,
|
||||
exec: {
|
||||
// Use the Dockerfile's ENTRYPOINT / CMD instead of hardcoding a command
|
||||
// here; the image is the source of truth for how to launch the binary.
|
||||
command: sdk.useEntrypoint(),
|
||||
env: {
|
||||
KEYSAT_BIND: '0.0.0.0:8080',
|
||||
KEYSAT_DB_PATH: '/data/keysat.db',
|
||||
KEYSAT_PUBLIC_URL: publicUrl,
|
||||
KEYSAT_ADMIN_API_KEY: store.admin_api_key,
|
||||
KEYSAT_OPERATOR_NAME: store.operator_name,
|
||||
// Reachable because of our dependency on btcpayserver.
|
||||
KEYSAT_ADMIN_API_KEY: storeData.admin_api_key,
|
||||
KEYSAT_OPERATOR_NAME: storeData.operator_name,
|
||||
// Reachable because of our dependency on btcpayserver. This is
|
||||
// the INTERNAL hostname used for daemon-to-daemon API calls.
|
||||
// Keysat's container can't reliably reach the public StartTunnel
|
||||
// URL from outside (egress is restricted), so all BTCPay API
|
||||
// traffic stays on the local Docker network — fast + always
|
||||
// reachable. The downside (BTCPay returns checkout URLs with
|
||||
// this internal hostname) is mitigated in the daemon: we
|
||||
// rewrite the host of every checkout URL to the public
|
||||
// BTCPAY_BROWSER_URL before handing it back to a buyer.
|
||||
BTCPAY_URL: 'http://btcpayserver.startos:23000',
|
||||
// BTCPay's web UI URL for OPERATOR-facing browser redirects
|
||||
// (the OAuth-style authorize flow). Operator is on the same
|
||||
// LAN as the Start9 typically, so this prefers mDNS / LAN.
|
||||
BTCPAY_BROWSER_URL: btcpayBrowserUrl,
|
||||
// BTCPay's PUBLIC URL for BUYER-facing redirects. Used by the
|
||||
// daemon to rewrite checkout URLs returned by BTCPay so they
|
||||
// resolve from random internet browsers. Prefers clearnet
|
||||
// domain names (e.g. `https://btcpay.your-domain.com`); falls
|
||||
// back to LAN/mDNS only if no public domain is set up. If
|
||||
// empty, daemon won't rewrite (only useful for local testing).
|
||||
BTCPAY_PUBLIC_URL: btcpayPublicUrl,
|
||||
// The three credentials below are left empty in the normal case —
|
||||
// the daemon now persists them in its own DB after the one-click
|
||||
// the daemon persists them in its own DB after the one-click
|
||||
// "Connect BTCPay" action completes. Only seed them here if you are
|
||||
// migrating from a pre-authorize-flow install.
|
||||
BTCPAY_API_KEY: '',
|
||||
|
||||
Reference in New Issue
Block a user