querysub 0.100.0 → 0.102.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.100.0",
3
+ "version": "0.102.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -24,7 +24,7 @@
24
24
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
25
25
  "pako": "^2.1.0",
26
26
  "preact": "^10.11.3",
27
- "socket-function": "^0.62.0",
27
+ "socket-function": "^0.66.0",
28
28
  "terser": "^5.31.0",
29
29
  "typesafecss": "^0.6.3",
30
30
  "yaml": "^2.5.0",
@@ -92,7 +92,15 @@ export function nestArchives(path: string, archives: Archives): Archives {
92
92
  targetPath: config.targetPath,
93
93
  }),
94
94
  assertPathValid: (path: string) => archives.assertPathValid(path),
95
- getBaseArchives: () => ({ parentPath: path, archives }),
95
+ getBaseArchives: () => {
96
+ let result = { parentPath: path, archives };
97
+ let base = archives.getBaseArchives?.();
98
+ if (base) {
99
+ result.parentPath = base.parentPath + path;
100
+ result.archives = base.archives;
101
+ }
102
+ return result;
103
+ },
96
104
  };
97
105
  }
98
106
 
@@ -8,7 +8,7 @@ import { httpsRequest } from "../https";
8
8
  import { delay } from "socket-function/src/batching";
9
9
  import { devDebugbreak } from "../config";
10
10
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
11
- import { blue, green } from "socket-function/src/formatting/logColors";
11
+ import { blue, green, magenta } from "socket-function/src/formatting/logColors";
12
12
  import debugbreak from "debugbreak";
13
13
  import { onTimeProfile } from "../-0-hooks/hooks";
14
14
 
@@ -49,6 +49,7 @@ const getAPI = lazy(async () => {
49
49
  Authorization: "Basic " + Buffer.from(creds.applicationKeyId + ":" + creds.applicationKey).toString("base64"),
50
50
  }
51
51
  });
52
+
52
53
  let auth = JSON.parse(authorizeRaw.toString()) as {
53
54
  accountId: string;
54
55
  authorizationToken: string;
@@ -88,6 +89,9 @@ const getAPI = lazy(async () => {
88
89
  bucketType: "allPrivate" | "allPublic";
89
90
  lifecycleRules?: any[];
90
91
  corsRules?: unknown[];
92
+ bucketInfo?: {
93
+ [key: string]: unknown;
94
+ };
91
95
  }, {
92
96
  accountId: string;
93
97
  bucketId: string;
@@ -101,6 +105,28 @@ const getAPI = lazy(async () => {
101
105
  revision: number;
102
106
  }>("b2_create_bucket", "POST");
103
107
 
108
+ const updateBucket = createB2Function<{
109
+ accountId: string;
110
+ bucketId: string;
111
+ bucketType?: "allPrivate" | "allPublic";
112
+ lifecycleRules?: any[];
113
+ bucketInfo?: {
114
+ [key: string]: unknown;
115
+ };
116
+ corsRules?: unknown[];
117
+ }, {
118
+ accountId: string;
119
+ bucketId: string;
120
+ bucketName: string;
121
+ bucketType: "allPrivate" | "allPublic";
122
+ bucketInfo: {
123
+ lifecycleRules: any[];
124
+ };
125
+ corsRules: any[];
126
+ lifecycleRules: any[];
127
+ revision: number;
128
+ }>("b2_update_bucket", "POST");
129
+
104
130
  // https://www.backblaze.com/apidocs/b2-update-bucket
105
131
  // TODO: b2_update_bucket, so we can update CORS, etc
106
132
 
@@ -316,8 +342,16 @@ const getAPI = lazy(async () => {
316
342
  fileId: string;
317
343
  }, {}>("b2_cancel_large_file", "POST", "noAccountId");
318
344
 
345
+ async function getDownloadURL(path: string) {
346
+ if (!path.startsWith("/")) {
347
+ path = "/" + path;
348
+ }
349
+ return auth.downloadUrl + path;
350
+ }
351
+
319
352
  return {
320
353
  createBucket,
354
+ updateBucket,
321
355
  listBuckets,
322
356
  downloadFileByName,
323
357
  uploadFile,
@@ -329,17 +363,19 @@ const getAPI = lazy(async () => {
329
363
  uploadPart,
330
364
  finishLargeFile,
331
365
  cancelLargeFile,
366
+ getDownloadURL,
332
367
  };
333
368
  });
334
369
 
335
370
  type B2Api = (typeof getAPI) extends () => Promise<infer T> ? T : never;
336
371
 
337
372
 
338
- class Backblaze {
373
+ export class ArchivesBackblaze {
339
374
  public constructor(private config: {
340
375
  bucketName: string;
341
376
  public?: boolean;
342
377
  immutable?: boolean;
378
+ cacheTime?: number;
343
379
  }) { }
344
380
 
345
381
  private bucketName = this.config.bucketName.replaceAll(/[^\w\d]/g, "-");
@@ -360,6 +396,27 @@ class Backblaze {
360
396
 
361
397
  private getBucketAPI = lazy(async () => {
362
398
  let api = await getAPI();
399
+
400
+ let cacheTime = this.config.cacheTime ?? 0;
401
+ if (this.config.immutable) {
402
+ cacheTime = 86400 * 1000;
403
+ }
404
+
405
+ let desiredCorsRules = this.config.public ? [{
406
+ corsRuleName: "onlyCurrentOrigin",
407
+ allowedOrigins: ["*"],
408
+ allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
409
+ allowedHeaders: [],
410
+ exposeHeaders: ["x-bz-content-sha1"],
411
+ maxAgeSeconds: cacheTime / 1000,
412
+ }] : [];
413
+ let bucketInfo: Record<string, unknown> = {};
414
+ if (cacheTime) {
415
+ bucketInfo["cache-control"] = `max-age=${cacheTime / 1000}`;
416
+ }
417
+
418
+
419
+ let exists = false;
363
420
  try {
364
421
  await api.createBucket({
365
422
  bucketName: this.bucketName,
@@ -370,27 +427,38 @@ class Backblaze {
370
427
  "daysFromHidingToDeleting": 7,
371
428
  "fileNamePrefix": ""
372
429
  }],
373
- corsRules: this.config.public ? [{
374
- corsRuleName: "onlyCurrentOrigin",
375
- allowedOrigins: ["*"],
376
- allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
377
- allowedHeaders: [],
378
- exposeHeaders: ["x-bz-content-sha1"],
379
- maxAgeSeconds: this.config.immutable ? 86400 : 0,
380
- }] : [],
430
+ corsRules: desiredCorsRules,
431
+ bucketInfo
381
432
  });
382
433
  } catch (e: any) {
383
434
  if (!e.stack.includes(`"duplicate_bucket_name"`)) {
384
435
  throw e;
385
436
  }
437
+ exists = true;
386
438
  }
387
- let bucketInfo = await api.listBuckets({
439
+
440
+ let bucketList = await api.listBuckets({
388
441
  bucketName: this.bucketName,
389
442
  });
390
- if (bucketInfo.buckets.length === 0) {
443
+ if (bucketList.buckets.length === 0) {
391
444
  throw new Error(`Bucket name "${this.bucketName}" is being used by someone else. Bucket names have to be globally unique. Try a different name until you find a free one.`);
392
445
  }
393
- this.bucketId = bucketInfo.buckets[0].bucketId;
446
+ this.bucketId = bucketList.buckets[0].bucketId;
447
+
448
+ if (exists) {
449
+ let bucket = bucketList.buckets[0];
450
+ if (JSON.stringify(bucket.corsRules) !== JSON.stringify(desiredCorsRules) || JSON.stringify(bucket.bucketInfo) !== JSON.stringify(bucketInfo)) {
451
+ console.log(magenta(`Updating CORS rules for ${this.bucketName}`));
452
+ await api.updateBucket({
453
+ accountId: bucket.accountId,
454
+ bucketId: bucket.bucketId,
455
+ bucketType: bucket.bucketType,
456
+ lifecycleRules: bucket.lifecycleRules,
457
+ corsRules: desiredCorsRules,
458
+ bucketInfo: bucketInfo,
459
+ });
460
+ }
461
+ }
394
462
  return api;
395
463
  });
396
464
 
@@ -719,19 +787,17 @@ class Backblaze {
719
787
  copyInstead?: boolean;
720
788
  }) {
721
789
  let { path, target, targetPath } = config;
722
- // Moving is NOT working. The API call works, but... the file
723
- while (true) {
724
- let targetUnwrapped = target.getBaseArchives?.();
725
- if (!targetUnwrapped) break;
726
- target = targetUnwrapped.archives;
727
- targetPath = targetUnwrapped.parentPath + targetPath;
790
+ let base = target.getBaseArchives?.();
791
+ if (base) {
792
+ target = base.archives;
793
+ targetPath = base.parentPath + targetPath;
728
794
  }
729
795
  // A self move should NOOP (and definitely not copy, and then delete itself!)
730
796
  if (target === this && path === targetPath) {
731
797
  this.log(`Backblaze move path to itself. Skipping move, as there is no work to do. ${path}`);
732
798
  return;
733
799
  }
734
- if (target instanceof Backblaze) {
800
+ if (target instanceof ArchivesBackblaze) {
735
801
  let targetBucketId = target.bucketId;
736
802
  if (targetBucketId === this.bucketId && path === targetPath) return;
737
803
  await this.apiRetryLogic(async (api) => {
@@ -771,6 +837,15 @@ class Backblaze {
771
837
  }): Promise<void> {
772
838
  return this.move({ ...config, copyInstead: true });
773
839
  }
840
+
841
+ public async getDownloadURL(path: string) {
842
+ return await this.apiRetryLogic(async (api) => {
843
+ if (path.startsWith("/")) {
844
+ path = path.slice(1);
845
+ }
846
+ return await api.getDownloadURL("file/" + this.bucketName + "/" + path);
847
+ });
848
+ }
774
849
  }
775
850
 
776
851
  /*
@@ -783,16 +858,23 @@ Names should be a UTF-8 string up to 1024 bytes with the following exceptions:
783
858
 
784
859
 
785
860
  export const getArchivesBackblaze = cache((domain: string) => {
786
- const archivesBackblaze: Archives = new Backblaze({ bucketName: domain });
787
-
788
- return archivesBackblaze;
861
+ return new ArchivesBackblaze({ bucketName: domain });
789
862
  });
790
863
  export const getArchivesBackblazePublicImmutable = cache((domain: string) => {
791
- const archivesBackblaze: Archives = new Backblaze({
864
+ return new ArchivesBackblaze({
792
865
  bucketName: domain + "-public-immutable",
793
866
  public: true,
794
867
  immutable: true
795
868
  });
869
+ });
796
870
 
797
- return archivesBackblaze;
871
+ // NOTE: Cache by a minute. This might be a bad idea, but... usually whole reason for public is
872
+ // for cloudflare caching (as otherwise we can just access it through a server), or for large files
873
+ // (which should be cached anyways, and probably even use immutable caching).
874
+ export const getArchivesBackblazePublic = cache((domain: string) => {
875
+ return new ArchivesBackblaze({
876
+ bucketName: domain + "-public",
877
+ public: true,
878
+ cacheTime: timeInMinute,
879
+ });
798
880
  });
@@ -0,0 +1,53 @@
1
+ import { Archives } from "../-a-archives/archives";
2
+ import { ArchivesBackblaze } from "../-a-archives/archivesBackBlaze";
3
+ import { cloudflareGETCall, cloudflarePOSTCall } from "./cloudflareHelpers";
4
+ import { setRecord, getZoneId } from "./dnsAuthority";
5
+ import debugbreak from "debugbreak";
6
+
7
+ // servicestest.querysub.com/servicesIndex.json
8
+
9
+ export async function hostArchives(config: {
10
+ archives: Archives;
11
+ // Ex, "archives"
12
+ subdomain: string;
13
+ // Ex, "example.com"
14
+ domain: string;
15
+ }): Promise<{
16
+ getURL: (path: string) => Promise<string>;
17
+ }> {
18
+ let { archives, subdomain, domain } = config;
19
+
20
+ let base = archives.getBaseArchives?.();
21
+ let parentPath = base?.parentPath ?? "";
22
+ archives = base?.archives ?? archives;
23
+ if (parentPath && !parentPath.endsWith("/")) {
24
+ parentPath += "/";
25
+ }
26
+
27
+ if (!(archives instanceof ArchivesBackblaze)) {
28
+ throw new Error(`hostArchives not implemented for ${archives.constructor.name}, only "ArchivesBackblaze" is implemented at the moment`);
29
+ }
30
+
31
+ let archiveT = archives as ArchivesBackblaze;
32
+ let baseURL = await archiveT.getDownloadURL("");
33
+ // Remove the trailing slash if it exists
34
+ if (baseURL.endsWith("/")) {
35
+ baseURL = baseURL.slice(0, -1);
36
+ }
37
+ let backblazeHost = new URL(baseURL).hostname;
38
+
39
+ // HACK: Assuming cloudflare is still our DNS provider. If it isn't... this won't work.
40
+ await setRecord("CNAME", subdomain + "." + domain, backblazeHost, "proxied");
41
+
42
+ async function getURL(path: string) {
43
+ if (path.startsWith("/")) {
44
+ path = path.slice(1);
45
+ }
46
+ let targetPath = await archiveT.getDownloadURL(parentPath + path);
47
+ let url = new URL(targetPath);
48
+ url.hostname = subdomain + "." + domain;
49
+ return url.toString();
50
+ }
51
+
52
+ return { getURL };
53
+ }
@@ -0,0 +1,52 @@
1
+ import { httpsRequest } from "socket-function/src/https";
2
+ import { getArchives } from "../-a-archives/archives";
3
+ import { lazy } from "socket-function/src/caching";
4
+
5
+ export const keys = getArchives("keys");
6
+
7
+ export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: string }> => {
8
+ let credsJSON = await keys.get("cloudflare.json");
9
+ if (!credsJSON) throw new Error(`b2:/keys/cloudflare.json is missing. It should contain { "key": "your-key", "email": "your-email" }`);
10
+ return JSON.parse(credsJSON.toString());
11
+ });
12
+
13
+ export async function cloudflareGETCall<T>(path: string, params?: { [key: string]: string }): Promise<T> {
14
+ let url = new URL(`https://api.cloudflare.com/client/v4` + path);
15
+ for (let key in params) {
16
+ url.searchParams.set(key, params[key]);
17
+ }
18
+ let creds = await getCloudflareCreds();
19
+ let result = await httpsRequest(url.toString(), [], "GET", undefined, {
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ "X-Auth-Email": creds.email,
23
+ "X-Auth-User-Service-Key": creds.key,
24
+ "X-Auth-Key": creds.key,
25
+ }
26
+ });
27
+ let result2 = JSON.parse(result.toString()) as { result: unknown; success: boolean; errors: { code: number; message: string }[] };
28
+ if (!result2.success) {
29
+ throw new Error(`Cloudflare call failed: ${result2.errors.map(x => x.message).join(", ")}`);
30
+ }
31
+ return result2.result as T;
32
+ }
33
+ export async function cloudflarePOSTCall<T>(path: string, params: { [key: string]: unknown }): Promise<T> {
34
+ return await cloudflareCall(path, Buffer.from(JSON.stringify(params)), "POST");
35
+ }
36
+ export async function cloudflareCall<T>(path: string, payload: Buffer, method: string): Promise<T> {
37
+ let url = new URL(`https://api.cloudflare.com/client/v4` + path);
38
+ let creds = await getCloudflareCreds();
39
+ let result = await httpsRequest(url.toString(), payload, method, undefined, {
40
+ headers: {
41
+ "Content-Type": "application/json",
42
+ "X-Auth-Email": creds.email,
43
+ "X-Auth-User-Service-Key": creds.key,
44
+ "X-Auth-Key": creds.key,
45
+ }
46
+ });
47
+ let result2 = JSON.parse(result.toString()) as { result: unknown; success: boolean; errors: { code: number; message: string }[] };
48
+ if (!result2.success) {
49
+ throw new Error(`Cloudflare call failed: ${result2.errors.map(x => x.message).join(", ")}`);
50
+ }
51
+ return result2.result as T;
52
+ }
@@ -9,6 +9,7 @@ import { delay } from "socket-function/src/batching";
9
9
  import debugbreak from "debugbreak";
10
10
  import { isClient } from "../config2";
11
11
  import { getArchives } from "../-a-archives/archives";
12
+ import { cloudflareCall, cloudflareGETCall, cloudflarePOSTCall, getCloudflareCreds } from "./cloudflareHelpers";
12
13
 
13
14
  const DNS_TTLSeconds = {
14
15
  "TXT": 60,
@@ -17,24 +18,18 @@ const DNS_TTLSeconds = {
17
18
 
18
19
  export const keys = getArchives("keys");
19
20
 
20
- export const getDNSCreds = lazy(async (): Promise<{ key: string; email: string }> => {
21
- let credsJSON = await keys.get("cloudflare.json");
22
- if (!credsJSON) throw new Error(`b2:/keys/cloudflare.json is missing. It should contain { "key": "your-key", "email": "your-email" }`);
23
- return JSON.parse(credsJSON.toString());
24
- });
25
-
26
21
  export const hasDNSWritePermissions = lazy(async () => {
27
22
  if (!isNode()) return false;
28
23
  if (isClient()) return false;
29
24
  try {
30
- await getDNSCreds();
25
+ await getCloudflareCreds();
31
26
  return true;
32
27
  } catch {
33
28
  return false;
34
29
  }
35
30
  });
36
31
 
37
- const getZoneId = cache(async (rootDomain: string): Promise<string> => {
32
+ export const getZoneId = cache(async (rootDomain: string): Promise<string> => {
38
33
  let zones = await cloudflareGETCall<{ id: string; name: string }[]>("/zones", {});
39
34
  let selected = zones.find(x => x.name === rootDomain);
40
35
  if (!selected) {
@@ -53,7 +48,13 @@ function getRootDomain(key: string) {
53
48
  export async function getRecordsRaw(type: string, key: string) {
54
49
  if (key.endsWith(".")) key = key.slice(0, -1);
55
50
  let zoneId = await getZoneId(getRootDomain(key));
56
- let results = await cloudflareGETCall<{ id: string; type: string; name: string; content: string }[]>(`/zones/${zoneId}/dns_records`);
51
+ let results = await cloudflareGETCall<{
52
+ id: string;
53
+ type: string;
54
+ name: string;
55
+ content: string;
56
+ proxied: boolean;
57
+ }[]>(`/zones/${zoneId}/dns_records`);
57
58
  return results.filter(x => x.type === type && x.name === key);
58
59
  }
59
60
  export async function getRecords(type: string, key: string) {
@@ -84,7 +85,7 @@ export async function setRecord(type: string, key: string, value: string, proxie
84
85
  if (key.endsWith(".")) key = key.slice(0, -1);
85
86
  let zoneId = await getZoneId(getRootDomain(key));
86
87
  let prevValues = await getRecordsRaw(type, key);
87
- if (prevValues.some(x => x.content === value)) return;
88
+ if (prevValues.some(x => x.content === value && x.proxied === !!proxied)) return;
88
89
 
89
90
  console.log(`Removing previous records of ${type} for ${key} ${JSON.stringify(prevValues.map(x => x.content))}`);
90
91
  let didDeletions = false;
@@ -126,78 +127,4 @@ export async function addRecord(type: string, key: string, value: string, proxie
126
127
  console.log(`Waiting ${ttl} seconds for DNS to propagate...`);
127
128
  await delay(ttl * 1000);
128
129
  console.log(`Done waiting for DNS to update.`);
129
- }
130
-
131
-
132
- async function cloudflareGETCall<T>(path: string, params?: { [key: string]: string }): Promise<T> {
133
- let url = new URL(`https://api.cloudflare.com/client/v4` + path);
134
- for (let key in params) {
135
- url.searchParams.set(key, params[key]);
136
- }
137
- let creds = await getDNSCreds();
138
- let result = await httpsRequest(url.toString(), [], "GET", undefined, {
139
- headers: {
140
- "Content-Type": "application/json",
141
- "X-Auth-Email": creds.email,
142
- "X-Auth-User-Service-Key": creds.key,
143
- "X-Auth-Key": creds.key,
144
- }
145
- });
146
- let result2 = JSON.parse(result.toString()) as { result: unknown; success: boolean; errors: { code: number; message: string }[] };
147
- if (!result2.success) {
148
- throw new Error(`Cloudflare call failed: ${result2.errors.map(x => x.message).join(", ")}`);
149
- }
150
- return result2.result as T;
151
- }
152
- async function cloudflarePOSTCall<T>(path: string, params: { [key: string]: unknown }): Promise<T> {
153
- return await cloudflareCall(path, Buffer.from(JSON.stringify(params)), "POST");
154
- }
155
- async function cloudflareCall<T>(path: string, payload: Buffer, method: string): Promise<T> {
156
- let url = new URL(`https://api.cloudflare.com/client/v4` + path);
157
- let creds = await getDNSCreds();
158
- let result = await httpsRequest(url.toString(), payload, method, undefined, {
159
- headers: {
160
- "Content-Type": "application/json",
161
- "X-Auth-Email": creds.email,
162
- "X-Auth-User-Service-Key": creds.key,
163
- "X-Auth-Key": creds.key,
164
- }
165
- });
166
- let result2 = JSON.parse(result.toString()) as { result: unknown; success: boolean; errors: { code: number; message: string }[] };
167
- if (!result2.success) {
168
- throw new Error(`Cloudflare call failed: ${result2.errors.map(x => x.message).join(", ")}`);
169
- }
170
- return result2.result as T;
171
- }
172
-
173
-
174
- /*
175
- async function setRecord(googleDns: googleCloud.DNS, zone: googleCloud.Zone, type: string, key: string, value: unknown) {
176
- let recordsResponse = await zone.getRecords();
177
- let prevValue = recordsResponse[0].filter(x => x.metadata.name === key && x.type === type);
178
-
179
- // NOTE: It seems like the data is ALWAYS an array, even if we set a value? So... just assume it is an array,
180
- // and take the first entry. If we ever need to actually support arrays this will change, but... I don't
181
- // think we will...
182
- if (prevValue.some(x => JSON.stringify((x.data as any)?.[0]) === JSON.stringify(value))) {
183
- console.log(`Record already set, (${type} ${zone.metadata.dnsName}) ${key} === ${JSON.stringify(value)}`);
184
- return;
185
- }
186
-
187
- for (let record of prevValue) {
188
- console.log(`Deleting record (${type} ${zone.metadata.dnsName}) ${key} = ${JSON.stringify(record.data)}`);
189
- await record.delete();
190
- }
191
-
192
- console.log((`Setting record (${type} in ${zone.metadata.dnsName}) ${key} = ${JSON.stringify(value)}`));
193
- const TTL = DNS_TTLSeconds[type as "A"] || 60;
194
- await zone.addRecords(zone.record(type, {
195
- name: key,
196
- data: value as any,
197
- ttl: TTL,
198
- }));
199
-
200
- // Wait for the record to propagate
201
- await delay(TTL * 1000 * 2);
202
- }
203
- */
130
+ }
@@ -31,10 +31,21 @@ import { shutdown } from "../diagnostics/periodic";
31
31
 
32
32
  let publicPort = -1;
33
33
 
34
+ const getHostedIP = lazy(async () => {
35
+ if (!isPublic()) {
36
+ return "127.0.0.1";
37
+ }
38
+ return await getExternalIP();
39
+ });
40
+ const getIPDomain = lazy(async () => {
41
+ let ip = await getHostedIP();
42
+ return ip.replaceAll(".", "-") + "." + getDomain();
43
+ });
44
+
34
45
  // Figure out how to hook up our watch so it actually updates the underlying server
35
- export function getSNICerts(config: {
46
+ export async function getSNICerts(config: {
36
47
  publicPort: number
37
- }): Exclude<SocketServerConfig["SNICerts"], undefined> {
48
+ }): Promise<Exclude<SocketServerConfig["SNICerts"], undefined>> {
38
49
  if (config.publicPort > 0) {
39
50
  if (publicPort !== -1 && publicPort !== config.publicPort) {
40
51
  throw new Error(`getSNICerts called with different publicPort ${publicPort} and ${config.publicPort}. We require the same port, so we can correctly maintain the A record for the edge node.`);
@@ -48,6 +59,7 @@ export function getSNICerts(config: {
48
59
  };
49
60
  certs["noproxy." + getDomain()] = certs[getDomain()];
50
61
  certs["127-0-0-1." + getDomain()] = certs[getDomain()];
62
+ certs[await getIPDomain()] = certs[getDomain()];
51
63
  return certs;
52
64
  }
53
65
 
@@ -156,29 +168,21 @@ export async function publishMachineARecords() {
156
168
  throw new Error(`Must mount before publishing machine A records`);
157
169
  }
158
170
 
159
- let ip = SocketFunction.mountedIP;
160
- if (ip === "0.0.0.0") {
161
- ip = await getExternalIP();
162
- }
171
+ let ip = await getHostedIP();
163
172
 
164
173
  let selfNodeId = SocketFunction.mountedNodeId;
165
174
  let nodeObj = getNodeIdLocation(selfNodeId);
166
175
  if (!nodeObj) throw new Error(`Invalid nodeId ${selfNodeId}`);
167
176
  let machineAddress = nodeObj.address.split(".").slice(1).join(".");
168
- if (ip === "127.0.0.1") {
169
- let prev = await getRecords("A", machineAddress);
170
- if (prev.length > 0 && prev[0] !== "127.0.0.1") {
171
- // NOTE: We NEED to publish some records. HOWEVER, it is very likely they will run some services
172
- // and not set public (such as the FunctionRunner). SO... just ignore this, leaving the records as
173
- // public, even though the current run doesn't have public set.
174
- if (process.argv[1].includes("server.ts")) {
175
- console.log(yellow(`Current process is not marked as public, but machine previous had public services. NOT publishing A records to point to 127.0.0.1, which will make service inaccessible if the port is not port forwarded (or open). I recommend using noproxy.DOMAIN.com to access the server. 127-0-0-1.DOMAIN.com might work as well.`));
176
- }
177
- return;
178
- }
179
- }
180
177
  await setRecord("A", machineAddress, ip);
181
178
  await setRecord("A", "*." + machineAddress, ip);
179
+ let ipDomain = await getIPDomain();
180
+ await setRecord("A", ipDomain, ip);
181
+
182
+ return {
183
+ ip,
184
+ ipDomain,
185
+ };
182
186
  }
183
187
 
184
188
  const runEdgeDomainAliveLoop = lazy(() => {
@@ -264,6 +268,7 @@ async function getHTTPSKeyCertInner(callerIP: string) {
264
268
  console.warn(`Tried to serve an edge node on the local domain, but SocketFunction.mount did NOT specify a public ip (ex, { ip: "0.0.0.0" }) AND there are already existing public servers. You can't load balance between real ips and 127.0.0.1! ${existingIPs.join(", ")}. You will need to use 127-0-0-1.${edgeDomain} to access the local server (instead of just ${edgeDomain}).`);
265
269
  }
266
270
  */
271
+ console.log(yellow(`Current process is not marked as public, but machine previous had public services. NOT setting ${edgeDomain} to 127.0.0.1, as this would make the public services inaccessible. Assuming the current process is for development, I recommend using noproxy.${edgeDomain} or 127-0-0-1.${edgeDomain} to access the server.`));
267
272
  }
268
273
  } else {
269
274
  if (existingIPs.includes("127.0.0.1")) {
@@ -59,6 +59,8 @@ export const getHTTPSKeyCert = cache(async (domain: string): Promise<{ key: stri
59
59
  // any HTTPS domains from impersonating servers. But... servers have two levels, so that isn't
60
60
  // an issue. And even if they didn't they store their public key in their domain, so you
61
61
  // can't really impersonate them anyways...
62
+ // - AND, we need this for IP type A records, which... we need to pick the server we want
63
+ // to connect to.
62
64
  altDomains.push("*." + domain);
63
65
 
64
66
  let isPending = await archives().getInfo(domain + "_pending");
@@ -23,6 +23,7 @@ import dns from "dns/promises";
23
23
  import { isDefined } from "../misc";
24
24
  import { diskLog, noDiskLogPrefix } from "../diagnostics/logs/diskLogger";
25
25
  import { getDebuggerUrl } from "../diagnostics/listenOnDebugger";
26
+ import { getBootedEdgeNode } from "../4-querysub/edge/edgeBootstrap";
26
27
 
27
28
  let HEARTBEAT_INTERVAL = timeInMinute * 2;
28
29
  // Interval which we check other heartbeats
@@ -65,7 +66,6 @@ export function isNodeDiscoveryLogging() {
65
66
  }
66
67
 
67
68
  export const getOurNodeId = getOwnNodeId;
68
- export const getOurMachineId = getOwnNodeId;
69
69
  export const getOurNodeIdAssert = getOwnNodeIdAssert;
70
70
 
71
71
  export const SPECIAL_NODE_ID_FOR_UNMOUNTED_NODE = "SPECIAL_NODE_ID_FOR_UNMOUNTED_NODE";
@@ -430,6 +430,7 @@ beforeGetNodeAllId = async () => {
430
430
  export async function onNodeDiscoveryReady() {
431
431
  await getAllNodeIds();
432
432
  }
433
+
433
434
  if (isServer()) {
434
435
  setImmediate(() => {
435
436
  logErrors(runHeartbeatAuditLoop());
@@ -456,36 +457,13 @@ if (isServer()) {
456
457
  };
457
458
  } else {
458
459
  setImmediate(() => {
459
- let browserNodeId = getBrowserUrlNode();
460
- let nodes = [getBrowserUrlNode()];
461
- logErrors(measureWrap((async function checkForLocalHost() {
462
- // If there is a server at the localhost domain, AND, it's the same as the current server...
463
- // use that domain instead. This is just an optimization, but makes a big difference
464
- // when using management tools to scan the DB (otherwise the entire DB has to go through
465
- // our router, which can easily cap it at 15MB/s, because rogers is terrible).
466
- // - Only if isNoNetwork, otherwise it causes unnecessary lag.
467
- let time = Date.now();
468
- let isNoNetwork = await NodeDiscoveryController.nodes[browserNodeId].isNoNetwork();
469
- console.log(magenta(`isNoNetwork took ${Date.now() - time}ms`));
470
- if (isNoNetwork && !location.search.includes("nolocalhost")) {
471
- let url = new URL("https://" + browserNodeId);
472
- url.hostname = "127-0-0-1." + url.hostname;
473
- let localhostNode = url.host;
474
- // NOTE: The timeout isn't that important, as this is only if no network, and only on page load.
475
- // - A low timeout here causes failure to use the localhost ip sometimes. WHICH, often breaks
476
- // authentication (as we are likely not authenticated for our public IP).
477
- let localhostNodeId = await timeoutToUndefinedSilent(1000, NodeDiscoveryController.nodes[localhostNode].getNodeId());
478
- let httpServerNodeId = await NodeDiscoveryController.nodes[browserNodeId].getNodeId();
479
- if (localhostNodeId === httpServerNodeId) {
480
- nodes[0] = localhostNode;
481
- rootDiscoveryNodeId = localhostNode;
482
- }
483
- }
484
-
485
- allNodeIds2 = new Set(nodes);
486
- discoveryReady.resolve();
487
- }))());
488
-
460
+ let edgeNode = getBootedEdgeNode();
461
+ if (!edgeNode) {
462
+ throw new Error(`No edge node set during edgeBootstrap? This should be impossible.`);
463
+ }
464
+ let nodes = [edgeNode.host];
465
+ allNodeIds2 = new Set(nodes);
466
+ discoveryReady.resolve();
489
467
 
490
468
  // NOTE: We run into TLS issues (as in, our servers use self signed certs), if we try to talk to just
491
469
  // any node, so... we better just talk to the edge node
@@ -3,7 +3,7 @@
3
3
  // a file prevents it from transforming it, so we'll miss a lot of files doing it this late).
4
4
  import "../inject";
5
5
 
6
- import { isNode, timeInMinute } from "socket-function/src/misc";
6
+ import { isNode, timeInDay, timeInHour, timeInMinute } from "socket-function/src/misc";
7
7
 
8
8
  import { SocketFunction } from "socket-function/SocketFunction";
9
9
  import { isHotReloading, onHotReload, watchFilesAndTriggerHotReloading } from "socket-function/hot/HotReloadController";
@@ -484,15 +484,24 @@ export class Querysub {
484
484
 
485
485
  await this.addSourceMapCheck(config);
486
486
 
487
- // NOTE: This is just static hosting, and unrelated to querysub (sort of). Any site with some kind of transpiler
488
- // and way to serve transpiled files can server our code.
489
487
  SocketFunction.expose(RequireController);
490
488
  setRequireBootRequire(config.rootPath);
489
+
490
+ let entryPaths = [config.clientsideEntryPoint, ...Querysub.afterBootImports];
491
491
  SocketFunction.setDefaultHTTPCall(RequireController, "requireHTML", {
492
- requireCalls: [
493
- config.clientsideEntryPoint,
494
- ...Querysub.afterBootImports,
495
- ]
492
+ // NOTE: This should be cached in our CDN anyways, so it should be fast
493
+ // to access even if every request hit the server (as it will hit the CDN's,
494
+ // server, which is usually close to the client). And it only impacts the initial
495
+ // load, which isn't that important for us.
496
+ cacheTime: timeInMinute * 5,
497
+ });
498
+
499
+
500
+ RequireController.injectHTMLBeforeStartup(async () => {
501
+ let edgeBootstrapFile = await getEdgeBootstrapScript({
502
+ edgeNodeConfigURL: await getEdgeNodeConfigURL(),
503
+ });
504
+ return `<script>${edgeBootstrapFile}</script>`;
496
505
  });
497
506
 
498
507
  if (!config.justHTTP) {
@@ -510,14 +519,19 @@ export class Querysub {
510
519
  autoForwardPort: true,
511
520
  ...await getThreadKeyCert(),
512
521
  SNICerts: {
513
- ...getSNICerts({
522
+ ...await getSNICerts({
514
523
  publicPort: config.port,
515
524
  }),
516
525
  },
517
526
  allowHostnames,
518
527
  });
519
528
 
520
- await publishMachineARecords();
529
+ let { ip, ipDomain } = await publishMachineARecords();
530
+
531
+ await registerEdgeNode({
532
+ host: ipDomain + ":" + config.port,
533
+ entryPaths,
534
+ });
521
535
  }
522
536
  private static async addSourceMapCheck(config: {
523
537
  sourceCheck?: MachineSourceCheck;
@@ -719,13 +733,6 @@ export class Querysub {
719
733
  public: isPublic(),
720
734
  port,
721
735
  ...await getThreadKeyCert(),
722
- // WAIT! Why were services getting SNI CERTS!!!??? This is used to set the
723
- // HTTPS cloudflare pool ips, so... services should DEFINITELY not use this!
724
- // SNICerts: {
725
- // ...getSNICerts({
726
- // publicPort: port,
727
- // }),
728
- // }
729
736
  });
730
737
 
731
738
  await publishMachineARecords();
@@ -883,4 +890,6 @@ registerGetCompressDisk(() => Querysub.COMPRESS_DISK);
883
890
 
884
891
  import "../diagnostics/watchdog";
885
892
  import "../diagnostics/trackResources";
886
- import "../diagnostics/benchmark";
893
+ import "../diagnostics/benchmark";
894
+ import { getEdgeNodeConfigURL, registerEdgeNode } from "./edge/edgeNodes"; import { getEdgeBootstrapScript } from "./edge/edgeBootstrap";
895
+
@@ -0,0 +1,349 @@
1
+ import { cache } from "socket-function/src/caching";
2
+ import { isServer } from "../../config2";
3
+ import { EdgeNodeConfig, EdgeNodesIndex } from "./edgeNodes";
4
+ import { timeInMinute } from "socket-function/src/misc";
5
+ import { measureBlock } from "socket-function/src/profiling/measure";
6
+
7
+ module.hotreload = true;
8
+ module.noserverhotreload = false;
9
+
10
+
11
+ declare global {
12
+ var BOOTED_EDGE_NODE: EdgeNodeConfig | undefined;
13
+ }
14
+
15
+
16
+ export function getBootedEdgeNode(): EdgeNodeConfig {
17
+ if (isServer()) throw new Error(`getBootedEdgeNode is not available on the server`);
18
+
19
+ let edgeNode = globalThis.BOOTED_EDGE_NODE;
20
+ if (!edgeNode) throw new Error(`No edge node booted? This should be impossible.`);
21
+ return edgeNode;
22
+ }
23
+
24
+ let getCachedConfig = cache(async (url: string): Promise<EdgeNodesIndex | undefined> => {
25
+ setTimeout(() => {
26
+ getCachedConfig.clear(url);
27
+ }, timeInMinute * 15);
28
+
29
+ try {
30
+ let response = await fetch(url);
31
+ return await response.json();
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ });
36
+
37
+ export async function getEdgeBootstrapScript(config: {
38
+ edgeNodeConfigURL: string;
39
+ }): Promise<string> {
40
+ return await measureBlock(async function getEdgeBootstrapScript() {
41
+ let cachedConfig = await getCachedConfig(config.edgeNodeConfigURL);
42
+ return `(${edgeNodeFunction.toString()})(${JSON.stringify({ ...config, cachedConfig })});`;
43
+ });
44
+ }
45
+
46
+ // DEFINED elsewhere
47
+ /*
48
+ declare global {
49
+ var onProgressHandler: undefined | ((progress: {
50
+ type: string;
51
+ addValue: number;
52
+ addMax: number;
53
+ }) => void);
54
+ var onErrorHandler: undefined | ((error: string) => void);
55
+
56
+
57
+ var BOOT_TIME: number;
58
+ var builtInModuleExports: {
59
+ [key: string]: unknown;
60
+ };
61
+ }
62
+ */
63
+
64
+ async function edgeNodeFunction(config: {
65
+ edgeNodeConfigURL: string;
66
+ cachedConfig: EdgeNodesIndex | undefined;
67
+ }) {
68
+ // IMPORTANT! Everything in this function is embedded, so this function can't use any external imports
69
+ // (except for type imports, of course).
70
+
71
+ function formatNumber(value: number) {
72
+ if (value < 1024) return value + "";
73
+ else if (value < 1024 * 1024) return (value / 1024).toFixed(1) + "K";
74
+ else if (value < 1024 * 1024 * 1024) return (value / 1024 / 1024).toFixed(1) + "M";
75
+ else return (value / 1024 / 1024 / 1024).toFixed(1) + "G";
76
+ }
77
+
78
+ function createProgressUI(): {
79
+ stop: () => void;
80
+ setMessage: (message: string) => void;
81
+ } {
82
+ let progressUI = document.createElement("div");
83
+ progressUI.style.position = "fixed";
84
+ progressUI.style.top = "0";
85
+ progressUI.style.left = "0";
86
+ progressUI.style.width = "100%";
87
+ progressUI.style.height = "100%";
88
+ progressUI.style.backgroundColor = "hsl(0, 0%, 20%)";
89
+ progressUI.style.display = "flex";
90
+ progressUI.style.justifyContent = "center";
91
+ progressUI.style.alignItems = "center";
92
+ progressUI.style.fontFamily = "Verdana, sans-serif";
93
+ progressUI.style.fontSize = "16px";
94
+ document.body.appendChild(progressUI);
95
+
96
+ const svgContainer = document.createElementNS("http://www.w3.org/2000/svg", "svg");
97
+ svgContainer.setAttribute("viewBox", "0 0 100 100");
98
+ svgContainer.setAttribute("width", "100%");
99
+ svgContainer.setAttribute("height", "100%");
100
+ progressUI.appendChild(svgContainer);
101
+
102
+ const progressText = document.createElementNS("http://www.w3.org/2000/svg", "text");
103
+ progressText.setAttribute("x", "50");
104
+ progressText.setAttribute("y", "50");
105
+ progressText.setAttribute("text-anchor", "middle");
106
+ progressText.setAttribute("dominant-baseline", "middle");
107
+ progressText.setAttribute("fill", "#ffffff");
108
+ progressText.setAttribute("font-size", "5");
109
+ svgContainer.appendChild(progressText);
110
+
111
+ const progressState = new Map<string, {
112
+ arc: SVGPathElement;
113
+ radius: number;
114
+ value: number;
115
+ max: number;
116
+ }>();
117
+
118
+ let lastRenderTime = 0;
119
+ let renderPending = false;
120
+
121
+ function updateProgress() {
122
+ const now = Date.now();
123
+ if (now - lastRenderTime < 10) {
124
+ if (!renderPending) {
125
+ renderPending = true;
126
+ setTimeout(() => {
127
+ renderPending = false;
128
+ updateProgress();
129
+ }, 10);
130
+ }
131
+ return;
132
+ }
133
+
134
+ lastRenderTime = now;
135
+
136
+ for (const [type, state] of progressState) {
137
+ let { arc, radius, value, max } = state;
138
+
139
+ let startAngle = -Math.PI / 2;
140
+ let fraction = value / max;
141
+ if (max === 0) fraction = 0;
142
+
143
+ let endAngle = startAngle + fraction * 2 * Math.PI;
144
+
145
+ let startX = 50 + radius * Math.cos(startAngle);
146
+ let startY = 50 + radius * Math.sin(startAngle);
147
+ let endX = 50 + radius * Math.cos(endAngle);
148
+ let endY = 50 + radius * Math.sin(endAngle);
149
+
150
+ let largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0;
151
+
152
+ let pathData = [
153
+ `M ${startX} ${startY}`,
154
+ `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`
155
+ ].join(" ");
156
+
157
+ if (fraction > 0.99) {
158
+ // Draw a complete circle using two 180-degree arcs
159
+ pathData = [
160
+ `M ${50 + radius} ${50}`,
161
+ `A ${radius} ${radius} 0 1 1 ${50 - radius} ${50}`,
162
+ `A ${radius} ${radius} 0 1 1 ${50 + radius} ${50}`
163
+ ].join(" ");
164
+ }
165
+
166
+ arc.setAttribute("d", pathData);
167
+ }
168
+ }
169
+
170
+ // Set up the progress handler
171
+ globalThis.onProgressHandler = (progress) => {
172
+ let state = progressState.get(progress.type);
173
+ if (!state) {
174
+ const radius = 40 - (progressState.size * 5);
175
+ const arc = document.createElementNS("http://www.w3.org/2000/svg", "path");
176
+ arc.setAttribute("fill", "transparent");
177
+ let hue = progressState.size * 60 + 120;
178
+ arc.setAttribute("stroke", `hsl(${hue}, 75%, 75%)`);
179
+ arc.setAttribute("stroke-width", "4");
180
+ svgContainer.appendChild(arc);
181
+ state = {
182
+ arc,
183
+ radius,
184
+ value: 0,
185
+ max: 0
186
+ };
187
+ progressState.set(progress.type, state);
188
+ }
189
+ state.value += progress.addValue;
190
+ state.max += progress.addMax;
191
+
192
+ progressText.textContent = `${progress.type} | ${formatNumber(state.value)}`;
193
+
194
+ updateProgress();
195
+ };
196
+
197
+ function setMessage(message: string) {
198
+ progressText.textContent = message;
199
+ }
200
+
201
+ let stopped = false;
202
+ return {
203
+ stop() {
204
+ stopped = true;
205
+ progressUI.remove();
206
+ globalThis.onProgressHandler = undefined;
207
+ },
208
+ setMessage
209
+ };
210
+ }
211
+
212
+ let cachedConfig = config.cachedConfig;
213
+
214
+ let progressUI = createProgressUI();
215
+ progressUI.setMessage("Finding available server...");
216
+ while (true) {
217
+ try {
218
+ let booted = await tryToBoot();
219
+ if (booted) break;
220
+ } catch (e) {
221
+ console.error(e);
222
+ }
223
+ progressUI.setMessage("No available servers, trying again in 15 seconds...");
224
+ console.log(`Failing to boot, trying in 15 seconds`);
225
+ await new Promise(resolve => setTimeout(resolve, 1000 * 15));
226
+ }
227
+ progressUI.stop();
228
+
229
+ function isIPDomain(domain: string) {
230
+ return domain.split("-").length === 4;
231
+ }
232
+
233
+ async function tryToBoot(): Promise<boolean> {
234
+ let nodeConfig = await getEdgeNodeConfig();
235
+ await bootEdgeNode(nodeConfig);
236
+ return true;
237
+ }
238
+ async function isNodeAlive(node: EdgeNodeConfig): Promise<boolean> {
239
+ try {
240
+ let abortController = new AbortController();
241
+ let url = `https://${node.host}?classGuid=RequireController-e2f811f3-14b8-4759-b0d6-73f14516cf1d&functionName=getModules&args=[[]]`;
242
+ let response = await fetch(url, { signal: abortController.signal });
243
+ // Cancel, so we don't use up all of our sockets (we are limited to 6 per domain in chrome)
244
+ // on non-responsive servers.
245
+ setTimeout(() => {
246
+ abortController.abort();
247
+ }, 1000 * 15);
248
+ return response.ok;
249
+ } catch (e) {
250
+ return false;
251
+ }
252
+ }
253
+ async function getEdgeNodeConfig(): Promise<EdgeNodeConfig> {
254
+ let edgeIndex = cachedConfig || await (await fetch(config.edgeNodeConfigURL)).json() as EdgeNodesIndex;
255
+ cachedConfig = undefined;
256
+
257
+ console.group(`Found edge nodes`);
258
+
259
+ let edgeNodes = edgeIndex.edgeNodes;
260
+
261
+ function getNodeDebugString(node: EdgeNodeConfig) {
262
+ return `${node.host} | ${node.entryPaths.join(" + ")} | git = ${node.gitHash.slice(0, 16)} | ${node.nodeId} | ${node.public ? "public" : "private"}`;
263
+ }
264
+
265
+ for (let node of edgeNodes) {
266
+ console.log(getNodeDebugString(node));
267
+ }
268
+ console.groupEnd();
269
+
270
+ // If two values have the same host, ignore the older one(s)
271
+ edgeNodes.sort((a, b) => -(a.bootTime - b.bootTime));
272
+ let usedHosts = new Set<string>();
273
+ edgeNodes = edgeNodes.filter(x => {
274
+ if (usedHosts.has(x.host)) {
275
+ console.log(`IGNORE overlapping: ${getNodeDebugString(x)}`);
276
+ return false;
277
+ }
278
+ return true;
279
+ });
280
+
281
+
282
+ // If they are using an IP domain, that's the only node we accept (we still need to pick the node
283
+ // though, as we need to know the entryPaths to import)
284
+ {
285
+ let curHost = document.location.host;
286
+ let exactNode = edgeNodes.find(x => x.host === curHost);
287
+ if (exactNode) {
288
+ console.log(`MATCH exact host: ${getNodeDebugString(exactNode)}`);
289
+ return exactNode;
290
+ }
291
+
292
+ let subDomain = document.location.hostname.split(".").slice(0, -2).join(".");
293
+ if (isIPDomain(subDomain)) {
294
+ throw new Error(`Using an IP domain, but cannot find a node for it`);
295
+ }
296
+ }
297
+
298
+ // I guess... only allow private nodes, if they specify the exact host (which would match above).
299
+ edgeNodes = edgeNodes.filter(x => x.public);
300
+
301
+ // TODO: Instead of randomly shuffling, use the node's current load when picking a node.
302
+ // All our future traffic (such as syncing) goes through the edge node we use, so it preferrable
303
+ // to pick a node with low utilization.
304
+ edgeNodes = shuffle(edgeNodes);
305
+
306
+ // We check multiple at a time, so a node being unresponsive doesn't cause lag
307
+ let PARALLEL_FACTOR = 3;
308
+ for (let i = 0; i < edgeNodes.length; i += PARALLEL_FACTOR) {
309
+ let promises = [];
310
+ for (let j = 0; j < PARALLEL_FACTOR; j++) {
311
+ promises.push((async () => {
312
+ let node = edgeNodes[i + j];
313
+ try {
314
+ let alive = await isNodeAlive(node);
315
+ if (alive) return node;
316
+ } catch { }
317
+ return undefined;
318
+ })());
319
+ }
320
+ let node = await Promise.race(promises);
321
+ if (node) {
322
+ return node;
323
+ }
324
+ }
325
+ throw new Error(`No alive nodes found`);
326
+ }
327
+
328
+ function shuffle<T>(array: T[]): T[] {
329
+ for (let i = array.length - 1; i > 0; i--) {
330
+ let j = Math.floor(Math.random() * (i + 1));
331
+ [array[i], array[j]] = [array[j], array[i]];
332
+ }
333
+ return array;
334
+ }
335
+
336
+ async function bootEdgeNode(edgeNodeConfig: EdgeNodeConfig) {
337
+ globalThis.BOOTED_EDGE_NODE = edgeNodeConfig;
338
+ const require = (globalThis as any).requireAsync || globalThis.require;
339
+ let host = edgeNodeConfig.host;
340
+ if (!host.endsWith("/")) host += "/";
341
+ for (let entryPath of edgeNodeConfig.entryPaths) {
342
+ try {
343
+ await require("https://" + host + entryPath);
344
+ } catch (e) {
345
+ console.error(e);
346
+ }
347
+ }
348
+ }
349
+ }
@@ -0,0 +1,166 @@
1
+ import { waitForFirstTimeSync } from "socket-function/time/trueTimeShim";
2
+ import { getOwnMachineId } from "../../-a-auth/certs";
3
+ import { getDomain, isPublic } from "../../config";
4
+ import child_process from "child_process";
5
+ import { getAllNodeIds, getOwnNodeId } from "../../-f-node-discovery/NodeDiscovery";
6
+ import { getArchivesBackblazePublic } from "../../-a-archives/archivesBackBlaze";
7
+ import { nestArchives } from "../../-a-archives/archives";
8
+ import { SocketFunction } from "socket-function/SocketFunction";
9
+ import { runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
10
+ import { compare, compareArray, timeInMinute } from "socket-function/src/misc";
11
+ import { cacheLimited, lazy } from "socket-function/src/caching";
12
+ import { canHaveChildren } from "socket-function/src/types";
13
+ import { shutdown } from "../../diagnostics/periodic";
14
+ import { hostArchives } from "../../-b-authorities/cdnAuthority";
15
+
16
+ const UPDATE_POLL_INTERVAL = timeInMinute;
17
+ const DEAD_NODE_COUNT_THRESHOLD = 15;
18
+
19
+ const edgeNodeStorage = nestArchives("edgenodes/", getArchivesBackblazePublic(getDomain()));
20
+
21
+ const getEdgeNodeConfig = cacheLimited(10000, async (nodeId: string) => {
22
+ let fileName = "node_" + nodeId;
23
+ let edgeNodeConfig = await edgeNodeStorage.get(fileName);
24
+ if (!edgeNodeConfig) return undefined;
25
+ return JSON.parse(edgeNodeConfig.toString());
26
+ });
27
+
28
+ // NOTE: We update this in the update loop
29
+ const getEdgeNodesIndex = lazy(async (): Promise<EdgeNodesIndex> => {
30
+ await startUpdateLoop();
31
+ let edgeNodesIndex = await edgeNodeStorage.get("edgeNodesIndex.json");
32
+ if (!edgeNodesIndex) return {
33
+ edgeNodes: [],
34
+ };
35
+ return JSON.parse(edgeNodesIndex.toString());
36
+ });
37
+
38
+ export type EdgeNodesIndex = {
39
+ edgeNodes: EdgeNodeConfig[];
40
+ };
41
+
42
+ // IMPORTANT! The EdgeNodeConfig MUST be immutable, otherwise every node has to read every EdgeNodeConfig
43
+ // every poll, which is too much reading.
44
+ export type EdgeNodeConfig = {
45
+ // NOTE: Only information needed for routing should be added here. The rest can be obtained by talking
46
+ // to the node itself (after picking the edgeNode node).
47
+ nodeId: string;
48
+ // (Redundant from nodeId)
49
+ machineId: string;
50
+ // EX: 127-0-0-1.example.com:3334
51
+ host: string;
52
+ gitHash: string;
53
+ bootTime: number;
54
+ entryPaths: string[];
55
+ public: boolean;
56
+ };
57
+ let registeredEdgeNode: EdgeNodeConfig | boolean | undefined;
58
+ export async function registerEdgeNode(config: {
59
+ host: string;
60
+ entryPaths: string[];
61
+ }) {
62
+ if (!SocketFunction.isMounted()) {
63
+ throw new Error("registerEdgeNode must be called after SocketFunction.mount");
64
+ }
65
+
66
+ if (registeredEdgeNode) {
67
+ throw new Error(`registerEdgeNode already called. Should only host 1 edgeNode per node. Previously registered with ${JSON.stringify(registeredEdgeNode)}, new call used ${JSON.stringify(config)}`);
68
+ }
69
+ registeredEdgeNode = true;
70
+
71
+ await waitForFirstTimeSync();
72
+
73
+ let gitHash = child_process.execSync("git rev-parse HEAD").toString().trim();
74
+ let nodeId = getOwnNodeId();
75
+ let machineId = getOwnMachineId();
76
+
77
+ let edgeNodeConfig: EdgeNodeConfig = {
78
+ nodeId,
79
+ machineId,
80
+ host: config.host,
81
+ gitHash,
82
+ bootTime: Date.now(),
83
+ entryPaths: config.entryPaths,
84
+ public: isPublic(),
85
+ };
86
+ registeredEdgeNode = edgeNodeConfig;
87
+
88
+ await edgeNodeStorage.set("node_" + nodeId, Buffer.from(JSON.stringify(edgeNodeConfig)));
89
+
90
+ await startUpdateLoop();
91
+ }
92
+
93
+ export const getEdgeNodeConfigURL = lazy(async () => {
94
+ let { getURL } = await hostArchives({
95
+ archives: edgeNodeStorage,
96
+ subdomain: `edge`,
97
+ domain: getDomain(),
98
+ });
99
+ return await getURL("edge-nodes-index.json");
100
+ });
101
+
102
+ const startUpdateLoop = lazy(async () => {
103
+ await getEdgeNodeConfigURL();
104
+ await runInfinitePollCallAtStart(UPDATE_POLL_INTERVAL, updateLoop);
105
+ });
106
+
107
+ async function updateLoop() {
108
+ await runEdgeNodesAliveCheck();
109
+ await updateEdgeNodesFile();
110
+ }
111
+
112
+ let deadCounts = new Map<string, number>();
113
+ async function runEdgeNodesAliveCheck() {
114
+ let edgeNodeNodeIds = await edgeNodeStorage.find("node_", { type: "files" });
115
+ let nodeIds = new Set(await getAllNodeIds());
116
+ for (let fileName of edgeNodeNodeIds) {
117
+ let nodeId = fileName.slice("node_".length);
118
+ if (!nodeIds.has(nodeId)) {
119
+ deadCounts.set(fileName, (deadCounts.get(fileName) ?? 0) + 1);
120
+ }
121
+ }
122
+
123
+ for (let [fileName, count] of Array.from(deadCounts)) {
124
+ if (count < DEAD_NODE_COUNT_THRESHOLD) continue;
125
+ console.log(`Node is dead. Removing from edgeNodes.`, { fileName, count });
126
+ await edgeNodeStorage.del(fileName);
127
+ }
128
+ }
129
+ async function updateEdgeNodesFile() {
130
+ let prevEdgeNodeFile = await edgeNodeStorage.get("edgeNodesIndex.json");
131
+ let prevEdgeNodeIndex: EdgeNodesIndex = {
132
+ edgeNodes: [],
133
+ };
134
+ try {
135
+ if (prevEdgeNodeFile) {
136
+ prevEdgeNodeIndex = JSON.parse(prevEdgeNodeFile.toString());
137
+ }
138
+ } catch (e) {
139
+ console.error("Failed to parse edgeNodesIndex.json", { error: e, prevEdgeNodeFile });
140
+ }
141
+ getEdgeNodesIndex.set(Promise.resolve(prevEdgeNodeIndex));
142
+
143
+ let edgeNodeNodeIds = await edgeNodeStorage.find("node_", { type: "files" });
144
+ edgeNodeNodeIds = edgeNodeNodeIds.map(x => x.slice("node_".length));
145
+
146
+ if (!edgeNodeNodeIds.includes(getOwnNodeId())) {
147
+ console.log(`Our node was removed as a edgeNode. This is a fatal error. Terminating now.`);
148
+ await shutdown();
149
+ }
150
+
151
+ let diff = !!compareArray(edgeNodeNodeIds.sort(), prevEdgeNodeIndex.edgeNodes.map(x => x.nodeId).sort());
152
+ if (!diff) return;
153
+
154
+ console.log("Detected edgeNodes changed. Updating edgeNodesIndex.json", { edgeNodeNodeIds });
155
+
156
+ let newEdgeNodeIndex: EdgeNodesIndex = {
157
+ edgeNodes: [],
158
+ };
159
+ for (let nodeId of edgeNodeNodeIds) {
160
+ let edgeNodeConfig = await getEdgeNodeConfig(nodeId);
161
+ if (!edgeNodeConfig) continue;
162
+ newEdgeNodeIndex.edgeNodes.push(edgeNodeConfig);
163
+ }
164
+
165
+ await edgeNodeStorage.set("edge-nodes-index.json", Buffer.from(JSON.stringify(newEdgeNodeIndex)));
166
+ }