@opentunnel/client 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/package.json +42 -1
- package/src/effect/api.ts +39 -0
- package/src/effect/client.ts +300 -0
- package/src/effect/csr.ts +85 -0
- package/src/effect/errors.ts +13 -0
- package/src/effect/index.ts +6 -0
- package/src/effect/storage.ts +222 -0
- package/src/effect/tunnel.ts +533 -0
- package/src/effect/types.ts +116 -0
- package/src/promise/client.ts +120 -0
- package/src/promise/index.ts +14 -0
- package/src/promise/storage.ts +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# OpenTunnel Client
|
|
2
|
+
|
|
3
|
+
`@opentunnel/client` is a pure TypeScript SDK for Bun that creates tunnels and
|
|
4
|
+
forwards them to local services from inside your process. TLS terminates in
|
|
5
|
+
your process, so the relay never sees plaintext or your private key.
|
|
6
|
+
|
|
7
|
+
It implements the same protocol and on-disk layout as the Rust client and CLI
|
|
8
|
+
(see `docs/protocol.md`), so a tunnel created by the CLI can be used here and
|
|
9
|
+
vice versa.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { create } from "@opentunnel/client"
|
|
15
|
+
|
|
16
|
+
const client = create()
|
|
17
|
+
const connection = await client.tunnel.connect({
|
|
18
|
+
routes: { api: "127.0.0.1:3000" },
|
|
19
|
+
})
|
|
20
|
+
console.log(`https://api.${connection.tunnel.hostname}`)
|
|
21
|
+
|
|
22
|
+
for await (const event of connection.events) console.log(event)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`connect` creates the profile's tunnel if it has none, resolves once the bridge
|
|
26
|
+
first attaches, and reconnects with backoff until you call `close()`. It
|
|
27
|
+
rejects on fatal errors such as an invalid token.
|
|
28
|
+
|
|
29
|
+
The Effect interface exposes the same capabilities, with scoped connections and
|
|
30
|
+
events as a `Stream`:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { OpenTunnelClient } from "@opentunnel/client/effect"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Routes
|
|
37
|
+
|
|
38
|
+
Routes map a name to a `host:port` target. A name is a subdomain label, or `@`
|
|
39
|
+
for the tunnel hostname itself. Path routing is not supported.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
await connection.setRoutes({ api: "127.0.0.1:4000", "@": "127.0.0.1:8080" })
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Changing only targets applies to new connections immediately. Adding or
|
|
46
|
+
removing names re-attaches the bridge.
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
interface Client {
|
|
52
|
+
profile: { list(): Promise<string[]> }
|
|
53
|
+
tunnel: {
|
|
54
|
+
list(): Promise<StoredTunnel[]>
|
|
55
|
+
get(options?: { profile?: string }): Promise<Identity | undefined>
|
|
56
|
+
pending(options?): Promise<{ id: string; hostname: string } | undefined>
|
|
57
|
+
create(options?: { profile?: string; onProgress?(stage): void }): Promise<Identity>
|
|
58
|
+
resume(options?): Promise<Identity | undefined>
|
|
59
|
+
ensure(options?: { profile?: string }): Promise<Identity>
|
|
60
|
+
remove(options?: { profile?: string }): Promise<void>
|
|
61
|
+
connect(options: { profile?: string; routes: Routes; signal?: AbortSignal }): Promise<Connection>
|
|
62
|
+
}
|
|
63
|
+
dispose(): Promise<void>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Connection {
|
|
67
|
+
tunnel: Identity
|
|
68
|
+
events: AsyncIterable<ClientEvent>
|
|
69
|
+
status(): Status
|
|
70
|
+
setRoutes(routes: Routes): Promise<void>
|
|
71
|
+
closed: Promise<void>
|
|
72
|
+
close(): Promise<void>
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Events are `connecting`, `connected`, `disconnected`, `reconnecting`,
|
|
77
|
+
`connection-opened`, `connection-closed`, and `stopped`.
|
|
78
|
+
|
|
79
|
+
## Storage
|
|
80
|
+
|
|
81
|
+
`create()` stores identities under `$XDG_DATA_HOME/opentunnel/<profile>/`, the
|
|
82
|
+
same files the CLI uses. Pass a store to isolate or own persistence:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { create, OpenTunnelStorage } from "@opentunnel/client"
|
|
86
|
+
|
|
87
|
+
const client = create({ store: OpenTunnelStorage.memory() })
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A memory store loses the tunnel's token when the process exits, and tunnels do
|
|
91
|
+
not expire, so a tunnel it created can no longer be deleted. Call
|
|
92
|
+
`client.tunnel.remove()` before exiting, or use the default store for tunnels
|
|
93
|
+
that should outlive the process.
|
|
94
|
+
|
|
95
|
+
## Backpressure
|
|
96
|
+
|
|
97
|
+
The SDK stops reading from a local socket while more than 1 MiB is queued on
|
|
98
|
+
the bridge WebSocket, and resets a connection whose local side stops reading
|
|
99
|
+
for long enough to buffer 8 MiB. The protocol has no per-connection flow
|
|
100
|
+
control yet, so one slow public reader can delay others on the same bridge.
|
package/package.json
CHANGED
|
@@ -1 +1,42 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@opentunnel/client",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenTunnel SDK for Bun: expose local services through a blind TLS tunnel",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/anomalyco/opentunnel.git",
|
|
9
|
+
"directory": "packages/client"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/promise/index.ts",
|
|
17
|
+
"./promise": "./src/promise/index.ts",
|
|
18
|
+
"./effect": "./src/effect/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.4.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc --build",
|
|
28
|
+
"test": "bun test"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@opentunnel/protocol": "0.1.0",
|
|
32
|
+
"effect": "4.0.0-beta.42"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@peculiar/x509": "2.0.0",
|
|
36
|
+
"@types/bun": "1.3.13",
|
|
37
|
+
"@types/node": "25.5.0",
|
|
38
|
+
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
|
39
|
+
"reflect-metadata": "0.2.2",
|
|
40
|
+
"typescript": "6.0.2"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Effect, Layer, ServiceMap } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
4
|
+
import { Api } from "@opentunnel/protocol/api/api";
|
|
5
|
+
import { Tunnel } from "@opentunnel/protocol/tunnel";
|
|
6
|
+
|
|
7
|
+
type Client = HttpApiClient.ForApi<typeof Api>;
|
|
8
|
+
|
|
9
|
+
interface OpenTunnelApi {
|
|
10
|
+
readonly client: Client;
|
|
11
|
+
readonly authorized: (token: Tunnel.Token) => Effect.Effect<Client>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class OpenTunnelApiClient extends ServiceMap.Service<OpenTunnelApiClient, OpenTunnelApi>()(
|
|
15
|
+
"@opentunnel/client/OpenTunnelApiClient",
|
|
16
|
+
) {
|
|
17
|
+
static layer(options: { readonly api: URL | string }) {
|
|
18
|
+
return Layer.effect(
|
|
19
|
+
OpenTunnelApiClient,
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const httpClient = yield* HttpClient.HttpClient;
|
|
22
|
+
const client = yield* HttpApiClient.makeWith(Api, {
|
|
23
|
+
baseUrl: options.api,
|
|
24
|
+
httpClient,
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
client,
|
|
28
|
+
authorized: (token) =>
|
|
29
|
+
HttpApiClient.makeWith(Api, {
|
|
30
|
+
baseUrl: options.api,
|
|
31
|
+
httpClient: httpClient.pipe(
|
|
32
|
+
HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
33
|
+
),
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
}),
|
|
37
|
+
).pipe(Layer.provide(FetchHttpClient.layer));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { Cause, Effect, Layer, Queue, ServiceMap, Stream } from "effect";
|
|
2
|
+
import { CSR } from "@opentunnel/protocol/csr";
|
|
3
|
+
import { Tunnel } from "@opentunnel/protocol/tunnel";
|
|
4
|
+
import { OpenTunnelApiClient } from "./api.js";
|
|
5
|
+
import { certificateRequest } from "./csr.js";
|
|
6
|
+
import { OpenTunnelClientError } from "./errors.js";
|
|
7
|
+
import { OpenTunnelStorage, type OpenTunnelStorage as Storage } from "./storage.js";
|
|
8
|
+
import type {
|
|
9
|
+
OpenTunnelClientEvent,
|
|
10
|
+
OpenTunnelConnection,
|
|
11
|
+
OpenTunnelEffectClient,
|
|
12
|
+
OpenTunnelIdentity,
|
|
13
|
+
OpenTunnelPendingIdentity,
|
|
14
|
+
OpenTunnelProfileOptions,
|
|
15
|
+
OpenTunnelProvisionStage,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
import { Tunnel as RunningTunnel, validateRoutes } from "./tunnel.js";
|
|
18
|
+
|
|
19
|
+
const profileName = (options?: OpenTunnelProfileOptions) => options?.profile ?? "default";
|
|
20
|
+
const clientError = (message: string, cause: unknown) =>
|
|
21
|
+
new OpenTunnelClientError({ message, cause });
|
|
22
|
+
|
|
23
|
+
const privateKeyPem = (buffer: ArrayBuffer): string => {
|
|
24
|
+
const bytes = new Uint8Array(buffer);
|
|
25
|
+
let binary = "";
|
|
26
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
27
|
+
const body = btoa(binary);
|
|
28
|
+
return `-----BEGIN PRIVATE KEY-----\n${body.match(/.{1,64}/g)?.join("\n") ?? body}\n-----END PRIVATE KEY-----\n`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface OpenTunnelClientOptions {
|
|
32
|
+
readonly api?: URL | string;
|
|
33
|
+
readonly storage?: Storage;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class OpenTunnelClient extends ServiceMap.Service<
|
|
37
|
+
OpenTunnelClient,
|
|
38
|
+
OpenTunnelEffectClient
|
|
39
|
+
>()("@opentunnel/client/OpenTunnelClient") {
|
|
40
|
+
static layer(options: OpenTunnelClientOptions = {}) {
|
|
41
|
+
const storage = options.storage ?? OpenTunnelStorage.xdg();
|
|
42
|
+
return Layer.effect(
|
|
43
|
+
OpenTunnelClient,
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const api = yield* OpenTunnelApiClient;
|
|
46
|
+
const apiUrl = new URL(options.api ?? "https://opentunnel.xyz");
|
|
47
|
+
|
|
48
|
+
const get = Effect.fn("OpenTunnelClient.tunnel.get")(function* (
|
|
49
|
+
input?: OpenTunnelProfileOptions,
|
|
50
|
+
) {
|
|
51
|
+
return yield* storage.load(profileName(input));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const completePending = Effect.fn("OpenTunnelClient.tunnel.completePending")(function* (options: {
|
|
55
|
+
readonly profile: string;
|
|
56
|
+
readonly pending: OpenTunnelPendingIdentity;
|
|
57
|
+
readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
|
|
58
|
+
}) {
|
|
59
|
+
const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(options.pending.token));
|
|
60
|
+
yield* Effect.sync(() => options.onProgress?.("requesting-certificate"));
|
|
61
|
+
yield* authorized.tunnel["tunnel.bindCertificate"]({
|
|
62
|
+
params: { id: Tunnel.ID.makeUnsafe(options.pending.id) },
|
|
63
|
+
payload: { csr: options.pending.csr as CSR.Raw },
|
|
64
|
+
}).pipe(
|
|
65
|
+
// A resumed provision may already have an issuance in flight.
|
|
66
|
+
Effect.catchTag("CertificateInProgressError", () => Effect.void),
|
|
67
|
+
Effect.mapError((cause) => clientError("Failed to start certificate issuance", cause)),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const certificate = yield* Effect.gen(function* () {
|
|
71
|
+
while (true) {
|
|
72
|
+
yield* Effect.sync(() => options.onProgress?.("waiting-certificate"));
|
|
73
|
+
const value = yield* authorized.tunnel["tunnel.getCertificate"]({
|
|
74
|
+
params: { id: Tunnel.ID.makeUnsafe(options.pending.id) },
|
|
75
|
+
}).pipe(
|
|
76
|
+
Effect.mapError((cause) => clientError("Failed to read certificate", cause)),
|
|
77
|
+
);
|
|
78
|
+
if (value.state.type === "ready") return value.state;
|
|
79
|
+
if (value.state.type === "failed") {
|
|
80
|
+
return yield* new OpenTunnelClientError({
|
|
81
|
+
message: `Certificate issuance failed: ${value.state.reason}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
yield* Effect.sleep("2 seconds");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const identity: OpenTunnelIdentity = {
|
|
88
|
+
id: options.pending.id,
|
|
89
|
+
hostname: options.pending.hostname,
|
|
90
|
+
token: options.pending.token,
|
|
91
|
+
privateKey: options.pending.privateKey,
|
|
92
|
+
certificate: certificate.certificate,
|
|
93
|
+
chain: certificate.chain,
|
|
94
|
+
certificateExpiry: new Date(certificate.expiry),
|
|
95
|
+
};
|
|
96
|
+
yield* Effect.sync(() => options.onProgress?.("saving-identity"));
|
|
97
|
+
yield* storage.save(options.profile, identity);
|
|
98
|
+
yield* Effect.sync(() => options.onProgress?.("ready"));
|
|
99
|
+
return identity;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const provision = Effect.fn("OpenTunnelClient.tunnel.provision")(function* (options: {
|
|
103
|
+
readonly profile: string;
|
|
104
|
+
readonly id: Tunnel.ID;
|
|
105
|
+
readonly hostname: string;
|
|
106
|
+
readonly token: Tunnel.Token;
|
|
107
|
+
readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
|
|
108
|
+
}) {
|
|
109
|
+
yield* Effect.sync(() => options.onProgress?.("generating-key"));
|
|
110
|
+
const keys = yield* Effect.tryPromise({
|
|
111
|
+
try: () =>
|
|
112
|
+
crypto.subtle.generateKey(
|
|
113
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
114
|
+
true,
|
|
115
|
+
["sign", "verify"],
|
|
116
|
+
) as Promise<CryptoKeyPair>,
|
|
117
|
+
catch: (cause) => clientError("Failed to generate certificate key", cause),
|
|
118
|
+
});
|
|
119
|
+
yield* Effect.sync(() => options.onProgress?.("generating-csr"));
|
|
120
|
+
const csr = yield* Effect.tryPromise({
|
|
121
|
+
try: () => certificateRequest(options.hostname, keys),
|
|
122
|
+
catch: (cause) => clientError("Failed to generate certificate request", cause),
|
|
123
|
+
});
|
|
124
|
+
const exported = yield* Effect.tryPromise({
|
|
125
|
+
try: () => crypto.subtle.exportKey("pkcs8", keys.privateKey),
|
|
126
|
+
catch: (cause) => clientError("Failed to export certificate key", cause),
|
|
127
|
+
});
|
|
128
|
+
const pending: OpenTunnelPendingIdentity = {
|
|
129
|
+
id: String(options.id),
|
|
130
|
+
hostname: options.hostname,
|
|
131
|
+
token: options.token,
|
|
132
|
+
privateKey: privateKeyPem(exported),
|
|
133
|
+
csr,
|
|
134
|
+
};
|
|
135
|
+
yield* storage.savePending(options.profile, pending);
|
|
136
|
+
return yield* completePending({
|
|
137
|
+
profile: options.profile,
|
|
138
|
+
pending,
|
|
139
|
+
onProgress: options.onProgress,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const create = Effect.fn("OpenTunnelClient.tunnel.create")(function* (
|
|
144
|
+
input?: OpenTunnelProfileOptions & {
|
|
145
|
+
readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
|
|
146
|
+
},
|
|
147
|
+
) {
|
|
148
|
+
const profile = profileName(input);
|
|
149
|
+
if (yield* storage.load(profile)) {
|
|
150
|
+
return yield* new OpenTunnelClientError({
|
|
151
|
+
message: `Profile '${profile}' already has a tunnel`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pending = yield* storage.loadPending(profile);
|
|
156
|
+
if (pending) {
|
|
157
|
+
yield* Effect.sync(() => input?.onProgress?.("resuming-certificate"));
|
|
158
|
+
return yield* completePending({ profile, pending, onProgress: input?.onProgress });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
yield* Effect.sync(() => input?.onProgress?.("creating-tunnel"));
|
|
162
|
+
const created = yield* api.client.tunnel["tunnel.create"]({
|
|
163
|
+
payload: {},
|
|
164
|
+
}).pipe(Effect.mapError((cause) => clientError("Failed to create tunnel", cause)));
|
|
165
|
+
return yield* provision({
|
|
166
|
+
profile,
|
|
167
|
+
id: created.tunnel.id,
|
|
168
|
+
hostname: String(created.tunnel.hostname),
|
|
169
|
+
token: created.token,
|
|
170
|
+
onProgress: input?.onProgress,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const ensure = Effect.fn("OpenTunnelClient.tunnel.ensure")(function* (
|
|
175
|
+
input?: OpenTunnelProfileOptions,
|
|
176
|
+
) {
|
|
177
|
+
const existing = yield* get(input);
|
|
178
|
+
if (!existing) return yield* create(input);
|
|
179
|
+
const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(existing.token));
|
|
180
|
+
const certificate = yield* authorized.tunnel["tunnel.getCertificate"]({
|
|
181
|
+
params: { id: Tunnel.ID.makeUnsafe(existing.id) },
|
|
182
|
+
}).pipe(
|
|
183
|
+
Effect.mapError((cause) => clientError("Failed to read certificate", cause)),
|
|
184
|
+
);
|
|
185
|
+
const state = certificate.state;
|
|
186
|
+
// The server renewed the certificate while this machine was offline.
|
|
187
|
+
if (state.type === "ready" && state.certificate !== existing.certificate) {
|
|
188
|
+
const renewed: OpenTunnelIdentity = {
|
|
189
|
+
...existing,
|
|
190
|
+
certificate: state.certificate,
|
|
191
|
+
chain: state.chain,
|
|
192
|
+
certificateExpiry: new Date(state.expiry),
|
|
193
|
+
};
|
|
194
|
+
yield* storage.save(profileName(input), renewed);
|
|
195
|
+
return renewed;
|
|
196
|
+
}
|
|
197
|
+
if (state.type !== "failed") return existing;
|
|
198
|
+
return yield* provision({
|
|
199
|
+
profile: profileName(input),
|
|
200
|
+
id: Tunnel.ID.makeUnsafe(existing.id),
|
|
201
|
+
hostname: existing.hostname,
|
|
202
|
+
token: Tunnel.Token.makeUnsafe(existing.token),
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const pending = Effect.fn("OpenTunnelClient.tunnel.pending")(function* (
|
|
207
|
+
input?: OpenTunnelProfileOptions,
|
|
208
|
+
) {
|
|
209
|
+
const value = yield* storage.loadPending(profileName(input));
|
|
210
|
+
return value ? { id: value.id, hostname: value.hostname } : undefined;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const resume = Effect.fn("OpenTunnelClient.tunnel.resume")(function* (
|
|
214
|
+
input?: OpenTunnelProfileOptions & {
|
|
215
|
+
readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
|
|
216
|
+
},
|
|
217
|
+
) {
|
|
218
|
+
const profile = profileName(input);
|
|
219
|
+
const value = yield* storage.loadPending(profile);
|
|
220
|
+
if (!value) return undefined;
|
|
221
|
+
yield* Effect.sync(() => input?.onProgress?.("resuming-certificate"));
|
|
222
|
+
return yield* completePending({ profile, pending: value, onProgress: input?.onProgress });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const client: OpenTunnelEffectClient = {
|
|
226
|
+
profile: { list: storage.profiles },
|
|
227
|
+
tunnel: {
|
|
228
|
+
list: storage.list,
|
|
229
|
+
get,
|
|
230
|
+
pending,
|
|
231
|
+
resume,
|
|
232
|
+
create,
|
|
233
|
+
ensure,
|
|
234
|
+
remove: Effect.fn("OpenTunnelClient.tunnel.remove")(function* (input) {
|
|
235
|
+
const profile = profileName(input);
|
|
236
|
+
const identity = yield* storage.load(profile);
|
|
237
|
+
if (!identity) return;
|
|
238
|
+
const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(identity.token));
|
|
239
|
+
yield* authorized.tunnel["tunnel.remove"]({
|
|
240
|
+
params: { id: Tunnel.ID.makeUnsafe(identity.id) },
|
|
241
|
+
}).pipe(
|
|
242
|
+
Effect.mapError((cause) => clientError("Failed to remove tunnel", cause)),
|
|
243
|
+
);
|
|
244
|
+
yield* storage.remove(profile);
|
|
245
|
+
}),
|
|
246
|
+
connect: Effect.fn("OpenTunnelClient.tunnel.connect")(function* (input) {
|
|
247
|
+
yield* Effect.try({
|
|
248
|
+
try: () => validateRoutes(input.routes),
|
|
249
|
+
catch: (cause) => clientError(cause instanceof Error ? cause.message : "Invalid routes", cause),
|
|
250
|
+
});
|
|
251
|
+
const identity = yield* ensure(input);
|
|
252
|
+
const events = yield* Queue.unbounded<OpenTunnelClientEvent, Cause.Done>();
|
|
253
|
+
const tunnel = yield* Effect.acquireRelease(
|
|
254
|
+
Effect.sync(() =>
|
|
255
|
+
new RunningTunnel({
|
|
256
|
+
api: apiUrl,
|
|
257
|
+
identity,
|
|
258
|
+
routes: input.routes,
|
|
259
|
+
onEvent: (event) => {
|
|
260
|
+
Queue.offerUnsafe(events, event);
|
|
261
|
+
if (event.type === "stopped") Queue.endUnsafe(events);
|
|
262
|
+
},
|
|
263
|
+
onRenewed: (renewed) => {
|
|
264
|
+
Effect.runFork(storage.save(profileName(input), renewed).pipe(Effect.ignore));
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
),
|
|
268
|
+
(tunnel) =>
|
|
269
|
+
Effect.promise(() => tunnel.close()).pipe(Effect.andThen(Queue.end(events))),
|
|
270
|
+
);
|
|
271
|
+
yield* Effect.tryPromise({
|
|
272
|
+
try: () => tunnel.ready,
|
|
273
|
+
catch: (cause) => clientError("Failed to connect tunnel", cause),
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
tunnel: identity,
|
|
277
|
+
events: Stream.fromQueue(events),
|
|
278
|
+
status: Effect.sync(() => tunnel.getStatus()),
|
|
279
|
+
setRoutes: (routes) =>
|
|
280
|
+
Effect.try({
|
|
281
|
+
try: () => tunnel.setRoutes(routes),
|
|
282
|
+
catch: (cause) =>
|
|
283
|
+
clientError(cause instanceof Error ? cause.message : "Invalid routes", cause),
|
|
284
|
+
}),
|
|
285
|
+
closed: Effect.tryPromise({
|
|
286
|
+
try: () => tunnel.closed,
|
|
287
|
+
catch: (cause) => clientError("Tunnel stopped", cause),
|
|
288
|
+
}),
|
|
289
|
+
close: Effect.promise(() => tunnel.close()),
|
|
290
|
+
} satisfies OpenTunnelConnection;
|
|
291
|
+
}),
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
return client;
|
|
295
|
+
}),
|
|
296
|
+
).pipe(
|
|
297
|
+
Layer.provide(OpenTunnelApiClient.layer({ api: options.api ?? "https://opentunnel.xyz" })),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A PKCS#10 certificate request for a tunnel: CN and SANs for the hostname and its wildcard, signed with the
|
|
2
|
+
// tunnel's P-256 key. Encoded here with WebCrypto rather than an X.509 library, so the SDK carries no ASN.1
|
|
3
|
+
// dependency whose schema registry can be split across duplicate installs in a consumer's tree.
|
|
4
|
+
|
|
5
|
+
const der = (tag: number, ...contents: Uint8Array[]) => {
|
|
6
|
+
const body = concat(contents);
|
|
7
|
+
const length =
|
|
8
|
+
body.length < 0x80
|
|
9
|
+
? [body.length]
|
|
10
|
+
: (() => {
|
|
11
|
+
const bytes: number[] = [];
|
|
12
|
+
for (let n = body.length; n > 0; n >>= 8) bytes.unshift(n & 0xff);
|
|
13
|
+
return [0x80 | bytes.length, ...bytes];
|
|
14
|
+
})();
|
|
15
|
+
return concat([new Uint8Array([tag, ...length]), body]);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const concat = (parts: readonly Uint8Array[]) => {
|
|
19
|
+
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
20
|
+
let offset = 0;
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
out.set(part, offset);
|
|
23
|
+
offset += part.length;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const sequence = (...contents: Uint8Array[]) => der(0x30, ...contents);
|
|
29
|
+
const set = (...contents: Uint8Array[]) => der(0x31, ...contents);
|
|
30
|
+
const utf8 = (value: string) => der(0x0c, new TextEncoder().encode(value));
|
|
31
|
+
const dnsName = (value: string) => der(0x82, new TextEncoder().encode(value));
|
|
32
|
+
|
|
33
|
+
const oid = (dotted: string) => {
|
|
34
|
+
const [first, second, ...rest] = dotted.split(".").map(Number);
|
|
35
|
+
const bytes = [first! * 40 + second!];
|
|
36
|
+
for (const arc of rest) {
|
|
37
|
+
const encoded = [arc & 0x7f];
|
|
38
|
+
for (let n = arc >> 7; n > 0; n >>= 7) encoded.unshift((n & 0x7f) | 0x80);
|
|
39
|
+
bytes.push(...encoded);
|
|
40
|
+
}
|
|
41
|
+
return der(0x06, new Uint8Array(bytes));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** A DER INTEGER from unsigned big-endian bytes: leading zeros dropped, a zero prepended if the top bit is set. */
|
|
45
|
+
const integer = (bytes: Uint8Array) => {
|
|
46
|
+
let start = 0;
|
|
47
|
+
while (start < bytes.length - 1 && bytes[start] === 0) start++;
|
|
48
|
+
const trimmed = bytes.subarray(start);
|
|
49
|
+
return der(0x02, trimmed[0]! & 0x80 ? concat([new Uint8Array([0]), trimmed]) : trimmed);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const COMMON_NAME = "2.5.4.3";
|
|
53
|
+
const EXTENSION_REQUEST = "1.2.840.113549.1.9.14";
|
|
54
|
+
const SUBJECT_ALT_NAME = "2.5.29.17";
|
|
55
|
+
const ECDSA_WITH_SHA256 = "1.2.840.10045.4.3.2";
|
|
56
|
+
|
|
57
|
+
export async function certificateRequest(hostname: string, keys: CryptoKeyPair): Promise<string> {
|
|
58
|
+
const publicKey = new Uint8Array(await crypto.subtle.exportKey("spki", keys.publicKey));
|
|
59
|
+
const info = sequence(
|
|
60
|
+
der(0x02, new Uint8Array([0])),
|
|
61
|
+
sequence(set(sequence(oid(COMMON_NAME), utf8(hostname)))),
|
|
62
|
+
publicKey,
|
|
63
|
+
der(
|
|
64
|
+
0xa0,
|
|
65
|
+
sequence(
|
|
66
|
+
oid(EXTENSION_REQUEST),
|
|
67
|
+
set(
|
|
68
|
+
sequence(
|
|
69
|
+
sequence(oid(SUBJECT_ALT_NAME), der(0x04, sequence(dnsName(hostname), dnsName(`*.${hostname}`)))),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
// WebCrypto signs ECDSA as r‖s; X.509 wants SEQUENCE { r INTEGER, s INTEGER }.
|
|
76
|
+
const raw = new Uint8Array(
|
|
77
|
+
await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, keys.privateKey, info),
|
|
78
|
+
);
|
|
79
|
+
const signature = sequence(integer(raw.subarray(0, 32)), integer(raw.subarray(32)));
|
|
80
|
+
const request = sequence(info, sequence(oid(ECDSA_WITH_SHA256)), der(0x03, new Uint8Array([0]), signature));
|
|
81
|
+
const base64 = Buffer.from(request)
|
|
82
|
+
.toString("base64")
|
|
83
|
+
.replace(/.{1,64}/g, "$&\n");
|
|
84
|
+
return `-----BEGIN CERTIFICATE REQUEST-----\n${base64}-----END CERTIFICATE REQUEST-----`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class OpenTunnelClientError extends Schema.TaggedErrorClass<OpenTunnelClientError>()(
|
|
4
|
+
"OpenTunnelClientError",
|
|
5
|
+
{ message: Schema.String, cause: Schema.optional(Schema.Defect) },
|
|
6
|
+
) {}
|
|
7
|
+
|
|
8
|
+
export class OpenTunnelStorageError extends Schema.TaggedErrorClass<OpenTunnelStorageError>()(
|
|
9
|
+
"OpenTunnelStorageError",
|
|
10
|
+
{ message: Schema.String, cause: Schema.optional(Schema.Defect) },
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
export type OpenTunnelError = OpenTunnelClientError | OpenTunnelStorageError;
|