@opentunnel/client 0.1.0 → 0.2.0

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 CHANGED
@@ -87,6 +87,11 @@ import { create, OpenTunnelStorage } from "@opentunnel/client"
87
87
  const client = create({ store: OpenTunnelStorage.memory() })
88
88
  ```
89
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
+
90
95
  ## Backpressure
91
96
 
92
97
  The SDK stops reading from a local socket while more than 1 MiB is queued on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentunnel/client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenTunnel SDK for Bun: expose local services through a blind TLS tunnel",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,15 +28,15 @@
28
28
  "test": "bun test"
29
29
  },
30
30
  "dependencies": {
31
- "@opentunnel/protocol": "0.0.0",
32
- "@peculiar/x509": "2.0.0",
33
- "effect": "4.0.0-beta.42",
34
- "reflect-metadata": "0.2.2"
31
+ "@opentunnel/protocol": "0.2.0",
32
+ "effect": "4.0.0-rc.112"
35
33
  },
36
34
  "devDependencies": {
35
+ "@peculiar/x509": "2.0.0",
36
+ "@types/bun": "1.3.13",
37
37
  "@types/node": "25.5.0",
38
38
  "@typescript/native-preview": "7.0.0-dev.20260328.1",
39
- "typescript": "6.0.2",
40
- "@types/bun": "1.3.13"
39
+ "reflect-metadata": "0.2.2",
40
+ "typescript": "6.0.2"
41
41
  }
42
42
  }
package/src/effect/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect, Layer, ServiceMap } from "effect";
1
+ import { Effect, Layer, Context } from "effect";
2
2
  import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3
3
  import { HttpApiClient } from "effect/unstable/httpapi";
4
4
  import { Api } from "@opentunnel/protocol/api/api";
@@ -11,7 +11,7 @@ interface OpenTunnelApi {
11
11
  readonly authorized: (token: Tunnel.Token) => Effect.Effect<Client>;
12
12
  }
13
13
 
14
- export class OpenTunnelApiClient extends ServiceMap.Service<OpenTunnelApiClient, OpenTunnelApi>()(
14
+ export class OpenTunnelApiClient extends Context.Service<OpenTunnelApiClient, OpenTunnelApi>()(
15
15
  "@opentunnel/client/OpenTunnelApiClient",
16
16
  ) {
17
17
  static layer(options: { readonly api: URL | string }) {
@@ -1,12 +1,8 @@
1
- import "reflect-metadata";
2
- import { Cause, Effect, Layer, Queue, ServiceMap, Stream } from "effect";
3
- import {
4
- Pkcs10CertificateRequestGenerator,
5
- SubjectAlternativeNameExtension,
6
- } from "@peculiar/x509";
1
+ import { Cause, Effect, Layer, Queue, Context, Stream } from "effect";
7
2
  import { CSR } from "@opentunnel/protocol/csr";
8
3
  import { Tunnel } from "@opentunnel/protocol/tunnel";
9
4
  import { OpenTunnelApiClient } from "./api.js";
5
+ import { certificateRequest } from "./csr.js";
10
6
  import { OpenTunnelClientError } from "./errors.js";
11
7
  import { OpenTunnelStorage, type OpenTunnelStorage as Storage } from "./storage.js";
12
8
  import type {
@@ -37,7 +33,7 @@ export interface OpenTunnelClientOptions {
37
33
  readonly storage?: Storage;
38
34
  }
39
35
 
40
- export class OpenTunnelClient extends ServiceMap.Service<
36
+ export class OpenTunnelClient extends Context.Service<
41
37
  OpenTunnelClient,
42
38
  OpenTunnelEffectClient
43
39
  >()("@opentunnel/client/OpenTunnelClient") {
@@ -60,10 +56,10 @@ export class OpenTunnelClient extends ServiceMap.Service<
60
56
  readonly pending: OpenTunnelPendingIdentity;
61
57
  readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
62
58
  }) {
63
- const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(options.pending.token));
59
+ const authorized = yield* api.authorized(Tunnel.Token.make(options.pending.token));
64
60
  yield* Effect.sync(() => options.onProgress?.("requesting-certificate"));
65
61
  yield* authorized.tunnel["tunnel.bindCertificate"]({
66
- params: { id: Tunnel.ID.makeUnsafe(options.pending.id) },
62
+ params: { id: Tunnel.ID.make(options.pending.id) },
67
63
  payload: { csr: options.pending.csr as CSR.Raw },
68
64
  }).pipe(
69
65
  // A resumed provision may already have an issuance in flight.
@@ -75,7 +71,7 @@ export class OpenTunnelClient extends ServiceMap.Service<
75
71
  while (true) {
76
72
  yield* Effect.sync(() => options.onProgress?.("waiting-certificate"));
77
73
  const value = yield* authorized.tunnel["tunnel.getCertificate"]({
78
- params: { id: Tunnel.ID.makeUnsafe(options.pending.id) },
74
+ params: { id: Tunnel.ID.make(options.pending.id) },
79
75
  }).pipe(
80
76
  Effect.mapError((cause) => clientError("Failed to read certificate", cause)),
81
77
  );
@@ -122,18 +118,7 @@ export class OpenTunnelClient extends ServiceMap.Service<
122
118
  });
123
119
  yield* Effect.sync(() => options.onProgress?.("generating-csr"));
124
120
  const csr = yield* Effect.tryPromise({
125
- try: () =>
126
- Pkcs10CertificateRequestGenerator.create({
127
- name: `CN=${options.hostname}`,
128
- extensions: [
129
- new SubjectAlternativeNameExtension([
130
- { type: "dns", value: options.hostname },
131
- { type: "dns", value: `*.${options.hostname}` },
132
- ]),
133
- ],
134
- signingAlgorithm: { name: "ECDSA", hash: "SHA-256" },
135
- keys,
136
- }),
121
+ try: () => certificateRequest(options.hostname, keys),
137
122
  catch: (cause) => clientError("Failed to generate certificate request", cause),
138
123
  });
139
124
  const exported = yield* Effect.tryPromise({
@@ -145,7 +130,7 @@ export class OpenTunnelClient extends ServiceMap.Service<
145
130
  hostname: options.hostname,
146
131
  token: options.token,
147
132
  privateKey: privateKeyPem(exported),
148
- csr: csr.toString(),
133
+ csr,
149
134
  };
150
135
  yield* storage.savePending(options.profile, pending);
151
136
  return yield* completePending({
@@ -191,9 +176,9 @@ export class OpenTunnelClient extends ServiceMap.Service<
191
176
  ) {
192
177
  const existing = yield* get(input);
193
178
  if (!existing) return yield* create(input);
194
- const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(existing.token));
179
+ const authorized = yield* api.authorized(Tunnel.Token.make(existing.token));
195
180
  const certificate = yield* authorized.tunnel["tunnel.getCertificate"]({
196
- params: { id: Tunnel.ID.makeUnsafe(existing.id) },
181
+ params: { id: Tunnel.ID.make(existing.id) },
197
182
  }).pipe(
198
183
  Effect.mapError((cause) => clientError("Failed to read certificate", cause)),
199
184
  );
@@ -212,9 +197,9 @@ export class OpenTunnelClient extends ServiceMap.Service<
212
197
  if (state.type !== "failed") return existing;
213
198
  return yield* provision({
214
199
  profile: profileName(input),
215
- id: Tunnel.ID.makeUnsafe(existing.id),
200
+ id: Tunnel.ID.make(existing.id),
216
201
  hostname: existing.hostname,
217
- token: Tunnel.Token.makeUnsafe(existing.token),
202
+ token: Tunnel.Token.make(existing.token),
218
203
  });
219
204
  });
220
205
 
@@ -250,9 +235,9 @@ export class OpenTunnelClient extends ServiceMap.Service<
250
235
  const profile = profileName(input);
251
236
  const identity = yield* storage.load(profile);
252
237
  if (!identity) return;
253
- const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(identity.token));
238
+ const authorized = yield* api.authorized(Tunnel.Token.make(identity.token));
254
239
  yield* authorized.tunnel["tunnel.remove"]({
255
- params: { id: Tunnel.ID.makeUnsafe(identity.id) },
240
+ params: { id: Tunnel.ID.make(identity.id) },
256
241
  }).pipe(
257
242
  Effect.mapError((cause) => clientError("Failed to remove tunnel", cause)),
258
243
  );
@@ -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
+ }
@@ -1,13 +1,13 @@
1
1
  import { Schema } from "effect";
2
2
 
3
- export class OpenTunnelClientError extends Schema.TaggedErrorClass<OpenTunnelClientError>()(
3
+ export class OpenTunnelClientError extends Schema.TaggedError<OpenTunnelClientError>()(
4
4
  "OpenTunnelClientError",
5
- { message: Schema.String, cause: Schema.optional(Schema.Defect) },
5
+ { message: Schema.String, cause: Schema.optional(Schema.Defect()) },
6
6
  ) {}
7
7
 
8
- export class OpenTunnelStorageError extends Schema.TaggedErrorClass<OpenTunnelStorageError>()(
8
+ export class OpenTunnelStorageError extends Schema.TaggedError<OpenTunnelStorageError>()(
9
9
  "OpenTunnelStorageError",
10
- { message: Schema.String, cause: Schema.optional(Schema.Defect) },
10
+ { message: Schema.String, cause: Schema.optional(Schema.Defect()) },
11
11
  ) {}
12
12
 
13
13
  export type OpenTunnelError = OpenTunnelClientError | OpenTunnelStorageError;
@@ -78,7 +78,7 @@ export function create(options: OpenTunnelClientOptions = {}): OpenTunnelPromise
78
78
  const runtime = ManagedRuntime.make(OpenTunnelClient.layer(effectOptions));
79
79
  const withClient = <A, E>(
80
80
  f: (client: OpenTunnelClient["Service"]) => Effect.Effect<A, E>,
81
- ) => runtime.runPromise(Effect.flatMap(OpenTunnelClient.asEffect(), f));
81
+ ) => runtime.runPromise(Effect.flatMap(OpenTunnelClient, f));
82
82
 
83
83
  return {
84
84
  profile: { list: () => withClient((client) => client.profile.list()) },
@@ -95,7 +95,7 @@ export function create(options: OpenTunnelClientOptions = {}): OpenTunnelPromise
95
95
  const closeScope = () => runtime.runPromise(Scope.close(scope, Exit.void));
96
96
  const connection = await runtime
97
97
  .runPromise(
98
- Effect.flatMap(OpenTunnelClient.asEffect(), (client) => client.tunnel.connect(input)).pipe(
98
+ Effect.flatMap(OpenTunnelClient, (client) => client.tunnel.connect(input)).pipe(
99
99
  Effect.provideService(Scope.Scope, scope),
100
100
  ),
101
101
  )