Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions packages/cli/src/commands/handlers/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { RemoteTunnel } from "../../services/remote-tunnel"
import { ServiceConfig } from "../../services/service-config"
import { QrBuild } from "../../ui/qr-build"

export default Runtime.handler(
Commands.commands.pair,
Expand All @@ -18,16 +19,35 @@ export default Runtime.handler(
)
if (input.remote && Option.isSome(input.url))
return yield* Effect.fail(new Error("--remote cannot be combined with --url"))
// Changing the setting restarts the service, and the ensure below starts it again with the tunnel.
if (input.remote && config.remote !== true) yield* ServiceConfig.set("remote", "true")
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = yield* pairingURLs(client, input)
const pairing = yield* Effect.tryPromise(() => client.server.pair())
// A device's first tunnel takes about half a minute (its certificate). In a terminal, the QR code is built
// where it will be printed while that runs; the link's length is known, so the code's shape is too.
const build =
input.remote && config.remote === undefined && QrBuild.enabled() && !(yield* RemoteTunnel.created())
? // The route is 16 random hex characters, the tunnel ID 12, the pairing code 22.
QrBuild.start(`https://${"x".repeat(16)}.${"x".repeat(12)}.opentunnel.xyz/auth/connect/${"x".repeat(22)}`)
: undefined
const setup = Effect.gen(function* () {
if (build) {
yield* RemoteTunnel.ensure((stage) => build.stage(QrBuild.stageOf(stage)))
build.stage("connecting")
}
// Changing the setting restarts the service, and the ensure below starts it again with the tunnel.
if (input.remote && config.remote === undefined) yield* ServiceConfig.set("remote", "true")
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = yield* pairingURLs(client, input)
const pairing = yield* Effect.tryPromise(() => client.server.pair())
return { endpoint, urls, pairing }
})
const { endpoint, urls, pairing } = yield* build ? setup.pipe(Effect.onError(() => Effect.sync(build.clear))) : setup
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
// Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses.
const remote = urls.filter((url) => !isLoopback(new URL(url).hostname))
process.stdout.write(
// --remote pairs through one public https address, so its QR code carries the link itself: a phone's camera
// opens it in the OpenCode app when installed (universal link) and in the web app otherwise. Every other QR
// code may list several addresses, which needs {"code","urls"} JSON that only the app scanners read.
const qr = input.remote ? links[0] : JSON.stringify({ code: pairing.code, urls: remote })
const output =
[
"",
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
Expand All @@ -37,7 +57,7 @@ export default Runtime.handler(
? [
"",
// uqr separates rows with "\n" on every platform, so splitting on EOL ("\r\n" on Windows) indents only the first row.
renderUnicodeCompact(JSON.stringify({ code: pairing.code, urls: remote }), {
renderUnicodeCompact(qr, {
border: 2,
})
.split("\n")
Expand All @@ -46,8 +66,13 @@ export default Runtime.handler(
]
: []),
"",
].join(EOL) + EOL,
)
].join(EOL) + EOL
// The animation finishes on the real code, then the output takes its place line for line.
if (build && remote.length) yield* Effect.promise(() => build.finish(qr, output))
else {
build?.clear()
process.stdout.write(output)
}

if (input.remote || Option.isSome(input.url)) return
const url = new URL(endpoint.url)
Expand Down Expand Up @@ -77,7 +102,8 @@ const pairingURLs = Effect.fnUntraced(function* (
// The service attaches the tunnel in the background, so wait for its URL to appear in server info.
const remoteURL = Effect.fnUntraced(function* (client: ReturnType<typeof OpenCode.make>) {
const tunnelURL = Effect.gen(function* () {
const hostname = yield* RemoteTunnel.hostname()
const route = (yield* ServiceConfig.read()).remote?.route
const hostname = route === undefined ? undefined : yield* RemoteTunnel.hostname(route)
const info = yield* Effect.tryPromise(() => client.server.info())
const url = info.urls.find((candidate) => hostname !== undefined && new URL(candidate).hostname === hostname)
if (url === undefined) return yield* Effect.fail(new Error("Remote tunnel is not ready"))
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
)
if (server === undefined) return
if (serviceOptions !== undefined && config.remote === true && server.address._tag === "TcpAddress") {
if (serviceOptions !== undefined && config.remote !== undefined && server.address._tag === "TcpAddress") {
const bound = server.address.hostname
// A wildcard bind also listens on loopback, which is all the tunnel needs to reach.
const host = bound === "0.0.0.0" || bound === "::" ? "127.0.0.1" : bound.includes(":") ? `[${bound}]` : bound
yield* Effect.forkScoped(
RemoteTunnel.run({
route: config.remote.route,
target: `${host}:${server.address.port}`,
onURL: (url) => {
remote.urls = url === undefined ? [] : [url]
Expand Down
36 changes: 20 additions & 16 deletions packages/cli/src/services/remote-tunnel.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
export * as RemoteTunnel from "./remote-tunnel"

import type { OpenTunnelError } from "@opentunnel/client/effect"
import type { OpenTunnelError, OpenTunnelProvisionStage } from "@opentunnel/client/effect"
import { Cause, Effect, Schedule } from "effect"
import { EOL } from "os"
import { OPENCODE_CHANNEL } from "../version"

// OpenTunnel keeps one tunnel per device in its default profile, shared by the opentunnel CLI and every app
// using the SDK; each claims its own routes. The service claims a subdomain rather than the tunnel hostname,
// which the CLI may route, and each channel's service gets its own subdomain.
export function route(channel = OPENCODE_CHANNEL) {
if (channel === "latest") return "opencode"
return `opencode-${channel.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`.slice(0, 63).replace(/-+$/, "")
}
// using the SDK; each claims its own routes. The service claims a subdomain (its route) rather than the tunnel
// hostname, which the CLI may route. Tunnel hostnames appear in public certificate logs but the certificate
// covers subdomains with a wildcard, so a random route keeps the service's address unguessable.

// Holds the route for the life of the service. The SDK reconnects through network failures itself, so only
// setup failures reach the retry here; a rejected token or failed certificate stops it for good.
export const run = Effect.fnUntraced(function* (input: {
readonly route: string
readonly target: string
readonly onURL: (url: string | undefined) => void
}) {
Expand All @@ -25,8 +22,8 @@ export const run = Effect.fnUntraced(function* (input: {
(error.cause instanceof OpenTunnelAttachError || error.message.startsWith("Certificate issuance failed"))
yield* Effect.gen(function* () {
const client = yield* OpenTunnelClient
const connection = yield* client.tunnel.connect({ routes: { [route()]: input.target } })
input.onURL(`https://${route()}.${connection.tunnel.hostname}`)
const connection = yield* client.tunnel.connect({ routes: { [input.route]: input.target } })
input.onURL(`https://${input.route}.${connection.tunnel.hostname}`)
yield* connection.closed
}).pipe(
Effect.scoped,
Expand All @@ -49,13 +46,14 @@ export const run = Effect.fnUntraced(function* (input: {

// A device without a tunnel creates its shared one here, in the foreground, because issuing the certificate
// takes a while; the service then only attaches its route on start. An interrupted issuance resumes next time.
export const ensure = Effect.fnUntraced(function* () {
// `onProgress` takes over reporting a first-time setup (pairing animates it) from the plain notice.
export const ensure = Effect.fnUntraced(function* (onProgress?: (stage: OpenTunnelProvisionStage) => void) {
const { OpenTunnelClient } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
return yield* Effect.gen(function* () {
const client = yield* OpenTunnelClient
if ((yield* client.tunnel.get()) === undefined)
process.stderr.write("Setting up remote access; this can take a minute..." + EOL)
return `${route()}.${(yield* client.tunnel.ensure()).hostname}`
if ((yield* client.tunnel.get()) !== undefined) return (yield* client.tunnel.ensure()).hostname
if (!onProgress) process.stderr.write("Setting up remote access; this can take a minute..." + EOL)
return (yield* client.tunnel.create({ onProgress })).hostname
}).pipe(
Effect.provide(OpenTunnelClient.layer()),
Effect.timeoutOrElse({
Expand All @@ -65,9 +63,15 @@ export const ensure = Effect.fnUntraced(function* () {
)
})

// Whether this device has its tunnel yet: the identity is persisted once the certificate is ready.
export const created = Effect.fnUntraced(function* () {
const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
return (yield* OpenTunnelStorage.xdg().load("default")) !== undefined
})

// The tunnel hostname is persisted once the certificate is ready, so this is undefined until then.
export const hostname = Effect.fnUntraced(function* () {
export const hostname = Effect.fnUntraced(function* (route: string) {
const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
const identity = yield* OpenTunnelStorage.xdg().load("default")
return identity === undefined ? undefined : `${route()}.${identity.hostname}`
return identity === undefined ? undefined : `${route}.${identity.hostname}`
})
50 changes: 42 additions & 8 deletions packages/cli/src/services/service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import { RemoteTunnel } from "./remote-tunnel"

export const Info = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
remote: Schema.optional(Schema.Boolean),
// Present when remote access is on. The route is generated, never user-set: the secret subdomain the service is
// served on.
remote: Schema.optional(Schema.Struct({ route: Schema.String })),
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
Expand All @@ -27,6 +29,10 @@ const keys = ["disabled", "remote", "hostname", "port", "password", "cors", "env
type Key = (typeof keys)[number]

const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
// Earlier builds stored remote access as a boolean.
const decodeLegacy = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ ...Info.fields, remote: Schema.Boolean })),
)
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))

export function filename(channel = OPENCODE_CHANNEL) {
Expand Down Expand Up @@ -130,12 +136,24 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile, legacyConfigFile } = yield* paths
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.orElseSucceed(() => ({}) as Info),
)
const text = yield* fs.readFileString(configFile).pipe(Effect.option)
if (Option.isNone(text)) return {} as Info
const info = yield* decodeInfo(text.value).pipe(Effect.option)
if (Option.isSome(info)) return info.value
const legacy = decodeLegacy(text.value)
if (Option.isNone(legacy)) return {} as Info
// Repair the file in place so every reader sees the same route.
const { remote: enabled, ...rest } = legacy.value
const repaired: Info = enabled ? { ...rest, remote: { route: route() } } : rest
yield* write(repaired)
return repaired
})

// 64 random bits as 16 hex characters, a valid DNS label.
function route() {
return randomBytes(8).toString("hex")
}

const write = Effect.fn("cli.service-config.write")(function* (value: Info) {
const { fs, configFile } = yield* paths
const temp = configFile + ".tmp"
Expand All @@ -155,10 +173,20 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu
return next
})

// Turns remote access on and returns its route, created once and kept so the remote URL survives restarts.
export const remote = Effect.fn("cli.service-config.remote")(function* () {
const existing = yield* read()
if (existing.remote) return existing.remote.route
const next = route()
yield* write({ ...existing, remote: { route: next } })
return next
})

export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* read()
return JSON.stringify(safe, null, 2)
// The route is as sensitive as the password: it is the unguessable half of the remote address.
return JSON.stringify(safe.remote === undefined ? safe : { ...safe, remote: {} }, null, 2)
}
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
Expand All @@ -167,7 +195,7 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
return String((yield* read()).disabled ?? false)
}
case "remote": {
return String((yield* read()).remote ?? false)
return String((yield* read()).remote !== undefined)
}
case "hostname": {
return (yield* read()).hostname ?? ""
Expand Down Expand Up @@ -206,7 +234,13 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
// A tunnel that cannot be created leaves remote access off instead of a service that keeps retrying.
if (value === "true") yield* RemoteTunnel.ensure()
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), remote: value === "true" })
if (value === "true") {
yield* remote()
return
}
// Disabling forgets the address, so enabling again issues a new unguessable one.
const { remote: _remote, ...next } = yield* read()
yield* write(next)
return
}
case "hostname": {
Expand Down
Loading
Loading