@opentunnel/protocol 0.0.0-stage → 0.1.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 +8 -2
- package/package.json +35 -4
- package/src/api/api.ts +12 -0
- package/src/api/auth.ts +17 -0
- package/src/api/errors.ts +49 -0
- package/src/api/index.ts +4 -0
- package/src/api/tunnel.ts +86 -0
- package/src/bridge-protocol.ts +190 -0
- package/src/certificate.ts +39 -0
- package/src/csr.ts +9 -0
- package/src/index.ts +6 -0
- package/src/names.ts +38 -0
- package/src/newtype.ts +38 -0
- package/src/tunnel.ts +39 -0
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OpenTunnel Protocol
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared OpenTunnel wire contracts:
|
|
4
|
+
|
|
5
|
+
- Tunnel, certificate, and CSR schemas
|
|
6
|
+
- Bridge control messages and binary framing
|
|
7
|
+
- Effect HTTP API definition
|
|
8
|
+
|
|
9
|
+
This package contains no server or client implementation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opentunnel/protocol",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/anomalyco/opentunnel.git",
|
|
8
|
+
"directory": "packages/protocol"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
"./*": "./src/*.ts",
|
|
16
|
+
"./api/*": "./src/api/*.ts",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc --build",
|
|
24
|
+
"test": "bun test"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"effect": "4.0.0-beta.42"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "25.5.0",
|
|
31
|
+
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
|
32
|
+
"bumpp": "11.0.1",
|
|
33
|
+
"typescript": "6.0.2",
|
|
34
|
+
"@types/bun": "1.3.13"
|
|
35
|
+
},
|
|
36
|
+
"description": "OpenTunnel wire protocol: schemas, bridge framing, and the HTTP API contract"
|
|
37
|
+
}
|
package/src/api/api.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { HttpApi, OpenApi } from "effect/unstable/httpapi";
|
|
2
|
+
import { TunnelGroup } from "./tunnel.js";
|
|
3
|
+
|
|
4
|
+
export const Api = HttpApi.make("opentunnel")
|
|
5
|
+
.add(TunnelGroup)
|
|
6
|
+
.annotateMerge(
|
|
7
|
+
OpenApi.annotations({
|
|
8
|
+
title: "OpenTunnel API",
|
|
9
|
+
version: "0.1.0",
|
|
10
|
+
description: "Create and manage blind TLS tunnels.",
|
|
11
|
+
}),
|
|
12
|
+
);
|
package/src/api/auth.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ServiceMap } from "effect";
|
|
2
|
+
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
3
|
+
import { Tunnel } from "../tunnel.js";
|
|
4
|
+
import { UnauthorizedError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
export class OpenTunnelAuthorizationToken extends ServiceMap.Service<
|
|
7
|
+
OpenTunnelAuthorizationToken,
|
|
8
|
+
Tunnel.Token
|
|
9
|
+
>()("@opentunnel/protocol/OpenTunnelAuthorizationToken") {}
|
|
10
|
+
|
|
11
|
+
export class OpenTunnelAuthorization extends HttpApiMiddleware.Service<
|
|
12
|
+
OpenTunnelAuthorization,
|
|
13
|
+
{ provides: OpenTunnelAuthorizationToken }
|
|
14
|
+
>()("@opentunnel/protocol/OpenTunnelAuthorization", {
|
|
15
|
+
security: { bearer: HttpApiSecurity.bearer },
|
|
16
|
+
error: UnauthorizedError,
|
|
17
|
+
}) {}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
|
|
4
|
+
"InvalidRequestError",
|
|
5
|
+
{ message: Schema.String },
|
|
6
|
+
{ httpApiStatus: 400 },
|
|
7
|
+
) {}
|
|
8
|
+
|
|
9
|
+
export class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()(
|
|
10
|
+
"UnauthorizedError",
|
|
11
|
+
{ message: Schema.String },
|
|
12
|
+
{ httpApiStatus: 401 },
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
export class TunnelNotFoundError extends Schema.TaggedErrorClass<TunnelNotFoundError>()(
|
|
16
|
+
"TunnelNotFoundError",
|
|
17
|
+
{ tunnelID: Schema.String, message: Schema.String },
|
|
18
|
+
{ httpApiStatus: 404 },
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
export class CertificateNotFoundError extends Schema.TaggedErrorClass<CertificateNotFoundError>()(
|
|
22
|
+
"CertificateNotFoundError",
|
|
23
|
+
{ tunnelID: Schema.String, message: Schema.String },
|
|
24
|
+
{ httpApiStatus: 404 },
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
export class InvalidHostnameError extends Schema.TaggedErrorClass<InvalidHostnameError>()(
|
|
28
|
+
"InvalidHostnameError",
|
|
29
|
+
{ provided: Schema.String, expected: Schema.String, message: Schema.String },
|
|
30
|
+
{ httpApiStatus: 400 },
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
export class HostnameUnavailableError extends Schema.TaggedErrorClass<HostnameUnavailableError>()(
|
|
34
|
+
"HostnameUnavailableError",
|
|
35
|
+
{ name: Schema.String, message: Schema.String },
|
|
36
|
+
{ httpApiStatus: 409 },
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
export class CertificateInProgressError extends Schema.TaggedErrorClass<CertificateInProgressError>()(
|
|
40
|
+
"CertificateInProgressError",
|
|
41
|
+
{ tunnelID: Schema.String, message: Schema.String },
|
|
42
|
+
{ httpApiStatus: 409 },
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
|
|
46
|
+
"ServiceUnavailableError",
|
|
47
|
+
{ message: Schema.String },
|
|
48
|
+
{ httpApiStatus: 503 },
|
|
49
|
+
) {}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi";
|
|
3
|
+
import { Certificate } from "../certificate.js";
|
|
4
|
+
import { CSR } from "../csr.js";
|
|
5
|
+
import { Tunnel } from "../tunnel.js";
|
|
6
|
+
import {
|
|
7
|
+
CertificateInProgressError,
|
|
8
|
+
CertificateNotFoundError,
|
|
9
|
+
HostnameUnavailableError,
|
|
10
|
+
InvalidHostnameError,
|
|
11
|
+
InvalidRequestError,
|
|
12
|
+
ServiceUnavailableError,
|
|
13
|
+
TunnelNotFoundError,
|
|
14
|
+
} from "./errors.js";
|
|
15
|
+
import { OpenTunnelAuthorization } from "./auth.js";
|
|
16
|
+
|
|
17
|
+
export const CreateTunnelRequest = Schema.Struct({
|
|
18
|
+
/** No longer supported; the server rejects requests that set it. */
|
|
19
|
+
name: Schema.String.pipe(Schema.optional),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const CreateTunnelResponse = Schema.Struct({
|
|
23
|
+
tunnel: Tunnel.Info,
|
|
24
|
+
token: Tunnel.Token,
|
|
25
|
+
}).pipe(HttpApiSchema.status(201));
|
|
26
|
+
|
|
27
|
+
export const TunnelGroup = HttpApiGroup.make("tunnel")
|
|
28
|
+
.add(
|
|
29
|
+
HttpApiEndpoint.post("tunnel.create", "/api/tunnel", {
|
|
30
|
+
payload: CreateTunnelRequest,
|
|
31
|
+
success: CreateTunnelResponse,
|
|
32
|
+
error: [InvalidRequestError, HostnameUnavailableError, ServiceUnavailableError],
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
.add(
|
|
36
|
+
HttpApiEndpoint.get("tunnel.get", "/api/tunnel/:id", {
|
|
37
|
+
params: Schema.Struct({ id: Tunnel.ID }),
|
|
38
|
+
success: Tunnel.Info,
|
|
39
|
+
error: [TunnelNotFoundError, ServiceUnavailableError],
|
|
40
|
+
}).middleware(OpenTunnelAuthorization),
|
|
41
|
+
)
|
|
42
|
+
.add(
|
|
43
|
+
HttpApiEndpoint.post("tunnel.bindCertificate", "/api/tunnel/:id/certificate", {
|
|
44
|
+
params: Schema.Struct({ id: Tunnel.ID }),
|
|
45
|
+
payload: Schema.Struct({ csr: CSR.Raw }),
|
|
46
|
+
success: Certificate.Info.pipe(HttpApiSchema.status(202)),
|
|
47
|
+
error: [
|
|
48
|
+
InvalidRequestError,
|
|
49
|
+
InvalidHostnameError,
|
|
50
|
+
TunnelNotFoundError,
|
|
51
|
+
CertificateInProgressError,
|
|
52
|
+
ServiceUnavailableError,
|
|
53
|
+
],
|
|
54
|
+
}).middleware(OpenTunnelAuthorization),
|
|
55
|
+
)
|
|
56
|
+
.add(
|
|
57
|
+
HttpApiEndpoint.get("tunnel.getCertificate", "/api/tunnel/:id/certificate", {
|
|
58
|
+
params: Schema.Struct({ id: Tunnel.ID }),
|
|
59
|
+
success: Certificate.Info,
|
|
60
|
+
error: [
|
|
61
|
+
TunnelNotFoundError,
|
|
62
|
+
CertificateNotFoundError,
|
|
63
|
+
ServiceUnavailableError,
|
|
64
|
+
],
|
|
65
|
+
}).middleware(OpenTunnelAuthorization),
|
|
66
|
+
)
|
|
67
|
+
.add(
|
|
68
|
+
HttpApiEndpoint.delete("tunnel.remove", "/api/tunnel/:id", {
|
|
69
|
+
params: Schema.Struct({ id: Tunnel.ID }),
|
|
70
|
+
success: HttpApiSchema.NoContent,
|
|
71
|
+
error: [TunnelNotFoundError, ServiceUnavailableError],
|
|
72
|
+
}).middleware(OpenTunnelAuthorization),
|
|
73
|
+
)
|
|
74
|
+
.add(
|
|
75
|
+
HttpApiEndpoint.get("tunnel.connect", "/api/tunnel/:id/connect", {
|
|
76
|
+
params: Schema.Struct({ id: Tunnel.ID }),
|
|
77
|
+
success: Schema.Boolean,
|
|
78
|
+
error: [TunnelNotFoundError],
|
|
79
|
+
}).annotateMerge(
|
|
80
|
+
OpenApi.annotations({
|
|
81
|
+
summary: "Connect a bridge WebSocket",
|
|
82
|
+
description: "This endpoint upgrades to the opentunnel WebSocket protocol.",
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
.annotateMerge(OpenApi.annotations({ title: "tunnel" }));
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
export * as BridgeProtocol from "./bridge-protocol.js";
|
|
2
|
+
|
|
3
|
+
import { Option, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bridge protocol types and schemas for the WebSocket binding.
|
|
7
|
+
* Based on the bridge protocol specification from specs.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Connection ID - unsigned 32-bit integer assigned by relay
|
|
11
|
+
export const ConnID = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 0xffffffff }));
|
|
12
|
+
export type ConnID = Schema.Schema.Type<typeof ConnID>;
|
|
13
|
+
|
|
14
|
+
// Client information
|
|
15
|
+
export class ClientInfo extends Schema.Class<ClientInfo>("BridgeProtocol/ClientInfo")({
|
|
16
|
+
version: Schema.String,
|
|
17
|
+
max_conns: Schema.Number,
|
|
18
|
+
}) {}
|
|
19
|
+
|
|
20
|
+
// Attach request - first message after session setup
|
|
21
|
+
export class AttachMessage extends Schema.Class<AttachMessage>("BridgeProtocol/AttachMessage")({
|
|
22
|
+
type: Schema.Literal("attach"),
|
|
23
|
+
token: Schema.String,
|
|
24
|
+
transport: Schema.Literal("ws"),
|
|
25
|
+
client: ClientInfo,
|
|
26
|
+
routes: Schema.Array(Schema.String).pipe(Schema.optional),
|
|
27
|
+
}) {}
|
|
28
|
+
|
|
29
|
+
// Attach success response
|
|
30
|
+
export class AttachedMessage extends Schema.Class<AttachedMessage>(
|
|
31
|
+
"BridgeProtocol/AttachedMessage",
|
|
32
|
+
)({
|
|
33
|
+
type: Schema.Literal("attached"),
|
|
34
|
+
session: Schema.String,
|
|
35
|
+
routes: Schema.Array(Schema.String),
|
|
36
|
+
heartbeat_ms: Schema.Number,
|
|
37
|
+
idle_timeout_ms: Schema.Number,
|
|
38
|
+
}) {}
|
|
39
|
+
|
|
40
|
+
// Attach failure response
|
|
41
|
+
export class AttachErrorMessage extends Schema.Class<AttachErrorMessage>(
|
|
42
|
+
"BridgeProtocol/AttachErrorMessage",
|
|
43
|
+
)({
|
|
44
|
+
type: Schema.Literal("attach_error"),
|
|
45
|
+
code: Schema.String,
|
|
46
|
+
}) {}
|
|
47
|
+
|
|
48
|
+
// Ping message
|
|
49
|
+
export class PingMessage extends Schema.Class<PingMessage>("BridgeProtocol/PingMessage")({
|
|
50
|
+
type: Schema.Literal("ping"),
|
|
51
|
+
time_sent: Schema.Number,
|
|
52
|
+
}) {}
|
|
53
|
+
|
|
54
|
+
// Pong message
|
|
55
|
+
export class PongMessage extends Schema.Class<PongMessage>("BridgeProtocol/PongMessage")({
|
|
56
|
+
type: Schema.Literal("pong"),
|
|
57
|
+
time_sent: Schema.Number,
|
|
58
|
+
}) {}
|
|
59
|
+
|
|
60
|
+
// Drain message - graceful shutdown
|
|
61
|
+
export class DrainMessage extends Schema.Class<DrainMessage>("BridgeProtocol/DrainMessage")({
|
|
62
|
+
type: Schema.Literal("drain"),
|
|
63
|
+
reason: Schema.String,
|
|
64
|
+
}) {}
|
|
65
|
+
|
|
66
|
+
// Open channel metadata - relay opens a new public connection
|
|
67
|
+
export class OpenMessage extends Schema.Class<OpenMessage>("BridgeProtocol/OpenMessage")({
|
|
68
|
+
type: Schema.Literal("open"),
|
|
69
|
+
conn: ConnID,
|
|
70
|
+
peer: Schema.String,
|
|
71
|
+
sni: Schema.String,
|
|
72
|
+
alpn: Schema.String,
|
|
73
|
+
}) {}
|
|
74
|
+
|
|
75
|
+
// End message - half-close
|
|
76
|
+
export class EndMessage extends Schema.Class<EndMessage>("BridgeProtocol/EndMessage")({
|
|
77
|
+
type: Schema.Literal("end"),
|
|
78
|
+
conn: ConnID,
|
|
79
|
+
}) {}
|
|
80
|
+
|
|
81
|
+
// Reset message - abortive close
|
|
82
|
+
export class ResetMessage extends Schema.Class<ResetMessage>("BridgeProtocol/ResetMessage")({
|
|
83
|
+
type: Schema.Literal("reset"),
|
|
84
|
+
conn: ConnID,
|
|
85
|
+
code: Schema.String,
|
|
86
|
+
}) {}
|
|
87
|
+
|
|
88
|
+
// Union of all control message types sent by client
|
|
89
|
+
export const ClientControlMessage = Schema.Union([
|
|
90
|
+
AttachMessage,
|
|
91
|
+
PingMessage,
|
|
92
|
+
PongMessage,
|
|
93
|
+
DrainMessage,
|
|
94
|
+
EndMessage,
|
|
95
|
+
ResetMessage,
|
|
96
|
+
]);
|
|
97
|
+
export type ClientControlMessage = Schema.Schema.Type<typeof ClientControlMessage>;
|
|
98
|
+
|
|
99
|
+
// Union of all control message types sent by server
|
|
100
|
+
export const ServerControlMessage = Schema.Union([
|
|
101
|
+
AttachedMessage,
|
|
102
|
+
AttachErrorMessage,
|
|
103
|
+
PingMessage,
|
|
104
|
+
PongMessage,
|
|
105
|
+
DrainMessage,
|
|
106
|
+
OpenMessage,
|
|
107
|
+
EndMessage,
|
|
108
|
+
ResetMessage,
|
|
109
|
+
]);
|
|
110
|
+
export type ServerControlMessage = Schema.Schema.Type<typeof ServerControlMessage>;
|
|
111
|
+
|
|
112
|
+
const decodeServerControl = Schema.decodeUnknownOption(ServerControlMessage);
|
|
113
|
+
|
|
114
|
+
/** Decodes a server control frame. Unknown or invalid messages yield `None`. */
|
|
115
|
+
export function decodeServerMessage(text: string): Option.Option<ServerControlMessage> {
|
|
116
|
+
try {
|
|
117
|
+
return decodeServerControl(JSON.parse(text));
|
|
118
|
+
} catch {
|
|
119
|
+
return Option.none();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Union of all control message types
|
|
124
|
+
export const ControlMessage = Schema.Union([
|
|
125
|
+
AttachMessage,
|
|
126
|
+
AttachedMessage,
|
|
127
|
+
AttachErrorMessage,
|
|
128
|
+
PingMessage,
|
|
129
|
+
PongMessage,
|
|
130
|
+
DrainMessage,
|
|
131
|
+
OpenMessage,
|
|
132
|
+
EndMessage,
|
|
133
|
+
ResetMessage,
|
|
134
|
+
]);
|
|
135
|
+
export type ControlMessage = Schema.Schema.Type<typeof ControlMessage>;
|
|
136
|
+
|
|
137
|
+
// WebSocket subprotocol name
|
|
138
|
+
export const WEBSOCKET_SUBPROTOCOL = "opentunnel";
|
|
139
|
+
|
|
140
|
+
// Error codes from specs.md
|
|
141
|
+
export const BridgeErrorCode = {
|
|
142
|
+
BAD_ATTACH: "bad_attach",
|
|
143
|
+
BAD_TOKEN: "bad_token",
|
|
144
|
+
TUNNEL_NOT_BOUND: "tunnel_not_bound",
|
|
145
|
+
CERT_NOT_READY: "cert_not_ready",
|
|
146
|
+
REPLACED: "replaced",
|
|
147
|
+
UPSTREAM_CONNECT_FAILED: "upstream_connect_failed",
|
|
148
|
+
UPSTREAM_IO_ERROR: "upstream_io_error",
|
|
149
|
+
CLIENT_IO_ERROR: "client_io_error",
|
|
150
|
+
IDLE_TIMEOUT: "idle_timeout",
|
|
151
|
+
ROUTE_CONFLICT: "route_conflict",
|
|
152
|
+
INVALID_ROUTE: "invalid_route",
|
|
153
|
+
UNKNOWN_ROUTE: "unknown_route",
|
|
154
|
+
} as const;
|
|
155
|
+
|
|
156
|
+
// Default timing constants from specs.md
|
|
157
|
+
export const BridgeTiming = {
|
|
158
|
+
HEARTBEAT_MS: 15000,
|
|
159
|
+
IDLE_TIMEOUT_MS: 45000,
|
|
160
|
+
CONNECT_TIMEOUT_MS: 10000,
|
|
161
|
+
RECONNECT_BACKOFF_MIN_MS: 250,
|
|
162
|
+
RECONNECT_BACKOFF_MAX_MS: 30000,
|
|
163
|
+
} as const;
|
|
164
|
+
|
|
165
|
+
// Data frame layout constants for WebSocket binary frames
|
|
166
|
+
// Format: u32_be conn + payload bytes
|
|
167
|
+
export const DataFrame = {
|
|
168
|
+
CONN_ID_SIZE: 4, // bytes
|
|
169
|
+
MAX_PAYLOAD_SIZE: 32768, // 32 KiB recommended max per frame
|
|
170
|
+
} as const;
|
|
171
|
+
|
|
172
|
+
// Parse a binary data frame to extract connection ID and payload
|
|
173
|
+
export function parseDataFrame(data: Uint8Array): { conn: number; payload: Uint8Array } | null {
|
|
174
|
+
if (data.length < DataFrame.CONN_ID_SIZE) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
178
|
+
const conn = view.getUint32(0, false); // big-endian
|
|
179
|
+
const payload = data.slice(DataFrame.CONN_ID_SIZE);
|
|
180
|
+
return { conn, payload };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Build a binary data frame from connection ID and payload
|
|
184
|
+
export function buildDataFrame(conn: number, payload: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
185
|
+
const result = new Uint8Array(DataFrame.CONN_ID_SIZE + payload.length);
|
|
186
|
+
const view = new DataView(result.buffer);
|
|
187
|
+
view.setUint32(0, conn, false); // big-endian
|
|
188
|
+
result.set(payload, DataFrame.CONN_ID_SIZE);
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export * as Certificate from "./certificate.js";
|
|
2
|
+
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import { Newtype } from "./newtype.js";
|
|
5
|
+
|
|
6
|
+
export class ID extends Newtype<ID>()("CertificateID", Schema.String) {}
|
|
7
|
+
|
|
8
|
+
export const Token = Schema.String.pipe(Schema.brand("ChallengeToken"));
|
|
9
|
+
export type Token = Schema.Schema.Type<typeof Token>;
|
|
10
|
+
|
|
11
|
+
export class StateChallenge extends Schema.Class<StateChallenge>("Certificate/StateChallenge")({
|
|
12
|
+
type: Schema.Literal("challenge"),
|
|
13
|
+
token: Schema.String,
|
|
14
|
+
key: Schema.String,
|
|
15
|
+
}) {}
|
|
16
|
+
|
|
17
|
+
export class StateIssuing extends Schema.Class<StateIssuing>("Certificate/StateIssuing")({
|
|
18
|
+
type: Schema.Literal("issuing"),
|
|
19
|
+
}) {}
|
|
20
|
+
|
|
21
|
+
export class StateReady extends Schema.Class<StateReady>("Certificate/StateReady")({
|
|
22
|
+
type: Schema.Literal("ready"),
|
|
23
|
+
certificate: Schema.String,
|
|
24
|
+
chain: Schema.String,
|
|
25
|
+
expiry: Schema.String,
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
export class StateFailed extends Schema.Class<StateFailed>("Certificate/StateFailed")({
|
|
29
|
+
type: Schema.Literal("failed"),
|
|
30
|
+
reason: Schema.String,
|
|
31
|
+
}) {}
|
|
32
|
+
|
|
33
|
+
export const State = Schema.Union([StateChallenge, StateIssuing, StateReady, StateFailed]);
|
|
34
|
+
export type State = Schema.Schema.Type<typeof State>;
|
|
35
|
+
|
|
36
|
+
export class Info extends Schema.Class<Info>("Certificate")({
|
|
37
|
+
id: ID,
|
|
38
|
+
state: State,
|
|
39
|
+
}) {}
|
package/src/csr.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * as CSR from "./csr.js";
|
|
2
|
+
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
export const Raw = Schema.String.pipe(Schema.brand("CSR.Raw"));
|
|
6
|
+
export type Raw = Schema.Schema.Type<typeof Raw>;
|
|
7
|
+
|
|
8
|
+
export const Hostname = Schema.String.pipe(Schema.brand("Hostname"));
|
|
9
|
+
export type Hostname = Schema.Schema.Type<typeof Hostname>;
|
package/src/index.ts
ADDED
package/src/names.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export * as Names from "./names.js";
|
|
2
|
+
|
|
3
|
+
/** The route name for the tunnel hostname itself. */
|
|
4
|
+
export const ROOT_ROUTE = "@";
|
|
5
|
+
|
|
6
|
+
const label = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
7
|
+
|
|
8
|
+
export const isValidRoute = (value: string): boolean => value === ROOT_ROUTE || label.test(value);
|
|
9
|
+
|
|
10
|
+
export const isValidProfile = (value: string): boolean => label.test(value);
|
|
11
|
+
|
|
12
|
+
/** Resolves the route name for a TLS SNI on the given tunnel hostname. */
|
|
13
|
+
export const routeForSni = (sni: string, hostname: string): string | undefined => {
|
|
14
|
+
const name = sni.toLowerCase();
|
|
15
|
+
const host = hostname.toLowerCase();
|
|
16
|
+
if (name === host) return ROOT_ROUTE;
|
|
17
|
+
const suffix = `.${host}`;
|
|
18
|
+
if (!name.endsWith(suffix)) return undefined;
|
|
19
|
+
const route = name.slice(0, -suffix.length);
|
|
20
|
+
return label.test(route) ? route : undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Validates a `host:port` route target and returns its parts. */
|
|
24
|
+
export const parseTarget = (value: string): { readonly host: string; readonly port: number } | undefined => {
|
|
25
|
+
const separator = value.lastIndexOf(":");
|
|
26
|
+
if (separator === -1) return undefined;
|
|
27
|
+
const portText = value.slice(separator + 1);
|
|
28
|
+
if (!/^\d{1,5}$/.test(portText)) return undefined;
|
|
29
|
+
const port = Number(portText);
|
|
30
|
+
if (port < 1 || port > 65535) return undefined;
|
|
31
|
+
let host = value.slice(0, separator);
|
|
32
|
+
if (host.startsWith("[")) {
|
|
33
|
+
if (!host.endsWith("]")) return undefined;
|
|
34
|
+
host = host.slice(1, -1);
|
|
35
|
+
}
|
|
36
|
+
if (!host || !/^[A-Za-z0-9.\-:_]+$/.test(host)) return undefined;
|
|
37
|
+
return { host, port };
|
|
38
|
+
};
|
package/src/newtype.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Nominal wrapper for scalar types with class syntax.
|
|
5
|
+
* Produces an opaque type that is accepted by Schema constructors
|
|
6
|
+
* (TaggedErrorClass, Class, Struct) when used as a field.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* class UserID extends Newtype<UserID>()("UserID", Schema.String) {
|
|
10
|
+
* static random() {
|
|
11
|
+
* return this.makeUnsafe(crypto.randomUUID());
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
*/
|
|
15
|
+
export function Newtype<Self>() {
|
|
16
|
+
return <const Tag extends string, S extends Schema.Top>(
|
|
17
|
+
tag: Tag,
|
|
18
|
+
schema: S,
|
|
19
|
+
) => {
|
|
20
|
+
abstract class Base {
|
|
21
|
+
declare readonly _newtype: Tag;
|
|
22
|
+
|
|
23
|
+
static makeUnsafe(value: Schema.Schema.Type<S>): Self {
|
|
24
|
+
return value as unknown as Self;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Object.setPrototypeOf(Base, schema);
|
|
29
|
+
|
|
30
|
+
return Base as unknown as (abstract new (_: never) => {
|
|
31
|
+
readonly _newtype: Tag;
|
|
32
|
+
}) & {
|
|
33
|
+
readonly makeUnsafe: (value: Schema.Schema.Type<S>) => Self;
|
|
34
|
+
} & Omit<Schema.Opaque<Self, S, {}>, "makeUnsafe" | "~type.make"> & {
|
|
35
|
+
readonly "~type.make": Self;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/tunnel.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export * as Tunnel from "./tunnel.js";
|
|
2
|
+
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import { CSR } from "./csr.js";
|
|
5
|
+
import { Certificate } from "./certificate.js";
|
|
6
|
+
import { Newtype } from "./newtype.js";
|
|
7
|
+
|
|
8
|
+
export class ID extends Newtype<ID>()("TunnelID", Schema.String) {}
|
|
9
|
+
|
|
10
|
+
export const Token = Schema.String.pipe(Schema.brand("TunnelToken"));
|
|
11
|
+
export type Token = Schema.Schema.Type<typeof Token>;
|
|
12
|
+
|
|
13
|
+
export class NotFoundError extends Schema.TaggedErrorClass()("NotFound", {
|
|
14
|
+
tunnelID: ID,
|
|
15
|
+
}) {}
|
|
16
|
+
|
|
17
|
+
export class NoCertificateError extends Schema.TaggedErrorClass()("NoCertificate", {
|
|
18
|
+
tunnelID: ID,
|
|
19
|
+
}) {}
|
|
20
|
+
|
|
21
|
+
export class CertificateNotReadyError extends Schema.TaggedErrorClass()(
|
|
22
|
+
"CertificateNotReady",
|
|
23
|
+
{ tunnelID: ID, currentState: Schema.String },
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
export class InvalidHostnameError extends Schema.TaggedErrorClass()(
|
|
27
|
+
"InvalidHostname",
|
|
28
|
+
{ provided: CSR.Hostname, expected: CSR.Hostname },
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
export const State = Schema.Literals(["offline", "online"]);
|
|
32
|
+
export type State = Schema.Schema.Type<typeof State>;
|
|
33
|
+
|
|
34
|
+
export class Info extends Schema.Class<Info>("Tunnel/Info")({
|
|
35
|
+
id: ID,
|
|
36
|
+
hostname: CSR.Hostname,
|
|
37
|
+
state: State,
|
|
38
|
+
certificateID: Certificate.ID.pipe(Schema.optional),
|
|
39
|
+
}) {}
|