sliftutils 1.5.6 → 1.6.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/index.d.ts CHANGED
@@ -150,6 +150,34 @@ declare module "sliftutils/misc/https/dns" {
150
150
  export declare function setRecord(type: string, key: string, value: string, proxied?: "proxied"): Promise<void>;
151
151
  /** Keeps existing records */
152
152
  export declare function addRecord(type: string, key: string, value: string, proxied?: "proxied"): Promise<void>;
153
+ /** Provide Cloudflare credentials directly (an API token, or a path to a file containing one), instead of relying on ./cloudflare.json */
154
+ export declare function setCloudflareCredentials(config: {
155
+ key?: string;
156
+ path?: string;
157
+ }): void;
158
+
159
+ }
160
+
161
+ declare module "sliftutils/misc/https/hostServer" {
162
+ export type HostServerConfig = {
163
+ /** Full domain to host on (e.g. "testsite.example.com"). The HTTPS cert is created for this domain and *.domain, so using a subdomain never touches the root domain (beyond its _acme-challenge TXT record). */
164
+ domain: string;
165
+ port: number;
166
+ /** Cloudflare API token (or a path to a file containing one). If neither is given, ./cloudflare.json is used. */
167
+ cloudflareApiToken?: string;
168
+ cloudflareApiTokenPath?: string;
169
+ /** Creates an unproxied A record pointing domain at this machine (publicIp, or our detected external IP) */
170
+ setDNSRecord?: boolean;
171
+ publicIp?: string;
172
+ allowHostnames?: string[];
173
+ };
174
+ /** Hosts a SocketFunction server on a real domain, with an automatically created and renewed Let's Encrypt HTTPS certificate (cached in the home folder, shared between processes on the machine). Expose your controllers (and any RequireController setup) before calling this. Returns the mounted nodeId. */
175
+ export declare function hostServer(config: HostServerConfig): Promise<string>;
176
+ /** Returns the cached HTTPS cert for the domain, creating/renewing it first if it is past this process's renewal threshold. Reads the disk cache on every call, so a renewal done by a parallel process is picked up instead of renewing again. */
177
+ export declare function getFreshHTTPSCert(domain: string): Promise<{
178
+ key: string;
179
+ cert: string;
180
+ }>;
153
181
 
154
182
  }
155
183
 
@@ -11,3 +11,8 @@ export declare function deleteRecord(type: string, key: string, value: string):
11
11
  export declare function setRecord(type: string, key: string, value: string, proxied?: "proxied"): Promise<void>;
12
12
  /** Keeps existing records */
13
13
  export declare function addRecord(type: string, key: string, value: string, proxied?: "proxied"): Promise<void>;
14
+ /** Provide Cloudflare credentials directly (an API token, or a path to a file containing one), instead of relying on ./cloudflare.json */
15
+ export declare function setCloudflareCredentials(config: {
16
+ key?: string;
17
+ path?: string;
18
+ }): void;
package/misc/https/dns.ts CHANGED
@@ -114,16 +114,32 @@ export async function addRecord(type: string, key: string, value: string, proxie
114
114
  }
115
115
 
116
116
 
117
- const getCloudflareCreds = lazy(async (): Promise<{ key: string; }> => {
117
+ let credsOverride: { key: string } | undefined;
118
+ /** Provide Cloudflare credentials directly (an API token, or a path to a file containing one), instead of relying on ./cloudflare.json */
119
+ export function setCloudflareCredentials(config: { key?: string; path?: string }) {
120
+ let key = config.key;
121
+ if (!key && config.path) {
122
+ key = fs.readFileSync(config.path, "utf8").trim();
123
+ }
124
+ if (!key) {
125
+ throw new Error(`Must provide either key or path in setCloudflareCredentials, received ${JSON.stringify(Object.keys(config))}`);
126
+ }
127
+ credsOverride = { key };
128
+ }
129
+
130
+ const getCloudflareCredsFromFile = lazy(async (): Promise<{ key: string; }> => {
118
131
  const path = "cloudflare.json";
119
132
  if (!fs.existsSync(path)) {
120
- throw new Error(`Must add cloudflare.json file to root of project.`);
133
+ throw new Error(`Must add cloudflare.json file to root of project (or call setCloudflareCredentials).`);
121
134
  }
122
135
  let creds = JSON.parse(fs.readFileSync(path, "utf8")) as { key: string; };
123
136
  return {
124
137
  key: creds.key,
125
138
  };
126
139
  });
140
+ async function getCloudflareCreds() {
141
+ return credsOverride || await getCloudflareCredsFromFile();
142
+ }
127
143
 
128
144
  async function cloudflareGETCall<T>(path: string, params?: { [key: string]: string }): Promise<T> {
129
145
  let url = new URL(`https://api.cloudflare.com/client/v4` + path);
@@ -0,0 +1,19 @@
1
+ export type HostServerConfig = {
2
+ /** Full domain to host on (e.g. "testsite.example.com"). The HTTPS cert is created for this domain and *.domain, so using a subdomain never touches the root domain (beyond its _acme-challenge TXT record). */
3
+ domain: string;
4
+ port: number;
5
+ /** Cloudflare API token (or a path to a file containing one). If neither is given, ./cloudflare.json is used. */
6
+ cloudflareApiToken?: string;
7
+ cloudflareApiTokenPath?: string;
8
+ /** Creates an unproxied A record pointing domain at this machine (publicIp, or our detected external IP) */
9
+ setDNSRecord?: boolean;
10
+ publicIp?: string;
11
+ allowHostnames?: string[];
12
+ };
13
+ /** Hosts a SocketFunction server on a real domain, with an automatically created and renewed Let's Encrypt HTTPS certificate (cached in the home folder, shared between processes on the machine). Expose your controllers (and any RequireController setup) before calling this. Returns the mounted nodeId. */
14
+ export declare function hostServer(config: HostServerConfig): Promise<string>;
15
+ /** Returns the cached HTTPS cert for the domain, creating/renewing it first if it is past this process's renewal threshold. Reads the disk cache on every call, so a renewal done by a parallel process is picked up instead of renewing again. */
16
+ export declare function getFreshHTTPSCert(domain: string): Promise<{
17
+ key: string;
18
+ cert: string;
19
+ }>;
@@ -0,0 +1,132 @@
1
+ import os from "os";
2
+ import fs from "fs";
3
+ import { SocketFunction } from "socket-function/SocketFunction";
4
+ import { timeInMinute } from "socket-function/src/misc";
5
+ import { delay } from "socket-function/src/batching";
6
+ import { getExternalIP } from "socket-function/src/networking";
7
+ import { magenta } from "socket-function/src/formatting/logColors";
8
+ import { getThreadKeyCert, loadIdentityCA } from "./certs";
9
+ import { generateCert, getAccountKey, parseCert } from "./httpsCerts";
10
+ import { setCloudflareCredentials, setRecord } from "./dns";
11
+
12
+ // Renew somewhere randomly between 40% and 60% of the way through the cert lifetime. The random threshold staggers parallel processes on the same machine, so usually one process renews early, and the others see the renewed cert on disk (we re-read the disk before every renewal check) and never renew themselves.
13
+ const RENEW_THRESHOLD_MIN = 0.4;
14
+ const RENEW_THRESHOLD_MAX = 0.6;
15
+ const CERT_CHECK_INTERVAL = timeInMinute * 15;
16
+
17
+ // One threshold per process, so a process is consistently early or late relative to its siblings
18
+ const renewThreshold = RENEW_THRESHOLD_MIN + Math.random() * (RENEW_THRESHOLD_MAX - RENEW_THRESHOLD_MIN);
19
+
20
+ export type HostServerConfig = {
21
+ /** Full domain to host on (e.g. "testsite.example.com"). The HTTPS cert is created for this domain and *.domain, so using a subdomain never touches the root domain (beyond its _acme-challenge TXT record). */
22
+ domain: string;
23
+ port: number;
24
+ /** Cloudflare API token (or a path to a file containing one). If neither is given, ./cloudflare.json is used. */
25
+ cloudflareApiToken?: string;
26
+ cloudflareApiTokenPath?: string;
27
+ /** Creates an unproxied A record pointing domain at this machine (publicIp, or our detected external IP) */
28
+ setDNSRecord?: boolean;
29
+ publicIp?: string;
30
+ allowHostnames?: string[];
31
+ };
32
+
33
+ /** Hosts a SocketFunction server on a real domain, with an automatically created and renewed Let's Encrypt HTTPS certificate (cached in the home folder, shared between processes on the machine). Expose your controllers (and any RequireController setup) before calling this. Returns the mounted nodeId. */
34
+ export async function hostServer(config: HostServerConfig): Promise<string> {
35
+ let { domain, port } = config;
36
+ if (config.cloudflareApiToken || config.cloudflareApiTokenPath) {
37
+ setCloudflareCredentials({ key: config.cloudflareApiToken, path: config.cloudflareApiTokenPath });
38
+ }
39
+ // The identity CA always lives on the root domain (nodeIds are threadHash.machineHash.root.tld)
40
+ let rootDomain = domain.split(".").slice(-2).join(".");
41
+ await loadIdentityCA(rootDomain);
42
+
43
+ if (config.setDNSRecord) {
44
+ let ip = config.publicIp || await getExternalIP();
45
+ await setRecord("A", domain, ip);
46
+ }
47
+
48
+ let keyCert = await getFreshHTTPSCert(domain);
49
+ let certListeners: ((value: { key: string; cert: string }) => void)[] = [];
50
+ void runCertRenewalLoop(domain, newKeyCert => {
51
+ keyCert = newKeyCert;
52
+ for (let listener of certListeners) {
53
+ listener(newKeyCert);
54
+ }
55
+ });
56
+
57
+ let nodeId = await SocketFunction.mount({
58
+ public: true,
59
+ port,
60
+ ...getThreadKeyCert(rootDomain),
61
+ SNICerts: {
62
+ [domain]: callback => {
63
+ callback(keyCert);
64
+ certListeners.push(callback);
65
+ },
66
+ },
67
+ allowHostnames: config.allowHostnames,
68
+ });
69
+ console.log(magenta(`Hosting https://${domain}:${port} (nodeId ${nodeId})`));
70
+ return nodeId;
71
+ }
72
+
73
+ function getCertDiskPath(domain: string) {
74
+ return os.homedir() + `/httpscert_${domain}.json`;
75
+ }
76
+ function readCertFromDisk(domain: string): { key: string; cert: string } | undefined {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(getCertDiskPath(domain), "utf8")) as { key: string; cert: string };
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ function getRenewTime(certPem: string, threshold: number) {
84
+ let certObj = parseCert(certPem);
85
+ let start = +new Date(certObj.validity.notBefore);
86
+ let end = +new Date(certObj.validity.notAfter);
87
+ return start + (end - start) * threshold;
88
+ }
89
+
90
+ /** Returns the cached HTTPS cert for the domain, creating/renewing it first if it is past this process's renewal threshold. Reads the disk cache on every call, so a renewal done by a parallel process is picked up instead of renewing again. */
91
+ export async function getFreshHTTPSCert(domain: string): Promise<{ key: string; cert: string }> {
92
+ let keyCert = readCertFromDisk(domain);
93
+ if (keyCert && getRenewTime(keyCert.cert, renewThreshold) > Date.now()) {
94
+ return keyCert;
95
+ }
96
+ if (keyCert) {
97
+ console.log(magenta(`HTTPS cert for ${domain} is past ${(renewThreshold * 100).toFixed(0)}% of its lifetime, renewing`));
98
+ } else {
99
+ console.log(magenta(`No HTTPS cert on disk for ${domain}, creating one`));
100
+ }
101
+ let accountKey = await getAccountKey(domain);
102
+ try {
103
+ keyCert = await generateCert({ accountKey, domain, altDomains: ["*." + domain] });
104
+ } catch (e) {
105
+ if (String(e).includes("authorization must be pending")) {
106
+ // Another process is mid-renewal. Wait for it to finish, then re-check the disk.
107
+ console.log(`Certificate authorization is pending in another process, waiting 2 minutes`);
108
+ await delay(timeInMinute * 2);
109
+ return await getFreshHTTPSCert(domain);
110
+ }
111
+ throw e;
112
+ }
113
+ fs.writeFileSync(getCertDiskPath(domain), JSON.stringify({ key: keyCert.key, cert: keyCert.cert }));
114
+ return { key: keyCert.key, cert: keyCert.cert };
115
+ }
116
+
117
+ async function runCertRenewalLoop(domain: string, onNewCert: (keyCert: { key: string; cert: string }) => void) {
118
+ let lastCert = readCertFromDisk(domain)?.cert;
119
+ while (true) {
120
+ await delay(CERT_CHECK_INTERVAL);
121
+ try {
122
+ let keyCert = await getFreshHTTPSCert(domain);
123
+ if (keyCert.cert !== lastCert) {
124
+ lastCert = keyCert.cert;
125
+ console.log(magenta(`HTTPS cert for ${domain} updated, applying to running server`));
126
+ onNewCert(keyCert);
127
+ }
128
+ } catch (e) {
129
+ console.error(`Failed to check/renew HTTPS cert for ${domain}`, e);
130
+ }
131
+ }
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.5.6",
3
+ "version": "1.6.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -1,3 +1,5 @@
1
+ module.allowclient = true;
2
+
1
3
  import * as preact from "preact";
2
4
  import { observable, Reaction } from "mobx";
3
5
  import { measureBlock } from "socket-function/src/profiling/measure";
@@ -0,0 +1,46 @@
1
+ module.allowclient = true;
2
+
3
+ import preact from "preact";
4
+ import { observable } from "mobx";
5
+ import { observer } from "../render-utils/observer";
6
+ import { isNode } from "socket-function/src/misc";
7
+ import { css } from "typesafecss";
8
+ import { SocketFunction } from "socket-function/SocketFunction";
9
+ import { ServerInfoController } from "./serverInfoController";
10
+
11
+ @observer
12
+ class TestPage extends preact.Component {
13
+ synced = observable({
14
+ serverOSName: "",
15
+ error: "",
16
+ });
17
+
18
+ componentDidMount() {
19
+ void (async () => {
20
+ try {
21
+ let nodeId = SocketFunction.connect({ address: location.hostname, port: +location.port || 443 });
22
+ this.synced.serverOSName = await ServerInfoController.nodes[nodeId].getServerOSName();
23
+ } catch (e) {
24
+ this.synced.error = String(e);
25
+ }
26
+ })();
27
+ }
28
+
29
+ render() {
30
+ return <div className={css.vbox(8).pad2(16)}>
31
+ <div>sliftutils hostServer test page</div>
32
+ <div>
33
+ {this.synced.error && `Error: ${this.synced.error}`
34
+ || this.synced.serverOSName && `Server OS (via SocketFunction): ${this.synced.serverOSName}`
35
+ || "Asking the server for its OS name..."}
36
+ </div>
37
+ </div>;
38
+ }
39
+ }
40
+
41
+ async function main() {
42
+ if (isNode()) return;
43
+ preact.render(<TestPage />, document.body);
44
+ }
45
+
46
+ main().catch(console.error);
@@ -0,0 +1,41 @@
1
+ process.env.NODE_ENV = "production";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { SocketFunction } from "socket-function/SocketFunction";
5
+ import { RequireController } from "socket-function/require/RequireController";
6
+ import { hostServer } from "../misc/https/hostServer";
7
+ import { ServerInfoController } from "./serverInfoController";
8
+ // Import browser code, so it is allowed to be required by the client
9
+ import "./browser";
10
+
11
+ const DOMAIN = "testsite.vidgridweb.com";
12
+ const PORT = 4443;
13
+
14
+ process.on("unhandledRejection", (error) => {
15
+ console.error("Unhandled promise rejection:", error);
16
+ });
17
+ process.on("uncaughtException", (error) => {
18
+ console.error("Uncaught exception:", error);
19
+ });
20
+
21
+ async function main() {
22
+ RequireController.allowAllNodeModules();
23
+ SocketFunction.expose(RequireController);
24
+ SocketFunction.expose(ServerInfoController);
25
+ SocketFunction.setDefaultHTTPCall(RequireController, "requireHTML", {
26
+ requireCalls: ["./testsite/browser.tsx"],
27
+ });
28
+ RequireController.addStaticRoot(path.resolve("."));
29
+
30
+ await hostServer({
31
+ domain: DOMAIN,
32
+ port: PORT,
33
+ cloudflareApiTokenPath: os.homedir() + "/vidgridweb.com.key",
34
+ setDNSRecord: true,
35
+ });
36
+ }
37
+
38
+ main().catch(e => {
39
+ console.error(e);
40
+ process.exit(1);
41
+ });
@@ -0,0 +1,19 @@
1
+ module.allowclient = true;
2
+
3
+ import os from "os";
4
+ import { SocketFunction } from "socket-function/SocketFunction";
5
+
6
+ class ServerInfoControllerBase {
7
+ // Innocuous server-only information, to prove the browser is really talking to the server
8
+ async getServerOSName(): Promise<string> {
9
+ return `${os.type()} ${os.release()} (${os.platform()})`;
10
+ }
11
+ }
12
+
13
+ export const ServerInfoController = SocketFunction.register(
14
+ "ServerInfoController-7f3b1c2a",
15
+ new ServerInfoControllerBase(),
16
+ () => ({
17
+ getServerOSName: {},
18
+ })
19
+ );