Files
mesh-catalog/modules/mssql/client.ts
T
jochen 620b47d309 mssql remaps a user only when orphaned; mailu's operator tool no longer re-enables
ALTER USER ... WITH LOGIN runs only when the user's SID is not the
login's, so an already-mapped user is left alone. The provisioner
enables a mailbox through its own method; the password tool an
operator uses keeps changing the password only.
2026-09-26 01:30:15 +02:00

293 lines
12 KiB
TypeScript

// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside mssql does.
//
// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query
// protocol is more surface than this should carry — so it shells out to the client the mssql
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu
// through doveadm. One boundary, `run()`, and every method is built on it.
//
// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's
// column-aligned text, since SQL Server owns the quoting and typing.
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
export interface QueryResult {
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
readonly command: string;
readonly rows: Record<string, unknown>[];
}
export interface MssqlConn {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
}
export class MssqlClient {
constructor(private readonly conn: MssqlConn) {}
/**
* Build from the module's resolved environment. Reads MESH_MSSQL_* first (the documented
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner
* container. Throws if it cannot find a host and an admin password.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): MssqlClient {
const url = env.MESH_PROVISION_MSSQL ? safeUrl(env.MESH_PROVISION_MSSQL) : undefined;
const host = env.MESH_MSSQL_HOST ?? url?.hostname;
const port = Number(env.MESH_MSSQL_PORT ?? url?.port ?? "1433") || 1433;
const user = env.MESH_MSSQL_USER ?? url?.username ?? "sa";
const password = env.MESH_MSSQL_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
if (!host || !password) {
throw new Error("mssql host or admin password is not set — mssql's own code cannot reach the server");
}
return new MssqlClient({ host, port, user, password });
}
get host(): string {
return this.conn.host;
}
get port(): number {
return this.conn.port;
}
/**
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a
* failed statement an error here rather than a success with a warning, and `-C` trusts the
* server's self-signed certificate the mssql image ships with.
*/
async exec(sql: string, database = "master"): Promise<void> {
await this.sqlcmd(sql, database);
}
/**
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd
* prints (split across output lines for a large result, and reassembled here) is parsed. An
* empty result yields no output at all — an empty array.
*/
async query(
select: string,
database = "master",
variables: Record<string, string> = {},
): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
const stdout = await this.sqlcmd(wrapped, database, variables);
return parseJsonRows(stdout);
}
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
private async sqlcmd(sql: string, database: string, variables: Record<string, string> = {}): Promise<string> {
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
// with postgres's — the module owns its own code (ADR 0039) and shells out to it.
const { stdout } = await run(
"sqlcmd",
[
"-S", `${this.conn.host},${this.conn.port}`,
"-U", this.conn.user,
"-d", database,
"-C",
"-b",
"-h", "-1",
"-y", "0",
"-Y", "0",
"-W",
"-Q", sql,
],
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
// variables: a value that must not appear on argv, or in the message of a failed command.
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
);
return stdout;
}
/**
* Create a login and a database it owns (mapped as a db_owner user), idempotently. The login,
* the database and the user all carry the consumer's minted name, so the consumer owns exactly
* its own database — a name it cannot learn is a database it cannot reach (ADR 0048).
*/
async createDatabaseAndLogin(database: string, login: string, password: string): Promise<void> {
const logins = await this.query(
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
);
if (logins.length === 0) {
await this.exec(
`CREATE LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}, CHECK_POLICY = OFF`,
);
} else {
await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`);
// A disabled login is refused like a wrong password; the check the provisioner runs reports it
// lost, so applying again must enable it or the two would disagree for ever.
await this.exec(`ALTER LOGIN ${ident(login)} ENABLE`);
}
const dbs = await this.query(
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
);
if (dbs.length === 0) {
// CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation.
await this.exec(`CREATE DATABASE ${ident(database)}`);
}
// Map the login to a db_owner user inside the database it owns.
const users = await this.query(
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)}`,
database,
);
if (users.length === 0) {
await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database);
} else {
// Re-point an existing user at the login when its SID is not the login's: a database restored
// from elsewhere keeps its user under the old login's SID, orphaned. Only then, so a user that
// is already mapped is left alone.
const orphaned = await this.query(
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)} ` +
`AND (sid IS NULL OR sid <> SUSER_SID(${literal(login)}))`,
database,
);
if (orphaned.length > 0) {
await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database);
}
}
await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database);
}
/**
* Whether `login` exists, is enabled, has exactly `password`, and is a db_owner user of
* `database`. Read-only: the password is compared with PWDCOMPARE against the stored hash, so
* nothing logs in and no failed-login is recorded (novox/hq issue 120).
*/
async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
// The password reaches sqlcmd as a scripting variable from the environment, never inside the
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's
// minted value, which carries no quote.
const server = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` +
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
"master",
{ MESHHOLDSPW: password },
);
if (Number(server[0]?.ok) !== 1) return false;
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
// right name and the wrong SID, and cannot be reached through the login.
const owner = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.database_principals dp ` +
`JOIN sys.server_principals sp ON dp.sid = sp.sid ` +
`WHERE dp.name = ${literal(login)} AND sp.name = ${literal(login)}) ` +
`AND IS_ROLEMEMBER('db_owner', ${literal(login)}) = 1 THEN 1 ELSE 0 END AS int) AS ok`,
database,
);
return Number(owner[0]?.ok) === 1;
}
/** Drop a database and its login, idempotently, after evicting live connections. */
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
const dbs = await this.query(
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
);
if (dbs.length > 0) {
// SINGLE_USER WITH ROLLBACK IMMEDIATE evicts every other session before the drop.
await this.exec(`ALTER DATABASE ${ident(database)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE`);
await this.exec(`DROP DATABASE ${ident(database)}`);
}
const logins = await this.query(
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
);
if (logins.length > 0) {
await this.exec(`DROP LOGIN ${ident(login)}`);
}
}
/** List the user databases (database_id > 4 excludes the system four), with size, for the tool. */
async listDatabases(): Promise<{ name: string; sizeBytes: number; state: string }[]> {
const rows = await this.query(
"SELECT d.name AS name, d.state_desc AS state, " +
"SUM(CAST(f.size AS bigint)) * 8 * 1024 AS size_bytes " +
"FROM sys.databases d JOIN sys.master_files f ON d.database_id = f.database_id " +
"WHERE d.database_id > 4 GROUP BY d.name, d.state_desc ORDER BY d.name",
);
return rows.map((r) => ({
name: String(r.name),
sizeBytes: Number(r.size_bytes ?? 0),
state: String(r.state ?? ""),
}));
}
/** Run a read-only SELECT against a named database, for the mssql_query tool. */
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
// The read-only guarantee is a wrapping transaction that is always rolled back: any write the
// statement attempts is undone. The rows are rendered by FOR JSON inside query().
const rows = await this.query(
`BEGIN TRANSACTION;\n${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;\nROLLBACK;`,
database,
);
return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows };
}
}
/** Generate a URL-safe password. */
export function generatePassword(): string {
return randomBytes(24).toString("base64url");
}
/** Quote a T-SQL identifier (square brackets, doubled internal `]`). */
export function ident(id: string): string {
return "[" + id.replace(/]/g, "]]") + "]";
}
/** Quote a T-SQL string literal (single quotes, doubled internal quotes). */
export function literal(val: string): string {
return "'" + val.replace(/'/g, "''") + "'";
}
/** Strip trailing semicolons and whitespace so FOR JSON can be appended to a caller's SELECT. */
function stripTrailingSemis(sql: string): string {
return sql.replace(/[\s;]+$/, "");
}
function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim();
} catch {
return undefined;
}
}
function safeUrl(raw: string): URL | undefined {
try {
return new URL(raw);
} catch {
return undefined;
}
}
/**
* Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or
* a pure DDL batch) means no rows.
*/
function parseJsonRows(stdout: string): Record<string, unknown>[] {
const joined = stdout
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter((l) => l.length > 0)
.join("");
if (joined.length === 0) return [];
const parsed = JSON.parse(joined);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
}