diff --git a/apps/server/src/cli/commands/tls.ts b/apps/server/src/cli/commands/tls.ts index 2f12ffea..92d911fd 100644 --- a/apps/server/src/cli/commands/tls.ts +++ b/apps/server/src/cli/commands/tls.ts @@ -1,9 +1,7 @@ import { AUTO_TLS_LOW_PORT_COMMAND, AUTO_TLS_VALIDATION_PORT, type AutoTlsErrorCode, type AutoTlsStatus } from '@monky/shared'; +import { AcmeChallenges, AutoTlsFailure, AutoTlsService, LetsEncryptIssuer, acmeTestOverrides, allowValidationPort } from '@monky/shared/dist/tls'; import { getPublicIp } from '../../infrastructure/discovery/ServerIpScanner'; -import { AcmeChallenges } from '../../infrastructure/tls/AcmeChallenges'; -import { AutoTlsFailure, AutoTlsService } from '../../infrastructure/tls/AutoTlsService'; -import { CertificateStore } from '../../infrastructure/tls/CertificateStore'; -import { LetsEncryptIssuer, acmeTestOverrides, allowValidationPort } from '../../infrastructure/tls/LetsEncryptIssuer'; +import { serverTlsStorage } from '../../infrastructure/tls/sharedTls'; import { ANSI, color } from '../constants'; import type { GlobalArgs } from '../context'; import { formatDate } from '../formatters'; @@ -32,10 +30,13 @@ export function describeAutoTlsError(code: AutoTlsErrorCode): string { /** The service as the CLI runs it: issuing here, serving later in the server process. */ function cliService(dataDir: string): AutoTlsService { - const store = new CertificateStore(dataDir); + const storage = serverTlsStorage(dataDir); + const store = storage.store; const overrides = acmeTestOverrides(); return new AutoTlsService({ store, + settings: storage.settings, + legacyDirectory: storage.legacyDirectory, // The server may be running on its own port; the CLI always answers on the validation port itself. issuer: new LetsEncryptIssuer({ store, challenges: new AcmeChallenges(), serverPort: -1, @@ -85,7 +86,7 @@ async function enable(dataDir: string): Promise { if (!(await confirm(t('tls.askGrant', { port: AUTO_TLS_VALIDATION_PORT, command: AUTO_TLS_LOW_PORT_COMMAND }), true))) throw error; status = await service.enable({ grantPortPermission: true }); } - console.log(color(t('tls.enabledNow', { address: `wss://${status.ip}` }), ANSI.green)); + console.log(color(t(service.reusedSharedCertificate ? 'tls.enabledReused' : 'tls.enabledNow', { address: `wss://${status.ip}` }), ANSI.green)); printStatus(status); } catch (error) { if (error instanceof AutoTlsFailure) throw new Error(`${t('tls.notEnabled')} ${describeAutoTlsError(error.code)}`); diff --git a/apps/server/src/cli/i18n/locales/en.ts b/apps/server/src/cli/i18n/locales/en.ts index a72ecb19..92491ed0 100644 --- a/apps/server/src/cli/i18n/locales/en.ts +++ b/apps/server/src/cli/i18n/locales/en.ts @@ -520,6 +520,7 @@ export const en: CliTranslationMap = { 'tls.checking': 'Asking Let\'s Encrypt for a test certificate to check that port {port} is reachable from the internet. This takes a few seconds...', 'tls.askGrant': 'Linux does not let the server open port {port} without permission. Allow it now with sudo? (runs: {command})', 'tls.enabledNow': 'Secure connection enabled. The server also accepts {address}.', + 'tls.enabledReused': 'Secure connection enabled with the certificate already shared on this machine. The server also accepts {address}.', 'tls.notEnabled': 'The secure connection was not enabled.', 'tls.disabledNow': 'Secure connection disabled. The server accepts ws:// only.', 'tls.restartNow': 'The server is running. Restart it now to apply the secure connection?', @@ -534,5 +535,5 @@ export const en: CliTranslationMap = { 'tls.error.rateLimited': 'Let\'s Encrypt asked to wait before issuing another certificate. Try again later.', 'tls.error.rejected': 'Let\'s Encrypt refused the request. See the server logs for the reason.', 'tls.error.network': 'Could not reach Let\'s Encrypt. Check the internet connection.', - 'tls.error.storage': 'Could not save the certificate in the server\'s data folder.', + 'tls.error.storage': 'Could not save the certificate in the shared certificate folder (~/.monky/tls or MONKY_TLS_DIR).', }; diff --git a/apps/server/src/cli/i18n/locales/pt-BR.ts b/apps/server/src/cli/i18n/locales/pt-BR.ts index ce674a24..90d58a2c 100644 --- a/apps/server/src/cli/i18n/locales/pt-BR.ts +++ b/apps/server/src/cli/i18n/locales/pt-BR.ts @@ -520,6 +520,7 @@ export const ptBR = { 'tls.checking': 'Pedindo à Let\'s Encrypt um certificado de teste para conferir se a porta {port} está acessível pela internet. Isso leva alguns segundos...', 'tls.askGrant': 'O Linux não deixa o servidor abrir a porta {port} sem permissão. Liberar agora com sudo? (executa: {command})', 'tls.enabledNow': 'Conexão segura ativada. O servidor também aceita {address}.', + 'tls.enabledReused': 'Conexão segura ativada com o certificado já compartilhado nesta máquina. O servidor também aceita {address}.', 'tls.notEnabled': 'A conexão segura não foi ativada.', 'tls.disabledNow': 'Conexão segura desativada. O servidor aceita apenas ws://.', 'tls.restartNow': 'O servidor está rodando. Reiniciar agora para aplicar a conexão segura?', @@ -534,5 +535,5 @@ export const ptBR = { 'tls.error.rateLimited': 'A Let\'s Encrypt pediu para esperar antes de emitir outro certificado. Tente de novo mais tarde.', 'tls.error.rejected': 'A Let\'s Encrypt recusou o pedido. Veja os logs do servidor para o motivo.', 'tls.error.network': 'Não foi possível falar com a Let\'s Encrypt. Confira a conexão com a internet.', - 'tls.error.storage': 'Não foi possível gravar o certificado na pasta de dados do servidor.', + 'tls.error.storage': 'Não foi possível gravar o certificado na pasta compartilhada de certificados (~/.monky/tls ou MONKY_TLS_DIR).', } as const; diff --git a/apps/server/src/infrastructure/tls/AcmeChallenges.ts b/apps/server/src/infrastructure/tls/AcmeChallenges.ts deleted file mode 100644 index b9fecd30..00000000 --- a/apps/server/src/infrastructure/tls/AcmeChallenges.ts +++ /dev/null @@ -1,60 +0,0 @@ -import http from 'node:http'; -import { GET_OR_HEAD, createHttpRequestListener, sendStatus, type HttpRoute } from '../http/HttpRouter'; -import { listenHttpServer, closeHttpServer } from '../lifecycle/httpLifecycle'; -import { AutoTlsFailure } from './AutoTlsService'; -import type { Http01Publisher } from './AcmeClient'; - -const CHALLENGE_PATH = '/.well-known/acme-challenge/'; -const TOKEN = /^[A-Za-z0-9_-]{1,256}$/; - -/** - * HTTP-01 answers while a validation is in progress (RFC 8555 §8.3). The - * server's own port serves them too, so a server already on port 80 needs no - * second listener. - */ -export class AcmeChallenges implements Http01Publisher { - private readonly answers = new Map(); - - public publish(token: string, keyAuthorization: string): void { - this.answers.set(token, keyAuthorization); - } - - public withdraw(token: string): void { - this.answers.delete(token); - } - - public routes(): HttpRoute[] { - return [{ - methods: GET_OR_HEAD, - path: CHALLENGE_PATH, - handle: (_req, res, target) => { - const token = target.pathname.slice(CHALLENGE_PATH.length); - const answer = TOKEN.test(token) ? this.answers.get(token) : undefined; - if (answer === undefined) { - sendStatus(res, 404); - return; - } - res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': Buffer.byteLength(answer) }); - res.end(answer); - }, - }]; - } - - /** - * Opens `port` only for the validation. Not being allowed to (ports below - * 1024 on Linux) and finding it taken are reported apart, since the owner - * fixes them differently. - */ - public async openPort(port: number): Promise<() => Promise> { - const server = http.createServer({ requestTimeout: 30_000, headersTimeout: 10_000 }, createHttpRequestListener(this.routes())); - try { - await listenHttpServer(server, port, '0.0.0.0'); - } catch (error) { - const code = error && typeof error === 'object' && 'code' in error ? error.code : null; - if (code === 'EACCES' || code === 'EPERM') throw new AutoTlsFailure('port-permission', `Port ${port} needs permission to open.`); - if (code === 'EADDRINUSE') throw new AutoTlsFailure('port-in-use', `Port ${port} is already in use.`); - throw new AutoTlsFailure('port-in-use', error instanceof Error ? error.message : String(error)); - } - return () => closeHttpServer(server); - } -} diff --git a/apps/server/src/infrastructure/tls/AutoTlsService.ts b/apps/server/src/infrastructure/tls/AutoTlsService.ts deleted file mode 100644 index ceff3487..00000000 --- a/apps/server/src/infrastructure/tls/AutoTlsService.ts +++ /dev/null @@ -1,300 +0,0 @@ -import fs from 'node:fs'; -import tls from 'node:tls'; -import type { AutoTlsErrorCode, AutoTlsStatus } from '@monky/shared'; -import { Logger } from '../logger/Logger'; -import { isPublicUnicastAddress } from '../network/ReachabilityProber'; -import { CertificateStore, inspectCertificate, type AutoTlsSettings, type StoredCertificate } from './CertificateStore'; - -const MINUTE = 60_000; -const HOUR = 60 * MINUTE; -const DAY = 24 * HOUR; - -/** A failure each surface can explain by its code; `detail` only goes to logs. */ -export class AutoTlsFailure extends Error { - constructor(public readonly code: AutoTlsErrorCode, detail?: string) { - super(detail ?? code); - this.name = 'AutoTlsFailure'; - } -} - -export interface IssuedCertificate { - certificatePem: string; - privateKeyPem: string; -} - -/** Talks to the CA. Every failure is an `AutoTlsFailure`. */ -export interface CertificateIssuer { - /** Proves the CA can reach this host on `ip` without keeping a certificate. */ - precheck(ip: string, signal: AbortSignal): Promise; - issue(ip: string, signal: AbortSignal): Promise; -} - -export interface AutoTlsServiceOptions { - store: CertificateStore; - issuer: CertificateIssuer; - publicIp: () => Promise; - /** Installs the context new connections use; `null` leaves only `ws://`. */ - install: (context: tls.SecureContext | null) => void; - /** A test CA validates on loopback or a LAN; Let's Encrypt only ever reaches public addresses. */ - allowNonPublicAddress?: boolean; - /** Lifts the system's restriction on the validation port; called only with the owner's consent. */ - grantPortPermission?: () => Promise; -} - -export interface AutoTlsEnableOptions { - /** The owner agreed to let the server lift the port restriction itself (`sudo -n`). */ - grantPortPermission?: boolean; -} - -/** - * Keeps a certificate for the server's public IP (#860): issues it when the - * owner turns the feature on, renews it ahead of expiry, issues a new one when - * the IP changes, and swaps it in for new connections without touching the - * open ones. While it cannot, the server keeps serving `ws://` and the last - * error is kept for the owner. - */ -export class AutoTlsService { - /** Let's Encrypt `shortlived` certificates last about 6 days; renewal starts with 2 to spare. */ - static RENEW_BEFORE_MS = 2 * DAY; - static IP_CHECK_INTERVAL_MS = HOUR; - static RETRY_DELAYS_MS: readonly number[] = [5 * MINUTE, 30 * MINUTE, 2 * HOUR, 6 * HOUR]; - - private settings: AutoTlsSettings; - private certificate: StoredCertificate | null; - private installed = false; - private busy = false; - private failures = 0; - private timer: NodeJS.Timeout | null = null; - private queue: Promise = Promise.resolve(); - private readonly abort = new AbortController(); - private readonly listeners = new Set<(status: AutoTlsStatus) => void>(); - - constructor(private readonly options: AutoTlsServiceOptions) { - this.settings = options.store.readSettings(); - this.certificate = this.settings.enabled ? options.store.readCertificate() : null; - } - - /** Installs a saved certificate that is still valid, then checks it in the background. */ - public start(): void { - if (!this.restore()) return; - this.schedule(0); - } - - /** Installs a saved certificate that is still valid, without checking anything; false when the feature is off. */ - public restore(): boolean { - if (!this.settings.enabled || this.abort.signal.aborted) return false; - const certificate = this.certificate; - if (certificate && Date.now() < certificate.notAfter) this.installCertificate(certificate); - return true; - } - - public status(): AutoTlsStatus { - const certificate = this.installed ? this.certificate : null; - return { - enabled: this.settings.enabled, - active: certificate !== null, - busy: this.busy, - ip: certificate?.ip ?? null, - validUntil: certificate?.notAfter ?? null, - nextRenewalAt: certificate ? AutoTlsService.renewalTime(certificate) : null, - lastError: this.settings.lastError, - }; - } - - /** Address new `wss://` connections are valid for, if any. */ - public secureAddress(): string | null { - return this.installed ? this.certificate?.ip ?? null : null; - } - - public subscribe(listener: (status: AutoTlsStatus) => void): () => void { - this.listeners.add(listener); - return () => { this.listeners.delete(listener); }; - } - - /** - * Turns the feature on only after the CA proved it can reach this host and - * a certificate was issued. A failure rejects with an `AutoTlsFailure` and - * leaves the feature off. `port-permission` may be retried with the owner's - * consent to `grantPortPermission`. - */ - public enable(options: AutoTlsEnableOptions = {}): Promise { - return this.exclusive(async () => { - const signal = this.abort.signal; - const ip = await this.resolveIp(); - try { - await this.options.issuer.precheck(ip, signal); - } catch (error) { - const grant = this.options.grantPortPermission; - if (!(error instanceof AutoTlsFailure) || error.code !== 'port-permission' || !options.grantPortPermission || !grant) throw error; - await grant(); - await this.options.issuer.precheck(ip, signal); - } - const issued = await this.options.issuer.issue(ip, signal); - this.throwIfDisposed(); - this.adopt(ip, issued); - this.saveSettings({ enabled: true, lastError: null }); - this.failures = 0; - Logger.info('NETWORK', `Automatic TLS enabled for ${ip}.`); - this.scheduleNext(); - }).then(() => this.status()); - } - - public disable(): Promise { - return this.exclusive(async () => { - this.cancelTimer(); - this.saveSettings({ enabled: false, lastError: null }); - this.uninstall(); - this.certificate = null; - this.failures = 0; - fs.rmSync(this.options.store.file('certificate.json'), { force: true }); - Logger.info('NETWORK', 'Automatic TLS disabled; the server accepts ws:// only.'); - }).then(() => this.status()); - } - - /** Idempotent: stops the timer and abandons work in flight. */ - public dispose(): void { - if (this.abort.signal.aborted) return; - this.abort.abort(); - this.cancelTimer(); - this.listeners.clear(); - } - - private static renewalTime(certificate: StoredCertificate): number { - return certificate.notAfter - AutoTlsService.RENEW_BEFORE_MS; - } - - private exclusive(work: () => Promise): Promise { - const run = this.queue.then(async () => { - this.throwIfDisposed(); - this.busy = true; - this.notify(); - try { - return await work(); - } finally { - this.busy = false; - this.notify(); - } - }); - this.queue = run.catch(() => undefined); - return run; - } - - private throwIfDisposed(): void { - if (this.abort.signal.aborted) throw new AutoTlsFailure('network', 'The server is stopping.'); - } - - private async resolveIp(): Promise { - const ip = await this.options.publicIp(); - if (!ip) throw new AutoTlsFailure('no-public-ip'); - if (!this.options.allowNonPublicAddress && !isPublicUnicastAddress(ip)) throw new AutoTlsFailure('address-not-public', ip); - return ip; - } - - /** Saves the new pair before it replaces the one in use. */ - private adopt(ip: string, issued: IssuedCertificate): void { - let certificate: StoredCertificate; - try { - certificate = inspectCertificate(ip, issued.certificatePem, issued.privateKeyPem); - this.options.store.writeCertificate(certificate); - } catch (error) { - throw new AutoTlsFailure('storage', error instanceof Error ? error.message : String(error)); - } - this.certificate = certificate; - this.installCertificate(certificate); - } - - private installCertificate(certificate: StoredCertificate): void { - this.options.install(tls.createSecureContext({ cert: certificate.certificatePem, key: certificate.privateKeyPem })); - this.installed = true; - } - - private uninstall(): void { - if (!this.installed) return; - this.options.install(null); - this.installed = false; - } - - private saveSettings(settings: AutoTlsSettings): void { - this.settings = settings; - try { - this.options.store.writeSettings(settings); - } catch (error) { - Logger.warn('NETWORK', 'Could not save the automatic TLS settings.', error); - } - } - - private schedule(delayMs: number): void { - this.cancelTimer(); - if (this.abort.signal.aborted) return; - this.timer = setTimeout(() => { - this.timer = null; - void this.exclusive(() => this.refresh()).catch((error: unknown) => { - if (!this.abort.signal.aborted) Logger.error('NETWORK', 'Automatic TLS check failed unexpectedly.', error); - }); - }, Math.max(0, delayMs)); - this.timer.unref?.(); - } - - private cancelTimer(): void { - if (this.timer) clearTimeout(this.timer); - this.timer = null; - } - - /** Wakes for the renewal, the hourly IP check or the expiry, whichever comes first. */ - private scheduleNext(): void { - const now = Date.now(); - const certificate = this.certificate; - const wake = certificate - ? Math.min(AutoTlsService.renewalTime(certificate), certificate.notAfter, now + AutoTlsService.IP_CHECK_INTERVAL_MS) - : now + AutoTlsService.IP_CHECK_INTERVAL_MS; - this.schedule(wake - now); - } - - private async refresh(): Promise { - if (!this.settings.enabled) return; - try { - const ip = await this.resolveIp(); - const current = this.certificate; - if (!current || current.ip !== ip || Date.now() >= AutoTlsService.renewalTime(current)) { - if (current && current.ip !== ip) Logger.info('NETWORK', `Public IP changed from ${current.ip} to ${ip}; issuing a new certificate.`); - const issued = await this.options.issuer.issue(ip, this.abort.signal); - this.throwIfDisposed(); - this.adopt(ip, issued); - Logger.info('NETWORK', `TLS certificate for ${ip} renewed.`); - } - this.failures = 0; - if (this.settings.lastError) this.saveSettings({ enabled: true, lastError: null }); - this.scheduleNext(); - } catch (error) { - if (this.abort.signal.aborted) return; - this.recordFailure(error); - } - } - - private recordFailure(error: unknown): void { - const failure = error instanceof AutoTlsFailure - ? error - : new AutoTlsFailure('network', error instanceof Error ? error.message : String(error)); - this.saveSettings({ enabled: true, lastError: { code: failure.code, detail: failure.message, at: Date.now() } }); - const certificate = this.certificate; - const now = Date.now(); - if (certificate && now >= certificate.notAfter) this.uninstall(); - const delays = AutoTlsService.RETRY_DELAYS_MS; - const retry = delays[Math.min(this.failures, delays.length - 1)]; - this.failures += 1; - const untilExpiry = certificate && this.installed ? certificate.notAfter - now : Infinity; - Logger.warn('NETWORK', `Could not renew the TLS certificate (${failure.code}); ws:// keeps working. Retrying in ${Math.round(retry / MINUTE)} min.`, failure.message); - this.schedule(Math.min(retry, untilExpiry)); - } - - private notify(): void { - const status = this.status(); - for (const listener of this.listeners) { - try { - listener(status); - } catch (error) { - Logger.warn('NETWORK', 'An automatic TLS status listener failed.', error); - } - } - } -} diff --git a/apps/server/src/infrastructure/tls/CertificateStore.ts b/apps/server/src/infrastructure/tls/CertificateStore.ts deleted file mode 100644 index def4ffa6..00000000 --- a/apps/server/src/infrastructure/tls/CertificateStore.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { X509Certificate, createPrivateKey } from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { isAutoTlsErrorCode, type AutoTlsError } from '@monky/shared'; -import { writeAtomically } from '../lifecycle/serverLock'; - -export interface StoredCertificate { - ip: string; - /** Leaf first, then the intermediates, as the CA delivered the chain. */ - certificatePem: string; - privateKeyPem: string; - notAfter: number; -} - -export interface AutoTlsSettings { - enabled: boolean; - lastError: AutoTlsError | null; -} - -const DEFAULT_SETTINGS: AutoTlsSettings = { enabled: false, lastError: null }; - -/** - * Reads the leaf's expiry and refuses a pair that does not belong together or - * does not cover `ip`: installing it would break every `wss://` connection. - */ -export function inspectCertificate(ip: string, certificatePem: string, privateKeyPem: string): StoredCertificate { - const leaf = new X509Certificate(certificatePem); - if (!leaf.checkPrivateKey(createPrivateKey(privateKeyPem))) throw new Error('The certificate does not match its private key.'); - if (leaf.checkIP(ip) === undefined) throw new Error(`The certificate does not cover ${ip}.`); - const notAfter = Date.parse(leaf.validTo); - if (!Number.isFinite(notAfter)) throw new Error('The certificate expiry could not be read.'); - return { ip, certificatePem, privateKeyPem, notAfter }; -} - -/** - * Everything automatic TLS keeps under `/tls`. The certificate and - * its key share one file written atomically, so a crash never leaves a new - * certificate beside an old key, and the previous pair stays in place until - * the new one is fully on disk. - */ -export class CertificateStore { - public readonly directory: string; - - constructor(dataDir: string) { - this.directory = path.join(dataDir, 'tls'); - } - - public file(name: string): string { - return path.join(this.directory, name); - } - - public readSettings(): AutoTlsSettings { - const value = this.readJson('settings.json'); - if (!value || typeof value !== 'object') return { ...DEFAULT_SETTINGS }; - const enabled = 'enabled' in value && value.enabled === true; - const lastError = 'lastError' in value ? parseError(value.lastError) : null; - return { enabled, lastError }; - } - - public writeSettings(settings: AutoTlsSettings): void { - this.writeJson('settings.json', settings); - } - - /** The saved certificate, or null when there is none or it no longer makes sense. */ - public readCertificate(): StoredCertificate | null { - const value = this.readJson('certificate.json'); - if (!value || typeof value !== 'object') return null; - if (!('ip' in value) || typeof value.ip !== 'string') return null; - if (!('certificatePem' in value) || typeof value.certificatePem !== 'string') return null; - if (!('privateKeyPem' in value) || typeof value.privateKeyPem !== 'string') return null; - try { - return inspectCertificate(value.ip, value.certificatePem, value.privateKeyPem); - } catch { - return null; - } - } - - public writeCertificate(certificate: StoredCertificate): void { - this.writeJson('certificate.json', { - ip: certificate.ip, - certificatePem: certificate.certificatePem, - privateKeyPem: certificate.privateKeyPem, - }); - } - - public readJson(name: string): unknown { - try { - return JSON.parse(fs.readFileSync(this.file(name), 'utf8')); - } catch { - return null; - } - } - - public writeJson(name: string, value: unknown): void { - fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 }); - writeAtomically(this.file(name), `${JSON.stringify(value, null, 2)}\n`); - } -} - -function parseError(value: unknown): AutoTlsError | null { - if (!value || typeof value !== 'object') return null; - if (!('code' in value) || !isAutoTlsErrorCode(value.code)) return null; - if (!('at' in value) || typeof value.at !== 'number') return null; - const detail = 'detail' in value && typeof value.detail === 'string' ? value.detail : null; - return { code: value.code, detail, at: value.at }; -} diff --git a/apps/server/src/infrastructure/tls/sharedTls.ts b/apps/server/src/infrastructure/tls/sharedTls.ts new file mode 100644 index 00000000..dc033666 --- /dev/null +++ b/apps/server/src/infrastructure/tls/sharedTls.ts @@ -0,0 +1,35 @@ +import path from 'node:path'; +import { CertificateStore, TlsSettingsStore, type AutoTlsServiceOptions, type TlsLogger } from '@monky/shared/dist/tls'; +import { GET_OR_HEAD, sendStatus, type HttpRoute } from '../http/HttpRouter'; +import { Logger } from '../logger/Logger'; +import type { AcmeChallenges } from '@monky/shared/dist/tls'; + +const ACME_CHALLENGE_PATH = '/.well-known/acme-challenge/'; + +const serverTlsLogger: TlsLogger = { + info: (message, meta) => Logger.info('NETWORK', message, meta), + warn: (message, meta) => Logger.warn('NETWORK', message, meta), + error: (message, meta) => Logger.error('NETWORK', message, meta), +}; + +/** The certificate is shared by the machine; only the opt-in stays in this server's data folder. */ +export function serverTlsStorage(dataDir: string): Pick { + const own = path.join(dataDir, 'tls'); + return { store: new CertificateStore(), settings: new TlsSettingsStore(own), legacyDirectory: own, log: serverTlsLogger }; +} + +export function acmeRoutes(challenges: AcmeChallenges): HttpRoute[] { + return [{ + methods: GET_OR_HEAD, + path: ACME_CHALLENGE_PATH, + handle: (_req, res, target) => { + const answer = challenges.answer(target.pathname); + if (answer === undefined) { + sendStatus(res, 404); + return; + } + res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': Buffer.byteLength(answer) }); + res.end(answer); + }, + }]; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dd69bdbf..ac68ab38 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -67,11 +67,9 @@ import { publicRoutes } from './infrastructure/http/publicRoutes'; import { attachmentRoutes } from './infrastructure/http/attachmentRoutes'; import { resolveServerVersion } from './runtimeVersion'; import { describeServerInvite } from './infrastructure/discovery/inviteInfo'; -import { AcmeChallenges } from './infrastructure/tls/AcmeChallenges'; -import { AutoTlsService, type AutoTlsEnableOptions } from './infrastructure/tls/AutoTlsService'; -import { CertificateStore } from './infrastructure/tls/CertificateStore'; +import { AcmeChallenges, AutoTlsService, LetsEncryptIssuer, acmeTestOverrides, allowValidationPort, type AutoTlsEnableOptions } from '@monky/shared/dist/tls'; import { DualProtocolListener } from './infrastructure/tls/DualProtocolListener'; -import { LetsEncryptIssuer, acmeTestOverrides, allowValidationPort } from './infrastructure/tls/LetsEncryptIssuer'; +import { acmeRoutes, serverTlsStorage } from './infrastructure/tls/sharedTls'; export interface ServerConfig { port: number; @@ -459,7 +457,7 @@ export class MonkyServer { // because Node derives it from requestTimeout and would turn it off as well (S-WS-11). const httpServer = http.createServer({ requestTimeout: 0, headersTimeout: 60_000 }, createHttpRequestListener([ ...publicRoutes({ avatars: avatarStorage, preview: describePreview, inviteInfo: describeInvite }), - ...acmeChallenges.routes(), + ...acmeRoutes(acmeChallenges), ...attachmentRoutes({ attachments: attachmentService, storage: attachmentStorage, @@ -472,11 +470,11 @@ export class MonkyServer { // Plain and TLS connections share the port (#860); it stops accepting before the HTTP side drains. const listener = new DualProtocolListener(httpServer); resources.defer('TLS listener', () => listener.close()); - const tlsStore = new CertificateStore(config.dataDir); + const tlsStorage = serverTlsStorage(config.dataDir); const autoTls = new AutoTlsService({ - store: tlsStore, + ...tlsStorage, issuer: new LetsEncryptIssuer({ - store: tlsStore, + store: tlsStorage.store, challenges: acmeChallenges, serverPort: config.port, directoryOverride: acmeOverrides.directoryOverride, diff --git a/apps/server/src/test-auto-tls.ts b/apps/server/src/test-auto-tls.ts index 7d8b6293..3897c40b 100644 --- a/apps/server/src/test-auto-tls.ts +++ b/apps/server/src/test-auto-tls.ts @@ -13,14 +13,10 @@ import type { AutoTlsStatus } from '@monky/shared'; import { MonkyServer } from './server'; import { Logger } from './infrastructure/logger/Logger'; import { closeHttpServer, listenHttpServer } from './infrastructure/lifecycle/httpLifecycle'; -import { AcmeChallenges } from './infrastructure/tls/AcmeChallenges'; -import { AcmeClient } from './infrastructure/tls/AcmeClient'; -import { AutoTlsFailure, AutoTlsService, type CertificateIssuer, type IssuedCertificate } from './infrastructure/tls/AutoTlsService'; -import { CertificateStore } from './infrastructure/tls/CertificateStore'; -import { createIpCsr } from './infrastructure/tls/Csr'; -import { ipAddressBytes } from './infrastructure/tls/der'; +import { AcmeChallenges, AcmeClient, AutoTlsFailure, AutoTlsService, CertificateStore, FileLock, LetsEncryptIssuer, TlsSettingsStore, inspectCertificate, type CertificateIssuer, type IssuedCertificate } from '@monky/shared/dist/tls'; +import { createIpCsr } from '@monky/shared/dist/tls/Csr'; +import { ipAddressBytes } from '@monky/shared/dist/tls/der'; import { DualProtocolListener } from './infrastructure/tls/DualProtocolListener'; -import { LetsEncryptIssuer } from './infrastructure/tls/LetsEncryptIssuer'; import { createTestCertificate, readCsr } from './testFixtures/certificates'; const DAY = 24 * 60 * 60 * 1000; @@ -411,15 +407,18 @@ class FakeIssuer implements CertificateIssuer { function service(t: TestContext, directory: string, issuer: FakeIssuer, ip: { value: string | null }) { const installs: Array = []; const grants: number[] = []; + const store = new CertificateStore(path.join(directory, 'shared')); const instance = new AutoTlsService({ - store: new CertificateStore(directory), + store, + settings: new TlsSettingsStore(path.join(directory, 'tls')), + legacyDirectory: path.join(directory, 'tls'), issuer, publicIp: async () => ip.value, install: (context) => installs.push(context), grantPortPermission: async () => { grants.push(Date.now()); }, }); t.after(() => instance.dispose()); - return { instance, installs, grants }; + return { instance, installs, grants, store }; } test('automatic TLS turns on only after the CA proved it reaches the host', async (t) => { @@ -458,7 +457,7 @@ test('automatic TLS turns on only after the CA proved it reaches the host', asyn assert.equal(disabled.enabled, false); assert.equal(disabled.active, false); assert.equal(installs.at(-1), null, 'turning it off leaves ws:// only'); - assert.equal(fs.existsSync(path.join(directory, 'tls', 'certificate.json')), false); + assert.equal(fs.existsSync(path.join(directory, 'shared', 'certificate.json')), true, 'the certificate stays shared for the others'); ip.value = '192.168.0.10'; await assert.rejects(instance.enable(), (error) => error instanceof AutoTlsFailure && error.code === 'address-not-public'); @@ -550,6 +549,155 @@ test('a restarted server serves its saved certificate at once', async (t) => { // --------------------------------------------------------------------------- +function savedCertificate(ip: string, notAfterFromNow: number) { + const issued = createTestCertificate({ ip, notBefore: Date.now() - 60_000, notAfter: Date.now() + notAfterFromNow }); + return inspectCertificate(ip, issued.certificatePem, issued.privateKeyPem); +} + +test('shared certificate: a valid one is reused without asking the CA', async (t) => { + const directory = temporaryDirectory(t); + const ip: { value: string | null } = { value: PUBLIC_IP }; + const { instance, installs, store } = service(t, directory, new FakeIssuer(), ip); + const shared = savedCertificate(PUBLIC_IP, 5 * DAY); + store.writeCertificate(shared); + const issuer = new FakeIssuer(); + const other = service(t, directory, issuer, ip); + + const status = await other.instance.enable(); + assert.equal(other.instance.reusedSharedCertificate, true); + assert.equal(status.active, true); + assert.equal(status.validUntil, shared.notAfter); + assert.deepEqual([issuer.prechecks, issuer.issues], [[], []]); + assert.ok(other.installs.at(-1)); + assert.equal(installs.length, 0); + assert.equal(instance.status().enabled, false, 'each process opts in on its own'); +}); + +test('shared certificate: issued once when absent, then every process reuses it', async (t) => { + const directory = temporaryDirectory(t); + const ip: { value: string | null } = { value: PUBLIC_IP }; + const first = new FakeIssuer(); + const a = service(t, directory, first, ip); + await a.instance.enable(); + assert.equal(a.instance.reusedSharedCertificate, false); + assert.deepEqual([first.prechecks.length, first.issues.length], [1, 1]); + assert.equal(a.store.findUsable(PUBLIC_IP)?.ip, PUBLIC_IP); + + const second = new FakeIssuer(); + const b = service(t, path.join(directory, 'other-process'), second, ip); + const sharedStore = new CertificateStore(path.join(directory, 'shared')); + assert.ok(sharedStore.findUsable(PUBLIC_IP)); + const c = new AutoTlsService({ + store: sharedStore, settings: new TlsSettingsStore(path.join(directory, 'another-tls')), + issuer: second, publicIp: async () => ip.value, install: () => {}, + }); + t.after(() => c.dispose()); + await c.enable(); + assert.equal(c.reusedSharedCertificate, true); + assert.deepEqual(second.issues, []); + assert.equal(b.instance.status().enabled, false); +}); + +test('shared certificate: an expired one or one for another IP is not trusted', async (t) => { + const directory = temporaryDirectory(t); + const ip: { value: string | null } = { value: PUBLIC_IP }; + const issuer = new FakeIssuer(); + const { instance, store } = service(t, directory, issuer, ip); + + store.writeCertificate(savedCertificate(PUBLIC_IP, -1000)); + assert.equal(store.findUsable(PUBLIC_IP), null, 'expired'); + store.writeCertificate(savedCertificate('8.8.8.8', 5 * DAY)); + assert.equal(store.findUsable(PUBLIC_IP), null, 'another IP'); + + await instance.enable(); + assert.equal(instance.reusedSharedCertificate, false); + assert.deepEqual(issuer.issues, [PUBLIC_IP]); + assert.equal(store.findUsable(PUBLIC_IP)?.ip, PUBLIC_IP, 'the new certificate replaces the stale one'); + + const damaged = savedCertificate(PUBLIC_IP, 5 * DAY); + fs.writeFileSync(store.file('certificate.json'), JSON.stringify({ ip: PUBLIC_IP, certificatePem: damaged.certificatePem, privateKeyPem: 'nope' })); + assert.equal(store.readCertificate(), null, 'a mismatched pair is ignored'); +}); + +test('shared certificate: a server\'s own certificate from before sharing is adopted once', async (t) => { + const directory = temporaryDirectory(t); + const ip: { value: string | null } = { value: PUBLIC_IP }; + const legacy = savedCertificate(PUBLIC_IP, 5 * DAY); + fs.mkdirSync(path.join(directory, 'tls'), { recursive: true }); + fs.writeFileSync(path.join(directory, 'tls', 'certificate.json'), JSON.stringify(legacy)); + fs.writeFileSync(path.join(directory, 'tls', 'settings.json'), JSON.stringify({ enabled: true, lastError: null })); + + const issuer = new FakeIssuer(); + const { instance, installs, store } = service(t, directory, issuer, ip); + assert.equal(store.readCertificate()?.certificatePem, legacy.certificatePem, 'copied into the shared folder'); + instance.start(); + assert.equal(instance.status().active, true, 'the opt-in is kept'); + assert.ok(installs[0]); + await flush(); + assert.deepEqual(issuer.issues, []); + + await instance.disable(); + assert.equal(fs.existsSync(path.join(directory, 'tls', 'certificate.json')), false, 'the per-server copy is gone'); + assert.ok(store.readCertificate(), 'the shared one remains'); + + const newer = savedCertificate(PUBLIC_IP, 6 * DAY); + store.writeCertificate(newer); + fs.writeFileSync(path.join(directory, 'tls', 'certificate.json'), JSON.stringify(legacy)); + const again = service(t, directory, issuer, ip); + assert.equal(again.store.readCertificate()?.certificatePem, newer.certificatePem, 'a valid shared certificate is not overwritten'); +}); + +test('shared certificate: while another process renews, this one waits and picks up the result', async (t) => { + quiet(t); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: Date.UTC(2030, 0, 1) }); + const directory = temporaryDirectory(t); + const ip: { value: string | null } = { value: PUBLIC_IP }; + const issuer = new FakeIssuer(); + const { instance, installs, store } = service(t, directory, issuer, ip); + const enabled = await instance.enable(); + assert.equal(issuer.issues.length, 1); + const installsBefore = installs.length; + + t.mock.timers.setTime(enabled.nextRenewalAt!); + const otherProcess = new FileLock(store.file('renew.lock')); + const release = otherProcess.tryAcquire(); + assert.ok(release, 'another process is renewing'); + + t.mock.timers.tick(0); + await flush(); + assert.equal(issuer.issues.length, 1, 'the lock keeps this process from renewing too'); + assert.equal(instance.status().lastError, null, 'waiting is not a failure'); + + const renewed = savedCertificate(PUBLIC_IP, 6 * DAY); + store.writeCertificate(renewed); + release(); + instance.syncFromDisk(); + assert.equal(instance.status().validUntil, renewed.notAfter, 'the renewed certificate is served'); + assert.equal(installs.length, installsBefore + 1); + assert.equal(issuer.issues.length, 1); + + t.mock.timers.tick(AutoTlsService.LOCK_RETRY_MS); + await flush(); + assert.equal(issuer.issues.length, 1, 'nothing left to renew'); +}); + +test('shared certificate: the lock is released after issuing and a dead holder does not block', async (t) => { + const directory = temporaryDirectory(t); + const lock = new FileLock(path.join(directory, 'renew.lock')); + const first = lock.tryAcquire(); + assert.ok(first); + assert.equal(new FileLock(path.join(directory, 'renew.lock')).tryAcquire(), null); + first(); + const second = lock.tryAcquire(); + assert.ok(second); + second(); + + fs.writeFileSync(path.join(directory, 'renew.lock'), JSON.stringify({ id: 'x', pid: 2 ** 22 - 3, at: Date.now() })); + const taken = new FileLock(path.join(directory, 'renew.lock')).tryAcquire(); + assert.ok(taken, 'a lock whose process is gone is taken over'); + taken(); +}); + test('a hosted server turns on wss:// on its own port and the invitation marks the secure address', async (t) => { quiet(t); const validationPort = await freePort(); @@ -566,6 +714,8 @@ test('a hosted server turns on wss:// on its own port and the invitation marks t process.env.MONKY_ACME_PUBLIC_IP = PUBLIC_IP; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'monky-auto-tls-server-')); + process.env.MONKY_TLS_DIR = path.join(dataDir, 'shared-tls'); + t.after(() => { delete process.env.MONKY_TLS_DIR; }); const server = await MonkyServer.create({ port: 0, dataDir, serverName: 'TLS fixture' }); t.after(async () => { await server.stop(); @@ -583,7 +733,7 @@ test('a hosted server turns on wss:// on its own port and the invitation marks t assert.equal(status.active, true); assert.deepEqual(ca.orders.map((order) => order.profile), ['shortlived', 'shortlived'], 'staging precheck, then the real one'); - const certificate = JSON.parse(fs.readFileSync(path.join(dataDir, 'tls', 'certificate.json'), 'utf8')) as { certificatePem: string }; + const certificate = JSON.parse(fs.readFileSync(path.join(dataDir, 'shared-tls', 'certificate.json'), 'utf8')) as { certificatePem: string }; // The certificate names the stand-in public IP, so only the chain is checked over loopback. const handshake = tls.connect({ host: '127.0.0.1', port, ca: certificate.certificatePem, checkServerIdentity: () => undefined }); await once(handshake, 'secureConnect'); diff --git a/apps/server/src/testFixtures/certificates.ts b/apps/server/src/testFixtures/certificates.ts index fa5d9210..9d118f21 100644 --- a/apps/server/src/testFixtures/certificates.ts +++ b/apps/server/src/testFixtures/certificates.ts @@ -1,7 +1,7 @@ import { generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto'; import { ipAddressBytes, objectIdentifier, octetString, sequence, set, smallInteger, tlv, bitString, -} from '../infrastructure/tls/der'; +} from '@monky/shared/dist/tls/der'; /** Test-only X.509 and DER helpers for automatic TLS (#860). */ diff --git a/docs-site/cli.md b/docs-site/cli.md index aaeb28f3..8c899f10 100644 --- a/docs-site/cli.md +++ b/docs-site/cli.md @@ -745,6 +745,36 @@ monky tls disable # volta a aceitar só ws:// - **Não funciona** com VPN (Radmin, Hamachi, ZeroTier, Tailscale), CGNAT ou só rede local: nesses casos o app desktop continua conectando por `ws://`. - A mudança vale a partir do próximo `monky start` ou `monky restart`; com o servidor no ar, o comando oferece reiniciar. +### Certificado compartilhado entre servidor e bots + +O certificado fica numa pasta da **máquina**, `~/.monky/tls` (mude com a variável `MONKY_TLS_DIR`), e é usado por todos: o servidor e qualquer bot feito com o `@monky/bot-sdk`. Quem ativa o TLS primeiro emite o certificado; os demais, ao rodar `tls enable`, só reaproveitam o que já existe para o IP público da máquina, sem pedir outro à Let's Encrypt. Cada servidor ou bot ativa e desativa por conta própria; `tls disable` nunca apaga o certificado compartilhado. + +- **Renovação:** quem estiver com o TLS ativo renova, e um arquivo de trava (`renew.lock`) garante que só um processo renove por vez e só ele abra a porta 80. Os outros enxergam o arquivo renovado em até 1 minuto e trocam o certificado sem reiniciar. +- **Migração:** se o servidor já tinha um certificado em `/tls/certificate.json`, ele é copiado para a pasta compartilhada na primeira vez. + +### Nos bots + +Todo bot do `@monky/bot-sdk` ganha o mesmo comando (troque `` pelo nome do comando do seu bot): + +```bash + tls # mostra o estado + tls enable # reaproveita o certificado compartilhado ou emite um novo + tls disable # este bot deixa de usar TLS +``` + +No código do bot: + +```js +const https = require('node:https'); +const { createBotTls } = require('@monky/bot-sdk'); + +const tls = createBotTls({ dataDir: process.env.MONKY_BOT_HOME }); +const material = tls.material(); // { cert, key } ou null se o TLS não estiver ativo +const server = material ? https.createServer(material, handler) : http.createServer(handler); +tls.onChange(({ material }) => material && server.setSecureContext?.(material)); +tls.start(); // renova e acompanha o arquivo compartilhado +``` + --- ## `monky update` diff --git a/docs-site/en/cli.md b/docs-site/en/cli.md index f45bcbda..a6c27908 100644 --- a/docs-site/en/cli.md +++ b/docs-site/en/cli.md @@ -743,6 +743,36 @@ monky tls disable # goes back to ws:// only - **Does not work** over a VPN (Radmin, Hamachi, ZeroTier, Tailscale), CGNAT or a local network only: there the desktop app keeps connecting over `ws://`. - The change takes effect on the next `monky start` or `monky restart`; with the server running, the command offers to restart it. +### Certificate shared between server and bots + +The certificate lives in a **machine-wide** folder, `~/.monky/tls` (change it with the `MONKY_TLS_DIR` variable), and is used by everyone: the server and any bot built with `@monky/bot-sdk`. Whoever enables TLS first issues the certificate; the others, when they run `tls enable`, just reuse the one that already exists for the machine's public IP, without asking Let's Encrypt for another. Each server or bot enables and disables on its own; `tls disable` never deletes the shared certificate. + +- **Renewal:** whoever has TLS enabled renews, and a lock file (`renew.lock`) ensures only one process renews at a time and only it opens port 80. The others see the renewed file within 1 minute and swap the certificate without restarting. +- **Migration:** if the server already had a certificate at `/tls/certificate.json`, it is copied to the shared folder the first time. + +### In bots + +Every `@monky/bot-sdk` bot gets the same command (replace `` with your bot's command name): + +```bash + tls # shows the state + tls enable # reuses the shared certificate or issues a new one + tls disable # this bot stops using TLS +``` + +In the bot's code: + +```js +const https = require('node:https'); +const { createBotTls } = require('@monky/bot-sdk'); + +const tls = createBotTls({ dataDir: process.env.MONKY_BOT_HOME }); +const material = tls.material(); // { cert, key }, or null if TLS is not enabled +const server = material ? https.createServer(material, handler) : http.createServer(handler); +tls.onChange(({ material }) => material && server.setSecureContext?.(material)); +tls.start(); // renews and follows the shared file +``` + --- ## `monky update` diff --git a/packages/bot-sdk/src/cli/commands/tls.ts b/packages/bot-sdk/src/cli/commands/tls.ts new file mode 100644 index 00000000..49340e0f --- /dev/null +++ b/packages/bot-sdk/src/cli/commands/tls.ts @@ -0,0 +1,102 @@ +import { AUTO_TLS_LOW_PORT_COMMAND, AUTO_TLS_VALIDATION_PORT, type AutoTlsErrorCode, type AutoTlsStatus } from '@monky/shared'; +import { createBotTls, AutoTlsFailure } from '../../tls'; +import { ANSI, color } from '../constants'; +import type { CliContext } from '../config'; +import { CliError, cliText } from '../locale'; +import { askCliConfirm } from '../prompts'; + +function describeError(locale: CliContext['locale'], code: AutoTlsErrorCode): string { + const port = AUTO_TLS_VALIDATION_PORT; + const text = (pt: string, en: string): string => cliText(locale, pt, en); + switch (code) { + case 'no-public-ip': return text('Não foi possível descobrir o IP público desta máquina. Confira a conexão com a internet.', 'Could not find this machine\'s public IP. Check the internet connection.'); + case 'address-not-public': return text('O IP desta máquina não é público (rede local ou CGNAT); a Let\'s Encrypt só emite para IPs públicos.', 'This machine\'s IP is not public (local network or CGNAT); Let\'s Encrypt only issues for public IPs.'); + case 'port-in-use': return text(`A porta ${port} está em uso por outro programa. Libere-a e tente de novo.`, `Port ${port} is in use by another program. Free it and try again.`); + case 'port-permission': return text(`Sem permissão para abrir a porta ${port}. No Linux: ${AUTO_TLS_LOW_PORT_COMMAND}`, `No permission to open port ${port}. On Linux: ${AUTO_TLS_LOW_PORT_COMMAND}`); + case 'privilege-unavailable': return text(`Não há root nem sudo sem senha para liberar a porta ${port}. Rode: ${AUTO_TLS_LOW_PORT_COMMAND}`, `No root or passwordless sudo to allow port ${port}. Run: ${AUTO_TLS_LOW_PORT_COMMAND}`); + case 'unreachable': return text(`A Let's Encrypt não alcançou a porta ${port} deste IP. Libere-a no firewall e no roteador.`, `Let's Encrypt could not reach port ${port} on this IP. Allow it in the firewall and router.`); + case 'rate-limited': return text('A Let\'s Encrypt limitou novos pedidos. Tente de novo mais tarde.', 'Let\'s Encrypt rate-limited new requests. Try again later.'); + case 'rejected': return text('A Let\'s Encrypt recusou o pedido de certificado.', 'Let\'s Encrypt rejected the certificate request.'); + case 'network': return text('Não foi possível falar com a Let\'s Encrypt ou outro processo está emitindo o certificado. Tente de novo.', 'Could not reach Let\'s Encrypt, or another process is issuing the certificate. Try again.'); + case 'storage': return text('Não foi possível gravar o certificado na pasta compartilhada (~/.monky/tls ou MONKY_TLS_DIR).', 'Could not save the certificate in the shared folder (~/.monky/tls or MONKY_TLS_DIR).'); + } +} + +function formatDate(context: CliContext, time: number | null): string { + return time === null ? '-' : new Date(time).toLocaleString(context.locale); +} + +function printStatus(context: CliContext, status: AutoTlsStatus): void { + const text = (pt: string, en: string): string => cliText(context.locale, pt, en); + console.log(`${text('TLS automático', 'Automatic TLS')}: ${status.enabled ? color(text('ligado', 'on'), ANSI.green) : color(text('desligado', 'off'), ANSI.yellow)}`); + if (status.enabled) { + console.log(`${text('Endereço', 'Address')}: ${status.ip ? `https://${status.ip} / wss://${status.ip}` : '-'}`); + console.log(`${text('Válido até', 'Valid until')}: ${formatDate(context, status.validUntil)}`); + console.log(`${text('Próxima renovação', 'Next renewal')}: ${formatDate(context, status.nextRenewalAt)}`); + } + if (status.lastError) { + console.log(color(`${text('Último erro', 'Last error')} (${formatDate(context, status.lastError.at)}): ${describeError(context.locale, status.lastError.code)}`, ANSI.yellow)); + } +} + +function interactive(): boolean { + return !!process.stdin.isTTY && !!process.stdout.isTTY; +} + +async function enable(context: CliContext): Promise { + const text = (pt: string, en: string): string => cliText(context.locale, pt, en); + const botTls = createBotTls({ dataDir: context.homeDir }); + try { + console.log(color(text( + `Procurando um certificado compartilhado nesta máquina; se não houver, a Let's Encrypt valida a porta ${AUTO_TLS_VALIDATION_PORT} (alguns segundos)...`, + `Looking for a certificate shared on this machine; if there is none, Let's Encrypt validates port ${AUTO_TLS_VALIDATION_PORT} (a few seconds)...`), ANSI.dim)); + let status: AutoTlsStatus; + try { + status = await botTls.enable(); + } catch (error) { + if (!(error instanceof AutoTlsFailure) || error.code !== 'port-permission' || !interactive()) throw error; + if (!await askCliConfirm(context.locale, text( + `Linux só deixa abrir a porta ${AUTO_TLS_VALIDATION_PORT} com permissão (${AUTO_TLS_LOW_PORT_COMMAND}). Liberar agora com sudo?`, + `Linux only allows opening port ${AUTO_TLS_VALIDATION_PORT} with permission (${AUTO_TLS_LOW_PORT_COMMAND}). Allow it now with sudo?`), true)) throw error; + status = await botTls.enable({ grantPortPermission: true }); + } + console.log(color(botTls.reusedSharedCertificate + ? text('TLS ativado com o certificado já compartilhado nesta máquina.', 'TLS enabled with the certificate already shared on this machine.') + : text('TLS ativado; o certificado novo ficou compartilhado com os outros bots e o servidor desta máquina.', + 'TLS enabled; the new certificate is now shared with the other bots and the server on this machine.'), ANSI.green)); + printStatus(context, status); + console.log(color(text( + 'Um bot em execução que usa createBotTls() passa a servir o certificado em cerca de 1 minuto.', + 'A running bot that uses createBotTls() starts serving the certificate within about 1 minute.'), ANSI.dim)); + } catch (error) { + if (error instanceof AutoTlsFailure) { + throw new CliError(`Não foi possível ativar o TLS. ${describeError('pt-BR', error.code)}`, + `Could not enable TLS. ${describeError('en', error.code)}`); + } + throw error; + } finally { + botTls.dispose(); + } +} + +/** ` tls [status|enable|disable]`: automatic TLS for the bot, with the certificate shared by the machine. */ +export async function tlsCommand(context: CliContext, args: string[]): Promise { + const [action = 'status', ...extra] = args; + if (extra.length || !['status', 'enable', 'disable'].includes(action)) { + throw new CliError(`Uso: ${context.cliName} tls [status|enable|disable]`, `Usage: ${context.cliName} tls [status|enable|disable]`); + } + if (action === 'enable') return enable(context); + const botTls = createBotTls({ dataDir: context.homeDir }); + try { + if (action === 'disable') { + await botTls.disable(); + console.log(color(cliText(context.locale, + 'TLS desativado para este bot. O certificado compartilhado continua para os outros.', + 'TLS disabled for this bot. The shared certificate stays for the others.'), ANSI.green)); + return; + } + printStatus(context, botTls.status()); + } finally { + botTls.dispose(); + } +} diff --git a/packages/bot-sdk/src/cli/index.ts b/packages/bot-sdk/src/cli/index.ts index 7a94ac92..66229774 100644 --- a/packages/bot-sdk/src/cli/index.ts +++ b/packages/bot-sdk/src/cli/index.ts @@ -32,6 +32,7 @@ ${color('COMANDOS', ANSI.bold)} restart [--fresh] Reinicia usando a configuração salva status Mostra estado do processo e configuração doctor [--local] Verifica se o bot pode operar e o que falta + tls [status|enable|disable] TLS automático (https/wss) com certificado compartilhado na máquina requirements Lista portas para liberar e configurações do bot consent [--accept FP|--revoke] Revisa a autorização de quem hospeda logs [--lines N] [--no-follow] @@ -80,6 +81,7 @@ ${color('COMMANDS', ANSI.bold)} restart [--fresh] Restart using the saved configuration status Show the process state and configuration doctor [--local] Check whether the bot can operate and what is missing + tls [status|enable|disable] Automatic TLS (https/wss) with a certificate shared on this machine requirements List the bot's ports to allow and its settings consent [--accept FP|--revoke] Review the host operator authorization logs [--lines N] [--no-follow] @@ -193,6 +195,11 @@ async function dispatchBotCli(context: CliContext, args: string[], explicitLocal await doctorCommand(context, rest); return; } + if (command === 'tls') { + const { tlsCommand } = await import('./commands/tls'); + await tlsCommand(context, rest); + return; + } if (command === 'requirements') { const [{ requirementsCommand }, { readConfig }] = await Promise.all([import('./requirementsView'), import('./config')]); requirementsCommand(context, rest, readConfig(context)); diff --git a/packages/bot-sdk/src/cli/menu.ts b/packages/bot-sdk/src/cli/menu.ts index a58e3f52..807bcd62 100644 --- a/packages/bot-sdk/src/cli/menu.ts +++ b/packages/bot-sdk/src/cli/menu.ts @@ -4,6 +4,7 @@ import { askCliChoice, askCliText, askCliValue, type CliChoice } from './prompts import { CliError, cliText, languageCommand } from './locale'; import { configCommand, logsCommand, restartCommand, startCommand, statusCommand, stopCommand } from './commands/lifecycle'; import { setupCommand } from './commands/setup'; +import { tlsCommand } from './commands/tls'; import { autoUpdateCommand, updateCommand } from './commands/update'; import { consentCommand } from './consent'; import { configEnvCommand, declaredVariables, printDeclaredVariables, readProfileEnvironment } from './profileEnvironment'; @@ -158,6 +159,24 @@ export async function configurationMenu(context: CliContext): Promise { } } +async function tlsMenu(context: CliContext): Promise { + while (true) { + await tlsCommand(context, ['status']); + const action = await askCliChoice(context.locale, cliText(context.locale, 'TLS automático', 'Automatic TLS'), [ + choice(context, 'enable', 'Ativar (reusa ou emite o certificado compartilhado)', 'Enable (reuses or issues the shared certificate)'), + choice(context, 'disable', 'Desativar para este bot', 'Disable for this bot'), + choice(context, 'back', 'Voltar', 'Back'), + ]); + if (action === 'back') return; + try { + await tlsCommand(context, [action]); + } catch (error: unknown) { + if (!(error instanceof CliError)) throw error; + console.error(cliText(context.locale, error.portuguese, error.english)); + } + } +} + export async function botCliMenu(context: CliContext): Promise { while (true) { const action = await askCliChoice(context.locale, context.displayName, [ @@ -166,6 +185,7 @@ export async function botCliMenu(context: CliContext): Promise { choice(context, 'status', 'Estado do bot', 'Bot status'), choice(context, 'logs', 'Logs recentes', 'Recent logs'), choice(context, 'doctor', 'Verificar se o bot pode operar (doctor)', 'Check whether the bot can operate (doctor)'), choice(context, 'requirements', 'Portas e configurações necessárias', 'Required ports and settings'), + choice(context, 'tls', 'TLS automático (https/wss)', 'Automatic TLS (https/wss)'), choice(context, 'config', 'Configuração', 'Configuration'), choice(context, 'language', 'Idioma / Language', 'Idioma / Language'), choice(context, 'exit', 'Sair do menu (mantém o bot)', 'Exit menu (keep the bot running)'), ]); @@ -185,6 +205,7 @@ export async function botCliMenu(context: CliContext): Promise { console.error(cliText(context.locale, error.portuguese, error.english)); } } else if (action === 'requirements') requirementsCommand(context, [], readConfig(context)); + else if (action === 'tls') await tlsMenu(context); else if (action === 'config') await configurationMenu(context); else await languageCommand(context, []); } diff --git a/packages/bot-sdk/src/cli/runner.ts b/packages/bot-sdk/src/cli/runner.ts index 2dea13cb..3ae4ceb3 100644 --- a/packages/bot-sdk/src/cli/runner.ts +++ b/packages/bot-sdk/src/cli/runner.ts @@ -93,6 +93,7 @@ export async function runConfiguredBot(env: NodeJS.ProcessEnv = process.env): Pr process.argv[1] = entry; for (const key of plan.clear) delete process.env[key]; Object.assign(process.env, plan.values); + process.env.MONKY_BOT_TLS_STATE_DIR = path.join(homeDir, 'tls'); await dynamicImport(pathToFileURL(entry).href); } diff --git a/packages/bot-sdk/src/index.ts b/packages/bot-sdk/src/index.ts index 2e7342bf..0311ae6b 100644 --- a/packages/bot-sdk/src/index.ts +++ b/packages/bot-sdk/src/index.ts @@ -32,6 +32,8 @@ export type { BotDeviceStorageStatus, BotScreenStorage, BotScreenStorageEntry, B export * from './managedTools'; export * from './localExecution/contracts'; export { handleReachabilityProbe } from './reachability'; +export { createBotTls, AutoTlsFailure, sharedTlsDirectory } from './tls'; +export type { BotTls, BotTlsChange, BotTlsEnableOptions, BotTlsMaterial, BotTlsOptions, AutoTlsStatus, TlsLogger } from './tls'; import { createReachabilityIdentity, runtimeBotIdentity, signBotAuthChallenge, type BotReachabilityIdentity, } from './reachability'; diff --git a/packages/bot-sdk/src/tls.ts b/packages/bot-sdk/src/tls.ts new file mode 100644 index 00000000..bb2f4463 --- /dev/null +++ b/packages/bot-sdk/src/tls.ts @@ -0,0 +1,125 @@ +import path from 'path'; +import tls from 'tls'; +import type { AutoTlsStatus } from '@monky/shared'; +import { + AcmeChallenges, AutoTlsFailure, AutoTlsService, CertificateStore, LetsEncryptIssuer, TlsSettingsStore, + acmeTestOverrides, allowValidationPort, discoverPublicIp, sharedTlsDirectory, + type StoredCertificate, type TlsLogger, +} from '@monky/shared/dist/tls'; + +export { AutoTlsFailure, sharedTlsDirectory }; +export type { AutoTlsStatus, TlsLogger }; + +/** What `https.createServer` and `server.setSecureContext` take. */ +export interface BotTlsMaterial { + cert: string; + key: string; +} + +export interface BotTlsChange { + status: AutoTlsStatus; + /** `null` when TLS is off: serve plain connections only. */ + material: BotTlsMaterial | null; +} + +export interface BotTlsOptions { + /** + * Folder holding this bot's own opt-in (`/tls/settings.json`). Defaults to + * `MONKY_BOT_TLS_STATE_DIR` (set for bots started by the CLI), then `/.monky/tls`. + */ + dataDir?: string; + log?: TlsLogger; + /** Replaces the lookup of the machine's public IP. */ + publicIp?: () => Promise; +} + +export interface BotTlsEnableOptions { + /** Let the process lift the Linux low-port restriction itself (root or passwordless sudo). */ + grantPortPermission?: boolean; +} + +export interface BotTls { + status(): AutoTlsStatus; + /** Reuses the certificate shared on this machine, or issues one and shares it. Rejects with `AutoTlsFailure`. */ + enable(options?: BotTlsEnableOptions): Promise; + /** Turns TLS off for this bot; the shared certificate stays for the others. */ + disable(): Promise; + /** The certificate being served now, or `null` while TLS is off. */ + material(): BotTlsMaterial | null; + secureContext(): tls.SecureContext | null; + /** Loads the saved certificate, renews it ahead of expiry and follows renewals made by other processes. */ + start(): void; + /** Called when the served certificate is installed, renewed or removed. Returns the unsubscribe function. */ + onChange(listener: (change: BotTlsChange) => void): () => void; + /** The last `enable()` found a valid shared certificate and asked the CA for nothing. */ + readonly reusedSharedCertificate: boolean; + dispose(): void; +} + +function stateDirectory(options: BotTlsOptions): string { + if (options.dataDir) return path.join(options.dataDir, 'tls'); + return process.env.MONKY_BOT_TLS_STATE_DIR || path.join(process.cwd(), '.monky', 'tls'); +} + +/** + * Automatic TLS for a bot (#860): a Let's Encrypt certificate for the machine's + * public IP, shared with the other bots and the server of this machine. + * + * ```ts + * const botTls = createBotTls(); + * botTls.start(); + * const server = https.createServer(botTls.material() ?? undefined, handler); + * botTls.onChange(({ material }) => { if (material) server.setSecureContext(material); }); + * ``` + */ +export function createBotTls(options: BotTlsOptions = {}): BotTls { + const overrides = acmeTestOverrides(); + const store = new CertificateStore(); + let current: { certificate: StoredCertificate; context: tls.SecureContext } | null = null; + const listeners = new Set<(change: BotTlsChange) => void>(); + + const material = (): BotTlsMaterial | null => + current ? { cert: current.certificate.certificatePem, key: current.certificate.privateKeyPem } : null; + + const service: AutoTlsService = new AutoTlsService({ + store, + settings: new TlsSettingsStore(stateDirectory(options)), + issuer: new LetsEncryptIssuer({ + store, challenges: new AcmeChallenges(), + directoryOverride: overrides.directoryOverride, validationPort: overrides.validationPort, + }), + publicIp: options.publicIp ?? (async () => overrides.publicIp || discoverPublicIp()), + allowNonPublicAddress: !!overrides.directoryOverride, + grantPortPermission: allowValidationPort, + log: options.log, + install: (context, certificate) => { + current = context && certificate ? { certificate, context } : null; + // The service marks the certificate as installed right after this returns. + queueMicrotask(() => { + const change: BotTlsChange = { status: service.status(), material: material() }; + for (const listener of listeners) { + try { + listener(change); + } catch (error) { + options.log?.warn('A TLS change listener failed.', error); + } + } + }); + }, + }); + + return { + status: () => service.status(), + enable: (enableOptions) => service.enable(enableOptions), + disable: () => service.disable(), + material, + secureContext: () => current?.context ?? null, + start: () => service.start(), + onChange(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + get reusedSharedCertificate() { return service.reusedSharedCertificate; }, + dispose: () => { service.dispose(); listeners.clear(); }, + }; +} diff --git a/packages/bot-sdk/test/tls.shared.test.cjs b/packages/bot-sdk/test/tls.shared.test.cjs new file mode 100644 index 00000000..ec9f85f5 --- /dev/null +++ b/packages/bot-sdk/test/tls.shared.test.cjs @@ -0,0 +1,111 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const https = require('node:https'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { AutoTlsService } = require('@monky/shared/dist/tls'); +const { bitString, ipAddressBytes, objectIdentifier, octetString, sequence, set, smallInteger, tlv } = require('@monky/shared/dist/tls/der'); +const { createBotTls } = require('../dist'); + +const IP = '1.1.1.1'; +const DAY = 24 * 60 * 60 * 1000; + +function derTime(ms) { + const iso = new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(/[-:T]/g, '').replace('.000', ''); + return tlv(0x17, Buffer.from(iso.slice(2), 'ascii')); +} + +function certificateFor(ip, notAfter) { + const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const name = sequence(set(sequence(objectIdentifier('2.5.4.3'), tlv(0x0c, Buffer.from('Monky test'))))); + const algorithm = sequence(objectIdentifier('1.2.840.10045.4.3.2')); + const tbs = sequence( + tlv(0xa0, smallInteger(2)), tlv(0x02, Buffer.concat([Buffer.from([1]), crypto.randomBytes(8)])), algorithm, name, + sequence(derTime(Date.now() - 60_000), derTime(notAfter)), name, publicKey.export({ type: 'spki', format: 'der' }), + tlv(0xa3, sequence(sequence(objectIdentifier('2.5.29.17'), octetString(sequence(tlv(0x87, ipAddressBytes(ip))))))), + ); + const der = sequence(tbs, algorithm, bitString(crypto.sign('sha256', tbs, privateKey))); + const body = der.toString('base64').match(/.{1,64}/g).join('\n'); + return { + ip, + certificatePem: `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`, + privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + }; +} + +function fingerprint(certificate) { + return new crypto.X509Certificate(certificate.certificatePem).fingerprint256; +} + +function servedFingerprint(port) { + return new Promise((resolve, reject) => { + const request = https.get({ + host: '127.0.0.1', port, agent: false, rejectUnauthorized: false, checkServerIdentity: () => undefined, + }, (response) => { + resolve(response.socket.getPeerCertificate().fingerprint256); + response.resume(); + }); + request.on('error', reject); + }); +} + +test('a bot serves https with the shared certificate and hot-swaps it when another process renews it', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'monky-bot-tls-')); + const saved = { shared: process.env.MONKY_TLS_DIR, state: process.env.MONKY_BOT_TLS_STATE_DIR, poll: AutoTlsService.PICKUP_INTERVAL_MS }; + process.env.MONKY_TLS_DIR = path.join(root, 'shared'); + process.env.MONKY_BOT_TLS_STATE_DIR = path.join(root, 'state'); + AutoTlsService.PICKUP_INTERVAL_MS = 40; + fs.mkdirSync(process.env.MONKY_TLS_DIR, { recursive: true }); + t.after(() => { + for (const [key, value] of [['MONKY_TLS_DIR', saved.shared], ['MONKY_BOT_TLS_STATE_DIR', saved.state]]) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + AutoTlsService.PICKUP_INTERVAL_MS = saved.poll; + fs.rmSync(root, { recursive: true, force: true }); + }); + + const sharedFile = path.join(process.env.MONKY_TLS_DIR, 'certificate.json'); + const first = certificateFor(IP, Date.now() + 4 * DAY); + fs.writeFileSync(sharedFile, JSON.stringify(first)); + + const bot = createBotTls({ publicIp: async () => IP }); + t.after(() => bot.dispose()); + assert.equal(bot.status().enabled, false); + assert.equal(bot.material(), null, 'nothing is served until the bot opts in'); + + const changes = []; + bot.onChange((change) => changes.push(change)); + const status = await bot.enable(); + assert.equal(bot.reusedSharedCertificate, true, 'the shared certificate is reused, the CA is never asked'); + assert.equal(status.active, true); + assert.equal(status.ip, IP); + assert.ok(fs.existsSync(path.join(root, 'state', 'settings.json')), 'the opt-in lives with this bot'); + + const server = https.createServer(bot.material(), (_request, response) => response.end('ok')); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const { port } = server.address(); + const swapped = new Promise((resolve) => { + bot.onChange(({ material }) => { + if (!material) return; + server.setSecureContext(material); + if (material.cert !== first.certificatePem) resolve(material); + }); + }); + bot.start(); + + assert.equal(await servedFingerprint(port), fingerprint(first)); + + const renewed = certificateFor(IP, Date.now() + 6 * DAY); + fs.writeFileSync(sharedFile, JSON.stringify(renewed)); + await swapped; + assert.equal(await servedFingerprint(port), fingerprint(renewed), 'new connections get the renewed certificate'); + assert.ok(changes.length >= 1); + + await bot.disable(); + assert.equal(bot.material(), null); + assert.ok(fs.existsSync(sharedFile), 'turning it off here keeps the certificate for the others'); +}); diff --git a/packages/shared/src/tls/AcmeChallenges.ts b/packages/shared/src/tls/AcmeChallenges.ts new file mode 100644 index 00000000..3713440f --- /dev/null +++ b/packages/shared/src/tls/AcmeChallenges.ts @@ -0,0 +1,70 @@ +import http from 'node:http'; +import { AutoTlsFailure } from './errors'; +import type { Http01Publisher } from './AcmeClient'; + +export const ACME_CHALLENGE_PATH = '/.well-known/acme-challenge/'; +const TOKEN = /^[A-Za-z0-9_-]{1,256}$/; + +/** + * HTTP-01 answers while a validation is in progress (RFC 8555 §8.3). A server + * already on port 80 serves them through its own router; anything else opens + * the port only for the validation. + */ +export class AcmeChallenges implements Http01Publisher { + private readonly answers = new Map(); + + public publish(token: string, keyAuthorization: string): void { + this.answers.set(token, keyAuthorization); + } + + public withdraw(token: string): void { + this.answers.delete(token); + } + + /** The key authorization published for the request path, if it is a live challenge. */ + public answer(pathname: string): string | undefined { + if (!pathname.startsWith(ACME_CHALLENGE_PATH)) return undefined; + const token = pathname.slice(ACME_CHALLENGE_PATH.length); + return TOKEN.test(token) ? this.answers.get(token) : undefined; + } + + public handle(req: http.IncomingMessage, res: http.ServerResponse): void { + const answer = req.method === 'GET' || req.method === 'HEAD' + ? this.answer(new URL(req.url ?? '/', 'http://localhost').pathname) + : undefined; + if (answer === undefined) { + res.writeHead(404, { 'content-length': 0 }); + res.end(); + return; + } + res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': Buffer.byteLength(answer) }); + res.end(req.method === 'HEAD' ? undefined : answer); + } + + /** + * Opens `port` only for the validation. Not being allowed to (ports below + * 1024 on Linux) and finding it taken are reported apart, since the owner + * fixes them differently. + */ + public async openPort(port: number): Promise<() => Promise> { + const server = http.createServer({ requestTimeout: 30_000, headersTimeout: 10_000 }, (req, res) => this.handle(req, res)); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '0.0.0.0', () => { + server.off('error', reject); + resolve(); + }); + }); + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? error.code : null; + if (code === 'EACCES' || code === 'EPERM') throw new AutoTlsFailure('port-permission', `Port ${port} needs permission to open.`); + if (code === 'EADDRINUSE') throw new AutoTlsFailure('port-in-use', `Port ${port} is already in use.`); + throw new AutoTlsFailure('port-in-use', error instanceof Error ? error.message : String(error)); + } + return () => new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } +} diff --git a/apps/server/src/infrastructure/tls/AcmeClient.ts b/packages/shared/src/tls/AcmeClient.ts similarity index 96% rename from apps/server/src/infrastructure/tls/AcmeClient.ts rename to packages/shared/src/tls/AcmeClient.ts index 11af7805..ec01a6b7 100644 --- a/apps/server/src/infrastructure/tls/AcmeClient.ts +++ b/packages/shared/src/tls/AcmeClient.ts @@ -1,6 +1,6 @@ import { createHash, createPublicKey, sign, type KeyObject } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; -import { AutoTlsFailure } from './AutoTlsService'; +import { AutoTlsFailure } from './errors'; /** * A minimal ACME client (RFC 8555) for one job: a certificate for one IP @@ -279,16 +279,24 @@ export class AcmeClient { private async request(url: string, init: RequestInit): Promise { let response: Response; let text: string; + const controller = new AbortController(); + const abort = (): void => controller.abort(this.options.signal.reason); + if (this.options.signal.aborted) abort(); + this.options.signal.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => controller.abort(new Error('The ACME request timed out.')), 30_000); try { response = await fetch(url, { ...init, headers: { 'user-agent': 'Monky-ACME/1', ...init.headers }, - signal: AbortSignal.any([this.options.signal, AbortSignal.timeout(30_000)]), + signal: controller.signal, }); text = init.method === 'HEAD' ? '' : await response.text(); } catch (error) { if (this.options.signal.aborted) throw error; throw new AutoTlsFailure('network', error instanceof Error ? error.message : String(error)); + } finally { + clearTimeout(timer); + this.options.signal.removeEventListener('abort', abort); } const nonce = response.headers.get('replay-nonce'); if (nonce) this.nonce = nonce; diff --git a/packages/shared/src/tls/AutoTlsService.ts b/packages/shared/src/tls/AutoTlsService.ts new file mode 100644 index 00000000..5e6ed816 --- /dev/null +++ b/packages/shared/src/tls/AutoTlsService.ts @@ -0,0 +1,424 @@ +import tls from 'node:tls'; +import type { AutoTlsErrorCode, AutoTlsStatus } from '../autoTls'; +import { inspectCertificate, removeLegacyCertificate, type AutoTlsSettings, type CertificateStore, type StoredCertificate, type TlsSettingsStore } from './CertificateStore'; +import { AutoTlsFailure, SILENT_TLS_LOGGER, type CertificateIssuer, type IssuedCertificate, type TlsLogger } from './errors'; +import { FileLock } from './fileLock'; +import { isPublicUnicastAddress } from './publicAddress'; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +export interface AutoTlsServiceOptions { + /** The machine-wide certificate shared by every server and bot. */ + store: CertificateStore; + /** This server's or bot's own opt-in. */ + settings: TlsSettingsStore; + issuer: CertificateIssuer; + publicIp: () => Promise; + /** Installs the context new connections use; `null` leaves no TLS. The certificate is `null` then too. */ + install: (context: tls.SecureContext | null, certificate: StoredCertificate | null) => void; + /** Held while issuing, so only one process of the machine talks to the CA and binds the validation port. */ + lock?: FileLock; + /** A data folder from before the certificate was shared; its certificate is adopted once. */ + legacyDirectory?: string; + /** A test CA validates on loopback or a LAN; Let's Encrypt only ever reaches public addresses. */ + allowNonPublicAddress?: boolean; + /** Lifts the system's restriction on the validation port; called only with the owner's consent. */ + grantPortPermission?: () => Promise; + log?: TlsLogger; +} + +export interface AutoTlsEnableOptions { + /** The owner agreed to let the process lift the port restriction itself (`sudo -n`). */ + grantPortPermission?: boolean; +} + +/** + * Keeps a certificate for the machine's public IP (#860) for one server or + * bot: reuses the shared one when it is valid, otherwise issues it, renews it + * ahead of expiry (only one process at a time, under a file lock), issues a + * new one when the IP changes, and swaps it in for new connections without + * touching the open ones. A certificate renewed by another process is picked + * up from the shared store. While it cannot, the process keeps serving without + * TLS and the last error is kept for the owner. + */ +export class AutoTlsService { + /** Let's Encrypt `shortlived` certificates last about 6 days; renewal starts with 2 to spare. */ + static RENEW_BEFORE_MS = 2 * DAY; + static IP_CHECK_INTERVAL_MS = HOUR; + static RETRY_DELAYS_MS: readonly number[] = [5 * MINUTE, 30 * MINUTE, 2 * HOUR, 6 * HOUR]; + /** When another process is renewing, how long before looking at the shared certificate again. */ + static LOCK_RETRY_MS = 30_000; + /** How long turning the feature on waits for another process that is issuing. */ + static LOCK_WAIT_MS = 3 * MINUTE; + /** How often the shared certificate and this process's own setting are checked for changes made elsewhere. */ + static PICKUP_INTERVAL_MS = MINUTE; + + /** The last `enable()` found a valid shared certificate and asked the CA for nothing. */ + public reusedSharedCertificate = false; + + private settings: AutoTlsSettings; + private certificate: StoredCertificate | null; + private installed = false; + private busy = false; + private failures = 0; + private timer: NodeJS.Timeout | null = null; + private pickupTimer: NodeJS.Timeout | null = null; + private stamp: string | null; + private queue: Promise = Promise.resolve(); + private readonly abort = new AbortController(); + private readonly listeners = new Set<(status: AutoTlsStatus) => void>(); + private readonly lock: FileLock; + private readonly log: TlsLogger; + + constructor(private readonly options: AutoTlsServiceOptions) { + this.log = options.log ?? SILENT_TLS_LOGGER; + this.lock = options.lock ?? new FileLock(options.store.file('renew.lock')); + if (options.legacyDirectory) { + try { + if (options.store.adoptLegacy(options.legacyDirectory)) this.log.info('Adopted the certificate this server kept before certificates were shared.'); + } catch (error) { + this.log.warn('Could not move the certificate to the shared folder.', error); + } + } + this.settings = options.settings.read(); + this.certificate = this.settings.enabled ? options.store.readCertificate() : null; + this.stamp = options.store.certificateStamp(); + } + + /** Installs a saved certificate that is still valid, then checks it in the background and watches for changes made elsewhere. */ + public start(): void { + this.startPickup(); + if (!this.restore()) return; + this.schedule(0); + } + + /** Installs a saved certificate that is still valid, without checking anything; false when the feature is off. */ + public restore(): boolean { + if (!this.settings.enabled || this.abort.signal.aborted) return false; + const certificate = this.certificate; + if (certificate && Date.now() < certificate.notAfter) this.installCertificate(certificate); + return true; + } + + public status(): AutoTlsStatus { + const certificate = this.installed ? this.certificate : null; + return { + enabled: this.settings.enabled, + active: certificate !== null, + busy: this.busy, + ip: certificate?.ip ?? null, + validUntil: certificate?.notAfter ?? null, + nextRenewalAt: certificate ? AutoTlsService.renewalTime(certificate) : null, + lastError: this.settings.lastError, + }; + } + + /** Address new secure connections are valid for, if any. */ + public secureAddress(): string | null { + return this.installed ? this.certificate?.ip ?? null : null; + } + + public subscribe(listener: (status: AutoTlsStatus) => void): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + /** + * Turns the feature on with the certificate already shared on this machine, + * or, when there is none for this IP, only after the CA proved it can reach + * this host and a certificate was issued. A failure rejects with an + * `AutoTlsFailure` and leaves the feature off. `port-permission` may be + * retried with the owner's consent to `grantPortPermission`. + */ + public enable(options: AutoTlsEnableOptions = {}): Promise { + return this.exclusive(async () => { + const signal = this.abort.signal; + const ip = await this.resolveIp(); + this.reusedSharedCertificate = false; + const shared = this.options.store.findUsable(ip); + if (shared) { + this.reusedSharedCertificate = true; + this.useCertificate(shared); + } else { + const release = await this.lock.acquire(AutoTlsService.LOCK_WAIT_MS, signal); + if (!release) { + this.throwIfDisposed(); + throw new AutoTlsFailure('network', 'Another process is still issuing the certificate.'); + } + try { + const concurrent = this.options.store.findUsable(ip); + if (concurrent) { + this.reusedSharedCertificate = true; + this.useCertificate(concurrent); + } else { + await this.precheck(ip, options); + const issued = await this.options.issuer.issue(ip, signal); + this.throwIfDisposed(); + this.adopt(ip, issued); + } + } finally { + release(); + } + } + this.saveSettings({ enabled: true, lastError: null }); + this.failures = 0; + this.log.info(`Automatic TLS enabled for ${ip}${this.reusedSharedCertificate ? ' with the shared certificate' : ''}.`); + this.scheduleNext(); + }).then(() => this.status()); + } + + /** Turns this process off; the shared certificate stays for the others. */ + public disable(): Promise { + return this.exclusive(async () => { + this.cancelTimer(); + this.saveSettings({ enabled: false, lastError: null }); + this.uninstall(); + this.certificate = null; + this.failures = 0; + if (this.options.legacyDirectory) removeLegacyCertificate(this.options.legacyDirectory); + this.log.info('Automatic TLS disabled; only plain connections are accepted.'); + }).then(() => this.status()); + } + + /** Follows what other processes changed: a renewed certificate, or this process's own setting. Runs by itself while started. */ + public syncFromDisk(): void { + if (this.abort.signal.aborted || this.busy) return; + const stored = this.options.settings.read(); + if (stored.enabled !== this.settings.enabled) { + this.settings = stored; + if (stored.enabled) { + this.certificate = this.options.store.readCertificate(); + this.restore(); + this.schedule(0); + } else { + this.cancelTimer(); + this.uninstall(); + this.certificate = null; + } + this.notify(); + return; + } + if (!this.settings.enabled) return; + const stamp = this.options.store.certificateStamp(); + if (stamp === this.stamp) return; + this.stamp = stamp; + const shared = this.options.store.readCertificate(); + const current = this.certificate; + if (!shared || Date.now() >= shared.notAfter) return; + if (current && (shared.ip !== current.ip || shared.notAfter <= current.notAfter)) return; + this.log.info(`Picked up the TLS certificate for ${shared.ip} renewed by another process.`); + this.useCertificate(shared); + this.notify(); + this.scheduleNext(); + } + + /** Idempotent: stops the timers and abandons work in flight. */ + public dispose(): void { + if (this.abort.signal.aborted) return; + this.abort.abort(); + this.cancelTimer(); + if (this.pickupTimer) clearInterval(this.pickupTimer); + this.pickupTimer = null; + this.listeners.clear(); + } + + private static renewalTime(certificate: StoredCertificate): number { + return certificate.notAfter - AutoTlsService.RENEW_BEFORE_MS; + } + + private startPickup(): void { + if (this.pickupTimer || this.abort.signal.aborted) return; + this.pickupTimer = setInterval(() => { + try { + this.syncFromDisk(); + } catch (error) { + this.log.warn('Could not check the shared TLS certificate.', error); + } + }, AutoTlsService.PICKUP_INTERVAL_MS); + this.pickupTimer.unref?.(); + } + + private exclusive(work: () => Promise): Promise { + const run = this.queue.then(async () => { + this.throwIfDisposed(); + this.busy = true; + this.notify(); + try { + return await work(); + } finally { + this.busy = false; + this.notify(); + } + }); + this.queue = run.catch(() => undefined); + return run; + } + + private throwIfDisposed(): void { + if (this.abort.signal.aborted) throw new AutoTlsFailure('network', 'The process is stopping.'); + } + + private async resolveIp(): Promise { + const ip = await this.options.publicIp(); + if (!ip) throw new AutoTlsFailure('no-public-ip'); + if (!this.options.allowNonPublicAddress && !isPublicUnicastAddress(ip)) throw new AutoTlsFailure('address-not-public', ip); + return ip; + } + + private async precheck(ip: string, options: AutoTlsEnableOptions): Promise { + const signal = this.abort.signal; + try { + await this.options.issuer.precheck(ip, signal); + } catch (error) { + const grant = this.options.grantPortPermission; + if (!(error instanceof AutoTlsFailure) || error.code !== 'port-permission' || !options.grantPortPermission || !grant) throw error; + await grant(); + await this.options.issuer.precheck(ip, signal); + } + } + + /** Saves the new pair before it replaces the one in use. */ + private adopt(ip: string, issued: IssuedCertificate): void { + let certificate: StoredCertificate; + try { + certificate = inspectCertificate(ip, issued.certificatePem, issued.privateKeyPem); + this.options.store.writeCertificate(certificate); + } catch (error) { + throw new AutoTlsFailure('storage', error instanceof Error ? error.message : String(error)); + } + this.useCertificate(certificate); + } + + private useCertificate(certificate: StoredCertificate): void { + this.certificate = certificate; + this.stamp = this.options.store.certificateStamp(); + this.installCertificate(certificate); + } + + private installCertificate(certificate: StoredCertificate): void { + this.options.install(tls.createSecureContext({ cert: certificate.certificatePem, key: certificate.privateKeyPem }), certificate); + this.installed = true; + } + + private uninstall(): void { + if (!this.installed) return; + this.options.install(null, null); + this.installed = false; + } + + private saveSettings(settings: AutoTlsSettings): void { + this.settings = settings; + try { + this.options.settings.write(settings); + } catch (error) { + this.log.warn('Could not save the automatic TLS settings.', error); + } + } + + private schedule(delayMs: number): void { + this.cancelTimer(); + if (this.abort.signal.aborted) return; + this.timer = setTimeout(() => { + this.timer = null; + void this.exclusive(() => this.refresh()).catch((error: unknown) => { + if (!this.abort.signal.aborted) this.log.error('Automatic TLS check failed unexpectedly.', error); + }); + }, Math.max(0, delayMs)); + this.timer.unref?.(); + } + + private cancelTimer(): void { + if (this.timer) clearTimeout(this.timer); + this.timer = null; + } + + /** Wakes for the renewal, the hourly IP check or the expiry, whichever comes first. */ + private scheduleNext(): void { + const now = Date.now(); + const certificate = this.certificate; + const wake = certificate + ? Math.min(AutoTlsService.renewalTime(certificate), certificate.notAfter, now + AutoTlsService.IP_CHECK_INTERVAL_MS) + : now + AutoTlsService.IP_CHECK_INTERVAL_MS; + this.schedule(wake - now); + } + + private async refresh(): Promise { + if (!this.settings.enabled) return; + try { + const ip = await this.resolveIp(); + const shared = this.options.store.findUsable(ip); + if (shared && (!this.certificate || shared.notAfter > this.certificate.notAfter)) this.useCertificate(shared); + if (this.needsIssuing(ip)) { + const release = this.lock.tryAcquire(); + if (!release) { + this.uninstallIfExpired(); + this.schedule(AutoTlsService.LOCK_RETRY_MS); + return; + } + try { + const renewed = this.options.store.findUsable(ip); + if (renewed && !AutoTlsService.isDue(renewed)) { + this.useCertificate(renewed); + } else { + const current = this.certificate; + if (current && current.ip !== ip) this.log.info(`Public IP changed from ${current.ip} to ${ip}; issuing a new certificate.`); + const issued = await this.options.issuer.issue(ip, this.abort.signal); + this.throwIfDisposed(); + this.adopt(ip, issued); + this.log.info(`TLS certificate for ${ip} renewed.`); + } + } finally { + release(); + } + } + this.failures = 0; + if (this.settings.lastError) this.saveSettings({ enabled: true, lastError: null }); + this.scheduleNext(); + } catch (error) { + if (this.abort.signal.aborted) return; + this.recordFailure(error); + } + } + + private static isDue(certificate: StoredCertificate): boolean { + return Date.now() >= AutoTlsService.renewalTime(certificate); + } + + private needsIssuing(ip: string): boolean { + const current = this.certificate; + return !current || current.ip !== ip || AutoTlsService.isDue(current); + } + + private uninstallIfExpired(): void { + if (this.certificate && Date.now() >= this.certificate.notAfter) this.uninstall(); + } + + private recordFailure(error: unknown): void { + const failure = error instanceof AutoTlsFailure + ? error + : new AutoTlsFailure('network', error instanceof Error ? error.message : String(error)); + this.saveSettings({ enabled: true, lastError: { code: failure.code as AutoTlsErrorCode, detail: failure.message, at: Date.now() } }); + const certificate = this.certificate; + const now = Date.now(); + this.uninstallIfExpired(); + const delays = AutoTlsService.RETRY_DELAYS_MS; + const retry = delays[Math.min(this.failures, delays.length - 1)]; + this.failures += 1; + const untilExpiry = certificate && this.installed ? certificate.notAfter - now : Infinity; + this.log.warn(`Could not renew the TLS certificate (${failure.code}); plain connections keep working. Retrying in ${Math.round(retry / MINUTE)} min.`, failure.message); + this.schedule(Math.min(retry, untilExpiry)); + } + + private notify(): void { + const status = this.status(); + for (const listener of this.listeners) { + try { + listener(status); + } catch (error) { + this.log.warn('An automatic TLS status listener failed.', error); + } + } + } +} diff --git a/packages/shared/src/tls/CertificateStore.ts b/packages/shared/src/tls/CertificateStore.ts new file mode 100644 index 00000000..c800ca10 --- /dev/null +++ b/packages/shared/src/tls/CertificateStore.ts @@ -0,0 +1,167 @@ +import { X509Certificate, createPrivateKey, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { isAutoTlsErrorCode, type AutoTlsError } from '../autoTls'; + +export interface StoredCertificate { + ip: string; + /** Leaf first, then the intermediates, as the CA delivered the chain. */ + certificatePem: string; + privateKeyPem: string; + notAfter: number; +} + +export interface AutoTlsSettings { + enabled: boolean; + lastError: AutoTlsError | null; +} + +const DEFAULT_SETTINGS: AutoTlsSettings = { enabled: false, lastError: null }; + +/** Where every server and bot of this machine keeps the one certificate they share. */ +export function sharedTlsDirectory(env: NodeJS.ProcessEnv = process.env): string { + return env.MONKY_TLS_DIR ? path.resolve(env.MONKY_TLS_DIR) : path.join(os.homedir(), '.monky', 'tls'); +} + +export function writeAtomically(file: string, contents: string): void { + const pending = `${file}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(pending, contents, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + fs.renameSync(pending, file); + } catch (error) { + fs.rmSync(pending, { force: true }); + throw error; + } +} + +/** + * Reads the leaf's expiry and refuses a pair that does not belong together or + * does not cover `ip`: installing it would break every `wss://` connection. + */ +export function inspectCertificate(ip: string, certificatePem: string, privateKeyPem: string): StoredCertificate { + const leaf = new X509Certificate(certificatePem); + if (!leaf.checkPrivateKey(createPrivateKey(privateKeyPem))) throw new Error('The certificate does not match its private key.'); + if (leaf.checkIP(ip) === undefined) throw new Error(`The certificate does not cover ${ip}.`); + const notAfter = Date.parse(leaf.validTo); + if (!Number.isFinite(notAfter)) throw new Error('The certificate expiry could not be read.'); + return { ip, certificatePem, privateKeyPem, notAfter }; +} + +/** Forgets the certificate a server kept in its own folder; the shared one stays for the others. */ +export function removeLegacyCertificate(legacyDirectory: string): void { + fs.rmSync(path.join(legacyDirectory, 'certificate.json'), { force: true }); +} + +class JsonDirectory { + constructor(public readonly directory: string) {} + + public file(name: string): string { + return path.join(this.directory, name); + } + + public readJson(name: string): unknown { + try { + return JSON.parse(fs.readFileSync(this.file(name), 'utf8')); + } catch { + return null; + } + } + + public writeJson(name: string, value: unknown): void { + fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 }); + writeAtomically(this.file(name), `${JSON.stringify(value, null, 2)}\n`); + } +} + +function parseCertificate(value: unknown): StoredCertificate | null { + if (!value || typeof value !== 'object') return null; + if (!('ip' in value) || typeof value.ip !== 'string') return null; + if (!('certificatePem' in value) || typeof value.certificatePem !== 'string') return null; + if (!('privateKeyPem' in value) || typeof value.privateKeyPem !== 'string') return null; + try { + return inspectCertificate(value.ip, value.certificatePem, value.privateKeyPem); + } catch { + return null; + } +} + +/** + * The certificate of this machine, shared by all servers and bots on it. The + * certificate and its key share one file written atomically, so a crash never + * leaves a new certificate beside an old key, and the previous pair stays in + * place until the new one is fully on disk. + */ +export class CertificateStore extends JsonDirectory { + constructor(directory: string = sharedTlsDirectory()) { + super(directory); + } + + /** The saved certificate, or null when there is none or it no longer makes sense. */ + public readCertificate(): StoredCertificate | null { + return parseCertificate(this.readJson('certificate.json')); + } + + /** Changes whenever another process replaces the certificate; cheap enough to poll. */ + public certificateStamp(): string | null { + try { + const stat = fs.statSync(this.file('certificate.json')); + return `${stat.mtimeMs}:${stat.size}`; + } catch { + return null; + } + } + + /** A certificate for `ip` that has not expired, if the shared store holds one. */ + public findUsable(ip: string, now = Date.now()): StoredCertificate | null { + const certificate = this.readCertificate(); + return certificate && certificate.ip === ip && now < certificate.notAfter ? certificate : null; + } + + public writeCertificate(certificate: StoredCertificate): void { + this.writeJson('certificate.json', { + ip: certificate.ip, + certificatePem: certificate.certificatePem, + privateKeyPem: certificate.privateKeyPem, + }); + } + + /** + * Copies the certificate a server kept in its own data folder before + * sharing existed, unless the shared store already has a certificate in use. + */ + public adoptLegacy(legacyDirectory: string, now = Date.now()): boolean { + const shared = this.readCertificate(); + if (shared && now < shared.notAfter) return false; + const legacyFiles = new JsonDirectory(legacyDirectory); + const legacy = parseCertificate(legacyFiles.readJson('certificate.json')); + if (!legacy || now >= legacy.notAfter) return false; + this.writeCertificate(legacy); + const accounts = legacyFiles.readJson('accounts.json'); + if (!this.readJson('accounts.json') && accounts && typeof accounts === 'object') this.writeJson('accounts.json', accounts); + return true; + } +} + +/** One server's or bot's own choice to use the shared certificate. */ +export class TlsSettingsStore extends JsonDirectory { + public read(): AutoTlsSettings { + const value = this.readJson('settings.json'); + if (!value || typeof value !== 'object') return { ...DEFAULT_SETTINGS }; + const enabled = 'enabled' in value && value.enabled === true; + const lastError = 'lastError' in value ? parseError(value.lastError) : null; + return { enabled, lastError }; + } + + public write(settings: AutoTlsSettings): void { + this.writeJson('settings.json', settings); + } +} + +function parseError(value: unknown): AutoTlsError | null { + if (!value || typeof value !== 'object') return null; + if (!('code' in value) || !isAutoTlsErrorCode(value.code)) return null; + if (!('at' in value) || typeof value.at !== 'number') return null; + const detail = 'detail' in value && typeof value.detail === 'string' ? value.detail : null; + return { code: value.code, detail, at: value.at }; +} diff --git a/apps/server/src/infrastructure/tls/Csr.ts b/packages/shared/src/tls/Csr.ts similarity index 100% rename from apps/server/src/infrastructure/tls/Csr.ts rename to packages/shared/src/tls/Csr.ts diff --git a/apps/server/src/infrastructure/tls/LetsEncryptIssuer.ts b/packages/shared/src/tls/LetsEncryptIssuer.ts similarity index 81% rename from apps/server/src/infrastructure/tls/LetsEncryptIssuer.ts rename to packages/shared/src/tls/LetsEncryptIssuer.ts index 14f83fdb..9f932f3a 100644 --- a/apps/server/src/infrastructure/tls/LetsEncryptIssuer.ts +++ b/packages/shared/src/tls/LetsEncryptIssuer.ts @@ -1,11 +1,8 @@ import { generateKeyPairSync, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto'; -import { - AUTO_TLS_LOW_PORT_SYSCTL, AUTO_TLS_LOW_PORT_SYSCTL_FILE, AUTO_TLS_VALIDATION_PORT, -} from '@monky/shared'; -import { detectElevation, runPrivileged } from '../security/privileged'; +import { AUTO_TLS_VALIDATION_PORT } from '../autoTls'; import { AcmeClient } from './AcmeClient'; import type { AcmeChallenges } from './AcmeChallenges'; -import { AutoTlsFailure, type CertificateIssuer, type IssuedCertificate } from './AutoTlsService'; +import { AutoTlsFailure, type CertificateIssuer, type IssuedCertificate } from './errors'; import type { CertificateStore } from './CertificateStore'; import { createIpCsr } from './Csr'; @@ -33,8 +30,8 @@ export function acmeTestOverrides(env: NodeJS.ProcessEnv = process.env): { export interface LetsEncryptIssuerOptions { store: CertificateStore; challenges: AcmeChallenges; - /** The server's own port: when it is the validation port, it already answers the challenge. */ - serverPort: number; + /** The server's own port: when it is the validation port, it already answers the challenge. Bots omit it. */ + serverPort?: number; /** * Test servers such as Pebble (`MONKY_ACME_DIRECTORY`). Used for the * precheck as well, so a test never reaches Let's Encrypt. @@ -105,16 +102,3 @@ export class LetsEncryptIssuer implements CertificateIssuer { return privateKey; } } - -/** - * Lets unprivileged processes open port 80 on Linux, persistently, through - * root or passwordless sudo. Only called after the owner agreed to it. - */ -export async function allowValidationPort(): Promise { - const elevation = detectElevation(); - if (!elevation) throw new AutoTlsFailure('privilege-unavailable', 'No root or passwordless sudo is available.'); - const persist = await runPrivileged(elevation, - ['sh', '-c', `printf '%s\\n' '${AUTO_TLS_LOW_PORT_SYSCTL}' > ${AUTO_TLS_LOW_PORT_SYSCTL_FILE}`], 15_000); - const apply = persist.ok ? await runPrivileged(elevation, ['sysctl', '-w', AUTO_TLS_LOW_PORT_SYSCTL], 15_000) : persist; - if (!apply.ok) throw new AutoTlsFailure('privilege-unavailable', apply.detail); -} diff --git a/apps/server/src/infrastructure/tls/der.ts b/packages/shared/src/tls/der.ts similarity index 100% rename from apps/server/src/infrastructure/tls/der.ts rename to packages/shared/src/tls/der.ts diff --git a/packages/shared/src/tls/errors.ts b/packages/shared/src/tls/errors.ts new file mode 100644 index 00000000..3cf5d77c --- /dev/null +++ b/packages/shared/src/tls/errors.ts @@ -0,0 +1,29 @@ +import type { AutoTlsErrorCode } from '../autoTls'; + +/** A failure each surface can explain by its code; `detail` only goes to logs. */ +export class AutoTlsFailure extends Error { + constructor(public readonly code: AutoTlsErrorCode, detail?: string) { + super(detail ?? code); + this.name = 'AutoTlsFailure'; + } +} + +export interface IssuedCertificate { + certificatePem: string; + privateKeyPem: string; +} + +/** Talks to the CA. Every failure is an `AutoTlsFailure`. */ +export interface CertificateIssuer { + /** Proves the CA can reach this host on `ip` without keeping a certificate. */ + precheck(ip: string, signal: AbortSignal): Promise; + issue(ip: string, signal: AbortSignal): Promise; +} + +export interface TlsLogger { + info(message: string, meta?: unknown): void; + warn(message: string, meta?: unknown): void; + error(message: string, meta?: unknown): void; +} + +export const SILENT_TLS_LOGGER: TlsLogger = { info() {}, warn() {}, error() {} }; diff --git a/packages/shared/src/tls/fileLock.ts b/packages/shared/src/tls/fileLock.ts new file mode 100644 index 00000000..a58974e1 --- /dev/null +++ b/packages/shared/src/tls/fileLock.ts @@ -0,0 +1,107 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export interface FileLockOptions { + /** A lock older than this is abandoned even if its process id looks alive (id reuse). */ + staleMs?: number; + pollMs?: number; +} + +interface LockOwner { + id: string; + pid: number; + at: number; +} + +const DEFAULT_STALE_MS = 15 * 60_000; +const UNREADABLE_GRACE_MS = 5_000; + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'EPERM'; + } +} + +/** + * A lock shared by every process of the machine that renews the certificate: + * the file's creation is the atomic step, and a lock left by a dead process, + * or kept far longer than any issuance takes, is taken over. + */ +export class FileLock { + private readonly staleMs: number; + private readonly pollMs: number; + + constructor(private readonly file: string, options: FileLockOptions = {}) { + this.staleMs = options.staleMs ?? DEFAULT_STALE_MS; + this.pollMs = options.pollMs ?? 500; + } + + /** The release function, or null when another process holds the lock. */ + public tryAcquire(): (() => void) | null { + fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 }); + for (let attempt = 0; attempt < 2; attempt++) { + const owner: LockOwner = { id: randomUUID(), pid: process.pid, at: Date.now() }; + try { + const descriptor = fs.openSync(this.file, 'wx', 0o600); + try { + fs.writeSync(descriptor, JSON.stringify(owner)); + } finally { + fs.closeSync(descriptor); + } + return () => this.release(owner.id); + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') throw error; + if (!this.isStale()) return null; + fs.rmSync(this.file, { force: true }); + } + } + return null; + } + + public async acquire(waitMs: number, signal?: AbortSignal): Promise<(() => void) | null> { + const deadline = Date.now() + waitMs; + for (;;) { + const release = this.tryAcquire(); + if (release) return release; + if (signal?.aborted || Date.now() >= deadline) return null; + await new Promise((resolve) => { + const timer = setTimeout(resolve, this.pollMs); + signal?.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); + }); + } + } + + private isStale(): boolean { + let text: string; + let modified: number; + try { + text = fs.readFileSync(this.file, 'utf8'); + modified = fs.statSync(this.file).mtimeMs; + } catch { + return true; + } + let owner: Partial | null = null; + try { + owner = JSON.parse(text) as Partial; + } catch { + owner = null; + } + if (!owner || typeof owner.pid !== 'number' || typeof owner.at !== 'number') { + return Date.now() - modified > UNREADABLE_GRACE_MS; + } + return Date.now() - owner.at > this.staleMs || !processIsAlive(owner.pid); + } + + private release(id: string): void { + try { + const owner = JSON.parse(fs.readFileSync(this.file, 'utf8')) as Partial; + if (owner.id === id) fs.rmSync(this.file, { force: true }); + } catch { + // Already gone or taken over. + } + } +} diff --git a/packages/shared/src/tls/index.ts b/packages/shared/src/tls/index.ts new file mode 100644 index 00000000..6ae62d44 --- /dev/null +++ b/packages/shared/src/tls/index.ts @@ -0,0 +1,9 @@ +export * from './errors'; +export * from './fileLock'; +export * from './CertificateStore'; +export * from './AcmeChallenges'; +export * from './AcmeClient'; +export * from './LetsEncryptIssuer'; +export * from './AutoTlsService'; +export * from './lowPort'; +export * from './publicAddress'; diff --git a/packages/shared/src/tls/lowPort.ts b/packages/shared/src/tls/lowPort.ts new file mode 100644 index 00000000..b86fb328 --- /dev/null +++ b/packages/shared/src/tls/lowPort.ts @@ -0,0 +1,52 @@ +import { spawn, spawnSync } from 'node:child_process'; +import os from 'node:os'; +import { AUTO_TLS_LOW_PORT_SYSCTL, AUTO_TLS_LOW_PORT_SYSCTL_FILE } from '../autoTls'; +import { AutoTlsFailure } from './errors'; + +type Elevation = 'root' | 'sudo'; + +/** `sudo -n` never prompts, so a background process cannot hang waiting for a password. */ +function detectElevation(): Elevation | null { + if (os.platform() !== 'linux') return null; + if (typeof process.getuid === 'function' && process.getuid() === 0) return 'root'; + try { + return spawnSync('sudo', ['-n', 'true'], { timeout: 5000 }).status === 0 ? 'sudo' : null; + } catch { + return null; + } +} + +function runPrivileged(elevation: Elevation, command: string[], timeoutMs: number): Promise<{ ok: boolean; detail: string }> { + const [bin, ...args] = elevation === 'sudo' ? ['sudo', '-n', ...command] : command; + return new Promise((resolve) => { + let stderr = ''; + let settled = false; + const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] }); + const finish = (ok: boolean, detail: string): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ ok, detail }); + }; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(false, `"${command.join(' ')}" timed out.`); + }, timeoutMs); + child.stderr?.on('data', (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-2000); }); + child.on('error', (error) => finish(false, error.message)); + child.on('close', (code) => finish(code === 0, code === 0 ? '' : stderr.trim() || `exited with code ${code}`)); + }); +} + +/** + * Lets unprivileged processes open port 80 on Linux, persistently, through + * root or passwordless sudo. Only called after the owner agreed to it. + */ +export async function allowValidationPort(): Promise { + const elevation = detectElevation(); + if (!elevation) throw new AutoTlsFailure('privilege-unavailable', 'No root or passwordless sudo is available.'); + const persist = await runPrivileged(elevation, + ['sh', '-c', `printf '%s\\n' '${AUTO_TLS_LOW_PORT_SYSCTL}' > ${AUTO_TLS_LOW_PORT_SYSCTL_FILE}`], 15_000); + const apply = persist.ok ? await runPrivileged(elevation, ['sysctl', '-w', AUTO_TLS_LOW_PORT_SYSCTL], 15_000) : persist; + if (!apply.ok) throw new AutoTlsFailure('privilege-unavailable', apply.detail); +} diff --git a/packages/shared/src/tls/publicAddress.ts b/packages/shared/src/tls/publicAddress.ts new file mode 100644 index 00000000..25668ab8 --- /dev/null +++ b/packages/shared/src/tls/publicAddress.ts @@ -0,0 +1,51 @@ +import { BlockList, isIP } from 'node:net'; + +const NON_PUBLIC = new BlockList(); +for (const [network, prefix] of [ + ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], + ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], + ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4], +] as const) NON_PUBLIC.addSubnet(network, prefix, 'ipv4'); +for (const [network, prefix] of [ + ['::', 128], ['::1', 128], ['64:ff9b::', 96], ['64:ff9b:1::', 48], ['100::', 64], ['2001::', 23], + ['2001:db8::', 32], ['2002::', 16], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8], +] as const) NON_PUBLIC.addSubnet(network, prefix, 'ipv6'); +const GLOBAL_UNICAST_V6 = new BlockList(); +GLOBAL_UNICAST_V6.addSubnet('2000::', 3, 'ipv6'); + +function normalize(address: string): string { + const unwrapped = address.startsWith('[') && address.endsWith(']') ? address.slice(1, -1) : address; + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(unwrapped); + return (mapped ? mapped[1] : unwrapped).toLowerCase(); +} + +/** An address a certificate authority can reach from the internet. */ +export function isPublicUnicastAddress(address: string): boolean { + const normalized = normalize(address); + const family = isIP(normalized); + if (family === 4) return !NON_PUBLIC.check(normalized, 'ipv4'); + if (family === 6) return GLOBAL_UNICAST_V6.check(normalized, 'ipv6') && !NON_PUBLIC.check(normalized, 'ipv6'); + return false; +} + +const PUBLIC_IP_PROVIDERS = [ + 'https://api.ipify.org?format=json', + 'https://api4.my-ip.io/v2/ip.json', +]; + +/** The machine's public address as seen from the internet, or null when no provider answers. */ +export async function discoverPublicIp(): Promise { + for (const url of PUBLIC_IP_PROVIDERS) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(3500) }); + if (!response.ok) continue; + const data: unknown = await response.json(); + if (!data || typeof data !== 'object') continue; + const ip = 'ip' in data && data.ip ? data.ip : 'ipAddress' in data ? data.ipAddress : undefined; + if (typeof ip === 'string' && isIP(ip)) return ip; + } catch { + // Try the next provider. + } + } + return null; +} diff --git a/release-notes/shared-machine-tls.json b/release-notes/shared-machine-tls.json new file mode 100644 index 00000000..cd02aef7 --- /dev/null +++ b/release-notes/shared-machine-tls.json @@ -0,0 +1,5 @@ +{ + "group": "novidades", + "pt-BR": "Servidor e bots da mesma máquina agora compartilham uma única conexão segura (TLS). Ative em qualquer um e o certificado existente é reutilizado, com renovação automática. Assim, os miniapps dos bots também funcionam na versão web.", + "en": "The server and the bots on the same machine now share a single secure connection (TLS). Turn it on in any of them and the existing certificate is reused, with automatic renewal. This lets bot miniapps work on the web version too." +}