Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/hub/proxy/handle-upgrade.ts
1496 views
1
// Websocket support
2
3
import { createProxyServer, type ProxyServer } from "http-proxy-3";
4
import LRU from "lru-cache";
5
import { getEventListeners } from "node:events";
6
import getLogger from "@cocalc/hub/logger";
7
import stripRememberMeCookie from "./strip-remember-me-cookie";
8
import { getTarget } from "./target";
9
import { stripBasePath } from "./util";
10
import { versionCheckFails } from "./version";
11
import { proxyConatWebsocket } from "./proxy-conat";
12
import basePath from "@cocalc/backend/base-path";
13
14
const LISTENERS_HACK = true;
15
16
const logger = getLogger("proxy:handle-upgrade");
17
18
export default function initUpgrade(
19
{ projectControl, isPersonal, httpServer, proxyConat },
20
proxy_regexp: string,
21
) {
22
const cache = new LRU<string, ProxyServer>({
23
max: 5000,
24
ttl: 1000 * 60 * 3,
25
});
26
27
const re = new RegExp(proxy_regexp);
28
29
let nextUpgrade: undefined | Function = undefined;
30
let socketioUpgrade: undefined | Function = undefined;
31
32
async function handleProxyUpgradeRequest(req, socket, head): Promise<void> {
33
if (LISTENERS_HACK) {
34
const v = getEventListeners(httpServer, "upgrade");
35
if (v.length > 1) {
36
// Nodejs basically assumes that there is only one listener for the "upgrade" handler,
37
// but depending on how you run CoCalc, two others may get added:
38
// - a socketio server
39
// - a nextjs server
40
// We check if anything extra got added and if so, identify it and properly
41
// use it. We identify the handle using `${f}` and using a heuristic for the
42
// code. That's the best I can do and it's obviously brittle.
43
// Note: rspack for the static app doesn't use a websocket, instead using SSE, so
44
// fortunately it's not relevant and hmr works fine. HMR for the nextjs server
45
// tends to just refresh the page, probably because we're using rspack there too.
46
for (const f of v) {
47
if (f === handler) {
48
// it's us -- leave it alone
49
continue;
50
}
51
const source = `${f}`;
52
logger.debug(`found extra listener`, { f, source });
53
if (source.includes("destroyUpgrade")) {
54
// WARNING/BRITTLE! the socketio source code for the upgrade handler has a destroyUpgrade
55
// option it checks for, whereas the nextjs one doesn't.
56
if (socketioUpgrade === undefined) {
57
socketioUpgrade = f;
58
} else {
59
logger.debug(
60
"WARNING! discovered unknown upgrade listener!",
61
source,
62
);
63
}
64
} else {
65
if (nextUpgrade === undefined) {
66
nextUpgrade = f;
67
} else {
68
logger.debug(
69
"WARNING! discovered unknown upgrade listener!",
70
source,
71
);
72
}
73
}
74
logger.debug(
75
`found extra listener -- detected, saved and removed 'upgrade' listener`,
76
source,
77
);
78
httpServer.removeListener("upgrade", f);
79
}
80
}
81
}
82
83
if (proxyConat && useSocketio(req.url)) {
84
proxyConatWebsocket(req, socket, head);
85
return;
86
}
87
88
if (!req.url.match(re)) {
89
// it's to be handled by socketio or next
90
if (socketioUpgrade !== undefined && useSocketio(req.url)) {
91
socketioUpgrade(req, socket, head);
92
return;
93
}
94
nextUpgrade?.(req, socket, head);
95
return;
96
}
97
98
socket.on("error", (err) => {
99
// server will crash sometimes without this:
100
logger.debug("WARNING -- websocket socket error", err);
101
});
102
103
const dbg = (...args) => {
104
logger.silly(req.url, ...args);
105
};
106
dbg("got upgrade request from url=", req.url);
107
108
// Check that minimum version requirement is satisfied (this is in the header).
109
// This is to have a way to stop buggy clients from causing trouble. It's a purely
110
// honor system sort of thing, but makes it possible for an admin to block clients
111
// until they run newer code. I used to have to use this a lot long ago...
112
if (versionCheckFails(req)) {
113
throw Error("client version check failed");
114
}
115
116
let remember_me, api_key;
117
if (req.headers["cookie"] != null) {
118
let cookie;
119
({ cookie, remember_me, api_key } = stripRememberMeCookie(
120
req.headers["cookie"],
121
));
122
req.headers["cookie"] = cookie;
123
}
124
125
dbg("calling getTarget");
126
const url = stripBasePath(req.url);
127
const { host, port, internal_url } = await getTarget({
128
url,
129
isPersonal,
130
projectControl,
131
remember_me,
132
api_key,
133
});
134
dbg("got ", { host, port });
135
136
const target = `ws://${host}:${port}`;
137
if (internal_url != null) {
138
req.url = internal_url;
139
}
140
141
{
142
const proxy = cache.get(target);
143
if (proxy != null) {
144
dbg("using cache");
145
proxy.ws(req, socket, head);
146
return;
147
}
148
}
149
150
dbg("target", target);
151
dbg("not using cache");
152
153
const proxy = createProxyServer({
154
ws: true,
155
target,
156
});
157
158
cache.set(target, proxy);
159
160
// taken from https://github.com/http-party/node-http-proxy/issues/1401
161
proxy.on("proxyRes", function (proxyRes) {
162
//console.log(
163
// "Raw [target] response",
164
// JSON.stringify(proxyRes.headers, true, 2)
165
//);
166
167
proxyRes.headers["x-reverse-proxy"] = "custom-proxy";
168
proxyRes.headers["cache-control"] = "no-cache, no-store";
169
170
//console.log(
171
// "Updated [proxied] response",
172
// JSON.stringify(proxyRes.headers, true, 2)
173
//);
174
});
175
176
proxy.on("error", (err) => {
177
logger.debug(`WARNING: websocket proxy error -- ${err}`);
178
});
179
180
proxy.ws(req, socket, head);
181
}
182
183
const handler = async (req, socket, head) => {
184
try {
185
await handleProxyUpgradeRequest(req, socket, head);
186
} catch (err) {
187
const msg = `WARNING: error upgrading websocket url=${req.url} -- ${err}`;
188
logger.debug(msg);
189
denyUpgrade(socket);
190
}
191
};
192
193
return handler;
194
}
195
196
function denyUpgrade(socket) {
197
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
198
socket.destroy();
199
}
200
201
function useSocketio(url: string) {
202
const u = new URL(url, "http://cocalc.com");
203
let pathname = u.pathname;
204
if (basePath.length > 1) {
205
pathname = pathname.slice(basePath.length);
206
}
207
return pathname == "/conat/";
208
}
209
210