locadot 2.0.0 → 2.1.0-beta.1

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.
@@ -5,10 +5,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.handleRequest = handleRequest;
7
7
  exports.handleUpgrade = handleUpgrade;
8
+ const http_1 = __importDefault(require("http"));
8
9
  const template_1 = require("../constants/template");
9
10
  const constants_1 = __importDefault(require("../constants"));
10
11
  const logger_1 = __importDefault(require("../utils/logger"));
11
12
  const urls_1 = require("../lib/urls");
13
+ const remote_1 = require("./remote");
12
14
  const cors_1 = require("./cors");
13
15
  const options_1 = require("./options");
14
16
  const guard_1 = require("./guard");
@@ -16,7 +18,16 @@ const passthrough_1 = require("./passthrough");
16
18
  const request_1 = require("./request");
17
19
  const stats_1 = require("./stats");
18
20
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
19
- const resolveHost = (req, ctx) => {
21
+ const resolveHost = (req, ctx, hub) => {
22
+ if (hub?.kind === "app") {
23
+ (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure() });
24
+ return hub.host;
25
+ }
26
+ if (hub?.kind === "local") {
27
+ const host = `localhost:${hub.port}`;
28
+ (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure() });
29
+ return host;
30
+ }
20
31
  const host = (0, request_1.hostOf)(req);
21
32
  const local = ctx.tunnelFor?.(host);
22
33
  if (local)
@@ -28,19 +39,62 @@ const passThrough = (req, entry) => Boolean(entry.cors);
28
39
  /** Same-origin only, and a tunnel visitor may only reach public hosts (see guard.ts). */
29
40
  const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host));
30
41
  const reason = (err) => err?.code || err?.message;
42
+ const remoteGone = (entry) => `the connection to ${entry.remote.name} was removed`;
43
+ /** Sender side: an admin peer reaching one of this machine's ports. */
44
+ const localEntry = (port) => ({ target: `http://localhost:${port}`, createdAt: "", updatedAt: "" });
45
+ const html = (res, status, body) => {
46
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
47
+ res.end(body);
48
+ };
49
+ /** `<port>.<domain>.localhost` on this proxy's scheme and port. */
50
+ const portUrl = (req, domain) => {
51
+ const port = (req.headers.host || "").replace(/^\[[^\]]*\]|^[^:]*/, "");
52
+ return `${(0, request_1.isTls)(req) ? "https" : "http"}://PORT.${domain}.localhost${/^:\d{1,5}$/.test(port) ? port : ""}/`;
53
+ };
54
+ const loopback = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
55
+ /**
56
+ * `<domain>.localhost` spends this machine's admin token, so only this machine's own browser may use it: tunnels
57
+ * (cloudflared connects from loopback but adds Cf-* headers), hub peers and LAN clients all share the listener.
58
+ */
59
+ const localTarget = (req, ctx, host, hub) => {
60
+ if ((hub && hub.kind !== "none") || (0, request_1.fromTunnel)(req) || !loopback.has(req.socket.remoteAddress || ""))
61
+ return undefined;
62
+ if (req.headers["cf-ray"] || req.headers["cf-connecting-ip"] || req.headers["cdn-loop"])
63
+ return undefined;
64
+ return ctx.localFor?.(host);
65
+ };
31
66
  function handleRequest(req, res, ctx) {
32
- const host = resolveHost(req, ctx);
67
+ const hub = ctx.hub?.classify(req);
68
+ if (hub?.kind === "api") {
69
+ ctx.hub.api(req, res);
70
+ return;
71
+ }
72
+ if (hub?.kind === "deny") {
73
+ res.writeHead(hub.status, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
74
+ res.end(`locadot: ${hub.message}\n`);
75
+ return;
76
+ }
77
+ const host = resolveHost(req, ctx, hub);
33
78
  try {
34
79
  if (constants_1.default.dashboardHosts.includes(host)) {
35
80
  ctx.dashboard(req, res);
36
81
  return;
37
82
  }
38
- const entry = ctx.lookup(host);
83
+ const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
39
84
  if (!entry) {
85
+ const local = localTarget(req, ctx, host, hub);
86
+ if (local) {
87
+ forwardLocal(req, res, ctx, host, local);
88
+ return;
89
+ }
40
90
  res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
41
91
  res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
42
92
  return;
43
93
  }
94
+ if (entry.remote) {
95
+ forwardRemote(req, res, ctx, host, entry);
96
+ return;
97
+ }
44
98
  // Answered here: upstreams often reject OPTIONS or don't send the headers the browser wants.
45
99
  if (entry.cors && (0, cors_1.isPreflight)(req)) {
46
100
  res.writeHead(204, (0, cors_1.preflightHeaders)(req));
@@ -87,15 +141,82 @@ function handleRequest(req, res, ctx) {
87
141
  res.end();
88
142
  }
89
143
  }
144
+ /** Receiver side: the sender does the routing, cors and rewriting; we only add the credentials. */
145
+ function forwardRemote(req, res, ctx, host, entry) {
146
+ const remote = ctx.remoteFor?.(entry.remote.name);
147
+ if (!remote) {
148
+ res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
149
+ res.end((0, template_1.upstreamDown)(host, entry.target, remoteGone(entry), dashboardUrl(req)));
150
+ return;
151
+ }
152
+ const started = Date.now();
153
+ res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
154
+ ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
155
+ logger_1.default.warn(`${host} → ${remote.name} (${remote.url}): ${reason(err)}`);
156
+ if (res.headersSent) {
157
+ res.destroy();
158
+ return;
159
+ }
160
+ res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
161
+ res.end((0, template_1.upstreamDown)(host, `${remote.name}: ${entry.remote.host}`, reason(err) || "error", dashboardUrl(req)));
162
+ });
163
+ }
164
+ /** Receiver side: the sender checks the role again and proxies to its own localhost:<port>. */
165
+ function forwardLocal(req, res, ctx, host, local) {
166
+ const { remote, domain, port } = local;
167
+ const allowed = (0, remote_1.canReachLocalhost)(remote);
168
+ if (!port || !allowed) {
169
+ const hosts = (ctx.remoteHosts?.(remote.name) || []).map((h) => ({ local: h, url: `${(0, urls_1.urlFor)(h, (0, request_1.isTls)(req))}/` }));
170
+ const body = (0, template_1.remoteLocalhost)({ domain, name: remote.name, sender: remote.sender.hostname, allowed, hosts, portUrl: portUrl(req, domain), dashboardUrl: dashboardUrl(req) });
171
+ html(res, port ? 403 : 200, body);
172
+ return;
173
+ }
174
+ const started = Date.now();
175
+ res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
176
+ ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port), (err) => {
177
+ logger_1.default.warn(`${host} → ${remote.name} localhost:${port}: ${reason(err)}`);
178
+ if (res.headersSent) {
179
+ res.destroy();
180
+ return;
181
+ }
182
+ html(res, 502, (0, template_1.upstreamDown)(host, `${remote.name}: localhost:${port}`, reason(err) || "error", dashboardUrl(req)));
183
+ });
184
+ }
90
185
  function handleUpgrade(req, socket, head, ctx) {
91
- const host = resolveHost(req, ctx);
92
186
  socket.on("error", () => socket.destroy());
187
+ const hub = ctx.hub?.classify(req);
188
+ if (hub?.kind === "api" || hub?.kind === "deny") {
189
+ const status = hub.kind === "deny" ? hub.status : 400;
190
+ socket.end(`HTTP/1.1 ${status} ${http_1.default.STATUS_CODES[status] || "Error"}\r\nConnection: close\r\n\r\n`);
191
+ return;
192
+ }
193
+ const host = resolveHost(req, ctx, hub);
93
194
  try {
94
- const entry = ctx.lookup(host);
195
+ const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
95
196
  if (!entry) {
197
+ const local = localTarget(req, ctx, host, hub);
198
+ if (local?.port && (0, remote_1.canReachLocalhost)(local.remote)) {
199
+ ctx.proxy.ws(req, socket, head, (0, remote_1.localOptions)(req, local.remote, local.port), (err) => {
200
+ logger_1.default.warn(`${host} WebSocket → ${local.remote.name} localhost:${local.port}: ${reason(err)}`);
201
+ socket.destroy();
202
+ });
203
+ return;
204
+ }
96
205
  socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
97
206
  return;
98
207
  }
208
+ if (entry.remote) {
209
+ const remote = ctx.remoteFor?.(entry.remote.name);
210
+ if (!remote) {
211
+ socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
212
+ return;
213
+ }
214
+ ctx.proxy.ws(req, socket, head, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
215
+ logger_1.default.warn(`${host} WebSocket → ${remote.name}: ${reason(err)}`);
216
+ socket.destroy();
217
+ });
218
+ return;
219
+ }
99
220
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
100
221
  if (via && !viaAllowed(req, via)) {
101
222
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
@@ -13,6 +13,10 @@ const localhost_1 = __importDefault(require("../lib/localhost"));
13
13
  const router_1 = require("../proxy/router");
14
14
  const response_1 = require("../proxy/response");
15
15
  const tunnel_1 = require("../proxy/tunnel");
16
+ const hub_tunnel_1 = require("../proxy/hub-tunnel");
17
+ const hub_1 = require("../proxy/hub");
18
+ const remote_1 = require("../proxy/remote");
19
+ const hub_config_1 = __importDefault(require("../lib/hub-config"));
16
20
  const file_1 = __importDefault(require("../utils/file"));
17
21
  const logger_1 = __importDefault(require("../utils/logger"));
18
22
  const certs_1 = require("../utils/certs");
@@ -24,10 +28,20 @@ const lifecycle_1 = require("./lifecycle");
24
28
  const state_1 = require("./state");
25
29
  const token_1 = require("./token");
26
30
  async function startCentralProxy() {
27
- const tunnels = new tunnel_1.TunnelManager(() => {
31
+ const origin = () => {
28
32
  const host = constants_1.default.server.bind.find((address) => !address.includes(":")) ? "127.0.0.1" : "[::1]";
29
33
  return `http://${host}:${constants_1.default.server.httpPort}`;
30
- });
34
+ };
35
+ const tunnels = new tunnel_1.TunnelManager(origin);
36
+ const hub = new hub_tunnel_1.HubTunnel(origin);
37
+ const reloadHub = () => {
38
+ try {
39
+ hub.sync(hub_config_1.default.read());
40
+ }
41
+ catch (error) {
42
+ logger_1.default.error(error);
43
+ }
44
+ };
31
45
  const registry = (0, state_1.createRegistryState)((hosts) => tunnels.sync(hosts));
32
46
  registry.reload();
33
47
  const stats = new Map();
@@ -53,6 +67,27 @@ async function startCentralProxy() {
53
67
  stats,
54
68
  lookup: (host) => registry.get().hosts[tunnels.hostFor(host) ?? host],
55
69
  tunnelFor: (host) => tunnels.hostFor(host),
70
+ remoteFor: remote_1.remoteFor,
71
+ localFor: remote_1.localFor,
72
+ remoteHosts: (name) => Object.entries(registry.get().hosts)
73
+ .filter(([, entry]) => entry.remote?.name === name)
74
+ .map(([host]) => host)
75
+ .sort(),
76
+ hub: {
77
+ classify: (req) => (0, hub_1.classify)(req, hub.publicHost(), registry.get().hosts, {
78
+ localhost: hub_config_1.default.readCached()?.localhost !== false,
79
+ blockedPorts: [info.httpPort, info.httpsPort],
80
+ }),
81
+ api: (req, res) => {
82
+ (0, hub_1.handleHubApi)(req, res, { getRegistry: registry.get, reload: registry.reload, retryTunnels: () => tunnels.sync(registry.get().hosts, true) }).catch((error) => {
83
+ logger_1.default.error(error);
84
+ if (!res.headersSent)
85
+ res.writeHead(500);
86
+ res.end();
87
+ });
88
+ },
89
+ secure: () => hub.state().url?.startsWith("https:") ?? false,
90
+ },
56
91
  dashboard: (req, res) => (0, dashboard_1.handleDashboardRequest)(req, res, {
57
92
  getRegistry: registry.get,
58
93
  getStats: () => Object.fromEntries(stats),
@@ -67,6 +102,11 @@ async function startCentralProxy() {
67
102
  shutdown: (reason) => shutdown(reason),
68
103
  tunnel: (host) => tunnels.state(host),
69
104
  retryTunnels: () => tunnels.sync(registry.get().hosts, true),
105
+ hub: () => hub.state(),
106
+ reloadHub,
107
+ setupNamedHub: (domain, tunnel) => {
108
+ hub.setupNamed(domain, tunnel).catch((error) => logger_1.default.warn(`hub setup for ${domain}: ${error?.message || error}`));
109
+ },
70
110
  }),
71
111
  };
72
112
  const tls = { ...(await (0, certs_1.defaultContext)()), SNICallback: (0, certs_1.createSNICallback)() };
@@ -78,9 +118,17 @@ async function startCentralProxy() {
78
118
  return server;
79
119
  }, info);
80
120
  const watcher = file_1.default.watch("REGISTRY_FILE", registry.reload);
121
+ // After binding, so the hub tunnel points at the port we actually got.
122
+ reloadHub();
123
+ const hubWatcher = file_1.default.watch("HUB_FILE", reloadHub);
81
124
  await refreshTrust();
82
125
  setInterval(refreshTrust, 5 * 60000).unref();
83
126
  locadot_file_1.default.writeProxyInfo(info);
84
- shutdown = (0, lifecycle_1.installShutdown)({ watcher, servers, stopTunnels: () => tunnels.stopAll() });
127
+ shutdown = (0, lifecycle_1.installShutdown)({ watcher, servers, stopTunnels: () => {
128
+ tunnels.stopAll();
129
+ hub.stop();
130
+ hubWatcher.close();
131
+ },
132
+ });
85
133
  logger_1.default.info(`☑️ locadot ${package_json_1.version} ready (pid ${process.pid})`);
86
134
  }
@@ -33,7 +33,7 @@ const launchArgs = () => {
33
33
  * and node hidden too. cmd also appends the output to the log, like the cron line does.
34
34
  */
35
35
  const windowsLauncherScript = (command, args) => {
36
- const env = `set LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort}&& set LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort}&&`;
36
+ const env = `set LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort}&& set LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort}&& set LOCADOT_USER_PORTS=${constants_1.default.userPortEnv()}&&`;
37
37
  const line = `cmd /d /c ${env} ${[command, ...args].map((a) => `"${a}"`).join(" ")} >> "${constants_1.default.paths.LOGS}" 2>&1`;
38
38
  return `CreateObject("WScript.Shell").Run "${line.replace(/"/g, '""')}", 0, False\r\n`;
39
39
  };
@@ -88,7 +88,7 @@ class Startup {
88
88
  break;
89
89
  }
90
90
  case "linux": {
91
- const env = `LOCADOT_HOME=${quote(constants_1.default.paths.HOME)} LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort} LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort}`;
91
+ const env = `LOCADOT_HOME=${quote(constants_1.default.paths.HOME)} LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort} LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort} LOCADOT_USER_PORTS=${constants_1.default.userPortEnv()}`;
92
92
  const line = `@reboot ${env} ${[command, ...args].map(quote).join(" ")} >> ${quote(constants_1.default.paths.LOGS)} 2>&1 ${CRON_MARK}`;
93
93
  const edit = cronEdit(line);
94
94
  if (linuxNeedsRoot()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.0.0",
3
+ "version": "2.1.0-beta.1",
4
4
  "description": "Secure your local development environment with HTTPS and custom domains like dev.localhost.",
5
5
  "homepage": "https://www.npmjs.com/package/locadot",
6
6
  "main": "dist/index.js",