@opentunnel/client 0.0.0 → 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 +95 -0
- package/package.json +42 -1
- package/src/effect/api.ts +39 -0
- package/src/effect/client.ts +315 -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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
export * as OpenTunnelStorage from "./storage.js";
|
|
2
|
+
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import * as Fs from "node:fs/promises";
|
|
5
|
+
import * as Os from "node:os";
|
|
6
|
+
import * as Path from "node:path";
|
|
7
|
+
import type {
|
|
8
|
+
OpenTunnelIdentity,
|
|
9
|
+
OpenTunnelPendingIdentity,
|
|
10
|
+
OpenTunnelStoredTunnel,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
import { OpenTunnelStorageError } from "./errors.js";
|
|
13
|
+
|
|
14
|
+
export interface OpenTunnelStorage {
|
|
15
|
+
readonly profiles: () => Effect.Effect<ReadonlyArray<string>, OpenTunnelStorageError>;
|
|
16
|
+
readonly load: (
|
|
17
|
+
profile: string,
|
|
18
|
+
) => Effect.Effect<OpenTunnelIdentity | undefined, OpenTunnelStorageError>;
|
|
19
|
+
readonly save: (
|
|
20
|
+
profile: string,
|
|
21
|
+
tunnel: OpenTunnelIdentity,
|
|
22
|
+
) => Effect.Effect<void, OpenTunnelStorageError>;
|
|
23
|
+
readonly loadPending: (
|
|
24
|
+
profile: string,
|
|
25
|
+
) => Effect.Effect<OpenTunnelPendingIdentity | undefined, OpenTunnelStorageError>;
|
|
26
|
+
readonly savePending: (
|
|
27
|
+
profile: string,
|
|
28
|
+
tunnel: OpenTunnelPendingIdentity,
|
|
29
|
+
) => Effect.Effect<void, OpenTunnelStorageError>;
|
|
30
|
+
readonly remove: (profile: string) => Effect.Effect<void, OpenTunnelStorageError>;
|
|
31
|
+
readonly list: () => Effect.Effect<ReadonlyArray<OpenTunnelStoredTunnel>, OpenTunnelStorageError>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function memory(): OpenTunnelStorage {
|
|
35
|
+
const identities = new Map<string, OpenTunnelIdentity>();
|
|
36
|
+
const pending = new Map<string, OpenTunnelPendingIdentity>();
|
|
37
|
+
return {
|
|
38
|
+
profiles: () => Effect.sync(() => [...new Set([...identities.keys(), ...pending.keys()])].sort()),
|
|
39
|
+
load: (profile) => Effect.sync(() => identities.get(profile)),
|
|
40
|
+
save: (profile, tunnel) => Effect.sync(() => {
|
|
41
|
+
identities.set(profile, tunnel);
|
|
42
|
+
pending.delete(profile);
|
|
43
|
+
}),
|
|
44
|
+
loadPending: (profile) => Effect.sync(() => pending.get(profile)),
|
|
45
|
+
savePending: (profile, tunnel) => Effect.sync(() => void pending.set(profile, tunnel)),
|
|
46
|
+
remove: (profile) => Effect.sync(() => {
|
|
47
|
+
identities.delete(profile);
|
|
48
|
+
pending.delete(profile);
|
|
49
|
+
}),
|
|
50
|
+
list: () =>
|
|
51
|
+
Effect.sync(() =>
|
|
52
|
+
[...identities].map(([profile, tunnel]) => ({ profile, tunnel })),
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function xdg(options: { readonly env?: NodeJS.ProcessEnv; readonly home?: string } = {}): OpenTunnelStorage {
|
|
58
|
+
const env = options.env ?? process.env;
|
|
59
|
+
const home = options.home ?? Os.homedir();
|
|
60
|
+
const dataHome = env.XDG_DATA_HOME ?? Path.join(home, ".local", "share");
|
|
61
|
+
const dataRoot = Path.join(dataHome, "opentunnel");
|
|
62
|
+
const profileData = (profile: string) => Path.join(dataRoot, profile);
|
|
63
|
+
|
|
64
|
+
const readOptional = (path: string) =>
|
|
65
|
+
Effect.tryPromise({
|
|
66
|
+
try: () => Fs.readFile(path, "utf8"),
|
|
67
|
+
catch: (cause) => cause,
|
|
68
|
+
}).pipe(
|
|
69
|
+
Effect.catch((cause: unknown) => Effect.gen(function* () {
|
|
70
|
+
if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return yield* new OpenTunnelStorageError({ message: `Failed to read ${path}`, cause });
|
|
74
|
+
})),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const profiles = Effect.fn("OpenTunnelStorage.profiles")(function* () {
|
|
78
|
+
const entries = yield* Effect.tryPromise({
|
|
79
|
+
try: () => Fs.readdir(dataRoot, { withFileTypes: true }),
|
|
80
|
+
catch: (cause) => cause,
|
|
81
|
+
}).pipe(
|
|
82
|
+
Effect.catch((cause: unknown) => Effect.gen(function* () {
|
|
83
|
+
if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
return yield* new OpenTunnelStorageError({
|
|
87
|
+
message: `Failed to list ${dataRoot}`,
|
|
88
|
+
cause,
|
|
89
|
+
});
|
|
90
|
+
})),
|
|
91
|
+
);
|
|
92
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const load = Effect.fn("OpenTunnelStorage.load")(function* (profile: string) {
|
|
96
|
+
const root = profileData(profile);
|
|
97
|
+
const metadata = yield* readOptional(Path.join(root, "tunnel.json"));
|
|
98
|
+
if (!metadata) return undefined;
|
|
99
|
+
const [token, privateKey, certificate, chain] = yield* Effect.all([
|
|
100
|
+
readOptional(Path.join(root, "token")),
|
|
101
|
+
readOptional(Path.join(root, "private-key.pem")),
|
|
102
|
+
readOptional(Path.join(root, "certificate.pem")),
|
|
103
|
+
readOptional(Path.join(root, "chain.pem")),
|
|
104
|
+
]);
|
|
105
|
+
if (!token || !privateKey || !certificate || chain === undefined) {
|
|
106
|
+
return yield* new OpenTunnelStorageError({
|
|
107
|
+
message: `Incomplete tunnel identity for ${profile}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const value = JSON.parse(metadata) as {
|
|
111
|
+
id: string;
|
|
112
|
+
hostname: string;
|
|
113
|
+
certificateExpiry: string;
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
id: value.id,
|
|
117
|
+
hostname: value.hostname,
|
|
118
|
+
token: token.trim(),
|
|
119
|
+
privateKey,
|
|
120
|
+
certificate,
|
|
121
|
+
chain,
|
|
122
|
+
certificateExpiry: new Date(value.certificateExpiry),
|
|
123
|
+
} satisfies OpenTunnelIdentity;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const loadPending = Effect.fn("OpenTunnelStorage.loadPending")(function* (profile: string) {
|
|
127
|
+
const root = profileData(profile);
|
|
128
|
+
const metadata = yield* readOptional(Path.join(root, "pending.json"));
|
|
129
|
+
if (!metadata) return undefined;
|
|
130
|
+
const [token, privateKey] = yield* Effect.all([
|
|
131
|
+
readOptional(Path.join(root, "token")),
|
|
132
|
+
readOptional(Path.join(root, "private-key.pem")),
|
|
133
|
+
]);
|
|
134
|
+
if (!token || !privateKey) {
|
|
135
|
+
return yield* new OpenTunnelStorageError({
|
|
136
|
+
message: `Incomplete pending tunnel identity for ${profile}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const value = JSON.parse(metadata) as { id: string; hostname: string; csr: string };
|
|
140
|
+
return {
|
|
141
|
+
id: value.id,
|
|
142
|
+
hostname: value.hostname,
|
|
143
|
+
token: token.trim(),
|
|
144
|
+
privateKey,
|
|
145
|
+
csr: value.csr,
|
|
146
|
+
} satisfies OpenTunnelPendingIdentity;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
profiles: () => profiles(),
|
|
151
|
+
load: (profile) => load(profile),
|
|
152
|
+
loadPending: (profile) => loadPending(profile),
|
|
153
|
+
savePending: (profile, tunnel) =>
|
|
154
|
+
Effect.tryPromise({
|
|
155
|
+
try: async () => {
|
|
156
|
+
const root = profileData(profile);
|
|
157
|
+
await Fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
158
|
+
await Promise.all([
|
|
159
|
+
Fs.writeFile(
|
|
160
|
+
Path.join(root, "pending.json"),
|
|
161
|
+
JSON.stringify({ id: tunnel.id, hostname: tunnel.hostname, csr: tunnel.csr }, null, 2),
|
|
162
|
+
{ mode: 0o600 },
|
|
163
|
+
),
|
|
164
|
+
Fs.writeFile(Path.join(root, "token"), `${tunnel.token}\n`, { mode: 0o600 }),
|
|
165
|
+
Fs.writeFile(Path.join(root, "private-key.pem"), tunnel.privateKey, { mode: 0o600 }),
|
|
166
|
+
]);
|
|
167
|
+
},
|
|
168
|
+
catch: (cause) => new OpenTunnelStorageError({
|
|
169
|
+
message: `Failed to save pending tunnel identity for ${profile}`,
|
|
170
|
+
cause,
|
|
171
|
+
}),
|
|
172
|
+
}),
|
|
173
|
+
save: (profile, tunnel) =>
|
|
174
|
+
Effect.tryPromise({
|
|
175
|
+
try: async () => {
|
|
176
|
+
const root = profileData(profile);
|
|
177
|
+
await Fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
178
|
+
await Promise.all([
|
|
179
|
+
Fs.writeFile(
|
|
180
|
+
Path.join(root, "tunnel.json"),
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
id: tunnel.id,
|
|
183
|
+
hostname: tunnel.hostname,
|
|
184
|
+
certificateExpiry: tunnel.certificateExpiry.toISOString(),
|
|
185
|
+
}, null, 2),
|
|
186
|
+
{ mode: 0o600 },
|
|
187
|
+
),
|
|
188
|
+
Fs.writeFile(Path.join(root, "token"), `${tunnel.token}\n`, { mode: 0o600 }),
|
|
189
|
+
Fs.writeFile(Path.join(root, "private-key.pem"), tunnel.privateKey, { mode: 0o600 }),
|
|
190
|
+
Fs.writeFile(Path.join(root, "certificate.pem"), tunnel.certificate, { mode: 0o600 }),
|
|
191
|
+
Fs.writeFile(Path.join(root, "chain.pem"), tunnel.chain, { mode: 0o600 }),
|
|
192
|
+
]);
|
|
193
|
+
await Fs.rm(Path.join(root, "pending.json"), { force: true });
|
|
194
|
+
},
|
|
195
|
+
catch: (cause) =>
|
|
196
|
+
new OpenTunnelStorageError({
|
|
197
|
+
message: `Failed to save tunnel identity for ${profile}`,
|
|
198
|
+
cause,
|
|
199
|
+
}),
|
|
200
|
+
}),
|
|
201
|
+
remove: (profile) =>
|
|
202
|
+
Effect.tryPromise({
|
|
203
|
+
try: () => Fs.rm(profileData(profile), { recursive: true, force: true }),
|
|
204
|
+
catch: (cause) =>
|
|
205
|
+
new OpenTunnelStorageError({
|
|
206
|
+
message: `Failed to remove tunnel identity for ${profile}`,
|
|
207
|
+
cause,
|
|
208
|
+
}),
|
|
209
|
+
}),
|
|
210
|
+
list: () =>
|
|
211
|
+
profiles().pipe(
|
|
212
|
+
Effect.flatMap((names) =>
|
|
213
|
+
Effect.forEach(names, (profile) =>
|
|
214
|
+
load(profile).pipe(
|
|
215
|
+
Effect.map((tunnel) => tunnel ? { profile, tunnel } : undefined),
|
|
216
|
+
),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
Effect.map((entries) => entries.filter((entry): entry is OpenTunnelStoredTunnel => entry !== undefined)),
|
|
220
|
+
),
|
|
221
|
+
};
|
|
222
|
+
}
|