socket-function 0.8.0 → 0.8.3

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/SocketFunction.ts CHANGED
@@ -30,6 +30,8 @@ export class SocketFunction {
30
30
  type: "gzip";
31
31
  };
32
32
  public static httpETagCache = false;
33
+ public static rejectUnauthorized = true;
34
+ public static additionalTrustedRootCAs: string[] = [];
33
35
 
34
36
  public static register<
35
37
  ClassInstance extends object,
@@ -1,3 +1,6 @@
1
+ import debugbreak from "debugbreak";
2
+ import * as tls from "tls";
3
+
1
4
  export const socket = Symbol("socket");
2
5
 
3
6
  export type SocketExposedInterface = {
@@ -30,13 +33,13 @@ export interface SocketFunctionHook<ExposedType extends SocketExposedInterface =
30
33
  export type HookContext<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> = {
31
34
  call: CallType;
32
35
  context: SocketRegistered["context"];
33
- // If the result is overriden, we continue evaluating hooks and perform the final call
36
+ // If the result is overriden, we continue evaluating hooks BUT NOT perform the final call
34
37
  overrideResult?: unknown;
35
38
  };
36
39
 
37
40
  export type ClientHookContext<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> = {
38
41
  call: CallType;
39
- // If the result is overriden, we continue evaluating hooks and perform the final call
42
+ // If the result is overriden, we continue evaluating hooks BUT NOT perform the final call
40
43
  overrideResult?: unknown;
41
44
  };
42
45
  export interface SocketFunctionClientHook<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> {
@@ -70,8 +73,28 @@ export type CallerContext = {
70
73
  // The location of the server. It helps if it is told, due to the fact that one server
71
74
  // can serve multiple domains.
72
75
  serverLocation: NetworkLocation;
76
+
77
+ // NOTE: Only set in NodeJS, as clientside we are not given access to the certificate information.
78
+ // TODO: Limit this type to only have the information we need, possible in a slightly different format.
79
+ certInfo: tls.DetailedPeerCertificate | undefined;
80
+
81
+ // TODO: Add callerBrowserAuthIP, which will allow "Proxy-IP" (or whatever cloudflare uses? It has to be a
82
+ // header which the browser is restricted from sending), to override this, allowing the browser to use
83
+ // proxies.
84
+ // - We have to also ONLY accept this from certain trusted servers, as otherwise it is too easy to spoof.
85
+ //callerBrowserAuthIP: string;
73
86
  };
74
87
 
88
+ export function setCertInfo(socket: tls.TLSSocket | undefined, context: CallerContext) {
89
+ if (!socket) return;
90
+ let cert = socket.getPeerCertificate(true);
91
+ /** Check for a property, because "If the peer does not provide a certificate, an empty object will be
92
+ returned. If the socket has been destroyed, `null` will be returned." */
93
+ if (cert?.issuer) {
94
+ context.certInfo = cert;
95
+ }
96
+ }
97
+
75
98
  // IMPORTANT! Nodes at the same network location may vary, so you cannot store NetworkLocation
76
99
  // in a list of allowed users, otherwise they can be impersonated!
77
100
  export interface NetworkLocation {
@@ -0,0 +1,65 @@
1
+ import { SocketFunction } from "../SocketFunction";
2
+ import { cache, lazy } from "../src/caching";
3
+ import * as fs from "fs";
4
+
5
+ /** Hot reloads server and client files, just trigger a refresh clientside,
6
+ * while triggering per file re-evaluation and export updates serverside.
7
+ * - Requires HotReloadController to be exposed.
8
+ */
9
+ export function watchFilesAndTriggerHotReloading() {
10
+ setInterval(() => {
11
+ for (let module of Object.values(require.cache)) {
12
+ if (!module) continue;
13
+ hotReloadModule(module);
14
+ }
15
+ }, 5000);
16
+ }
17
+
18
+
19
+ const hotReloadModule = cache((module: NodeJS.Module) => {
20
+ if (!module.updateContents) return;
21
+ fs.watchFile(module.filename, { persistent: false, interval: 1000 }, (curr, prev) => {
22
+ if (curr.mtime.getTime() === prev.mtime.getTime()) return;
23
+ console.log(`Hot reloading due to change: ${module.filename}`);
24
+ module.updateContents?.();
25
+ triggerClientSideReload();
26
+ });
27
+ });
28
+ let reloadTriggering = false;
29
+ let clientWatcherNodes = new Set<string>();
30
+ function triggerClientSideReload() {
31
+ if (reloadTriggering) return;
32
+ reloadTriggering = true;
33
+ setTimeout(async () => {
34
+ reloadTriggering = false;
35
+ for (let clientNodeId of clientWatcherNodes) {
36
+ console.log(`Notifying client of hot reload: ${clientNodeId}`);
37
+ HotReloadController.nodes[clientNodeId].fileUpdated().catch(() => {
38
+ console.log(`Removing erroring client: ${clientNodeId}`);
39
+ clientWatcherNodes.delete(clientNodeId);
40
+ });
41
+ }
42
+ }, 300);
43
+ }
44
+
45
+ class HotReloadControllerBase {
46
+ async watchFiles() {
47
+ let callerId = HotReloadController.context.caller?.nodeId;
48
+ if (!callerId) {
49
+ throw new Error("No nodeId?");
50
+ }
51
+ clientWatcherNodes.add(callerId);
52
+ }
53
+ async fileUpdated() {
54
+ document.location.reload();
55
+ }
56
+ }
57
+
58
+ export const HotReloadController = SocketFunction.register(
59
+ "HotReloadController-032b2250-3aac-4187-8c95-75412742b8f5",
60
+ new HotReloadControllerBase(),
61
+ {
62
+ watchFiles: {},
63
+ fileUpdated: {}
64
+ }
65
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "0.8.0",
3
+ "version": "0.8.3",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "dependencies": {
@@ -9,7 +9,7 @@
9
9
  "@types/ws": "^8.5.3",
10
10
  "cookie": "^0.5.0",
11
11
  "debugbreak": "^0.6.5",
12
- "typenode": "*",
12
+ "typenode": "^0.5.6",
13
13
  "ws": "^8.8.0"
14
14
  },
15
15
  "scripts": {
package/spec.txt CHANGED
@@ -1,6 +1,5 @@
1
1
  spec.txt
2
2
 
3
- - Add a flag for rejectUnauthorized, that is off by default
4
3
  - Add the ability to specify the certs yourself (so you can specify your identity with a real cert)
5
4
  - Then use real certificates on the server
6
5
  - Fix multiple clients on the same machines
@@ -1,13 +1,14 @@
1
- import { CallerContext, CallType, NetworkLocation } from "../SocketFunctionTypes";
1
+ import { CallerContext, CallType, NetworkLocation, setCertInfo } from "../SocketFunctionTypes";
2
2
  import * as ws from "ws";
3
3
  import type * as net from "net";
4
4
  import { performLocalCall } from "./callManager";
5
5
  import { convertErrorStackToError, formatNumberSuffixed, isNode } from "./misc";
6
- import { createWebsocket, getNodeId, getTLSSocket } from "./nodeAuthentication";
6
+ import { createWebsocketFactory, getNodeId, getTLSSocket } from "./nodeAuthentication";
7
7
  import debugbreak from "debugbreak";
8
8
  import http from "http";
9
9
  import { SocketFunction } from "../SocketFunction";
10
10
  import { gzip } from "zlib";
11
+ import * as tls from "tls";
11
12
 
12
13
  const retryInterval = 2000;
13
14
 
@@ -80,6 +81,8 @@ export async function callFactoryFromWS(
80
81
 
81
82
  export interface SenderInterface {
82
83
  nodeId?: string;
84
+ // Only set AFTER "open" (if set at all, as in the browser we don't have access to the socket).
85
+ socket?: tls.TLSSocket;
83
86
 
84
87
  send(data: string | Buffer): void;
85
88
 
@@ -107,6 +110,8 @@ async function createCallFactory(
107
110
  niceConnectionName += `(${fromPort})`;
108
111
  }
109
112
 
113
+ const createWebsocket = createWebsocketFactory();
114
+
110
115
  let retriesEnabled = location.listeningPorts.length > 0;
111
116
 
112
117
  let lastReceivedSeqNum = 0;
@@ -127,7 +132,7 @@ async function createCallFactory(
127
132
  let nextSeqNum = Math.random();
128
133
 
129
134
  const pendingNodeId = "PENDING";
130
- let callerContext: CallerContext = { location, nodeId: pendingNodeId, serverLocation, fromPort };
135
+ let callerContext: CallerContext = { location, nodeId: pendingNodeId, serverLocation, fromPort, certInfo: undefined };
131
136
  let webSocket!: SenderInterface;
132
137
  if (!webSocketBase) {
133
138
  await tryToReconnect();
@@ -258,8 +263,8 @@ async function createCallFactory(
258
263
  return;
259
264
  }
260
265
 
261
- console.error(`Connection retry to ${location.address}:${port} failed, retrying in ${retryInterval}ms`);
262
266
  reconnectAttempts++;
267
+ console.error(`Connection retry to ${location.address}:${port} failed (attempt ${reconnectAttempts}), retrying in ${retryInterval}ms, error: ${JSON.stringify(connectError)}`);
263
268
  await new Promise(resolve => setTimeout(resolve, retryInterval));
264
269
  }
265
270
  })();
@@ -278,6 +283,8 @@ async function createCallFactory(
278
283
  });
279
284
 
280
285
  webSocket.addEventListener("message", onMessage);
286
+
287
+ setCertInfo(webSocket.socket || (webSocket as any)._socket, callerContext);
281
288
  }
282
289
 
283
290
 
@@ -361,8 +368,6 @@ async function createCallFactory(
361
368
  }
362
369
  return;
363
370
  }
364
- debugbreak(1);
365
- debugger;
366
371
  throw new Error(`Unhandled data type ${typeof message}`);
367
372
  } catch (e: any) {
368
373
  console.error(e.stack);
@@ -2,7 +2,7 @@ import https from "https";
2
2
  import http from "http";
3
3
  import net from "net";
4
4
  import tls from "tls";
5
- import { CallerContext, CallType, NetworkLocation } from "../SocketFunctionTypes";
5
+ import { CallerContext, CallType, NetworkLocation, setCertInfo } from "../SocketFunctionTypes";
6
6
  import { performLocalCall } from "./callManager";
7
7
  import { getNodeIdRaw } from "./nodeAuthentication";
8
8
  import debugbreak from "debugbreak";
@@ -111,7 +111,9 @@ export async function httpCallHandler(request: http.IncomingMessage, response: h
111
111
  listeningPorts: [],
112
112
  },
113
113
  serverLocation: getServerLocationFromRequest(request),
114
+ certInfo: undefined,
114
115
  };
116
+ setCertInfo(socket, caller);
115
117
 
116
118
  let classGuid = urlObj.searchParams.get("classGuid");
117
119
  let functionName = urlObj.searchParams.get("functionName");
@@ -173,8 +175,8 @@ export async function httpCallHandler(request: http.IncomingMessage, response: h
173
175
 
174
176
  // NOTE: Our ETag caching is only to reduce data sent on the wire, we evaluate the calls
175
177
  // every time (so it is strictly a wire cache, not computation cache)
176
- response.setHeader("cache-control", "private, s-maxage=0, max-age=0, must-revalidate");
177
178
  if (SocketFunction.httpETagCache) {
179
+ response.setHeader("cache-control", "private, s-maxage=0, max-age=0, must-revalidate");
178
180
  let hash = sha256Hash(resultBuffer);
179
181
  response.setHeader("ETag", hash);
180
182
  if (request.headers["if-none-match"] === hash) {
@@ -30,8 +30,7 @@ export async function performLocalCall(
30
30
  }
31
31
 
32
32
  let controller = classDef.controller;
33
- let shape = classDef.shape;
34
- let functionShape = shape[call.functionName];
33
+ let functionShape = classDef.shape[call.functionName];
35
34
  if (!functionShape) {
36
35
  throw new Error(`Function ${call.functionName} not exposed`);
37
36
  }
@@ -41,7 +40,7 @@ export async function performLocalCall(
41
40
  }
42
41
 
43
42
  let curContext: CallContextType = {};
44
- let serverContext = await runServerHooks(call, { caller, curContext }, shape);
43
+ let serverContext = await runServerHooks(call, { caller, curContext }, functionShape);
45
44
  if ("overrideResult" in serverContext) {
46
45
  return serverContext.overrideResult;
47
46
  }
@@ -11,14 +11,20 @@ import crypto from "crypto";
11
11
  import { isNode, sha256Hash } from "./misc";
12
12
  import { getArgs } from "./args";
13
13
  import { SenderInterface } from "./CallFactory";
14
+ import { SocketFunction } from "../SocketFunction";
14
15
 
15
- export const getCertKeyPair = lazy((): { key: Buffer; cert: Buffer } => {
16
+ let certKeyPairOverride: { key: Buffer; cert: Buffer } | undefined;
17
+ export function getCertKeyPair(): { key: Buffer; cert: Buffer } {
18
+ if (certKeyPairOverride) return certKeyPairOverride;
19
+ return getCertKeyPairBase();
20
+ }
21
+ const getCertKeyPairBase = lazy((): { key: Buffer; cert: Buffer } => {
16
22
  // TODO: Also get this working clientside...
17
- // - Probably using node-forge, maybe using this as an example: https://github.com/jfromaniello/selfsigned/blob/master/index.js
23
+ // - Use https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey
24
+ // - We might need node-forge for the Certificate Signing Request and x509 stuff
25
+ // - Use ECDSA keys
18
26
  // - ALSO, get our nodeId set in our cookies, so HTTP requests can work as well
19
- // - We will need to call some kind of endpoint to do this?
20
- // - Then download the certs and try to get the user to install them, so chrome can use
21
- // them? Otherwise there is no point of having certs clientside.
27
+ // - We will need callHTTPHandler to support this
22
28
 
23
29
  // https://nodejs.org/en/knowledge/HTTP/servers/how-to-create-a-HTTPS-server/
24
30
 
@@ -38,6 +44,16 @@ export const getCertKeyPair = lazy((): { key: Buffer; cert: Buffer } => {
38
44
  return { key, cert };
39
45
  });
40
46
 
47
+ export function overrideCertKeyPair<T>(certKey: { key: Buffer; cert: Buffer; }, code: () => T): T {
48
+ let prevOverride = certKeyPairOverride;
49
+ certKeyPairOverride = certKey;
50
+ try {
51
+ return code();
52
+ } finally {
53
+ certKeyPairOverride = prevOverride;
54
+ }
55
+ }
56
+
41
57
  export function getTLSSocket(webSocket: ws.WebSocket) {
42
58
  return (webSocket as any)._socket as tls.TLSSocket;
43
59
  }
@@ -67,86 +83,59 @@ export const getNodeId = cacheWeak(function (webSocket: SenderInterface | ws.Web
67
83
  if (webSocket.nodeId) {
68
84
  return webSocket.nodeId;
69
85
  }
70
- throw new Error(`Missing nodeId. If it is from the browser, this likely means your websocket and HTTP request are using different domains (so the cookies are lost). If it is from NodeJs peer certificate must use an RSA key or EC key (which should have a .pubkey property)`);
86
+ throw new Error(`Missing nodeId. If it is from the browser, this likely means your websocket and HTTP request are using different domains (so the cookies are lost). If it is from NodeJs peer certificate must use an RSA key or EC key (which should have a .modulus property)`);
71
87
  }
72
88
  return nodeId;
73
89
  });
74
90
 
91
+ export function getNodeIdFromCert(cert: { modulus: Buffer }) {
92
+ // Apparently some implementations strip preceding zeros, which makes sense, as it is a modulus so
93
+ // preceding zeros aren't needed.
94
+ let startIndex = 0;
95
+ while (startIndex < cert.modulus.length && cert.modulus[startIndex] === 0) {
96
+ startIndex++;
97
+ }
98
+ return sha256Hash(cert.modulus.slice(startIndex));
99
+ }
75
100
  export function getNodeIdRaw(socket: tls.TLSSocket) {
76
101
  let peerCert = socket.getPeerCertificate();
77
102
  if (!peerCert) {
78
103
  throw new Error("WebSocket connections must provided a peer certificate");
79
104
  }
80
- let pubkey = (peerCert as any).pubkey as Buffer | undefined;
81
- if (!pubkey) {
82
- return undefined;
83
- }
84
- return sha256Hash(pubkey);
105
+
106
+ if (!peerCert.modulus) return undefined;
107
+ return getNodeIdFromCert({ modulus: Buffer.from(peerCert.modulus, "hex") });
85
108
  }
86
109
 
87
- export function createWebsocket(address: string, port: number): SenderInterface {
88
- console.log(`Connecting to ${address}:${port}`);
110
+ /** NOTE: We create a factory, which embeds the key/cert information. Otherwise retries might use
111
+ * a different key/cert context.
112
+ */
113
+ export function createWebsocketFactory(): (address: string, port: number) => SenderInterface {
114
+
89
115
  if (!isNode()) {
90
116
  // NOTE: We assume an HTTP request has already been made, which will setup a nodeId cookie
91
117
  // (And as this point we can't even use peer certificates if we wanted to, as this must be done
92
118
  // directly in the browser)
93
- return new WebSocket(`wss://${address}:${port}`);
119
+ return (address: string, port: number) => {
120
+ console.log(`Connecting to ${address}:${port}`);
121
+ return new WebSocket(`wss://${address}:${port}`);
122
+ };
94
123
  } else {
95
124
  let { key, cert } = getCertKeyPair();
96
- return new ws.WebSocket(`wss://${address}:${port}`, {
97
- cert,
98
- key,
99
- rejectUnauthorized: false,
100
- });
125
+ let rejectUnauthorized = SocketFunction.rejectUnauthorized;
126
+ return (address: string, port: number) => {
127
+ console.log(`Connecting to ${address}:${port}`);
128
+ let webSocket = new ws.WebSocket(`wss://${address}:${port}`, {
129
+ cert,
130
+ key,
131
+ rejectUnauthorized,
132
+ ca: tls.rootCertificates.concat(SocketFunction.additionalTrustedRootCAs),
133
+ });
134
+ let result = Object.assign(webSocket, { socket: undefined as tls.TLSSocket | undefined });
135
+ webSocket.once("upgrade", e => {
136
+ result.socket = e.socket as tls.TLSSocket;
137
+ });
138
+ return result;
139
+ };
101
140
  }
102
- }
103
-
104
-
105
-
106
- /*
107
- const port = 2422;
108
- let { key, cert } = getCertKeyPair();
109
- console.log(process.argv);
110
- if (process.argv.includes("--server")) {
111
-
112
- let server = https.createServer({
113
- key,
114
- cert,
115
- rejectUnauthorized: false,
116
- requestCert: true
117
- });
118
- let listenPromise = new Promise<void>((resolve, error) => {
119
- server.on("listening", () => {
120
- resolve();
121
- });
122
- server.on("error", e => {
123
- error(e);
124
- });
125
- });
126
-
127
- server.on("request", (request, response) => {
128
- // TODO: Handle HTTP requests
129
- // - HTTP CAN have a nodeId, simply through setting cookies
130
- // - Cookies could always be set via a request before we open
131
- // the websocket connection?
132
- });
133
-
134
- const webSocketServer = new ws.Server({
135
- noServer: true,
136
- });
137
- server.on("upgrade", (request, socket, upgradeHead) => {
138
- webSocketServer.handleUpgrade(request, socket, upgradeHead, (ws) => {
139
- console.log("peer", getTLSSocket(ws).getPeerCertificate()?.pubkey.toString("hex").slice(100));
140
- console.log("cert", getTLSSocket(ws).getCertificate()?.pubkey.toString("hex").slice(100));
141
- });
142
- });
143
-
144
- server.listen(2422, "127.0.0.1");
145
- } else {
146
- let socket = new ws.WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false, cert, key });
147
- socket.on("open", () => {
148
- console.log("peer", getTLSSocket(socket).getPeerCertificate()?.pubkey.toString("hex").slice(100));
149
- console.log("cert", getTLSSocket(socket).getCertificate()?.pubkey.toString("hex").slice(100));
150
- });
151
- }
152
- */
141
+ }
@@ -11,6 +11,7 @@ import { getCertKeyPair, getNodeId, getNodeIdRaw } from "./nodeAuthentication";
11
11
  import debugbreak from "debugbreak";
12
12
  import { cache } from "./caching";
13
13
  import { getNodeIdFromRequest, getServerLocationFromRequest, httpCallHandler } from "./callHTTPHandler";
14
+ import { SocketFunction } from "../SocketFunction";
14
15
 
15
16
  // TODO: Support conditional peer certificate requests, as it the certificate prompt
16
17
  // seems suspicious in the browser (the user can just click cancel though).
@@ -41,8 +42,18 @@ export async function startSocketServer(
41
42
  // so it is easy to read, and consistent.
42
43
  let httpsServer = https.createServer({
43
44
  ...config,
44
- rejectUnauthorized: false,
45
+ rejectUnauthorized: SocketFunction.rejectUnauthorized,
45
46
  requestCert: true,
47
+ ca: tls.rootCertificates.concat(SocketFunction.additionalTrustedRootCAs),
48
+ });
49
+ httpsServer.on("connection", socket => {
50
+ console.log("Client connection established");
51
+ });
52
+ httpsServer.on("error", e => {
53
+ console.error(`Connection attempt error ${e.message}`);
54
+ });
55
+ httpsServer.on("tlsClientError", e => {
56
+ console.error(`TLS client error ${e.message}`);
46
57
  });
47
58
 
48
59