@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 +5 -0
- package/package.json +7 -7
- package/src/effect/api.ts +2 -2
- package/src/effect/client.ts +14 -29
- package/src/effect/csr.ts +85 -0
- package/src/effect/errors.ts +4 -4
- package/src/promise/client.ts +2 -2
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.
|
|
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.
|
|
32
|
-
"
|
|
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
|
-
"
|
|
40
|
-
"
|
|
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,
|
|
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
|
|
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 }) {
|
package/src/effect/client.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import "
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
200
|
+
id: Tunnel.ID.make(existing.id),
|
|
216
201
|
hostname: existing.hostname,
|
|
217
|
-
token: Tunnel.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.
|
|
238
|
+
const authorized = yield* api.authorized(Tunnel.Token.make(identity.token));
|
|
254
239
|
yield* authorized.tunnel["tunnel.remove"]({
|
|
255
|
-
params: { id: Tunnel.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
|
+
}
|
package/src/effect/errors.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
|
|
3
|
-
export class OpenTunnelClientError extends Schema.
|
|
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.
|
|
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;
|
package/src/promise/client.ts
CHANGED
|
@@ -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
|
|
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
|
|
98
|
+
Effect.flatMap(OpenTunnelClient, (client) => client.tunnel.connect(input)).pipe(
|
|
99
99
|
Effect.provideService(Scope.Scope, scope),
|
|
100
100
|
),
|
|
101
101
|
)
|