@the-meridian/cli 1.1.0 → 1.2.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/CHANGELOG.md +10 -0
- package/dist/index.js +893 -60
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -67,6 +67,7 @@ var deviceInfoResponseSchema = z.object({
|
|
|
67
67
|
created_at: z.string().optional()
|
|
68
68
|
}).loose();
|
|
69
69
|
var devDatabaseStatusSchema = z.union([z.enum(["provisioning", "ready", "error"]), z.string()]);
|
|
70
|
+
var devDatabaseAccessSchema = z.union([z.enum(["gateway", "proxy"]), z.string()]);
|
|
70
71
|
var devDatabaseSchema = z.object({
|
|
71
72
|
id: idSchema,
|
|
72
73
|
status: devDatabaseStatusSchema,
|
|
@@ -78,7 +79,21 @@ var devDatabaseSchema = z.object({
|
|
|
78
79
|
region: z.string().nullish(),
|
|
79
80
|
created_at: z.string().nullish(),
|
|
80
81
|
/** Set by the API only while `status` is `error`; surfaced verbatim to the developer. */
|
|
81
|
-
error_message: z.string().nullish()
|
|
82
|
+
error_message: z.string().nullish(),
|
|
83
|
+
/**
|
|
84
|
+
* How the CLI reaches the database: `gateway` (the Meridian database
|
|
85
|
+
* gateway with a per-developer certificate) or `proxy` (cloud-sql-proxy).
|
|
86
|
+
* Absent from an API older than the gateway, which means `proxy`.
|
|
87
|
+
*/
|
|
88
|
+
access: devDatabaseAccessSchema.nullish()
|
|
89
|
+
}).loose();
|
|
90
|
+
var devDatabaseCertificateSchema = z.object({
|
|
91
|
+
certificate_pem: z.string(),
|
|
92
|
+
ca_pem: z.string(),
|
|
93
|
+
host: z.string(),
|
|
94
|
+
port: z.coerce.number(),
|
|
95
|
+
expires_at: z.string(),
|
|
96
|
+
fingerprint: z.string()
|
|
82
97
|
}).loose();
|
|
83
98
|
var devCredentialsSchema = z.object({
|
|
84
99
|
host: z.string(),
|
|
@@ -342,6 +357,18 @@ var ApiClient = class _ApiClient {
|
|
|
342
357
|
`/app/cli/apps/${encodePathSegment(appId)}/dev-database/proxy-token`
|
|
343
358
|
);
|
|
344
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Signs `publicKeyPem` (SPKI) as this developer's client certificate for the
|
|
362
|
+
* database gateway. Each call replaces the previous certificate server-side.
|
|
363
|
+
*/
|
|
364
|
+
async issueDevDatabaseCertificate(appId, publicKeyPem) {
|
|
365
|
+
return this.request(
|
|
366
|
+
devDatabaseCertificateSchema,
|
|
367
|
+
"POST",
|
|
368
|
+
`/app/cli/apps/${encodePathSegment(appId)}/dev-database/certificate`,
|
|
369
|
+
{ body: { public_key: publicKeyPem } }
|
|
370
|
+
);
|
|
371
|
+
}
|
|
345
372
|
async resetDevDatabase(appId) {
|
|
346
373
|
return this.request(devDatabaseSchema, "POST", `/app/cli/apps/${encodePathSegment(appId)}/dev-database/reset`);
|
|
347
374
|
}
|
|
@@ -523,7 +550,9 @@ function extractErrorCode(body) {
|
|
|
523
550
|
}
|
|
524
551
|
var HINTS_BY_ERROR_CODE = {
|
|
525
552
|
dev_database_limit_reached: "Your plan does not include a developer database for you on this app. Every collaborator gets their own out of the plan's allowance, so upgrading the organization's plan in the Meridian dashboard is what unlocks it.",
|
|
553
|
+
dev_databases_unavailable: "This Meridian environment does not offer developer databases. Run `meridian dev` against production (the default API URL) instead.",
|
|
526
554
|
dev_database_over_storage_limit: "Run `meridian db reset` to drop and recreate the schema. Reset and delete stay available while you're over the limit.",
|
|
555
|
+
cli_upgrade_required: "Upgrade the Meridian CLI: npm i -g @the-meridian/cli",
|
|
527
556
|
EXTERNAL_ACCESS_UNAVAILABLE: "External access is not switched on for this database yet, or the database is not ready. Check the database under Hosting in the Meridian dashboard.",
|
|
528
557
|
FEATURE_NOT_ENTITLED: "Your organization's plan does not include managed developer databases; upgrade it in the Meridian dashboard."
|
|
529
558
|
};
|
|
@@ -1742,14 +1771,770 @@ function redactDatabaseUrl(url) {
|
|
|
1742
1771
|
return url.replace(/^(mysql:\/\/[^:@/]+):([^@]*)@/, "$1:****@");
|
|
1743
1772
|
}
|
|
1744
1773
|
|
|
1774
|
+
// src/lib/gateway/certificate.ts
|
|
1775
|
+
import { generateKeyPair } from "crypto";
|
|
1776
|
+
import { createSecureContext } from "tls";
|
|
1777
|
+
import { promisify } from "util";
|
|
1778
|
+
var generateKeyPairAsync = promisify(generateKeyPair);
|
|
1779
|
+
function gatewayCertificateFromPem(material) {
|
|
1780
|
+
return {
|
|
1781
|
+
host: material.host,
|
|
1782
|
+
port: material.port,
|
|
1783
|
+
expiresAt: material.expiresAt,
|
|
1784
|
+
fingerprint: material.fingerprint,
|
|
1785
|
+
secureContext: createSecureContext({
|
|
1786
|
+
key: material.keyPem,
|
|
1787
|
+
cert: material.certificatePem,
|
|
1788
|
+
ca: material.caPem,
|
|
1789
|
+
minVersion: "TLSv1.2"
|
|
1790
|
+
})
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
async function generateGatewayKeyPair() {
|
|
1794
|
+
const { publicKey, privateKey } = await generateKeyPairAsync("ec", { namedCurve: "prime256v1" });
|
|
1795
|
+
return {
|
|
1796
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
1797
|
+
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString()
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
async function issueGatewayCertificate(client, appId) {
|
|
1801
|
+
const { publicKeyPem, privateKeyPem } = await generateGatewayKeyPair();
|
|
1802
|
+
const issued = await client.issueDevDatabaseCertificate(appId, publicKeyPem);
|
|
1803
|
+
return gatewayCertificateFromPem({
|
|
1804
|
+
keyPem: privateKeyPem,
|
|
1805
|
+
certificatePem: issued.certificate_pem,
|
|
1806
|
+
caPem: issued.ca_pem,
|
|
1807
|
+
host: issued.host,
|
|
1808
|
+
port: issued.port,
|
|
1809
|
+
expiresAt: issued.expires_at,
|
|
1810
|
+
fingerprint: issued.fingerprint
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
var ASSUMED_LIFETIME_MS = 24 * 60 * 6e4;
|
|
1814
|
+
var DEFAULT_REFRESH_BEFORE_MS = 4 * 60 * 6e4;
|
|
1815
|
+
var MIN_REFRESH_DELAY_MS = 6e4;
|
|
1816
|
+
function certificateRefreshDelay(expiresAt, now, refreshBeforeMs = DEFAULT_REFRESH_BEFORE_MS) {
|
|
1817
|
+
const parsed = Date.parse(expiresAt);
|
|
1818
|
+
const expiry = Number.isNaN(parsed) ? now + ASSUMED_LIFETIME_MS : parsed;
|
|
1819
|
+
return Math.max(MIN_REFRESH_DELAY_MS, expiry - refreshBeforeMs - now);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// src/lib/gateway/forwarder.ts
|
|
1823
|
+
import { connect as netConnect, createServer, isIP } from "net";
|
|
1824
|
+
import { connect as tlsConnect } from "tls";
|
|
1825
|
+
|
|
1826
|
+
// src/lib/gateway/packets.ts
|
|
1827
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
1828
|
+
var CLIENT = {
|
|
1829
|
+
LONG_PASSWORD: 1,
|
|
1830
|
+
FOUND_ROWS: 2,
|
|
1831
|
+
LONG_FLAG: 4,
|
|
1832
|
+
CONNECT_WITH_DB: 8,
|
|
1833
|
+
NO_SCHEMA: 16,
|
|
1834
|
+
COMPRESS: 32,
|
|
1835
|
+
ODBC: 64,
|
|
1836
|
+
LOCAL_FILES: 128,
|
|
1837
|
+
IGNORE_SPACE: 256,
|
|
1838
|
+
PROTOCOL_41: 512,
|
|
1839
|
+
INTERACTIVE: 1024,
|
|
1840
|
+
SSL: 2048,
|
|
1841
|
+
IGNORE_SIGPIPE: 4096,
|
|
1842
|
+
TRANSACTIONS: 8192,
|
|
1843
|
+
RESERVED: 16384,
|
|
1844
|
+
SECURE_CONNECTION: 32768,
|
|
1845
|
+
MULTI_STATEMENTS: 65536,
|
|
1846
|
+
MULTI_RESULTS: 131072,
|
|
1847
|
+
PS_MULTI_RESULTS: 262144,
|
|
1848
|
+
PLUGIN_AUTH: 524288,
|
|
1849
|
+
CONNECT_ATTRS: 1048576,
|
|
1850
|
+
PLUGIN_AUTH_LENENC_CLIENT_DATA: 2097152,
|
|
1851
|
+
CAN_HANDLE_EXPIRED_PASSWORDS: 4194304,
|
|
1852
|
+
SESSION_TRACK: 8388608,
|
|
1853
|
+
DEPRECATE_EOF: 16777216,
|
|
1854
|
+
OPTIONAL_RESULTSET_METADATA: 33554432,
|
|
1855
|
+
ZSTD_COMPRESSION: 67108864,
|
|
1856
|
+
QUERY_ATTRIBUTES: 134217728
|
|
1857
|
+
};
|
|
1858
|
+
var GATEWAY_CAPABILITIES = CLIENT.LONG_PASSWORD | CLIENT.FOUND_ROWS | CLIENT.LONG_FLAG | CLIENT.CONNECT_WITH_DB | CLIENT.IGNORE_SPACE | CLIENT.PROTOCOL_41 | CLIENT.INTERACTIVE | CLIENT.SSL | CLIENT.IGNORE_SIGPIPE | CLIENT.TRANSACTIONS | CLIENT.RESERVED | CLIENT.SECURE_CONNECTION | CLIENT.MULTI_STATEMENTS | CLIENT.MULTI_RESULTS | CLIENT.PS_MULTI_RESULTS | CLIENT.PLUGIN_AUTH | CLIENT.CONNECT_ATTRS | CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA | CLIENT.CAN_HANDLE_EXPIRED_PASSWORDS | CLIENT.SESSION_TRACK | CLIENT.DEPRECATE_EOF;
|
|
1859
|
+
var LOCAL_CAPABILITIES = GATEWAY_CAPABILITIES & ~CLIENT.SSL;
|
|
1860
|
+
var PLUGIN_NATIVE = "mysql_native_password";
|
|
1861
|
+
var PLUGIN_CACHING_SHA2 = "caching_sha2_password";
|
|
1862
|
+
var SCRAMBLE_LENGTH = 20;
|
|
1863
|
+
var DEFAULT_CHARSET = 255;
|
|
1864
|
+
var MAX_PAYLOAD = 16777215;
|
|
1865
|
+
var MAX_HANDSHAKE_PACKET = 64 * 1024;
|
|
1866
|
+
var AUTH_MORE_DATA = 1;
|
|
1867
|
+
var FAST_AUTH_SUCCESS = 3;
|
|
1868
|
+
var PERFORM_FULL_AUTH = 4;
|
|
1869
|
+
var SERVER_STATUS_AUTOCOMMIT = 2;
|
|
1870
|
+
function encodePacket(seq, payload) {
|
|
1871
|
+
if (payload.length >= MAX_PAYLOAD) throw new Error(`refusing to write a ${payload.length} byte handshake packet`);
|
|
1872
|
+
const header = Buffer.alloc(4);
|
|
1873
|
+
header.writeUIntLE(payload.length, 0, 3);
|
|
1874
|
+
header[3] = seq & 255;
|
|
1875
|
+
return Buffer.concat([header, payload]);
|
|
1876
|
+
}
|
|
1877
|
+
var PacketReader = class {
|
|
1878
|
+
#socket;
|
|
1879
|
+
#maxPacket;
|
|
1880
|
+
#buffer = Buffer.alloc(0);
|
|
1881
|
+
#failure;
|
|
1882
|
+
#waiter;
|
|
1883
|
+
#onData = (chunk) => {
|
|
1884
|
+
this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]);
|
|
1885
|
+
this.#settle();
|
|
1886
|
+
};
|
|
1887
|
+
#onClose = () => {
|
|
1888
|
+
this.#fail(new Error("the connection closed during the MySQL handshake"));
|
|
1889
|
+
};
|
|
1890
|
+
#onError = (error2) => {
|
|
1891
|
+
this.#fail(error2);
|
|
1892
|
+
};
|
|
1893
|
+
constructor(socket, maxPacket = MAX_HANDSHAKE_PACKET) {
|
|
1894
|
+
this.#socket = socket;
|
|
1895
|
+
this.#maxPacket = maxPacket;
|
|
1896
|
+
socket.on("data", this.#onData);
|
|
1897
|
+
socket.on("end", this.#onClose);
|
|
1898
|
+
socket.on("close", this.#onClose);
|
|
1899
|
+
socket.on("error", this.#onError);
|
|
1900
|
+
}
|
|
1901
|
+
read() {
|
|
1902
|
+
return new Promise((resolve2, reject) => {
|
|
1903
|
+
this.#waiter = { resolve: resolve2, reject };
|
|
1904
|
+
this.#settle();
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
/** Stops reading. `pause` keeps later bytes in the socket for a pipe() to pick up. */
|
|
1908
|
+
release(options) {
|
|
1909
|
+
if (options.pause) this.#socket.pause();
|
|
1910
|
+
this.#socket.off("data", this.#onData);
|
|
1911
|
+
this.#socket.off("end", this.#onClose);
|
|
1912
|
+
this.#socket.off("close", this.#onClose);
|
|
1913
|
+
this.#socket.off("error", this.#onError);
|
|
1914
|
+
const rest = this.#buffer;
|
|
1915
|
+
this.#buffer = Buffer.alloc(0);
|
|
1916
|
+
return rest;
|
|
1917
|
+
}
|
|
1918
|
+
#fail(error2) {
|
|
1919
|
+
if (this.#failure === void 0) this.#failure = error2;
|
|
1920
|
+
this.#settle();
|
|
1921
|
+
}
|
|
1922
|
+
#settle() {
|
|
1923
|
+
const waiter = this.#waiter;
|
|
1924
|
+
if (waiter === void 0) return;
|
|
1925
|
+
if (this.#buffer.length >= 4) {
|
|
1926
|
+
const length = this.#buffer.readUIntLE(0, 3);
|
|
1927
|
+
if (length > this.#maxPacket) {
|
|
1928
|
+
this.#waiter = void 0;
|
|
1929
|
+
waiter.reject(new Error(`a ${length} byte packet during the MySQL handshake is too large`));
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
if (this.#buffer.length >= 4 + length) {
|
|
1933
|
+
const seq = this.#buffer[3] ?? 0;
|
|
1934
|
+
const payload = Buffer.from(this.#buffer.subarray(4, 4 + length));
|
|
1935
|
+
this.#buffer = this.#buffer.subarray(4 + length);
|
|
1936
|
+
this.#waiter = void 0;
|
|
1937
|
+
waiter.resolve({ seq, payload });
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
if (this.#failure !== void 0) {
|
|
1942
|
+
this.#waiter = void 0;
|
|
1943
|
+
waiter.reject(this.#failure);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
var Cursor = class {
|
|
1948
|
+
constructor(buf) {
|
|
1949
|
+
this.buf = buf;
|
|
1950
|
+
}
|
|
1951
|
+
buf;
|
|
1952
|
+
pos = 0;
|
|
1953
|
+
get remaining() {
|
|
1954
|
+
return this.buf.length - this.pos;
|
|
1955
|
+
}
|
|
1956
|
+
bytes(n, what) {
|
|
1957
|
+
if (n < 0 || this.remaining < n) throw new Error(`truncated packet reading ${what}`);
|
|
1958
|
+
const out = this.buf.subarray(this.pos, this.pos + n);
|
|
1959
|
+
this.pos += n;
|
|
1960
|
+
return out;
|
|
1961
|
+
}
|
|
1962
|
+
u8(what) {
|
|
1963
|
+
return this.bytes(1, what)[0] ?? 0;
|
|
1964
|
+
}
|
|
1965
|
+
u16(what) {
|
|
1966
|
+
return this.bytes(2, what).readUInt16LE(0);
|
|
1967
|
+
}
|
|
1968
|
+
u32(what) {
|
|
1969
|
+
return this.bytes(4, what).readUInt32LE(0);
|
|
1970
|
+
}
|
|
1971
|
+
nulString(what) {
|
|
1972
|
+
const end = this.buf.indexOf(0, this.pos);
|
|
1973
|
+
if (end < 0) throw new Error(`truncated packet reading ${what}`);
|
|
1974
|
+
const out = this.buf.subarray(this.pos, end);
|
|
1975
|
+
this.pos = end + 1;
|
|
1976
|
+
return out;
|
|
1977
|
+
}
|
|
1978
|
+
/** Everything left, without a trailing NUL. */
|
|
1979
|
+
restNul() {
|
|
1980
|
+
let out = this.buf.subarray(this.pos);
|
|
1981
|
+
this.pos = this.buf.length;
|
|
1982
|
+
if (out.length > 0 && out[out.length - 1] === 0) out = out.subarray(0, out.length - 1);
|
|
1983
|
+
return out;
|
|
1984
|
+
}
|
|
1985
|
+
lenencInt(what) {
|
|
1986
|
+
const first = this.u8(what);
|
|
1987
|
+
if (first < 251) return first;
|
|
1988
|
+
if (first === 252) return this.u16(what);
|
|
1989
|
+
if (first === 253) return this.bytes(3, what).readUIntLE(0, 3);
|
|
1990
|
+
if (first === 254) {
|
|
1991
|
+
const value = this.bytes(8, what).readBigUInt64LE(0);
|
|
1992
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${what} is too large`);
|
|
1993
|
+
return Number(value);
|
|
1994
|
+
}
|
|
1995
|
+
throw new Error(`invalid length-encoded integer reading ${what}`);
|
|
1996
|
+
}
|
|
1997
|
+
lenencBytes(what) {
|
|
1998
|
+
return this.bytes(this.lenencInt(what), what);
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
function lenencInt(n) {
|
|
2002
|
+
if (n < 251) return Buffer.from([n]);
|
|
2003
|
+
if (n <= 65535) {
|
|
2004
|
+
const b2 = Buffer.alloc(3);
|
|
2005
|
+
b2[0] = 252;
|
|
2006
|
+
b2.writeUInt16LE(n, 1);
|
|
2007
|
+
return b2;
|
|
2008
|
+
}
|
|
2009
|
+
if (n <= 16777215) {
|
|
2010
|
+
const b2 = Buffer.alloc(4);
|
|
2011
|
+
b2[0] = 253;
|
|
2012
|
+
b2.writeUIntLE(n, 1, 3);
|
|
2013
|
+
return b2;
|
|
2014
|
+
}
|
|
2015
|
+
const b = Buffer.alloc(9);
|
|
2016
|
+
b[0] = 254;
|
|
2017
|
+
b.writeBigUInt64LE(BigInt(n), 1);
|
|
2018
|
+
return b;
|
|
2019
|
+
}
|
|
2020
|
+
function lenencBytes(value) {
|
|
2021
|
+
return Buffer.concat([lenencInt(value.length), value]);
|
|
2022
|
+
}
|
|
2023
|
+
function u16(n) {
|
|
2024
|
+
const b = Buffer.alloc(2);
|
|
2025
|
+
b.writeUInt16LE(n & 65535, 0);
|
|
2026
|
+
return b;
|
|
2027
|
+
}
|
|
2028
|
+
function u32(n) {
|
|
2029
|
+
const b = Buffer.alloc(4);
|
|
2030
|
+
b.writeUInt32LE(n >>> 0, 0);
|
|
2031
|
+
return b;
|
|
2032
|
+
}
|
|
2033
|
+
var NUL = Buffer.from([0]);
|
|
2034
|
+
function encodeGreeting(g) {
|
|
2035
|
+
if (g.scramble.length !== SCRAMBLE_LENGTH) throw new Error("the greeting scramble must be 20 bytes");
|
|
2036
|
+
return Buffer.concat([
|
|
2037
|
+
Buffer.from([10]),
|
|
2038
|
+
Buffer.from(g.serverVersion, "utf8"),
|
|
2039
|
+
NUL,
|
|
2040
|
+
u32(g.connectionId),
|
|
2041
|
+
g.scramble.subarray(0, 8),
|
|
2042
|
+
NUL,
|
|
2043
|
+
u16(g.capabilities),
|
|
2044
|
+
Buffer.from([g.charset]),
|
|
2045
|
+
u16(g.status),
|
|
2046
|
+
u16(g.capabilities >>> 16),
|
|
2047
|
+
Buffer.from([SCRAMBLE_LENGTH + 1]),
|
|
2048
|
+
Buffer.alloc(10),
|
|
2049
|
+
g.scramble.subarray(8),
|
|
2050
|
+
NUL,
|
|
2051
|
+
Buffer.from(g.authPlugin, "utf8"),
|
|
2052
|
+
NUL
|
|
2053
|
+
]);
|
|
2054
|
+
}
|
|
2055
|
+
function localGreeting(connectionId, scramble, serverVersion) {
|
|
2056
|
+
return {
|
|
2057
|
+
serverVersion,
|
|
2058
|
+
connectionId,
|
|
2059
|
+
scramble,
|
|
2060
|
+
capabilities: LOCAL_CAPABILITIES,
|
|
2061
|
+
charset: DEFAULT_CHARSET,
|
|
2062
|
+
status: SERVER_STATUS_AUTOCOMMIT,
|
|
2063
|
+
authPlugin: PLUGIN_NATIVE
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
function parseGreeting(payload) {
|
|
2067
|
+
if (payload[0] === 255) throw serverErrorFromPacket(payload);
|
|
2068
|
+
const r = new Cursor(payload);
|
|
2069
|
+
const version = r.u8("protocol version");
|
|
2070
|
+
if (version !== 10) throw new Error(`unsupported handshake version ${version}`);
|
|
2071
|
+
const serverVersion = r.nulString("server version").toString("utf8");
|
|
2072
|
+
const connectionId = r.u32("connection id");
|
|
2073
|
+
const part1 = Buffer.from(r.bytes(8, "scramble part 1"));
|
|
2074
|
+
r.u8("filler");
|
|
2075
|
+
let capabilities = r.u16("capabilities");
|
|
2076
|
+
let charset = DEFAULT_CHARSET;
|
|
2077
|
+
let status = 0;
|
|
2078
|
+
let authPlugin = "";
|
|
2079
|
+
let scramble = part1;
|
|
2080
|
+
if (r.remaining > 0) {
|
|
2081
|
+
charset = r.u8("charset");
|
|
2082
|
+
status = r.u16("status");
|
|
2083
|
+
capabilities = (capabilities | r.u16("capabilities upper") << 16) >>> 0;
|
|
2084
|
+
const authLength = r.u8("auth data length");
|
|
2085
|
+
r.bytes(10, "reserved");
|
|
2086
|
+
if ((capabilities & CLIENT.SECURE_CONNECTION) !== 0) {
|
|
2087
|
+
let part2 = r.bytes(Math.max(13, authLength - 8), "scramble part 2");
|
|
2088
|
+
if (part2.length > 0 && part2[part2.length - 1] === 0) part2 = part2.subarray(0, part2.length - 1);
|
|
2089
|
+
scramble = Buffer.concat([part1, part2]);
|
|
2090
|
+
}
|
|
2091
|
+
if ((capabilities & CLIENT.PLUGIN_AUTH) !== 0) authPlugin = r.restNul().toString("utf8");
|
|
2092
|
+
}
|
|
2093
|
+
if (scramble.length < SCRAMBLE_LENGTH) throw new Error(`the server scramble is ${scramble.length} bytes`);
|
|
2094
|
+
return {
|
|
2095
|
+
serverVersion,
|
|
2096
|
+
connectionId,
|
|
2097
|
+
scramble: scramble.subarray(0, SCRAMBLE_LENGTH),
|
|
2098
|
+
capabilities,
|
|
2099
|
+
charset,
|
|
2100
|
+
status,
|
|
2101
|
+
authPlugin: authPlugin === "" ? PLUGIN_NATIVE : authPlugin
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
function encodeSslRequest(capabilities, maxPacketSize, charset) {
|
|
2105
|
+
return Buffer.concat([u32(capabilities | CLIENT.SSL), u32(maxPacketSize), Buffer.from([charset]), Buffer.alloc(23)]);
|
|
2106
|
+
}
|
|
2107
|
+
function parseHandshakeResponse(payload) {
|
|
2108
|
+
const r = new Cursor(payload);
|
|
2109
|
+
const capabilities = r.u32("capabilities");
|
|
2110
|
+
if ((capabilities & CLIENT.PROTOCOL_41) === 0) throw new Error("the client does not speak protocol 4.1");
|
|
2111
|
+
const maxPacketSize = r.u32("max packet size");
|
|
2112
|
+
const charset = r.u8("charset");
|
|
2113
|
+
r.bytes(23, "filler");
|
|
2114
|
+
if (r.remaining === 0) {
|
|
2115
|
+
return { capabilities, maxPacketSize, charset, username: "", authResponse: Buffer.alloc(0), database: "", authPlugin: "", attributes: [] };
|
|
2116
|
+
}
|
|
2117
|
+
const username = r.nulString("username").toString("utf8");
|
|
2118
|
+
let authResponse;
|
|
2119
|
+
if ((capabilities & CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA) !== 0) authResponse = r.lenencBytes("auth response");
|
|
2120
|
+
else if ((capabilities & CLIENT.SECURE_CONNECTION) !== 0) authResponse = r.bytes(r.u8("auth response length"), "auth response");
|
|
2121
|
+
else authResponse = r.nulString("auth response");
|
|
2122
|
+
let database = "";
|
|
2123
|
+
if ((capabilities & CLIENT.CONNECT_WITH_DB) !== 0 && r.remaining > 0) database = r.nulString("database").toString("utf8");
|
|
2124
|
+
let authPlugin = "";
|
|
2125
|
+
if ((capabilities & CLIENT.PLUGIN_AUTH) !== 0 && r.remaining > 0) authPlugin = r.nulString("auth plugin").toString("utf8");
|
|
2126
|
+
const attributes = [];
|
|
2127
|
+
if ((capabilities & CLIENT.CONNECT_ATTRS) !== 0 && r.remaining > 0) {
|
|
2128
|
+
const attrs = new Cursor(r.lenencBytes("attributes"));
|
|
2129
|
+
while (attrs.remaining > 0) {
|
|
2130
|
+
const key = attrs.lenencBytes("attribute key").toString("utf8");
|
|
2131
|
+
const value = attrs.lenencBytes("attribute value").toString("utf8");
|
|
2132
|
+
attributes.push([key, value]);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
return {
|
|
2136
|
+
capabilities,
|
|
2137
|
+
maxPacketSize,
|
|
2138
|
+
charset,
|
|
2139
|
+
username,
|
|
2140
|
+
authResponse: Buffer.from(authResponse),
|
|
2141
|
+
database,
|
|
2142
|
+
authPlugin,
|
|
2143
|
+
attributes
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
function encodeHandshakeResponse(h) {
|
|
2147
|
+
const parts = [u32(h.capabilities), u32(h.maxPacketSize), Buffer.from([h.charset]), Buffer.alloc(23)];
|
|
2148
|
+
parts.push(Buffer.from(h.username, "utf8"), NUL);
|
|
2149
|
+
if ((h.capabilities & CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA) !== 0) parts.push(lenencBytes(h.authResponse));
|
|
2150
|
+
else if ((h.capabilities & CLIENT.SECURE_CONNECTION) !== 0) parts.push(Buffer.from([h.authResponse.length]), h.authResponse);
|
|
2151
|
+
else parts.push(h.authResponse, NUL);
|
|
2152
|
+
if ((h.capabilities & CLIENT.CONNECT_WITH_DB) !== 0) parts.push(Buffer.from(h.database, "utf8"), NUL);
|
|
2153
|
+
if ((h.capabilities & CLIENT.PLUGIN_AUTH) !== 0) parts.push(Buffer.from(h.authPlugin, "utf8"), NUL);
|
|
2154
|
+
if ((h.capabilities & CLIENT.CONNECT_ATTRS) !== 0) {
|
|
2155
|
+
const attrs = h.attributes.map(
|
|
2156
|
+
([key, value]) => Buffer.concat([lenencBytes(Buffer.from(key, "utf8")), lenencBytes(Buffer.from(value, "utf8"))])
|
|
2157
|
+
);
|
|
2158
|
+
parts.push(lenencBytes(Buffer.concat(attrs)));
|
|
2159
|
+
}
|
|
2160
|
+
return Buffer.concat(parts);
|
|
2161
|
+
}
|
|
2162
|
+
function encodeAuthSwitchRequest(plugin, scramble) {
|
|
2163
|
+
return Buffer.concat([Buffer.from([254]), Buffer.from(plugin, "utf8"), NUL, scramble, NUL]);
|
|
2164
|
+
}
|
|
2165
|
+
function parseAuthSwitchRequest(payload) {
|
|
2166
|
+
if (payload[0] !== 254) throw new Error("not an auth switch request");
|
|
2167
|
+
const r = new Cursor(payload);
|
|
2168
|
+
r.pos = 1;
|
|
2169
|
+
const plugin = r.nulString("auth plugin").toString("utf8");
|
|
2170
|
+
let scramble = Buffer.from(r.restNul());
|
|
2171
|
+
if (scramble.length > SCRAMBLE_LENGTH) scramble = scramble.subarray(0, SCRAMBLE_LENGTH);
|
|
2172
|
+
return { plugin, scramble };
|
|
2173
|
+
}
|
|
2174
|
+
var isOk = (payload) => payload[0] === 0;
|
|
2175
|
+
var isErr = (payload) => payload[0] === 255;
|
|
2176
|
+
var isAuthSwitch = (payload) => payload[0] === 254;
|
|
2177
|
+
var isAuthMoreData = (payload) => payload[0] === AUTH_MORE_DATA;
|
|
2178
|
+
function encodeErr(code, sqlState, message) {
|
|
2179
|
+
const state2 = sqlState.length === 5 ? sqlState : "HY000";
|
|
2180
|
+
return Buffer.concat([Buffer.from([255]), u16(code), Buffer.from(`#${state2}`, "latin1"), Buffer.from(message, "utf8")]);
|
|
2181
|
+
}
|
|
2182
|
+
var MysqlServerError = class extends Error {
|
|
2183
|
+
constructor(code, sqlState, message) {
|
|
2184
|
+
super(message);
|
|
2185
|
+
this.code = code;
|
|
2186
|
+
this.sqlState = sqlState;
|
|
2187
|
+
this.name = "MysqlServerError";
|
|
2188
|
+
}
|
|
2189
|
+
code;
|
|
2190
|
+
sqlState;
|
|
2191
|
+
};
|
|
2192
|
+
function serverErrorFromPacket(payload) {
|
|
2193
|
+
if (payload.length < 3 || payload[0] !== 255) return new MysqlServerError(0, "HY000", "unknown error");
|
|
2194
|
+
const code = payload.readUInt16LE(1);
|
|
2195
|
+
let rest = payload.subarray(3);
|
|
2196
|
+
let sqlState = "HY000";
|
|
2197
|
+
if (rest.length >= 6 && rest[0] === 35) {
|
|
2198
|
+
sqlState = rest.subarray(1, 6).toString("latin1");
|
|
2199
|
+
rest = rest.subarray(6);
|
|
2200
|
+
}
|
|
2201
|
+
return new MysqlServerError(code, sqlState, rest.toString("utf8"));
|
|
2202
|
+
}
|
|
2203
|
+
function newScramble() {
|
|
2204
|
+
const b = randomBytes(SCRAMBLE_LENGTH);
|
|
2205
|
+
for (let i = 0; i < b.length; i++) {
|
|
2206
|
+
let v = (b[i] ?? 0) & 127 | 1;
|
|
2207
|
+
if (v === 36) v = 37;
|
|
2208
|
+
b[i] = v;
|
|
2209
|
+
}
|
|
2210
|
+
return b;
|
|
2211
|
+
}
|
|
2212
|
+
function xor(a, b) {
|
|
2213
|
+
const out = Buffer.alloc(a.length);
|
|
2214
|
+
for (let i = 0; i < a.length; i++) out[i] = (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
2215
|
+
return out;
|
|
2216
|
+
}
|
|
2217
|
+
function scrambleNative(password, nonce) {
|
|
2218
|
+
if (password === "") return Buffer.alloc(0);
|
|
2219
|
+
const h1 = createHash("sha1").update(password, "utf8").digest();
|
|
2220
|
+
const h2 = createHash("sha1").update(h1).digest();
|
|
2221
|
+
const h3 = createHash("sha1").update(nonce).update(h2).digest();
|
|
2222
|
+
return xor(h1, h3);
|
|
2223
|
+
}
|
|
2224
|
+
function scrambleCachingSha2(password, nonce) {
|
|
2225
|
+
if (password === "") return Buffer.alloc(0);
|
|
2226
|
+
const h1 = createHash("sha256").update(password, "utf8").digest();
|
|
2227
|
+
const h2 = createHash("sha256").update(h1).digest();
|
|
2228
|
+
const h3 = createHash("sha256").update(h2).update(nonce).digest();
|
|
2229
|
+
return xor(h1, h3);
|
|
2230
|
+
}
|
|
2231
|
+
function scrambleFor(plugin, password, nonce) {
|
|
2232
|
+
switch (plugin) {
|
|
2233
|
+
case PLUGIN_NATIVE:
|
|
2234
|
+
return scrambleNative(password, nonce);
|
|
2235
|
+
case PLUGIN_CACHING_SHA2:
|
|
2236
|
+
return scrambleCachingSha2(password, nonce);
|
|
2237
|
+
default:
|
|
2238
|
+
throw new Error(`unsupported auth plugin "${plugin}"`);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
function scrambleMatches(received, expected) {
|
|
2242
|
+
return received.length === expected.length && timingSafeEqual(received, expected);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// src/lib/gateway/forwarder.ts
|
|
2246
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
2247
|
+
var DEFAULT_HANDSHAKE_TIMEOUT_MS = 3e4;
|
|
2248
|
+
var REFRESH_RETRY_BACKOFFS_MS = [3e4, 6e4, 12e4, 3e5];
|
|
2249
|
+
var SERVER_VERSION = "8.0.40-meridian-forwarder";
|
|
2250
|
+
var CLOSE_GRACE_MS = 2e3;
|
|
2251
|
+
var ER_ACCESS_DENIED = 1045;
|
|
2252
|
+
var ER_HANDSHAKE = 1043;
|
|
2253
|
+
var CR_CONN_HOST_ERROR = 2003;
|
|
2254
|
+
var LocalRefusal = class extends Error {
|
|
2255
|
+
constructor(code, sqlState, message) {
|
|
2256
|
+
super(message);
|
|
2257
|
+
this.code = code;
|
|
2258
|
+
this.sqlState = sqlState;
|
|
2259
|
+
}
|
|
2260
|
+
code;
|
|
2261
|
+
sqlState;
|
|
2262
|
+
};
|
|
2263
|
+
function accessDenied(username) {
|
|
2264
|
+
return new LocalRefusal(ER_ACCESS_DENIED, "28000", `Access denied for user '${username}'@'localhost' (using password: YES)`);
|
|
2265
|
+
}
|
|
2266
|
+
function upstreamCapabilities(clientCapabilities, database) {
|
|
2267
|
+
let caps = clientCapabilities & LOCAL_CAPABILITIES | CLIENT.SSL | CLIENT.PROTOCOL_41 | CLIENT.SECURE_CONNECTION | CLIENT.PLUGIN_AUTH | CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA;
|
|
2268
|
+
if (database !== "") caps |= CLIENT.CONNECT_WITH_DB;
|
|
2269
|
+
return caps >>> 0;
|
|
2270
|
+
}
|
|
2271
|
+
function waitFor(socket, event) {
|
|
2272
|
+
return new Promise((resolve2, reject) => {
|
|
2273
|
+
const onError = (error2) => {
|
|
2274
|
+
socket.off(event, onDone);
|
|
2275
|
+
reject(error2);
|
|
2276
|
+
};
|
|
2277
|
+
const onDone = () => {
|
|
2278
|
+
socket.off("error", onError);
|
|
2279
|
+
resolve2();
|
|
2280
|
+
};
|
|
2281
|
+
socket.once(event, onDone);
|
|
2282
|
+
socket.once("error", onError);
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
function splice(a, b) {
|
|
2286
|
+
const closeOther = (other) => () => {
|
|
2287
|
+
if (other.destroyed) return;
|
|
2288
|
+
other.end();
|
|
2289
|
+
setTimeout(() => other.destroy(), CLOSE_GRACE_MS).unref();
|
|
2290
|
+
};
|
|
2291
|
+
a.on("close", closeOther(b));
|
|
2292
|
+
b.on("close", closeOther(a));
|
|
2293
|
+
a.pipe(b);
|
|
2294
|
+
b.pipe(a);
|
|
2295
|
+
}
|
|
2296
|
+
async function startGatewayForwarder(options) {
|
|
2297
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
2298
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
|
2299
|
+
const refreshBeforeMs = options.refreshBeforeMs ?? DEFAULT_REFRESH_BEFORE_MS;
|
|
2300
|
+
const now = options.now ?? Date.now;
|
|
2301
|
+
const emit = (event) => {
|
|
2302
|
+
try {
|
|
2303
|
+
options.onEvent?.(event);
|
|
2304
|
+
} catch {
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
let certificate = options.certificate;
|
|
2308
|
+
let running = false;
|
|
2309
|
+
let stopped = false;
|
|
2310
|
+
let connectionId = 0;
|
|
2311
|
+
let refreshTimer;
|
|
2312
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
2313
|
+
const track = (socket) => {
|
|
2314
|
+
sockets.add(socket);
|
|
2315
|
+
socket.once("close", () => sockets.delete(socket));
|
|
2316
|
+
socket.on("error", () => socket.destroy());
|
|
2317
|
+
};
|
|
2318
|
+
async function openUpstream(cert, client) {
|
|
2319
|
+
const raw = netConnect({ host: cert.host, port: cert.port });
|
|
2320
|
+
track(raw);
|
|
2321
|
+
raw.setNoDelay(true);
|
|
2322
|
+
await waitFor(raw, "connect");
|
|
2323
|
+
const rawReader = new PacketReader(raw);
|
|
2324
|
+
const greetingPacket = await rawReader.read();
|
|
2325
|
+
const greeting = parseGreeting(greetingPacket.payload);
|
|
2326
|
+
if ((greeting.capabilities & CLIENT.SSL) === 0) throw new Error("the gateway did not offer TLS");
|
|
2327
|
+
const capabilities = upstreamCapabilities(client.capabilities, client.database);
|
|
2328
|
+
let seq = greetingPacket.seq + 1;
|
|
2329
|
+
raw.write(encodePacket(seq, encodeSslRequest(capabilities, client.maxPacketSize, client.charset)));
|
|
2330
|
+
rawReader.release({ pause: false });
|
|
2331
|
+
const socket = tlsConnect({
|
|
2332
|
+
socket: raw,
|
|
2333
|
+
secureContext: cert.secureContext,
|
|
2334
|
+
host: cert.host,
|
|
2335
|
+
// SNI carries host names only; an IP is still verified against the certificate.
|
|
2336
|
+
...isIP(cert.host) === 0 ? { servername: cert.host } : {},
|
|
2337
|
+
rejectUnauthorized: true
|
|
2338
|
+
});
|
|
2339
|
+
track(socket);
|
|
2340
|
+
socket.setNoDelay(true);
|
|
2341
|
+
await waitFor(socket, "secureConnect");
|
|
2342
|
+
const reader = new PacketReader(socket);
|
|
2343
|
+
let plugin = PLUGIN_CACHING_SHA2;
|
|
2344
|
+
seq++;
|
|
2345
|
+
socket.write(
|
|
2346
|
+
encodePacket(
|
|
2347
|
+
seq,
|
|
2348
|
+
encodeHandshakeResponse({
|
|
2349
|
+
capabilities,
|
|
2350
|
+
maxPacketSize: client.maxPacketSize,
|
|
2351
|
+
charset: client.charset,
|
|
2352
|
+
username: options.username,
|
|
2353
|
+
authResponse: scrambleCachingSha2(options.password, greeting.scramble),
|
|
2354
|
+
database: client.database,
|
|
2355
|
+
authPlugin: plugin,
|
|
2356
|
+
attributes: client.attributes
|
|
2357
|
+
})
|
|
2358
|
+
)
|
|
2359
|
+
);
|
|
2360
|
+
for (let round = 0; round < 6; round++) {
|
|
2361
|
+
const packet = await reader.read();
|
|
2362
|
+
seq = packet.seq;
|
|
2363
|
+
const payload = packet.payload;
|
|
2364
|
+
if (isOk(payload)) return { socket, ok: payload, leftover: reader.release({ pause: true }) };
|
|
2365
|
+
if (isErr(payload)) throw serverErrorFromPacket(payload);
|
|
2366
|
+
if (isAuthSwitch(payload)) {
|
|
2367
|
+
const request = parseAuthSwitchRequest(payload);
|
|
2368
|
+
plugin = request.plugin;
|
|
2369
|
+
seq++;
|
|
2370
|
+
socket.write(encodePacket(seq, scrambleFor(plugin, options.password, request.scramble)));
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
if (isAuthMoreData(payload) && plugin === PLUGIN_CACHING_SHA2 && payload.length >= 2) {
|
|
2374
|
+
if (payload[1] === FAST_AUTH_SUCCESS) continue;
|
|
2375
|
+
if (payload[1] === PERFORM_FULL_AUTH) {
|
|
2376
|
+
seq++;
|
|
2377
|
+
socket.write(encodePacket(seq, Buffer.concat([Buffer.from(options.password, "utf8"), Buffer.from([0])])));
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
throw new Error(`unexpected packet 0x${(payload[0] ?? 0).toString(16)} from the gateway during authentication`);
|
|
2382
|
+
}
|
|
2383
|
+
throw new Error("the gateway's authentication did not finish");
|
|
2384
|
+
}
|
|
2385
|
+
async function authenticateLocal(local, reader) {
|
|
2386
|
+
const scramble = newScramble();
|
|
2387
|
+
connectionId = connectionId + 1 >>> 0;
|
|
2388
|
+
local.write(encodePacket(0, encodeGreeting(localGreeting(connectionId, scramble, SERVER_VERSION))));
|
|
2389
|
+
const first = await reader.read();
|
|
2390
|
+
let seq = first.seq;
|
|
2391
|
+
const client = parseHandshakeResponse(first.payload);
|
|
2392
|
+
if (first.payload.length === 32) {
|
|
2393
|
+
throw new LocalRefusal(ER_HANDSHAKE, "08S01", "The Meridian forwarder on this port does not use TLS; connect without it (the connection never leaves this machine).");
|
|
2394
|
+
}
|
|
2395
|
+
if ((client.capabilities & (CLIENT.COMPRESS | CLIENT.ZSTD_COMPRESSION)) !== 0) {
|
|
2396
|
+
throw new LocalRefusal(ER_HANDSHAKE, "08S01", "Compression is not supported by the Meridian database gateway; turn it off in your client.");
|
|
2397
|
+
}
|
|
2398
|
+
if (client.username !== options.username) throw accessDenied(client.username);
|
|
2399
|
+
let authResponse = client.authResponse;
|
|
2400
|
+
let plugin = client.authPlugin;
|
|
2401
|
+
if (plugin === "" && (client.capabilities & CLIENT.SECURE_CONNECTION) !== 0) plugin = PLUGIN_NATIVE;
|
|
2402
|
+
if (plugin !== PLUGIN_NATIVE && plugin !== PLUGIN_CACHING_SHA2) {
|
|
2403
|
+
seq++;
|
|
2404
|
+
local.write(encodePacket(seq, encodeAuthSwitchRequest(PLUGIN_NATIVE, scramble)));
|
|
2405
|
+
const answer = await reader.read();
|
|
2406
|
+
seq = answer.seq;
|
|
2407
|
+
authResponse = answer.payload;
|
|
2408
|
+
plugin = PLUGIN_NATIVE;
|
|
2409
|
+
}
|
|
2410
|
+
const expected = plugin === PLUGIN_CACHING_SHA2 ? scrambleCachingSha2(options.password, scramble) : scrambleNative(options.password, scramble);
|
|
2411
|
+
if (!scrambleMatches(authResponse, expected)) throw accessDenied(client.username);
|
|
2412
|
+
if (plugin === PLUGIN_CACHING_SHA2) {
|
|
2413
|
+
seq++;
|
|
2414
|
+
local.write(encodePacket(seq, Buffer.from([AUTH_MORE_DATA, FAST_AUTH_SUCCESS])));
|
|
2415
|
+
}
|
|
2416
|
+
return { client, seq };
|
|
2417
|
+
}
|
|
2418
|
+
async function handleConnection(local) {
|
|
2419
|
+
track(local);
|
|
2420
|
+
local.setNoDelay(true);
|
|
2421
|
+
const reader = new PacketReader(local);
|
|
2422
|
+
let upstream;
|
|
2423
|
+
const timer = setTimeout(() => {
|
|
2424
|
+
emit({ type: "connection-error", message: "A local MySQL handshake timed out." });
|
|
2425
|
+
local.destroy();
|
|
2426
|
+
upstream?.destroy();
|
|
2427
|
+
}, handshakeTimeoutMs);
|
|
2428
|
+
timer.unref();
|
|
2429
|
+
let seq = 0;
|
|
2430
|
+
try {
|
|
2431
|
+
const authenticated = await authenticateLocal(local, reader);
|
|
2432
|
+
seq = authenticated.seq;
|
|
2433
|
+
const cert = certificate;
|
|
2434
|
+
let session;
|
|
2435
|
+
try {
|
|
2436
|
+
session = await openUpstream(cert, authenticated.client);
|
|
2437
|
+
} catch (error2) {
|
|
2438
|
+
if (error2 instanceof MysqlServerError) {
|
|
2439
|
+
throw new LocalRefusal(error2.code, error2.sqlState, error2.message);
|
|
2440
|
+
}
|
|
2441
|
+
throw new LocalRefusal(
|
|
2442
|
+
CR_CONN_HOST_ERROR,
|
|
2443
|
+
"HY000",
|
|
2444
|
+
`Cannot reach the Meridian database gateway at ${cert.host}:${cert.port}: ${describeError(error2)}`
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
upstream = session.socket;
|
|
2448
|
+
clearTimeout(timer);
|
|
2449
|
+
local.write(encodePacket(seq + 1, session.ok));
|
|
2450
|
+
const pipelined = reader.release({ pause: true });
|
|
2451
|
+
if (pipelined.length > 0) upstream.write(pipelined);
|
|
2452
|
+
if (session.leftover.length > 0) local.write(session.leftover);
|
|
2453
|
+
splice(local, upstream);
|
|
2454
|
+
} catch (error2) {
|
|
2455
|
+
clearTimeout(timer);
|
|
2456
|
+
upstream?.destroy();
|
|
2457
|
+
if (error2 instanceof LocalRefusal) {
|
|
2458
|
+
if (error2.code !== ER_ACCESS_DENIED) emit({ type: "connection-error", message: error2.message });
|
|
2459
|
+
if (!local.destroyed) local.end(encodePacket(seq + 1, encodeErr(error2.code, error2.sqlState, error2.message)));
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
emit({ type: "connection-error", message: describeError(error2) });
|
|
2463
|
+
local.destroy();
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
async function refresh() {
|
|
2467
|
+
if (options.refreshCertificate === void 0) return;
|
|
2468
|
+
const next = await options.refreshCertificate();
|
|
2469
|
+
certificate = next;
|
|
2470
|
+
emit({ type: "certificate-refreshed", expiresAt: next.expiresAt });
|
|
2471
|
+
}
|
|
2472
|
+
function scheduleRefresh(delayMs, attempt) {
|
|
2473
|
+
if (options.refreshCertificate === void 0 || stopped) return;
|
|
2474
|
+
refreshTimer = setTimeout(() => {
|
|
2475
|
+
refresh().then(
|
|
2476
|
+
() => scheduleRefresh(certificateRefreshDelay(certificate.expiresAt, now(), refreshBeforeMs), 0),
|
|
2477
|
+
(error2) => {
|
|
2478
|
+
const backoff = REFRESH_RETRY_BACKOFFS_MS[Math.min(attempt, REFRESH_RETRY_BACKOFFS_MS.length - 1)] ?? 3e5;
|
|
2479
|
+
emit({
|
|
2480
|
+
type: "error",
|
|
2481
|
+
message: `Could not renew the database gateway certificate (${describeError(error2)}); retrying in ${Math.round(backoff / 1e3)}s.`
|
|
2482
|
+
});
|
|
2483
|
+
scheduleRefresh(backoff, attempt + 1);
|
|
2484
|
+
}
|
|
2485
|
+
);
|
|
2486
|
+
}, delayMs);
|
|
2487
|
+
refreshTimer.unref();
|
|
2488
|
+
}
|
|
2489
|
+
const server = createServer((local) => {
|
|
2490
|
+
void handleConnection(local);
|
|
2491
|
+
});
|
|
2492
|
+
await new Promise((resolve2, reject) => {
|
|
2493
|
+
server.once("error", (error2) => {
|
|
2494
|
+
reject(
|
|
2495
|
+
error2.code === "EADDRINUSE" ? new CliError(`Port ${options.port} is already in use.`, {
|
|
2496
|
+
hint: "Pick a different port with --port, or free up that port and try again."
|
|
2497
|
+
}) : error2
|
|
2498
|
+
);
|
|
2499
|
+
});
|
|
2500
|
+
server.listen({ port: options.port, host, exclusive: true }, () => resolve2());
|
|
2501
|
+
});
|
|
2502
|
+
server.on("error", (error2) => emit({ type: "error", message: describeError(error2) }));
|
|
2503
|
+
const address = server.address();
|
|
2504
|
+
const port = typeof address === "object" && address !== null ? address.port : options.port;
|
|
2505
|
+
running = true;
|
|
2506
|
+
emit({ type: "ready", port });
|
|
2507
|
+
scheduleRefresh(certificateRefreshDelay(certificate.expiresAt, now(), refreshBeforeMs), 0);
|
|
2508
|
+
return {
|
|
2509
|
+
host,
|
|
2510
|
+
port,
|
|
2511
|
+
get running() {
|
|
2512
|
+
return running;
|
|
2513
|
+
},
|
|
2514
|
+
get certificate() {
|
|
2515
|
+
return certificate;
|
|
2516
|
+
},
|
|
2517
|
+
refresh,
|
|
2518
|
+
async stop() {
|
|
2519
|
+
if (stopped) return;
|
|
2520
|
+
stopped = true;
|
|
2521
|
+
running = false;
|
|
2522
|
+
if (refreshTimer !== void 0) clearTimeout(refreshTimer);
|
|
2523
|
+
const closed = new Promise((resolve2) => server.close(() => resolve2()));
|
|
2524
|
+
for (const socket of sockets) socket.destroy();
|
|
2525
|
+
await closed;
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
|
|
1745
2530
|
// src/lib/port.ts
|
|
1746
|
-
import { createServer } from "net";
|
|
2531
|
+
import { createServer as createServer2 } from "net";
|
|
1747
2532
|
var DEFAULT_START_PORT = 3307;
|
|
1748
2533
|
var DEFAULT_ATTEMPTS = 20;
|
|
1749
2534
|
var MAX_PORT = 65535;
|
|
1750
2535
|
function isPortFree(port, host = "127.0.0.1") {
|
|
1751
2536
|
return new Promise((resolve2) => {
|
|
1752
|
-
const server =
|
|
2537
|
+
const server = createServer2();
|
|
1753
2538
|
server.once("error", () => {
|
|
1754
2539
|
resolve2(false);
|
|
1755
2540
|
});
|
|
@@ -1761,7 +2546,7 @@ function isPortFree(port, host = "127.0.0.1") {
|
|
|
1761
2546
|
}
|
|
1762
2547
|
function bindEphemeralPort(host) {
|
|
1763
2548
|
return new Promise((resolve2) => {
|
|
1764
|
-
const server =
|
|
2549
|
+
const server = createServer2();
|
|
1765
2550
|
server.once("error", () => resolve2(null));
|
|
1766
2551
|
server.listen({ port: 0, host, exclusive: true }, () => {
|
|
1767
2552
|
const address = server.address();
|
|
@@ -1998,7 +2783,7 @@ function diagnoseMigrationFailure(root) {
|
|
|
1998
2783
|
}
|
|
1999
2784
|
|
|
2000
2785
|
// src/lib/proxy/binary.ts
|
|
2001
|
-
import { createHash } from "crypto";
|
|
2786
|
+
import { createHash as createHash2 } from "crypto";
|
|
2002
2787
|
import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync as renameSync3, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
2003
2788
|
import { join as join4 } from "path";
|
|
2004
2789
|
var PROXY_VERSION = "2.24.1";
|
|
@@ -2047,7 +2832,7 @@ function resolveProxyAsset(options) {
|
|
|
2047
2832
|
};
|
|
2048
2833
|
}
|
|
2049
2834
|
function sha256(data) {
|
|
2050
|
-
return
|
|
2835
|
+
return createHash2("sha256").update(data).digest("hex");
|
|
2051
2836
|
}
|
|
2052
2837
|
var CHECKSUM_ROW_PATTERN = /\[([^\]]+)]\([^)]*\)\s*\|\s*([0-9a-fA-F]{64})/g;
|
|
2053
2838
|
function parseChecksumTable(releaseBody) {
|
|
@@ -2188,8 +2973,8 @@ var PROBE_INTERVAL_MS = 150;
|
|
|
2188
2973
|
var KILL_GRACE_MS = 2e3;
|
|
2189
2974
|
var CRASH_BACKOFFS_MS = [200, 1e3, 3e3];
|
|
2190
2975
|
var MAX_CRASH_RESTARTS = CRASH_BACKOFFS_MS.length;
|
|
2191
|
-
var
|
|
2192
|
-
var
|
|
2976
|
+
var REFRESH_RETRY_BACKOFFS_MS2 = [3e4, 6e4, 12e4];
|
|
2977
|
+
var MIN_REFRESH_DELAY_MS2 = 6e4;
|
|
2193
2978
|
var LOG_RING_SIZE = 20;
|
|
2194
2979
|
var STDERR_TAIL_LINES = 10;
|
|
2195
2980
|
function buildProxyArgs(input2) {
|
|
@@ -2218,7 +3003,7 @@ function computeRefreshDelayMs(token, refreshSkewMs, assumedLifetimeMs) {
|
|
|
2218
3003
|
if (!Number.isNaN(parsed)) expiresAtMs = parsed;
|
|
2219
3004
|
}
|
|
2220
3005
|
const effectiveExpiryMs = expiresAtMs ?? Date.now() + assumedLifetimeMs;
|
|
2221
|
-
return Math.max(effectiveExpiryMs - Date.now() - refreshSkewMs,
|
|
3006
|
+
return Math.max(effectiveExpiryMs - Date.now() - refreshSkewMs, MIN_REFRESH_DELAY_MS2);
|
|
2222
3007
|
}
|
|
2223
3008
|
function buildStartupFailureError(code, signal, lastLines) {
|
|
2224
3009
|
const tail = lastLines.slice(-STDERR_TAIL_LINES).join("\n");
|
|
@@ -2396,7 +3181,7 @@ async function startProxy(options) {
|
|
|
2396
3181
|
}
|
|
2397
3182
|
function scheduleRefreshRetry() {
|
|
2398
3183
|
if (stopped) return;
|
|
2399
|
-
const delayMs =
|
|
3184
|
+
const delayMs = REFRESH_RETRY_BACKOFFS_MS2[Math.min(refreshBackoffIndex, REFRESH_RETRY_BACKOFFS_MS2.length - 1)] ?? 12e4;
|
|
2400
3185
|
refreshBackoffIndex += 1;
|
|
2401
3186
|
if (refreshTimer !== void 0) clearTimeout(refreshTimer);
|
|
2402
3187
|
refreshTimer = setTimeoutImpl(() => {
|
|
@@ -2596,6 +3381,8 @@ function resolveDeps(overrides) {
|
|
|
2596
3381
|
return {
|
|
2597
3382
|
ensureProxyBinary,
|
|
2598
3383
|
startProxy,
|
|
3384
|
+
issueGatewayCertificate,
|
|
3385
|
+
startGatewayForwarder,
|
|
2599
3386
|
findFreePort,
|
|
2600
3387
|
patchEnvFile,
|
|
2601
3388
|
waitForDatabase,
|
|
@@ -2722,6 +3509,97 @@ function handleProxyEvent(event) {
|
|
|
2722
3509
|
return;
|
|
2723
3510
|
}
|
|
2724
3511
|
}
|
|
3512
|
+
async function pickLocalPort(options, deps) {
|
|
3513
|
+
const findPortOptions = { start: DEFAULT_PORT_START };
|
|
3514
|
+
if (options.port !== void 0) findPortOptions.preferred = options.port;
|
|
3515
|
+
const port = await deps.findFreePort(findPortOptions);
|
|
3516
|
+
if (options.port !== void 0 && port !== options.port) {
|
|
3517
|
+
throw new CliError(`Port ${options.port} is already in use.`, {
|
|
3518
|
+
hint: "Pick a different port with --port, or free up that port and try again."
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3521
|
+
return port;
|
|
3522
|
+
}
|
|
3523
|
+
async function startProxyEndpoint(client, appId, devDatabase, credentials, options, deps) {
|
|
3524
|
+
const proxyToken = await withSpinner(
|
|
3525
|
+
"Requesting a Cloud SQL Auth Proxy token...",
|
|
3526
|
+
() => client.issueProxyToken(appId),
|
|
3527
|
+
{ success: "Issued a Cloud SQL Auth Proxy token." }
|
|
3528
|
+
);
|
|
3529
|
+
const binaryPath = await withSpinner(
|
|
3530
|
+
"Preparing the Cloud SQL Auth Proxy...",
|
|
3531
|
+
(spinner2) => {
|
|
3532
|
+
const ensureOptions = {
|
|
3533
|
+
onProgress: (received, total) => {
|
|
3534
|
+
spinner2.update(
|
|
3535
|
+
total !== void 0 ? `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)}/${formatMb(total)} MB)` : `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)} MB)`
|
|
3536
|
+
);
|
|
3537
|
+
}
|
|
3538
|
+
};
|
|
3539
|
+
return deps.ensureProxyBinary(ensureOptions);
|
|
3540
|
+
},
|
|
3541
|
+
{ success: "Cloud SQL Auth Proxy is ready." }
|
|
3542
|
+
);
|
|
3543
|
+
const port = await pickLocalPort(options, deps);
|
|
3544
|
+
const instanceConnectionName = firstNonEmpty(
|
|
3545
|
+
credentials.instance_connection_name,
|
|
3546
|
+
proxyToken.instance_connection_name,
|
|
3547
|
+
devDatabase.instance_connection_name
|
|
3548
|
+
);
|
|
3549
|
+
if (instanceConnectionName === void 0) {
|
|
3550
|
+
throw new CliError("The Meridian API did not return a Cloud SQL instance connection name.", {
|
|
3551
|
+
hint: "Try again, or contact Meridian support if this persists."
|
|
3552
|
+
});
|
|
3553
|
+
}
|
|
3554
|
+
return withSpinner(
|
|
3555
|
+
`Starting the Cloud SQL Auth Proxy on 127.0.0.1:${port}...`,
|
|
3556
|
+
() => deps.startProxy({
|
|
3557
|
+
binaryPath,
|
|
3558
|
+
instanceConnectionName,
|
|
3559
|
+
token: toProxyToken(proxyToken),
|
|
3560
|
+
port,
|
|
3561
|
+
...options.refreshToken !== false ? { refreshToken: async () => toProxyToken(await client.issueProxyToken(appId)) } : {},
|
|
3562
|
+
onEvent: handleProxyEvent
|
|
3563
|
+
}),
|
|
3564
|
+
{ success: `Cloud SQL Auth Proxy listening on 127.0.0.1:${port}.` }
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
function handleForwarderEvent(event) {
|
|
3568
|
+
switch (event.type) {
|
|
3569
|
+
case "ready":
|
|
3570
|
+
debug(`Database gateway forwarder listening on port ${event.port}.`);
|
|
3571
|
+
return;
|
|
3572
|
+
case "connection-error":
|
|
3573
|
+
debug(`Database gateway connection failed: ${event.message}`);
|
|
3574
|
+
return;
|
|
3575
|
+
case "certificate-refreshed":
|
|
3576
|
+
step(`Renewed the database gateway certificate (expires ${event.expiresAt}).`);
|
|
3577
|
+
return;
|
|
3578
|
+
case "error":
|
|
3579
|
+
warn(`Database gateway: ${event.message} Restart \`meridian dev\` if the database becomes unreachable.`);
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
async function startGatewayEndpoint(client, appId, credentials, options, deps) {
|
|
3584
|
+
const certificate = await withSpinner(
|
|
3585
|
+
"Requesting a database gateway certificate...",
|
|
3586
|
+
() => deps.issueGatewayCertificate(client, appId),
|
|
3587
|
+
{ success: (cert) => `Issued a database gateway certificate (expires ${cert.expiresAt}).` }
|
|
3588
|
+
);
|
|
3589
|
+
const port = await pickLocalPort(options, deps);
|
|
3590
|
+
return withSpinner(
|
|
3591
|
+
`Starting the database gateway forwarder on 127.0.0.1:${port}...`,
|
|
3592
|
+
() => deps.startGatewayForwarder({
|
|
3593
|
+
port,
|
|
3594
|
+
username: credentials.username,
|
|
3595
|
+
password: credentials.password,
|
|
3596
|
+
certificate,
|
|
3597
|
+
...options.refreshToken !== false ? { refreshCertificate: () => deps.issueGatewayCertificate(client, appId) } : {},
|
|
3598
|
+
onEvent: handleForwarderEvent
|
|
3599
|
+
}),
|
|
3600
|
+
{ success: `Database gateway forwarder listening on 127.0.0.1:${port} (${certificate.host}).` }
|
|
3601
|
+
);
|
|
3602
|
+
}
|
|
2725
3603
|
async function startDevSession(options = {}) {
|
|
2726
3604
|
const deps = resolveDeps(options.deps);
|
|
2727
3605
|
let proxy;
|
|
@@ -2738,55 +3616,8 @@ async function startDevSession(options = {}) {
|
|
|
2738
3616
|
success: (creds) => `Fetched credentials for database "${creds.database}"${typeof creds.expires_at === "string" ? ` (expire ${creds.expires_at})` : ""}.`
|
|
2739
3617
|
}
|
|
2740
3618
|
);
|
|
2741
|
-
const
|
|
2742
|
-
|
|
2743
|
-
() => client.issueProxyToken(appId),
|
|
2744
|
-
{ success: "Issued a Cloud SQL Auth Proxy token." }
|
|
2745
|
-
);
|
|
2746
|
-
const binaryPath = await withSpinner(
|
|
2747
|
-
"Preparing the Cloud SQL Auth Proxy...",
|
|
2748
|
-
(spinner2) => {
|
|
2749
|
-
const ensureOptions = {
|
|
2750
|
-
onProgress: (received, total) => {
|
|
2751
|
-
spinner2.update(
|
|
2752
|
-
total !== void 0 ? `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)}/${formatMb(total)} MB)` : `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)} MB)`
|
|
2753
|
-
);
|
|
2754
|
-
}
|
|
2755
|
-
};
|
|
2756
|
-
return deps.ensureProxyBinary(ensureOptions);
|
|
2757
|
-
},
|
|
2758
|
-
{ success: "Cloud SQL Auth Proxy is ready." }
|
|
2759
|
-
);
|
|
2760
|
-
const findPortOptions = { start: DEFAULT_PORT_START };
|
|
2761
|
-
if (options.port !== void 0) findPortOptions.preferred = options.port;
|
|
2762
|
-
const port = await deps.findFreePort(findPortOptions);
|
|
2763
|
-
if (options.port !== void 0 && port !== options.port) {
|
|
2764
|
-
throw new CliError(`Port ${options.port} is already in use.`, {
|
|
2765
|
-
hint: "Pick a different port with --port, or free up that port and try again."
|
|
2766
|
-
});
|
|
2767
|
-
}
|
|
2768
|
-
const instanceConnectionName = firstNonEmpty(
|
|
2769
|
-
credentials.instance_connection_name,
|
|
2770
|
-
proxyToken.instance_connection_name,
|
|
2771
|
-
devDatabase.instance_connection_name
|
|
2772
|
-
);
|
|
2773
|
-
if (instanceConnectionName === void 0) {
|
|
2774
|
-
throw new CliError("The Meridian API did not return a Cloud SQL instance connection name.", {
|
|
2775
|
-
hint: "Try again, or contact Meridian support if this persists."
|
|
2776
|
-
});
|
|
2777
|
-
}
|
|
2778
|
-
proxy = await withSpinner(
|
|
2779
|
-
`Starting the Cloud SQL Auth Proxy on 127.0.0.1:${port}...`,
|
|
2780
|
-
() => deps.startProxy({
|
|
2781
|
-
binaryPath,
|
|
2782
|
-
instanceConnectionName,
|
|
2783
|
-
token: toProxyToken(proxyToken),
|
|
2784
|
-
port,
|
|
2785
|
-
...options.refreshToken !== false ? { refreshToken: async () => toProxyToken(await client.issueProxyToken(appId)) } : {},
|
|
2786
|
-
onEvent: handleProxyEvent
|
|
2787
|
-
}),
|
|
2788
|
-
{ success: `Cloud SQL Auth Proxy listening on 127.0.0.1:${port}.` }
|
|
2789
|
-
);
|
|
3619
|
+
const access = devDatabase.access === "gateway" ? "gateway" : "proxy";
|
|
3620
|
+
proxy = access === "gateway" ? await startGatewayEndpoint(client, appId, credentials, options, deps) : await startProxyEndpoint(client, appId, devDatabase, credentials, options, deps);
|
|
2790
3621
|
const connection = connectionInfoFromCredentials(credentials, { host: proxy.host, port: proxy.port });
|
|
2791
3622
|
if (options.waitForDb !== false) {
|
|
2792
3623
|
await withSpinner(
|
|
@@ -2838,6 +3669,7 @@ async function startDevSession(options = {}) {
|
|
|
2838
3669
|
connection,
|
|
2839
3670
|
databaseUrl,
|
|
2840
3671
|
...shadowDatabaseUrl !== void 0 ? { shadowDatabaseUrl } : {},
|
|
3672
|
+
access,
|
|
2841
3673
|
proxy,
|
|
2842
3674
|
env: {
|
|
2843
3675
|
...process.env,
|
|
@@ -3506,7 +4338,7 @@ function printDevSummary(session, noEnv) {
|
|
|
3506
4338
|
const entries = [
|
|
3507
4339
|
["App", appLabel(session.project)],
|
|
3508
4340
|
["Database", session.devDatabase.database],
|
|
3509
|
-
["Proxy", `127.0.0.1:${session.proxy.port}`],
|
|
4341
|
+
[session.access === "gateway" ? "Gateway" : "Proxy", `127.0.0.1:${session.proxy.port}`],
|
|
3510
4342
|
["Env file", envEntry],
|
|
3511
4343
|
["DATABASE_URL", redactDatabaseUrl(session.databaseUrl)]
|
|
3512
4344
|
];
|
|
@@ -3521,6 +4353,7 @@ function buildDevJsonPayload(session) {
|
|
|
3521
4353
|
return {
|
|
3522
4354
|
app: appLabel(session.project),
|
|
3523
4355
|
database: session.devDatabase.database,
|
|
4356
|
+
access: session.access,
|
|
3524
4357
|
proxy: { host: session.proxy.host, port: session.proxy.port },
|
|
3525
4358
|
database_url: redactDatabaseUrl(session.databaseUrl),
|
|
3526
4359
|
env_file: session.envPatch?.path ?? null,
|